Files
OpenMesh/surfaces/gui/e2e/fixtures.ts

2269 lines
111 KiB
TypeScript
Raw Normal View History

import { test as base, expect, type Page } from "@playwright/test";
// The app-wide /ws/events socket each page opened (UX-026 toast et al.) — specs
// push server events through it via sendAppEvent below.
const eventSockets = new WeakMap<Page, { send: (data: string) => void }>();
/** Push an app-wide event exactly as the server would over /ws/events. Waits for
* the GUI to have connected its socket first. */
export async function sendAppEvent(page: Page, obj: unknown): Promise<void> {
for (let i = 0; i < 50 && !eventSockets.get(page); i++) await page.waitForTimeout(100);
const ws = eventSockets.get(page);
if (!ws) throw new Error("the app never opened /ws/events");
ws.send(JSON.stringify(obj));
}
// Hermetic API mock. Every /v1 request the GUI makes is fulfilled from the fixtures below (shapes
// mirrored from the real backend), and the event WebSocket is a SCRIPTED FAKE AGENT (ready on
// connect; user_message → turn_start/deltas/assistant_message/turn_done; "run a tool" triggers the
// approval flow), so specs run with no Python server and never touch real state. Mutations
// (sessions, personas, inbox, routing, channel subscriptions) are held in per-test in-memory state
// so add/remove/toggle reflect through the real UI on re-fetch.
const HEALTH = { status: "ok", default_workspace: null, model: "anthropic:claude-opus-4-8" };
const SETTINGS = {
provider: "openai",
auto_approve: true, // surfaces the Auto-approve mode entry (reviewer feature flag)
model: "anthropic:claude-opus-4-8",
models: ["anthropic:claude-opus-4-8", "gpt-5.5", "gpt-4o", "gpt-4o-mini", "o3-mini"],
has_key: true,
model_ready: true,
source: "store",
onboarded: true,
experimental_connectors: false,
surfaces: { cowork: true, chat: false, code: true },
nav_layout: "grouped",
scratch_base: "~/OpenWorker",
secrets_path: "/Users/test/.config/coworker/secrets.json",
sessions_peek: 5,
// Token savings (PDF attachments): 2-page limit keeps the composer threshold test's
// fixture PDF small; the real default is 20.
pdf_fallback: "text",
pdf_max_pages: 2,
pdf_max_mb: 10,
// Curated-matrix display names (subset — mirrors /v1/settings.model_labels).
model_labels: {
"anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic",
"zai:glm-5.2": "GLM-5.2 · Z AI",
"ark:dola-seed-evolving-latest-version": "Dola Seed Evolving · BytePlus Ark",
"ark:dola-seed-2-1-turbo-260628": "Dola Seed 2.1 Turbo · BytePlus Ark",
"ark-agent-plan-cn:doubao-seed-evolving": "Doubao Seed Evolving · Volcengine Agent Plan",
"ark-agent-plan-cn:doubao-seed-2.1-turbo": "Doubao Seed 2.1 Turbo · Volcengine Agent Plan",
},
// Context windows (subset — mirrors /v1/settings.model_context_windows); drives the
// composer usage chip's context-fill meter.
model_context_windows: {
"anthropic:claude-opus-4-8": 200_000,
},
};
// UX-035 lineup: Chat is gone; Code ships disabled; the security bundles group under
// "Security"; ops is ships:false (visible here because the mock plays an internal build).
const PERSONAS = {
internal: true,
personas: [
{ id: "cowork", name: "OpenWorker", icon: "cowork", tagline: "Produce a deliverable — research, analysis, scripts", requires_folder: false, builtin: true, tools: ["files", "search"], enabled: true, surfaced: true, default: true, ships: true, group: "general" },
{ id: "code", name: "Code", icon: "code", tagline: "Work in a codebase — files, git, shell", requires_folder: true, builtin: true, tools: ["code_files", "git"], enabled: false, surfaced: false, default: false, ships: true, group: "general" },
{ id: "security", name: "Security Coworker", icon: "shield", tagline: "Find and fix security issues — scan, triage, PR", requires_folder: true, builtin: true, tools: ["code_files", "git", "shell"], enabled: true, surfaced: true, default: false, ships: true, group: "security" },
{ id: "ops", name: "Ops Coworker", icon: "wrench", tagline: "Operate and investigate — runbooks, logs, infrastructure", requires_folder: false, builtin: true, tools: ["files", "shell"], enabled: true, surfaced: true, default: false, ships: false, group: "general" },
// A non-builtin install (disabled pending consent — invisible to picker specs) so the
// Personas page's delete/enable affordances have a target.
{ id: "acme-notes", name: "Acme Notes", icon: "pencil", tagline: "Acme's note-taking coworker", requires_folder: false, builtin: false, tools: ["files"], enabled: false, surfaced: false, default: false, ships: true, group: "general" },
],
};
// The boot-resume target (most recent updated_at) — existing specs open it by title.
const PINNED_SESSION = {
session_id: "pinned-cowork-1",
title: "Draft the launch note",
workspace: "/Users/test/OpenWorker/launch-note",
agent: "cowork",
model: "anthropic:claude-opus-4-8",
mode: "interactive",
updated_at: "2026-07-01 09:00:00",
messages: 2,
pinned: true,
archived: false,
attention: 0,
liveness: "idle",
subscriptions: [],
};
// Seven unpinned Coworker sessions: enough to exercise the sidebar peek cap (5) + "Show more (2)".
// wp-3 carries the pending Inbox approval below (attention badge parity). All OLDER than the
// pinned session so boot-resume stays deterministic.
const EXTRA_SESSIONS = Array.from({ length: 7 }, (_, i) => ({
session_id: `wp-${i + 1}`,
title: `Weekly plan ${i + 1}`,
workspace: "",
agent: "cowork",
model: "anthropic:claude-opus-4-8",
mode: "interactive",
updated_at: `2026-06-2${8 - Math.min(i, 7)} 10:00:00`,
messages: 3,
pinned: false,
archived: false,
attention: i + 1 === 3 ? 1 : 0,
liveness: "idle",
subscriptions: [],
}));
// One Ops session (older than everything above so boot-resume stays deterministic) — the
// target for the disable-archives-conversations confirm flow on the Personas page.
const OPS_SESSION = {
session_id: "ops-1",
title: "Ops triage",
workspace: "/Users/test/OpenWorker/ops-triage",
agent: "ops",
model: "anthropic:claude-opus-4-8",
mode: "interactive",
updated_at: "2026-06-15 10:00:00",
messages: 4,
pinned: false,
archived: false,
attention: 0,
liveness: "idle",
subscriptions: [],
};
// A session whose turn is LIVE on the server — its ws `ready` carries running:true, the
// reconnect-mid-turn case (owner catch 2026-08-24): Stop + waiting row must show without
// a local turn_start. Older than the pinned session so boot-resume stays deterministic.
const LIVE_SESSION = {
session_id: "resume-live-1",
title: "Long audit",
workspace: "",
agent: "cowork",
model: "anthropic:claude-opus-4-8",
mode: "interactive",
updated_at: "2026-06-20 10:00:00",
messages: 2,
pinned: false,
archived: false,
attention: 0,
liveness: "working",
subscriptions: [],
};
// §31: a mention-spawned session — lives in the sidebar's collapsed "From Slack" group, never
// in Recent. Older than everything else so boot-resume stays deterministic.
const SLACK_SESSION = {
session_id: "slack-thread-1",
title: "#general — check the deploy?",
workspace: "",
agent: "cowork",
model: "anthropic:claude-opus-4-8",
mode: "interactive",
updated_at: "2026-06-10 10:00:00",
messages: 2,
pinned: false,
archived: false,
attention: 0,
liveness: "idle",
subscriptions: [],
origin: "slack",
origin_label: "#general · T0AB",
};
const CONNECTORS = {
connectors: [
{ name: "browser", title: "Browser", icon: "B", blurb: "Headless browser.", auth: "none", two_way: false, channels: false, available: true, brand_color: "#6b7280", logo: "", fields: [], instructions: [], connected: true, account: null, enabled: true, allowed_users: [], tools: [], managed: false, managed_profile: false },
{ name: "telegram", title: "Telegram", icon: "T", blurb: "Two-way Telegram messaging.", auth: "bot_token", two_way: true, channels: true, available: true, brand_color: "#229ed9", logo: "telegram", fields: [{ key: "bot_token", label: "Bot token", secret: true, required: true, help: "", placeholder: "123456:ABC…" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false },
// Managed-capable connector (one-click via cloud when signed in; manual paste otherwise).
// Carries pre-connect detail copy (§38): about + access + tools drive available-detail.spec.ts.
{ name: "gmail", title: "Gmail", icon: "✉", blurb: "Search, summarize, draft, and send email.", about: "Search, summarize, and send over your Gmail.", access: ["Reads and searches your mail.", "Sends email as you.", "Never deletes mail or changes account settings."], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#ea4335", logo: "gmail", fields: [{ key: "access_token", label: "OAuth access token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "gmail_search", label: "Search mail", kind: "read", description: "Search messages.", enabled: true, requires_approval: false }, { name: "gmail_send", label: "Send email", kind: "write", description: "Send a message.", enabled: true, requires_approval: true }], managed: true, managed_profile: false },
{ name: "google_calendar", title: "Google Calendar", icon: "◷", blurb: "Read availability, summarize schedules, and create events.", auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#4285f4", logo: "google_calendar", fields: [{ key: "access_token", label: "OAuth access token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: true, managed_profile: false },
// Two-mode connector: one-click with access radios (read | write) OR a private-app token.
{ name: "hubspot", title: "HubSpot", icon: "⊚", blurb: "Search CRM records; log notes and tasks, update records. No deletes.", auth: "token", two_way: false, channels: false, available: true, brand_color: "#ff7a59", logo: "hubspot", fields: [{ key: "token", label: "Private app token", secret: true, required: true, help: "", placeholder: "pat-…" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: true, managed_profile: false },
// Generic multi-account connector (accounts.py layer): one-click OR integration token.
{ name: "notion", title: "Notion", icon: "◰", blurb: "Search pages, read content, query databases, create pages.", auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#1f2328", logo: "", fields: [{ key: "access_token", label: "Integration secret", secret: true, required: true, help: "", placeholder: "ntn_…" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: true, managed_profile: false },
// Managed email-keyed multi-account connector (outlook) — drives the onboarding tools gallery.
{ name: "outlook", title: "Outlook", icon: "◎", blurb: "Microsoft 365 mail and calendar: search, draft, and send email; manage events and respond to invites.", aliases: ["calendar", "email", "mail", "microsoft", "office"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#0078d4", logo: "outlook", fields: [{ key: "access_token", label: "OAuth access token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: true, managed_profile: false },
// Sixth active card in the onboarding gallery (promoted 2026-07-19 to even the grid).
{ name: "attio", title: "Attio", icon: "▣", blurb: "Search and read Attio CRM records; log notes.", auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#2d6ae0", logo: "attio", fields: [{ key: "access_token", label: "OAuth access token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: true, managed_profile: false },
// MCP-BACKED connectors (§42): vendor-hosted MCP + local OAuth, pinned tool subset.
// monday is one-click ONLY (no manual fields); jira also has a manual token path
// (two-mode modal). Neither needs cloud sign-in.
{ name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this computer."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false },
{ name: "jira", title: "Jira", icon: "◆", blurb: "Search, summarize, create, and update issues.", aliases: ["issues", "tickets", "atlassian"], auth: "api_token", two_way: false, channels: false, available: true, brand_color: "#0052cc", logo: "jira", mcp: true, fields: [{ key: "base_url", label: "Atlassian site URL", secret: false, required: true, help: "", placeholder: "" }, { key: "email", label: "Account email", secret: false, required: true, help: "", placeholder: "" }, { key: "api_token", label: "API token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false },
],
};
// Two pending items across two personas: drives the Inbox kind tabs, the persona filter chips
// (which only render with >1 persona), and resolve-removes-card. The question's session is NOT in
// the sessions list on purpose — the Inbox must be self-contained (server-joined context fields).
const INBOX_ITEMS = [
{
id: "inb-approval-1",
session_id: "wp-3",
kind: "approval",
title: "Approve: run_shell",
body: "rm -rf build/",
state: "pending",
resolution: null,
inbox: "default",
created_at: "2026-07-01 08:00:00",
resolved_at: null,
session_title: "Weekly plan 3",
session_agent: "cowork",
session_workspace: "",
session_exists: true,
},
{
id: "inb-question-1",
session_id: "ops-1",
kind: "question",
title: "Which environment should I restart?",
body: "",
options: ["staging", "production"],
allow_text: true,
multi: false,
state: "pending",
resolution: null,
inbox: "default",
created_at: "2026-07-01 08:05:00",
resolved_at: null,
session_title: "Investigate alerts",
session_agent: "ops",
session_workspace: "",
session_exists: true,
},
];
// Mutable cloud sign-in state: POST /v1/cloud/login flips it (the real flow
// goes through the browser; the mock completes instantly), logout flips back.
export const CLOUD_STATE = {
signed_in: false,
account: "",
user_id: "",
telemetry_enabled: true,
};
const GALLERY_PERSONAS = [
{
slug: "sales",
version: 1,
name: "Sales Coworker",
icon: "chart",
tagline: "Research accounts, prep meetings, draft follow-ups",
description: "A sales-focused coworker.",
family: "knowledge",
workspace: "deliverable",
publisher: "OpenWorker",
recommended_connectors: ["hubspot", "gmail"],
risk_summary: "Declarative manifest; no executable code.",
featured: true,
},
{
slug: "recruiter",
version: 1,
name: "Recruiter",
icon: "search",
tagline: "Sourcing summaries and scheduling loops",
description: "A recruiting coworker.",
family: "knowledge",
workspace: "deliverable",
publisher: "OpenWorker",
recommended_connectors: ["gmail"],
risk_summary: "Declarative manifest; no executable code.",
featured: false,
},
];
// Persona detail (GET /v1/personas/:id) — SourcesDrawer/PersonaView read `recommends` and
// `default_connections` as arrays, so these must be present (not the catch-all {}).
const PERSONA_DETAIL = {
id: "cowork",
name: "OpenWorker",
icon: "cowork",
tagline: "Produce a deliverable — research, analysis, scripts",
description: "",
enabled: true,
tools: ["files", "search"],
recommended_models: ["anthropic:claude-opus-4-8"],
default_permission_mode: "interactive",
workspace: "deliverable",
recommends: [],
default_connections: [],
};
const CONNECTIONS = {
connected: [
{ connector: "browser", enabled: true, detail: "Browser" },
{ connector: "slack", enabled: true, detail: "Slack" },
// two_way WITHOUT channels (relay mentions, no subscriptions) — pins the
// "GitHub shows Channels" regression (owner report 2026-07-13).
{ connector: "github", enabled: true, detail: "GitHub" },
],
recommended: [
{ connector: "gmail", reason: "email context for morning summaries", tier: "core", connected: false },
],
attention: 1,
};
// One scheduled automation with a running run: its session id uses the real `__run__` convention
// so the session view's run banner (detection is id-based) can be exercised end-to-end.
const AUTOMATION = {
id: "task-1",
title: "Daily AI News",
instructions: "Fetch the latest AI news and produce an HTML+Tailwind presentation.",
schedule: "Every day at ~5:40 PM",
schedule_raw: { kind: "cron", cron: "40 17 * * *", fire_at: null, timezone: "local" },
workspace: "",
agent: "cowork",
enabled: true,
next_run: Math.floor(Date.now() / 1000) + 3600,
last_run: Math.floor(Date.now() / 1000) - 60,
last_status: "running",
run_count: 1,
notify_on_completion: false,
// One standing scoped approval (§25) so the detail page's revoke list has content.
always_allowed: [
{ entry: "send_message slack:T1/C1", tool: "send_message", target: "slack:T1/C1" },
],
// UX-023 sidebar badges: two unopened runs, the newest of them failed.
unseen_runs: 2,
unseen_failed: true,
seen_runs_at: 0,
};
// A second, quiet automation so the Scheduled band shows badge-less rows too.
const AUTOMATION_CLEAN = {
...AUTOMATION,
id: "task-2",
title: "Weekly CRM digest",
schedule: "Every Monday at ~9:00 AM",
last_status: "ok",
unseen_runs: 0,
unseen_failed: false,
always_allowed: [],
};
const AUTOMATION_RUNS = [
{
run_id: "r1",
task_id: "task-1",
session_id: "__run__r1",
started_at: Math.floor(Date.now() / 1000) - 60,
finished_at: null,
status: "running",
result_text: null,
artifacts: [],
error: null,
trigger: "schedule",
},
];
const PRIMARY_ROOT = { path: "/Users/test/OpenWorker/launch-note", writable: true, label: "scratch", primary: true, exists: true };
const baseName = (p: string) => p.split("/").filter(Boolean).pop() || p;
const PROVIDERS = [
// openai: configured + used (drives the "Last used" sub-line and the status dot).
{ name: "openai", title: "OpenAI", needs_key: true, fields: [{ key: "api_key", label: "OpenAI API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["gpt-5.5"], key_set_at: "2026-06-12", last_used_at: Math.floor(Date.now() / 1000) - 7200 },
// anthropic: configured but never used ("Not used yet").
{ name: "anthropic", title: "Claude (Anthropic)", needs_key: true, fields: [{ key: "api_key", label: "API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["claude-opus-4-8"], key_set_at: null, last_used_at: null },
// zai: an OpenAI-compatible vendor — unconfigured, with a prefilled editable endpoint + blurb.
{ name: "zai", title: "Z AI (GLM)", needs_key: true, blurb: "Uses Z AI's OpenAI-compatible API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Z AI API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Prefilled with Z AI's international endpoint.", placeholder: "https://api.z.ai/api/paas/v4", default: "https://api.z.ai/api/paas/v4" }], configured: false, values: {}, suggested_models: ["glm-5.2"], key_set_at: null, last_used_at: null },
// Ark uses two provider identities: BytePlus pay-as-you-go and Volcengine Agent Plan CN
// have independent credentials, endpoints, and strict curated model lists.
{ name: "ark", title: "BytePlus Ark", needs_key: true, blurb: "Uses BytePlus Ark's OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "BytePlus Ark API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "BytePlus Ark's Asia Pacific endpoint.", placeholder: "https://ark.ap-southeast.bytepluses.com/api/v3", default: "https://ark.ap-southeast.bytepluses.com/api/v3" }], configured: false, values: {}, suggested_models: ["dola-seed-evolving-latest-version", "dola-seed-2-1-turbo-260628"], key_set_at: null, last_used_at: null },
{ name: "ark-agent-plan-cn", title: "Volcengine Ark Agent Plan", needs_key: true, blurb: "Uses Volcengine Ark Agent Plan's OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Volcengine Ark Agent Plan API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Volcengine Ark Agent Plan's China (Beijing) endpoint.", placeholder: "https://ark.cn-beijing.volces.com/api/plan/v3", default: "https://ark.cn-beijing.volces.com/api/plan/v3" }], configured: false, values: {}, suggested_models: ["doubao-seed-evolving", "doubao-seed-2.1-turbo"], key_set_at: null, last_used_at: null },
// ollama: keyless local provider — "configured" without proving anything runs; the
// onboarding gallery shows "No key needed" and its form is endpoint + Detect (§39).
{ name: "ollama", title: "Ollama (local models)", needs_key: false, fields: [{ key: "base_url", label: "Endpoint", secret: false, required: false, help: "", placeholder: "http://127.0.0.1:11434", default: "http://127.0.0.1:11434" }], configured: true, values: {}, suggested_models: ["qwen3-coder:30b"], key_set_at: null, last_used_at: null },
// openai-codex: the subscription OAuth provider — no key form; the gallery card and
// form render sign-in state instead (auth: "oauth"). Starts signed out.
{ name: "openai-codex", title: "ChatGPT subscription", needs_key: false, auth: "oauth", signed_in: false, account: null, authorizing: false, last_error: null, blurb: "Sign in with your ChatGPT plan and run OpenAI models through your subscription — no API key. Tokens stay on this machine.", fields: [], configured: false, values: {}, suggested_models: ["gpt-5.6-sol"], key_set_at: null, last_used_at: null },
];
/** Install the API + WebSocket mocks on a page. Returns handles for assertions/seed data. */
export async function mockApi(page: import("@playwright/test").Page) {
// The rail defaults to HIDDEN (UX-038 follow-up). Existing specs were written
// against a visible rail, so run them in the "user opened it" state; the
// default + persistence themselves are pinned by rail-default.spec.ts.
await page.addInitScript(() => {
try {
if (!localStorage.getItem("ocw-e2e-rail-default")) {
localStorage.setItem("coworker:rail-hidden:v1", "0");
}
} catch { /* ignore */ }
});
const subscriptions: any[] = [
// One existing subscription (a non-pinned session) so the Slack page's per-workspace
// "Listening" row has an entry. Relay-mode channels are team-qualified (slack:T…/C…).
{ session_id: "wp-1", session_title: "Weekly plan 1", agent: "cowork", channel: "slack:T1DL/C0AAA111", channel_name: "ocw-test", routing_target: null, collision: false },
];
// Parked unauthorized messages (§19) — mutable so Allow/Dismiss round-trip through the UI.
// The relay is multi-workspace: parked items carry their team so the Slack page files them
// under the right workspace card.
const parked: any[] = [
{ id: "pk1", platform: "slack", chat_id: "C0AAA111", chat_name: "#ocw-test", user_id: "U0NEW", user_name: "Maya", chat_type: "channel", text: "hey ocw, can you summarize this thread?", ts: Date.now() / 1000 - 120, team_id: "T1DL" },
];
// Slack connector — PER-TEST state (managed relay, two workspaces) so allow/disconnect
// mutations never leak across tests sharing a worker. Backend parity: `workspaces` mirrors
// the slack:team:* profiles, each with its OWN allow-list.
const slackState = {
connected: true,
mode: "relay" as "" | "relay",
account: "deeplearning.ai",
allowed_users: [] as string[], // flat list (manual Socket Mode only)
approval_owner_ids: [] as string[],
workspaces: [
// T1DL mirrors a managed install: the installer (authed_user) was pre-added
// to the allow-list on connect (UX-027) — keys the "you" chip + setup card.
{ team_id: "T1DL", account: "deeplearning.ai", domain: "dlaiteam", allowed_users: ["U_ME"] as string[], allow_all: false, allowed_user_names: {} as Record<string, string | null>, approval_owner_ids: ["U_ME"] as string[], approval_owner_names: { U_ME: "Rohit Prasad" } as Record<string, string | null>, installer_user_id: "U_ME", installer_name: "Rohit Prasad" },
{ team_id: "T2AC", account: "acme-partners", domain: "acmehq", allowed_users: [] as string[], allow_all: false, allowed_user_names: {} as Record<string, string | null>, approval_owner_ids: [] as string[], approval_owner_names: {} as Record<string, string | null>, installer_user_id: "", installer_name: "" },
],
};
const slackConnector = () => ({
name: "slack", title: "Slack", icon: "#", blurb: "Two-way Slack messaging.",
auth: "bot_token", two_way: true, channels: true, available: true, brand_color: "#611f69", logo: "slack",
fields: [], instructions: [], connected: slackState.connected,
account: slackState.account, enabled: slackState.connected,
allowed_users: [...slackState.allowed_users],
approval_owner_ids: [...slackState.approval_owner_ids],
tools: [], managed: true,
managed_profile: slackState.mode === "relay", mode: slackState.mode,
workspaces: slackState.workspaces.map((w) => ({ ...w, allowed_users: [...w.allowed_users] })),
unauthorized: parked.map((x) => ({ ...x })),
});
// GitHub — PER-TEST multi-installation state (managed relay, one installation +
// one parked mention) mirroring the backend's github:install:<id> profiles.
const githubParked: any[] = [
{ id: "gh-pk1", platform: "github", chat_id: "acme/site#7", chat_name: "acme/site#7", user_id: "maya-dev", user_name: "maya-dev", chat_type: "channel", text: "@ocw please take a look at this flaky test", ts: Date.now() / 1000 - 90, team_id: "101" },
];
const githubState = {
connected: true,
mode: "relay" as "" | "relay",
installations: [
{ installation_id: "101", account_login: "acme", account_type: "Organization", repo_selection: "selected", github_login: "rohit-dev", allowed_users: ["rohit-dev"], allow_all: false },
],
};
const githubConnector = () => ({
name: "github", title: "GitHub", icon: "⌘", blurb: "Work with issues, pull requests, repository files, and CI status.",
auth: "token", two_way: true, channels: false, available: true, brand_color: "#1f2328", logo: "github",
fields: [{ key: "token", label: "Personal access token", secret: true, required: true, help: "", placeholder: "" }],
instructions: [], connected: githubState.connected,
account: githubState.installations[0]?.account_login ?? null,
enabled: githubState.connected, allowed_users: [], tools: [], managed: true,
managed_profile: githubState.mode === "relay", mode: githubState.mode,
installations: githubState.installations.map((i) => ({ ...i, allowed_users: [...i.allowed_users] })),
unauthorized: githubParked.map((x) => ({ ...x })),
});
// Gmail — PER-TEST multi-account state (starts disconnected; managed connects add
// mailboxes instantly, mirroring the backend's gmail:account:<email> profiles).
// NOTE: the real server currently sends managed_paused: true for the Google trio
// (CASA pending). The fixture keeps gmail UNPAUSED because the cloud-machinery specs
// use its one-click as their subject; the paused UI is covered by google-paused.spec.ts
// via a per-test connectors override.
const gmailState = {
accounts: [] as {
email: string; default: boolean; managed: boolean; scopes: string; needs_reauth: boolean;
}[],
filters: { senders: [] as string[], labels: [] as string[] },
};
const GMAIL_NEXT = ["rohit@gmail.com", "work@dlai.com", "third@x.com"];
const gmailConnector = () => {
const base = CONNECTORS.connectors.find((c: any) => c.name === "gmail");
return {
...base,
connected: gmailState.accounts.length > 0,
enabled: gmailState.accounts.length > 0,
account: gmailState.accounts.find((a) => a.default)?.email ?? null,
accounts: gmailState.accounts.map((a) => ({ ...a })),
filters: { senders: [...gmailState.filters.senders], labels: [...gmailState.filters.labels] },
};
};
// Google Calendar — PER-TEST multi-account state (gmail's shape, no filters).
const gcalState = {
accounts: [] as {
email: string; default: boolean; managed: boolean; scopes: string; needs_reauth: boolean;
}[],
};
const GCAL_NEXT = ["rohit@gmail.com", "work@dlai.com", "third@x.com"];
const gcalConnector = () => {
const base = CONNECTORS.connectors.find((c: any) => c.name === "google_calendar");
return {
...base,
connected: gcalState.accounts.length > 0,
enabled: gcalState.accounts.length > 0,
account: gcalState.accounts.find((a) => a.default)?.email ?? null,
accounts: gcalState.accounts.map((a) => ({ ...a })),
};
};
// Notion — PER-TEST generic multi-account state (accounts.py layer: AccountRow shape).
const notionState = {
accounts: [] as { account_id: string; name: string; default: boolean; managed: boolean }[],
};
const NOTION_NEXT = [
{ account_id: "ws-1", name: "Rohit's Workspace" },
{ account_id: "ws-2", name: "Ops Space" },
];
const notionConnector = () => {
const base = CONNECTORS.connectors.find((c: any) => c.name === "notion");
return {
...base,
connected: notionState.accounts.length > 0,
enabled: notionState.accounts.length > 0,
account: notionState.accounts.find((a) => a.default)?.name ?? null,
accounts: notionState.accounts.map((a) => ({ ...a })),
};
};
// Outlook — email-keyed managed accounts (mirrors outlook:account:<email> profiles).
const outlookState = {
accounts: [] as { account_id: string; name: string; default: boolean; managed: boolean }[],
};
// MCP-backed connectors (§42) — per-test connect state; the mock "browser flow"
// completes instantly so the modal's poll picks it up.
const mcpState = { monday: false, jira: false };
const mcpConnector = (name: "monday" | "jira") => {
const base = CONNECTORS.connectors.find((c: any) => c.name === name);
return {
...base,
connected: mcpState[name],
enabled: mcpState[name],
mode: mcpState[name] ? "mcp" : "",
};
};
const outlookConnector = () => {
const base = CONNECTORS.connectors.find((c: any) => c.name === "outlook");
return {
...base,
connected: outlookState.accounts.length > 0,
enabled: outlookState.accounts.length > 0,
account: outlookState.accounts.find((a) => a.default)?.name ?? null,
accounts: outlookState.accounts.map((a) => ({ ...a })),
};
};
// HubSpot — PER-TEST multi-portal state (starts disconnected; managed connects add
// portals instantly, mirroring the backend's hubspot:portal:<hub_id> profiles).
const hubspotState = {
portals: [] as {
hub_id: string; name: string; sandbox: boolean; default: boolean;
managed: boolean; access: string;
}[],
hidden_fields: [] as string[],
nextAccess: "read", // captured from the last connect-managed body
};
const HUBSPOT_NEXT = [
{ hub_id: "111", name: "Acme Inc", sandbox: false },
{ hub_id: "222", name: "Acme Sandbox", sandbox: true },
];
const hubspotConnector = () => {
const base = CONNECTORS.connectors.find((c: any) => c.name === "hubspot");
return {
...base,
connected: hubspotState.portals.length > 0,
enabled: hubspotState.portals.length > 0,
account: hubspotState.portals.find((p) => p.default)?.name ?? null,
portals: hubspotState.portals.map((p) => ({ ...p })),
hidden_fields: [...hubspotState.hidden_fields],
};
};
// Installed personas — mutable so enable/surface/delete round-trip through the UI.
const personas: any[] = PERSONAS.personas.map((p) => ({ ...p }));
// Sessions — mutable so archive (PATCH), rename (PATCH), and delete round-trip.
// UX-044: mutable binding state for the project-menu mocks.
const projectBindings: Record<string, string> = {};
const projectNames: Record<string, { name: string; key: string }[]> = {
memory: [
{ name: "openworker", key: "/k/openworker" },
{ name: "personal-ops", key: "/k/ops" },
],
board: [{ name: "aicreator-ops", key: "/k/aico" }],
};
const sessions: any[] = [
{ ...PINNED_SESSION },
...EXTRA_SESSIONS.map((s) => ({ ...s })),
{ ...OPS_SESSION },
{ ...LIVE_SESSION },
{ ...SLACK_SESSION },
];
// Inbox items + the outbound routing binding — mutable for resolve + the inline Slack config.
const inbox: any[] = INBOX_ITEMS.map((i) => ({ ...i }));
const routing: { name: string; channel: string | null; target: string } = {
name: "default",
channel: null,
target: "",
};
// Session roots — the primary (writable, non-removable) scratch plus any added folders. Mutable so
// the RO/RW add/toggle round-trips through the real UI. POST upserts by path (a toggle re-adds).
const roots: any[] = [{ ...PRIMARY_ROOT }];
// Session connections — PER-TEST copy so the Access section's mute toggle (POST) can flip
// `enabled` without leaking into sibling tests.
const connections = {
connected: CONNECTIONS.connected.map((c) => ({ ...c })),
recommended: CONNECTIONS.recommended.map((r) => ({ ...r })),
attention: CONNECTIONS.attention,
};
// Providers — mutable so save (POST) flips `configured` and stamps key_set_at, matching the
// backend's set_provider. verify (POST) never mutates: it's a live read-only credential check.
const providers: any[] = PROVIDERS.map((p) => ({ ...p }));
// Automations — mutable so Run now appends a run, enable/disable toggles, and delete removes.
const automations: any[] = [{ ...AUTOMATION }, { ...AUTOMATION_CLEAN }];
// MCP servers (empty by default; the granola OAuth quick-add test populates it).
const mcpServers: any[] = [];
const automationRuns: any[] = AUTOMATION_RUNS.map((r) => ({ ...r }));
// Per-session unattended flag — mutable so the composer's "Send to Inbox" toggle persists and
// the app reads it back (which is what gates parking approvals to the Inbox vs an inline card).
const unattended: Record<string, boolean> = {};
// Skills (SKILLS-SPEC) — mutable folder-is-truth mirror: Settings CRUD, the enabled flag,
// staged uploads (stage → preview → confirm), and the composer's per-session menu all
// round-trip through this one list.
const skills: any[] = [
{ name: "weekly-report", description: "Monday status report", instructions: "1. Collect updates\n2. Write it up", scope: "global", source: "local", enabled: true, path: "/state/skills/weekly-report", files: 0 },
{ name: "html-to-markdown", description: "Convert an HTML document or fragment to clean markdown.", instructions: "Convert the given HTML to markdown, preserving structure.", scope: "global", source: "uploaded", enabled: true, path: "/state/skills/html-to-markdown", files: 2 },
];
let stagedSkill: any = null;
// Agent teams (OPE-96): the session's board — empty until a test opts in by sending
// "plan the work" (the fake agent then files items; no draft state — the board only
// holds accepted work). Mutable so transitions round-trip through the real endpoints.
const boardItems: any[] = [];
// # team chat log — seeded with one lead question so mention highlighting renders.
const chatMessages: any[] = [
{
seq: 1,
ts: new Date().toISOString(),
author: "lead",
author_role: "lead",
text: "@nia does the api assume the assets bucket is public? quick check before you write it up.",
mentions: ["nia"],
},
];
// Pure notes added from the detail pane (never change state) — appended to
// the item's timeline so the pane reflects them after reload.
const itemNotes: Record<number, any[]> = {};
const seedBoard = () => {
if (boardItems.length) return;
boardItems.push(
{ id: 1, title: "Code security review — api", description: "", criteria: "every finding triaged with file:line evidence", state: "open", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 2, title: "Secrets — git history, both repos", description: "", criteria: "every hit dismissed-with-reason or rotation-instructed", state: "open", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 3, title: "Dependency audit — lockfiles", description: "", criteria: "reachable vs theoretical separated; upgrade branch green", state: "in_progress", assignee: "dep-audit", creator: "lead", refs: [], links: [] },
{ id: 4, title: "Cloud posture — infra", description: "", criteria: "trivy config clean or findings triaged", state: "blocked", assignee: "cloud-posture", creator: "lead", refs: [], links: [], blocker: "need tfvars for staging" },
{ id: 5, title: "Report rollup", description: "", criteria: "one report, all sections", state: "review", assignee: "security", creator: "lead", refs: [`attachment://${"a".repeat(64)}.png#rendered-page.png`], links: [] },
);
};
const boardPayload = () =>
boardItems.length
? { space: "/Users/test/OpenWorker/launch-note", name: "launch-note", items: boardItems }
: { space: null, name: "", items: [] };
// Fresh cloud sign-in state per test (module state outlives a page).
Object.assign(CLOUD_STATE, {
signed_in: false,
account: "",
user_id: "",
telemetry_enabled: true,
});
// The scripted fake agent behind the session WebSocket. Speaks the real event protocol
// ({type, data}), so the full send → stream → render loop and the approval round-trip run
// through the production code paths:
// · on connect: `ready`
// · user_message: turn_start (with input, exercising the foreground dedupe) → two
// assistant_deltas → assistant_message "Echo: <text>" → turn_done
// · a message containing "run a tool": tool_proposed + permission_required, then the turn
// SUSPENDS until the client's approval decision arrives (deny → skipped; else → ran)
// App-wide event stream: register the socket so sendAppEvent can push into it.
await page.routeWebSocket(/\/ws\/events$/, (ws) => {
eventSockets.set(page, ws);
});
await page.routeWebSocket(/\/ws\/session\//, (ws) => {
const send = (type: string, data: Record<string, unknown> = {}) =>
ws.send(JSON.stringify({ type, data }));
// The page's session id, from the socket URL — team approval stamps THIS session
// as the lead (the active conversation IS the lead; workers hang off it).
const sid = ws.url().split("/ws/session/")[1]?.split("?")[0] || "sess-lead";
send("ready", sid === "resume-live-1" ? { running: true } : {});
let pendingTool = "run_shell"; // which proposal the next approval decision resolves
let epicTimer: ReturnType<typeof setInterval> | null = null; // the slow stream, stoppable via interrupt
let hadTurn = false; // a user_message landed — set_model is now a mid-session switch
ws.onMessage((raw) => {
const msg = JSON.parse(String(raw));
if (msg.type === "user_message") {
hadTurn = true;
// Force-run (SKILLS-SPEC §6): like the real server, TURN_START ships the user's
// literal "/name …" line as `display` so the client dedupes on what the user sees.
send("turn_start", {
input: msg.text,
...(msg.skill ? { display: `/${msg.skill}${msg.text ? ` ${msg.text}` : ""}` } : {}),
});
if (/trip the reviewer/i.test(msg.text)) {
send("tool_proposed", { name: "run_shell", arguments: { command: "semgrep scan" } });
send("tool_finished", {
name: "run_shell",
status: "denied",
reason: "blocked by the safety reviewer",
reviewer_reason: "This creates files even though you asked not to.",
allow_anyway: true,
reviewer_paused:
"Auto-approve is paused for the rest of this turn — the reviewer blocked 5 actions in a row, so approvals now come to you.",
});
return; // turn stays open: the pause is a mid-turn state
}
if (/run an unsure tool/i.test(msg.text)) {
// The Auto-Approve reviewer answered `unsure`: the card carries its reason.
pendingTool = "run_shell";
send("tool_proposed", { name: "run_shell", arguments: { command: "python3 helper.py" } });
send("permission_required", {
name: "run_shell",
arguments: { command: "python3 helper.py" },
reason: "requires approval",
reviewer_unsure: "This runs a newly created script whose effects cannot be determined from the command.",
});
return; // suspended on the approval
}
if (/run a tool/i.test(msg.text)) {
pendingTool = "run_shell";
send("tool_proposed", { name: "run_shell", arguments: { command: "ls" } });
send("permission_required", {
name: "run_shell",
arguments: { command: "ls" },
reason: "The coworker wants to run a command.",
readonly_ok: true, // `ls` classifies read-only server-side
});
return; // suspended on the approval
}
// Agent teams: the decomposition gate — the lead proposes work items and
// SUSPENDS until the items_response verdict arrives (approval creates them).
// A board wake arriving on this session: the digest rides `source` with
// structured rows — the BoardWakeCard renders collapsed by default.
if (/board wake/i.test(msg.text)) {
send("turn_start", {
source: {
connector: "board",
kind: "channel",
channel_id: "/Users/test/OpenWorker/launch-note",
channel_name: "Team board",
sender_id: "board",
sender_name: "Board",
ts: Date.now() / 1000,
text: "⏰ Board wake — your team needs decisions:\n- #2 moved to review by webb",
board: {
rows: [
{
kind: "moved",
item: 2,
title: "Statements page",
actor: "webb",
to: "review",
note: "Ready for review on feat/customer-statements, commit 029f9f7. Build verified; final verdict stays with the tester.",
},
{ kind: "filed", item: 5, title: "Follow-up: rate limit", actor: "nia" },
],
},
},
});
send("assistant_message", { text: "Reviewing the hand-off now." });
send("turn_done");
return;
}
if (/propose the split/i.test(msg.text)) {
send("items_proposed", {
items: [
{ title: "Statement API endpoint", criteria: "returns opening/closing balances over the chosen range; 8 endpoint tests green; malformed, missing, and reversed date ranges return 400; draft invoices are excluded from issued totals; inclusive boundaries verified end to end" },
{ title: "Statements dashboard page", criteria: "renders seeded data for Ada / Northgate; empty + error states covered" },
{ title: "Statement totals reconcile", criteria: "running balance matches invoices minus payments for the range" },
{ title: "Verification pass", criteria: "tester confirms page renders with live API data" },
],
note: "Shared journal case: statements.",
});
return; // suspended on the items decision
}
// Agent teams (OPE-97): the staffing gate — the lead proposes a roster and
// SUSPENDS until the team_response verdict arrives.
if (/staff the team/i.test(msg.text)) {
send("team_proposed", {
members: [
{ persona: "swe-worker", name: "nia", model: "anthropic:claude-opus-4-8", reason: "implementation" },
{ persona: "design-worker", name: "webb", reason: "UI polish" },
{ persona: "test-worker", name: "checks", reason: "verifies against acceptance criteria" },
],
enable_chat: false,
note: "Three workers cover the plan; checks verifies before anything closes.",
});
return; // suspended on the staffing decision
}
// Agent teams (OPE-96): a decomposition turn — the plan was approved in
// conversation (plan-approval flow); the agent files the items and the
// board rail appears on the next board fetch.
if (/plan the work/i.test(msg.text)) {
seedBoard();
send("assistant_message", {
text: "Plan approved — filed 5 work items on the board.",
});
send("turn_done");
return;
}
// A deliverable turn ending in an artifact chip (§34) — for the chip-open flow.
if (/show the report/i.test(msg.text)) {
send("assistant_message", {
text: "Done — [Security review](artifact:reports/security-review.html)",
});
send("turn_done");
return;
}
// The pre-fix payload shape (owner-hit 2026-08-14): no installable/version/summary
// — an older sidecar, or any surface that forgets the field. Must render NOT
// installable, never a guessed Install offer.
if (/request an unpinned tool/i.test(msg.text)) {
send("tool_requested", {
name: "somescanner",
reason: "scan the Terraform for misconfigurations",
});
return; // suspended on the tool request
}
// OPE-85: the agent hits a missing scanner and asks instead of skipping the check.
if (/scan for secrets/i.test(msg.text)) {
send("tool_requested", {
name: "gitleaks",
reason: "scan the git history for committed secrets",
installable: true,
version: "8.30.1",
summary: "scans git history and the working tree for committed secrets",
source: "github.com/gitleaks",
});
return; // suspended on the tool request
}
// §35 compact row: a routine workspace write (content rides in the args).
if (/write a file/i.test(msg.text)) {
pendingTool = "write_file";
const args = {
path: "src/fetch_data.py",
content: "import json\nimport urllib.request\n\ncompanies = [\"NVDA\", \"AMD\"]\nprint(len(companies))\ndone = True",
};
send("tool_proposed", { name: "write_file", arguments: args });
send("permission_required", { name: "write_file", arguments: args, reason: "" });
return; // suspended on the approval
}
// A one-paragraph digest with NO newlines — the owner-repro shape that once
// ballooned the card to full-transcript height (char clamp, 2026-07-15).
if (/post the long digest/i.test(msg.text)) {
pendingTool = "send_message";
const args = {
target: "slack:T1/C1",
text:
"aisuite — last 24 hours of work (through Jul 15): 5 PRs merged covering chat-completion streaming with unified chunks across providers, multimodal input conversion, Slack collaboration improvements, human attribution for outbound posts, and repo-wide formatting. ".repeat(
6,
),
};
send("tool_proposed", { name: "send_message", arguments: args });
send("permission_required", { name: "send_message", arguments: args, reason: "", category: "messaging" });
return;
}
// Standing scoped approvals (§25): an eligible connector-ish write — the event
// carries the pinnable target, exactly like the real engine computes it.
if (/post the digest/i.test(msg.text)) {
pendingTool = "send_message";
send("tool_proposed", {
name: "send_message",
arguments: { target: "slack:T1/C1", text: "Weekly digest ready" },
});
send("permission_required", {
name: "send_message",
arguments: { target: "slack:T1/C1", text: "Weekly digest ready" },
reason: "",
category: "messaging",
standing_target: "slack:T1/C1",
});
return;
}
// §25 consent card: the agent proposes the automation's permission set on the
// gated create call; the existing approval card renders disclosure/grant lines.
if (/create an automation/i.test(msg.text)) {
pendingTool = "create_scheduled_task";
send("tool_proposed", { name: "create_scheduled_task", arguments: {} });
send("permission_required", {
name: "create_scheduled_task",
arguments: {
title: "Weekly digest",
instructions: "Summarize the week and post it.",
cron: "0 9 * * 1",
permissions: [
{ tool: "send_message", target: "slack:T1/C1", access: "write" },
{ tool: "github_list_commits", target: "rohit/agent-platform", access: "read" },
],
},
reason: "",
category: "automation",
});
return;
}
// A reasoning model's turn: thinking deltas tick in slowly, then the answer —
// the assistant_message carries the full trace like the real engine's payload.
if (/think hard/i.test(msg.text)) {
const thoughts = ["Weighing options. ", "Comparing tradeoffs. ", "Settling it. "];
let tick = 0;
const timer = setInterval(() => {
if (tick < thoughts.length) {
send("reasoning_delta", { text: thoughts[tick] });
tick += 1;
return;
}
clearInterval(timer);
send("assistant_delta", { text: "Decision made." });
send("assistant_message", {
text: "Decision made.",
reasoning: thoughts.join(""),
});
send("turn_done");
}, 120);
return;
}
// Auto-compaction (OPE-27): the server signals `compacting` (the transient
// spinner label), summarizes for a beat, then emits the marker and the turn
// continues normally — the divider must render inline.
if (/compact the context/i.test(msg.text)) {
send("compacting", {});
setTimeout(() => {
send("compacted", { text: "Context compacted — earlier turns were summarized" });
send("assistant_message", { text: "Still on it — continuing where I left off." });
send("turn_done");
}, 400);
return;
}
// A turn that dies on a provider error; the follow-up {type:"retry"} recovers.
if (/fail the turn/i.test(msg.text)) {
send("error", { error: "model unreachable" });
send("turn_done");
return;
}
// A deliberately SLOW multi-second stream (~40 ticks × 120ms) so specs can
// interact mid-turn — the follow/pin scroll contract (FB-004) is untestable
// against the instant echo below.
if (/stream the epic/i.test(msg.text)) {
let ticks = 0;
const line = "The epic scrolls ever onward, line upon line upon line. ";
epicTimer = setInterval(() => {
ticks += 1;
send("assistant_delta", { text: line.repeat(3) + "\n\n" });
if (ticks >= 40) {
clearInterval(epicTimer!);
epicTimer = null;
send("assistant_message", { text: ("The epic concludes. " + line).repeat(20) });
send("turn_done");
}
}, 120);
return;
}
send("assistant_delta", { text: "Echo: " });
send("assistant_delta", { text: msg.text });
// Echo the model the message carried — pins the model-per-message contract (the
// composer's visible model must ride on every user_message; 2026-07-04 fix).
// Same for `skill`: the force-run pick must ride as its OWN FIELD, never as text.
// `usage` mirrors the real engine's assistant_message sidecar (OPE-42): fixed
// counts per turn so the usage-chip specs can assert exact accumulation.
send("assistant_message", {
text: `Echo: ${msg.text} [model=${msg.model || "none"}]${msg.skill ? ` [skill=${msg.skill}]` : ""}`,
usage: {
model: msg.model || "anthropic:claude-opus-4-8",
input: 1_000,
output: 200,
cache_read: 8_000,
cache_write: 800,
},
});
send("turn_done");
} else if (msg.type === "approval") {
if (pendingTool === "run_shell") {
if (msg.decision === "deny") {
send("tool_finished", { name: "run_shell", status: "denied" });
send("assistant_message", { text: "Understood — skipped the command." });
} else {
send("tool_finished", { name: "run_shell", status: "done", result_preview: "README.md" });
send("assistant_message", { text: "The command ran; 1 file found." });
}
} else if (msg.decision === "deny") {
send("tool_finished", { name: pendingTool, status: "denied" });
send("assistant_message", { text: "Understood — skipped it." });
} else {
send("tool_finished", { name: pendingTool, status: "done", result_preview: "ok" });
// The decision echoes back so specs can pin what rode the wire (e.g. always_task).
send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` });
}
send("turn_done");
} else if (msg.type === "items_response") {
if (msg.approved) {
seedBoard(); // "created on the board" — the board fetch now shows them
send("assistant_message", {
text: "Items created on the board — [Board · 5 items](board:) if you want to watch. Staffing next.",
});
} else {
send("assistant_message", { text: "Understood — reworking the split." });
}
send("turn_done");
} else if (msg.type === "team_response") {
if (msg.approved) {
// Server-side create_team pre-spawned the workers. The ACTIVE session IS
// the lead (seventeenth pass): stamp it in the sessions list — RECENT keeps
// this ONE entry — and hang the workers off it for the drawer's Team panel.
let lead = sessions.find((s) => s.session_id === sid);
if (!lead) {
lead = {
session_id: sid,
workspace: "/Users/test/OpenWorker/launch-note",
agent: "cowork",
model: "m",
mode: "interactive",
messages: 2,
};
sessions.unshift(lead);
}
lead.title = "Build the statements page";
lead.updated_at = new Date().toISOString();
lead.team = {
role: "lead",
team_id: "t1",
chat_enabled: !!msg.enable_chat,
chat_unread: msg.enable_chat ? 1 : 0,
};
// The lead sets its check-in timer after staffing — it shows as sleeping.
lead.liveness = "sleeping";
lead.sleeping_until = new Date(Date.now() + 4 * 60_000).toISOString();
for (const [actor, persona, status, item] of [
["nia", "swe-worker", "in_progress", "#1 in progress"],
["webb", "design-worker", "idle", "idle"],
["checks", "test-worker", "blocked", "#4 blocked"],
] as const) {
sessions.push({
session_id: `sess-${actor}`,
title: actor,
workspace: "/Users/test/OpenWorker/launch-note",
agent: persona,
model: "m",
mode: "interactive",
updated_at: new Date().toISOString(),
messages: 0,
team: {
role: "worker",
team_id: "t1",
lead_session: sid,
actor,
status,
current_item: item,
},
});
}
send("assistant_message", {
text: "Team created — nia, webb and checks are standing by. Assigning items now.",
});
} else {
send("assistant_message", { text: "Understood — tell me how to change the roster." });
}
send("turn_done");
} else if (msg.type === "tool_response") {
// Either way the turn continues — the point of the contract is that declining
// degrades the report openly instead of dropping the check.
if (msg.approved) {
send("assistant_message", {
text: "Installed gitleaks 8.30.1 — scanned history, no secrets found.",
});
} else {
send("assistant_message", {
text: "Skipped gitleaks. Coverage: history secret sweep done by hand instead.",
});
}
send("turn_done");
} else if (msg.type === "interrupt") {
// Stop mid-stream: like the real engine, end the turn with `interrupted` and
// NO assistant_message — the client owns promoting the partial into the transcript.
if (epicTimer) {
clearInterval(epicTimer);
epicTimer = null;
}
send("interrupted", {});
send("turn_done");
} else if (msg.type === "set_mode") {
// Mirrors the server: full explainer the FIRST time a session enters
// Auto-Approve, a one-line marker for every later change.
const anyWs = ws as any;
if (msg.mode === "auto-approve" && !anyWs.__modeNoticeShown) {
anyWs.__modeNoticeShown = true;
send("mode_notice", {
title: "Auto-approve is on.",
text:
"Auto-approve uses a model to let routine actions through without asking; " +
"anything it isn't sure about still comes to you. It cuts interruptions but " +
"still carries some risk i.e. a command it allows still reaches anything you " +
"can. These are model judgments, and not guarantees.",
});
} else {
const labels: Record<string, string> = {
discuss: "Discuss",
interactive: "Ask for approval",
"bypass-approvals": "Bypass approvals",
"auto-approve": "Auto-approve",
};
send("mode_notice", { text: `${labels[msg.mode] || msg.mode} is on.` });
}
} else if (msg.type === "set_model") {
// Mid-session switch: the server applies it and broadcasts the persisted marker.
// Like the real server, the FIRST bind (fresh session) is silent.
if (hadTurn)
send("model_changed", {
model: msg.model,
text: `Model switched to ${msg.model}`,
});
} else if (msg.type === "retry") {
// Like the real engine: re-runs with NO new user message (turn_start input is empty).
send("turn_start", { input: "" });
send("assistant_message", { text: "Recovered after retry." });
send("turn_done");
}
});
});
await page.route("**/v1/**", async (route) => {
const req = route.request();
const p = new URL(req.url()).pathname;
const m = req.method();
const json = (body: unknown, status = 200) =>
route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
// session-scoped (id-agnostic — any session resolves to the same fixture).
// POST = the per-session mute override (§32 Access toggles) — flip the shared state so
// the section's reload sees the change.
if (/\/v1\/sessions\/[^/]+\/connections$/.test(p)) {
if (m === "POST") {
const b = req.postDataJSON() || {};
const row = connections.connected.find((c) => c.connector === b.connector);
if (row) row.enabled = !!b.enabled;
return json({ ok: true });
}
return json(connections);
}
// UX-044 project bindings: stateful per-run so specs can bind/unbind/name.
if (/\/v1\/sessions\/[^/]+\/project-menu$/.test(p)) {
const kind = new URL(req.url()).searchParams.get("kind") || "memory";
return json({
kind,
bound: projectBindings[kind] ?? null,
derived: {
kind: "folder",
label: "~/fleet/ro4d/demo-universe/notes",
full: "/Users/u/fleet/ro4d/demo-universe/notes",
key: "/Users/u/fleet/ro4d/demo-universe/notes",
},
named: projectNames[kind] || [],
});
}
if (/\/v1\/sessions\/[^/]+\/bindings$/.test(p) && m === "PUT") {
const b = req.postDataJSON() || {};
if (b.name) projectBindings[b.kind] = b.name;
else delete projectBindings[b.kind];
return json({ ok: true, bindings: projectBindings });
}
if (/\/v1\/sessions\/[^/]+\/project-name$/.test(p) && m === "POST") {
const b = req.postDataJSON() || {};
(projectNames[b.kind] ||= []).unshift({ name: b.name, key: "/Users/u/fleet/ro4d/demo-universe/notes" });
return json({ ok: true, kind: b.kind, name: b.name });
}
if (/\/v1\/sessions\/[^/]+\/roots$/.test(p)) {
if (m === "POST") {
const b = req.postDataJSON();
const existing = roots.find((r) => r.path === b.path);
if (existing) existing.writable = !!b.writable;
else roots.push({ path: b.path, writable: !!b.writable, label: baseName(b.path), primary: false, exists: true });
return json({ ok: true, roots });
}
if (m === "DELETE") {
const rp = new URL(req.url()).searchParams.get("path");
const i = roots.findIndex((r) => r.path === rp && !r.primary);
if (i >= 0) roots.splice(i, 1);
return json({ ok: true, roots });
}
return json({ roots });
}
// Artifacts (OPE-91): one HTML report whose content actively probes the sandbox —
// an inline script that renders proof-of-execution, a parent-window escape attempt,
// and an external subresource that must be CSP-blocked.
if (/\/v1\/sessions\/[^/]+\/artifacts\/read$/.test(p)) {
const reqPath = new URL(req.url()).searchParams.get("path") || "";
// UX-037 Files: a session root (or subfolder) reads as a folder listing.
const rootHit = roots.find((r) => reqPath === r.path);
if (rootHit) {
return json({
ok: true,
path: reqPath,
kind: "folder",
entries: [
{ name: "reports", dir: true, size: 0 },
{ name: "notes.md", dir: false, size: 128 },
],
});
}
if (reqPath.endsWith("/reports")) {
return json({
ok: true,
path: reqPath,
kind: "folder",
entries: [{ name: "security-review.html", dir: false, size: 2048 }],
});
}
if (reqPath.endsWith("notes.md")) {
return json({ ok: true, path: reqPath, kind: "markdown", content: "# Notes\n\nhello from the explorer" });
}
return json({
ok: true,
path: "reports/security-review.html",
kind: "html",
content: [
"<h1>Security review</h1>",
'<div id="probe">script did not run</div>',
"<script>",
' document.getElementById("probe").textContent = "script ran in sandbox";',
" try { window.parent.document.title = 'ESCAPED'; } catch (e) {",
' document.getElementById("probe").textContent += " · parent blocked";',
" }",
"</script>",
'<img src="https://evil.example/exfil.png" onerror="document.getElementById(\'probe\').textContent += \' · network blocked\'">',
].join("\n"),
});
}
if (/\/v1\/sessions\/[^/]+\/artifacts\/reveal$/.test(p)) return json({ ok: true });
// Item detail (merged event timeline + attachments) for the detail pane.
if (/\/v1\/sessions\/[^/]+\/board\/item$/.test(p)) {
const id = Number(new URL(req.url()).searchParams.get("id"));
const item = boardItems.find((i) => i.id === id);
if (!item) return json({ error: "no such item" });
const at = new Date().toISOString();
const timeline =
id === 5
? [
{ seq: 30, ts: at, actor: "lead", kind: "created" },
{ seq: 31, ts: at, actor: "lead", kind: "assigned", assignee: "security" },
{ seq: 32, ts: at, actor: "security", kind: "moved", to: "in_progress" },
{
seq: 41,
ts: at,
actor: "security",
kind: "comment",
body: "Rolled all four sections into report.md — balances reconcile against the seeded rows.",
},
{
seq: 42,
ts: at,
actor: "security",
kind: "comment",
body: "attached the rendered page",
refs: [`attachment://${"a".repeat(64)}.png#rendered-page.png`],
},
{
seq: 43,
ts: at,
actor: "security",
kind: "moved",
to: "review",
body: "Ready — balances verified against seeded rows.",
},
]
: [{ seq: 30, ts: at, actor: "lead", kind: "created" }];
return json({ ...item, timeline: timeline.concat(itemNotes[id] || []) });
}
if (/\/v1\/sessions\/[^/]+\/board\/comment$/.test(p) && m === "POST") {
const b = req.postDataJSON() || {};
const id = Number(b.item);
(itemNotes[id] = itemNotes[id] || []).push({
seq: 90 + (itemNotes[id]?.length || 0),
ts: new Date().toISOString(),
actor: "user",
kind: "comment",
body: String(b.body || ""),
});
return json({ ok: true });
}
if (/\/v1\/sessions\/[^/]+\/board\/attachment$/.test(p)) {
// A real 1x1 PNG so the <img> actually loads (the spec asserts it renders).
return route.fulfill({
status: 200,
contentType: "image/png",
body: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
),
});
}
// Agent teams (OPE-96): board reads + the user-side mutations.
if (/\/v1\/sessions\/[^/]+\/board\/transition$/.test(p)) {
const b = req.postDataJSON() || {};
const item = boardItems.find((i) => i.id === Number(b.item));
if (!item) return json({ error: "no such item" });
item.state = String(b.to);
return json(item);
}
if (/\/v1\/sessions\/[^/]+\/board$/.test(p)) return json(boardPayload());
// # team chat (OPE-99): one group, message log, user posts append.
if (/\/v1\/teams\/[^/]+\/chat$/.test(p)) {
if (m === "POST") {
const b = req.postDataJSON() || {};
chatMessages.push({
seq: chatMessages.length + 1,
ts: new Date().toISOString(),
author: "user",
author_role: "user",
text: String(b.text || ""),
mentions: ["nia", "webb", "checks", "lead"].filter((h) =>
String(b.text || "").includes(`@${h}`),
),
});
return json(chatMessages[chatMessages.length - 1]);
}
return json({
enabled: true,
team_id: "t1",
members: [
{ name: "nia", persona: "swe-worker", role: "worker" },
{ name: "webb", persona: "design-worker", role: "worker" },
{ name: "checks", persona: "test-worker", role: "worker" },
{ name: "lead", persona: "swe-lead", role: "lead" },
],
messages: chatMessages,
});
}
if (p.endsWith("/v1/teams/journal")) {
return json({
cases: boardItems.length
? [{ case: "findings", entries: 12, last_ts: new Date().toISOString() }]
: [],
});
}
if (/\/v1\/sessions\/[^/]+\/artifacts$/.test(p)) {
return json({
artifacts: [
{
path: "reports/security-review.html",
abs_path: "/Users/test/OpenWorker/launch-note/reports/security-review.html",
name: "security-review.html",
kind: "html",
size: 2048,
modified_at: Math.floor(Date.now() / 1000) - 60,
},
],
});
}
if (/\/v1\/sessions\/[^/]+\/messages$/.test(p)) return json({ messages: [] });
if (/\/v1\/sessions\/[^/]+\/unattended$/.test(p)) {
const id = decodeURIComponent(p.split("/").slice(-2)[0]);
if (m === "POST") {
unattended[id] = !!req.postDataJSON().unattended;
return json({ ok: true, unattended: unattended[id] });
}
return json({ unattended: !!unattended[id] });
}
if (/\/v1\/sessions\/[^/]+$/.test(p)) {
const id = decodeURIComponent(p.split("/").pop()!);
const i = sessions.findIndex((s) => s.session_id === id);
if (m === "PATCH") {
if (i >= 0) Object.assign(sessions[i], req.postDataJSON());
return json({ ok: true });
}
if (m === "DELETE") {
if (i >= 0) sessions.splice(i, 1);
return json({ ok: true });
}
return json(i >= 0 ? sessions[i] : PINNED_SESSION);
}
// Skills (SKILLS-SPEC §5/§6). Order matters: upload/confirm before the {name} regexes.
if (/\/v1\/sessions\/[^/]+\/skills$/.test(p)) {
// The composer's live menu: every Settings-enabled skill (§4 — disabled = invisible).
return json({
skills: skills
.filter((s) => s.enabled)
.map((s) => ({ name: s.name, description: s.description, scope: s.scope, enabled: true })),
});
}
if (p.endsWith("/v1/skills/upload/confirm") && m === "POST") {
const b = req.postDataJSON() || {};
if (!stagedSkill || b.token !== stagedSkill.token)
return json({ ok: false, error: "Upload expired — pick the file again." });
skills.push({
name: stagedSkill.name, description: stagedSkill.description,
instructions: stagedSkill.instructions, scope: "global", source: "uploaded",
enabled: true, path: `/state/skills/${stagedSkill.name}`, files: stagedSkill.files.length,
});
stagedSkill = null;
return json({ ok: true });
}
if (p.endsWith("/v1/skills/upload") && m === "POST") {
// Stage → preview; nothing lands until confirm. Fixed parse (the mock reads no zips).
stagedSkill = {
token: "stage-1", name: "greet", description: "says hello",
instructions: "Say hello warmly.", files: ["notes.txt"],
};
return json({ ok: true, ...stagedSkill });
}
{
const mr = p.match(/\/v1\/skills\/([^/]+)\/reveal$/);
if (mr && m === "POST") {
return json(
skills.some((s) => s.name === decodeURIComponent(mr[1]))
? { ok: true }
: { ok: false, error: `Unknown skill: ${decodeURIComponent(mr[1])}` },
);
}
}
if (/\/v1\/skills\/[^/]+$/.test(p) && (m === "PATCH" || m === "DELETE")) {
const name = decodeURIComponent(p.split("/").pop()!);
const i = skills.findIndex((s) => s.name === name);
if (i < 0) return json({ ok: false, error: `Unknown skill: ${name}` });
if (m === "DELETE") {
skills.splice(i, 1);
return json({ ok: true });
}
const b = req.postDataJSON() || {};
if (typeof b.enabled === "boolean") skills[i].enabled = b.enabled;
if (typeof b.description === "string") skills[i].description = b.description;
if (typeof b.instructions === "string") skills[i].instructions = b.instructions;
return json({ ok: true });
}
if (p.endsWith("/v1/skills") && m === "POST") {
const b = req.postDataJSON() || {};
if (!b.name || !(b.instructions || "").trim())
return json({ ok: false, error: "Skill name and instructions are required." });
if (skills.some((s) => s.name === b.name))
return json({ ok: false, error: `A skill named '${b.name}' already exists in that scope.` });
skills.push({
name: b.name, description: b.description || "", instructions: b.instructions,
scope: "global", source: "local", enabled: true, path: `/state/skills/${b.name}`, files: 0,
});
return json({ ok: true });
}
if (p.endsWith("/v1/skills")) return json({ skills });
if (p.endsWith("/v1/health")) return json(HEALTH);
if (p.endsWith("/v1/settings")) return json(SETTINGS);
if (p.endsWith("/v1/settings/context-bar") && m === "POST") {
Object.assign(SETTINGS, req.postDataJSON());
return json({ ok: true, context_bar: SETTINGS.context_bar });
}
if (p.endsWith("/v1/settings/pdf") && m === "POST") {
Object.assign(SETTINGS, req.postDataJSON());
return json({
ok: true,
pdf_fallback: SETTINGS.pdf_fallback,
pdf_max_pages: SETTINGS.pdf_max_pages,
pdf_max_mb: SETTINGS.pdf_max_mb,
});
}
if (p.endsWith("/v1/attachments/inspect-pdf") && m === "POST") {
// Page count for the composer threshold check: the tests encode it in the PDF body
// as "%%pages=N" (the mock doesn't parse real PDFs).
const data = String(req.postDataJSON()?.data_url || "");
const match = /%%pages=(\d+)/.exec(atob(data.split(",")[1] || "") || "");
return json({ ok: true, pages: match ? Number(match[1]) : 1, bytes: data.length });
}
if (p.endsWith("/v1/workspaces/recent")) return json({ workspaces: [] });
if (p.endsWith("/v1/workspaces/pick") && m === "POST") {
return json({ ok: true, path: "/tmp/picked-folder" });
}
if (p.endsWith("/v1/workspaces/open") && m === "POST") {
const b = req.postDataJSON();
return json({ ok: true, path: b.path, git_branch: "main" });
}
if (p.endsWith("/v1/workspaces/temp") && m === "POST") {
// UX-029: "Start in a temporary folder" — created at send time, git-ready.
const b = req.postDataJSON();
return json({ ok: true, path: `/tmp/ow-temp/${b.session_id}`, git: b.git !== false });
}
if (/\/v1\/sessions\/[^/]+\/save-as-project$/.test(p) && m === "POST") {
const b = req.postDataJSON();
return json({ ok: true, path: b.path });
}
if (/\/v1\/personas\/[^/]+\/export$/.test(p) && m === "POST") {
// Sharing v1: export the bundle zip into the chosen folder.
const id = p.split("/").slice(-2)[0];
const b = req.postDataJSON();
return json({ ok: true, path: `${b.dir}/${id}-coworker-v1.zip` });
}
// must precede the /v1/personas/{id} catch-all (install matches it too)
if (p.endsWith("/v1/personas/install") && m === "POST") {
const b = req.postDataJSON();
if (b.zip_b64) {
// Sharing v1: a bundle zip import — consent with version + replaces + recommends.
const imported = {
id: "team-sec", name: "Team Security Coworker", icon: "shield",
tagline: "Our security playbook", requires_folder: true, builtin: false,
tools: ["code_files", "search", "shell"],
enabled: false, surfaced: false, default: false, version: "2",
};
if (!personas.some((x) => x.id === "team-sec")) personas.push(imported);
return json({
ok: true,
personas,
consent: [{
id: "team-sec", name: "Team Security Coworker",
description: "Reviews code the way our team does.",
tools: ["code_files", "search", "shell"],
risk: ["read", "write_local", "exec"],
connectors: true, mcp: [], messaging: false,
recommended_mode: "interactive", recommended_models: [],
recommends: [{ kind: "connector", ref: "github", reason: "open fix PRs", tier: "core" }],
version: "2",
replaces: { version: "1", installed_at: "2026-08-01", capabilities_grew: true },
source: "/tmp/team-sec.zip", builtin: false,
}],
});
}
if (b.gallery_slug) {
return json(
CLOUD_STATE.signed_in
? { ok: true, consent: [{ id: b.gallery_slug }], personas }
: { ok: false, error: "gallery requires cloud sign-in" },
);
}
return json({ ok: false, error: "unsupported in mock" });
}
if (/\/v1\/personas\/[^/]+$/.test(p) && m === "POST") {
// Persona flag update (enabled/surfaced/default). Backend parity: enabling implies
// surfacing (registry.set_enabled sets surfaced — the PM-invisible bug fix).
const id = p.split("/").pop();
const t = personas.find((x) => x.id === id);
if (!t) return json({ ok: false, error: `unknown persona: ${id}` });
const b = req.postDataJSON();
if (b.default) personas.forEach((x) => (x.default = x.id === id));
let archivedCount = 0;
if (typeof b.enabled === "boolean") {
t.enabled = b.enabled;
if (b.enabled) t.surfaced = true;
// Backend parity (disable-archives, §18): disabling archives the persona's real
// sessions server-side, so its sidebar section disappears with it.
if (!b.enabled) {
for (const s of sessions) {
if (s.agent === id && !s.archived && !s.session_id.startsWith("__")) {
s.archived = true;
archivedCount++;
}
}
}
}
if (typeof b.surfaced === "boolean") t.surfaced = b.surfaced;
return json({ ok: true, personas, archived_sessions: archivedCount });
}
if (/\/v1\/personas\/[^/]+$/.test(p) && m === "DELETE") {
const id = p.split("/").pop();
const i = personas.findIndex((x) => x.id === id && !x.builtin);
if (i < 0) return json({ ok: false, error: `unknown persona: ${id}` });
personas.splice(i, 1);
return json({ ok: true, personas });
}
if (/\/v1\/personas\/[^/]+$/.test(p)) {
// Detail merges the live list row over the static shape, so enable/surface/default
// state and builtin-ness track the same mutable array the list serves.
const id = decodeURIComponent(p.split("/").pop() || "");
const base = personas.find((x) => x.id === id);
return json({
...PERSONA_DETAIL,
media: [],
surfaced: true,
default: false,
builtin: true,
group: "general",
...(base || {}),
recommends: PERSONA_DETAIL.recommends,
default_connections: PERSONA_DETAIL.default_connections,
});
}
if (p.endsWith("/v1/personas")) return json({ internal: PERSONAS.internal, personas });
if (p.endsWith("/v1/sessions")) return json({ sessions });
if (/\/v1\/connectors\/slack\/unauthorized\/[^/]+$/.test(p) && m === "POST") {
const id = p.split("/").pop();
const i = parked.findIndex((x) => x.id === id);
if (i < 0) return json({ ok: false, error: "unknown item" });
const b = req.postDataJSON();
const item = parked.splice(i, 1)[0];
// Backend parity: allowing routes to the item's OWN workspace's list (ids are
// workspace-scoped); a team-less item lands on the flat list (manual mode).
if (b.action === "allow" || b.action === "allow_deliver") {
const pool = item.team_id
? slackState.workspaces.find((w) => w.team_id === item.team_id)?.allowed_users
: slackState.allowed_users;
if (pool && !pool.includes(item.user_id)) pool.push(item.user_id);
}
return json({ ok: true });
}
// Per-workspace allow/disallow (team_id in the body) + the flat manual list without it.
if (/\/v1\/connectors\/slack\/(allow|disallow)$/.test(p) && m === "POST") {
const b = req.postDataJSON();
const add = p.endsWith("/allow");
const ws = b.team_id
? slackState.workspaces.find((w) => w.team_id === b.team_id)
: null;
const pool = b.team_id ? ws?.allowed_users : slackState.allowed_users;
if (!pool) return json({ ok: false, error: "workspace not connected" });
const i = pool.indexOf(b.user_id);
if (add && i < 0) pool.push(b.user_id);
if (!add && i >= 0) pool.splice(i, 1);
// Directory picks carry the display name — backend seeds the people directory.
if (add && b.name && ws) ws.allowed_user_names[b.user_id] = b.name;
return json({ ok: true, allowed_users: [...pool], team_id: b.team_id ?? null });
}
if (p.endsWith("/v1/connectors/slack/approval-owners/add") && m === "POST") {
const b = req.postDataJSON();
if (!slackState.approval_owner_ids.includes(b.user_id))
slackState.approval_owner_ids.push(b.user_id);
if (!slackState.allowed_users.includes(b.user_id))
slackState.allowed_users.push(b.user_id);
return json({
ok: true,
approval_owner_ids: [...slackState.approval_owner_ids],
allowed_users: [...slackState.allowed_users],
});
}
if (p.endsWith("/v1/connectors/slack/approval-owners/remove") && m === "POST") {
const b = req.postDataJSON();
const i = slackState.approval_owner_ids.indexOf(b.user_id);
if (i >= 0) slackState.approval_owner_ids.splice(i, 1);
return json({ ok: true, approval_owner_ids: [...slackState.approval_owner_ids] });
}
// Workspace rosters for the pickers (users.list / conversations.list, mocked).
if (/\/v1\/connectors\/slack\/workspaces\/[^/]+\/directory$/.test(p) && m === "GET") {
const q = (new URL(req.url()).searchParams.get("q") || "").toLowerCase();
const members = [
{ id: "U9MAYA", name: "Maya Chen", handle: "maya", guest: false },
{ id: "U8ROHIT", name: "Rohit Prasad", handle: "rohit", guest: false },
{ id: "U7CAL", name: "Contractor Cal", handle: "cal", guest: true },
].filter((mem) => !q || mem.name.toLowerCase().includes(q) || mem.handle.includes(q));
return json({ ok: true, members });
}
if (/\/v1\/connectors\/slack\/workspaces\/[^/]+\/channels$/.test(p) && m === "GET") {
const team = decodeURIComponent(p.split("/workspaces/")[1].split("/")[0]);
const q = (new URL(req.url()).searchParams.get("q") || "").toLowerCase();
const channels = [
{ id: "C9LAUNCH", name: "launch-team", is_private: false, is_member: true },
{ id: "C8LEADS", name: "leads", is_private: true, is_member: true },
{ id: "C7LOBBY", name: "lobby", is_private: false, is_member: false },
].filter((c) => !q || c.name.includes(q));
return json({ ok: true, channels, team });
}
// Slack health, three layers (M3.6 Step 2): socket live + all tokens good by
// default; sign-in mirrors CLOUD_STATE. Specs force reconnecting/offline/dead
// tokens by registering a later page.route override (later routes match first).
if (p.endsWith("/v1/connectors/slack/status"))
return json({
ok: true,
mode: slackState.mode,
relay: { state: "live", reconnects: 0, last_event_at: Date.now() / 1000 - 30, last_error: "" },
signed_in: CLOUD_STATE.signed_in,
teams: Object.fromEntries(
slackState.workspaces.map((w) => [w.team_id, { token_ok: true }]),
),
});
// Stop relaying one workspace; removing the last flips the connector off (backend parity).
if (/\/v1\/connectors\/slack\/workspaces\/[^/]+\/disconnect$/.test(p) && m === "POST") {
const teamId = decodeURIComponent(p.split("/").slice(-2)[0]);
const i = slackState.workspaces.findIndex((w) => w.team_id === teamId);
if (i < 0) return json({ ok: false, error: "workspace not connected" });
slackState.workspaces.splice(i, 1);
if (slackState.workspaces.length === 0) {
slackState.connected = false;
slackState.mode = "";
}
return json({ ok: true, remaining_workspaces: slackState.workspaces.length });
}
// GitHub relay (github-relay-spec §8): per-installation allow/disallow, parked
// resolution, status, per-installation disconnect.
if (/\/v1\/connectors\/github\/unauthorized\/[^/]+$/.test(p) && m === "POST") {
const id = p.split("/").pop();
const i = githubParked.findIndex((x) => x.id === id);
if (i < 0) return json({ ok: false, error: "unknown item" });
const b = req.postDataJSON();
const item = githubParked.splice(i, 1)[0];
if (b.action === "allow" || b.action === "allow_deliver") {
const pool = githubState.installations.find(
(x) => x.installation_id === item.team_id,
)?.allowed_users;
if (pool && !pool.includes(item.user_id)) pool.push(item.user_id);
}
return json({ ok: true });
}
if (/\/v1\/connectors\/github\/(allow|disallow)$/.test(p) && m === "POST") {
const b = req.postDataJSON();
const pool = githubState.installations.find(
(x) => x.installation_id === b.team_id,
)?.allowed_users;
if (!pool) return json({ ok: false, error: "installation not connected" });
const add = p.endsWith("/allow");
const i = pool.indexOf(b.user_id);
if (add && i < 0) pool.push(b.user_id);
if (!add && i >= 0) pool.splice(i, 1);
return json({ ok: true, allowed_users: [...pool], team_id: b.team_id ?? null });
}
if (p.endsWith("/v1/connectors/github/status"))
return json({
ok: true,
mode: githubState.mode,
relay: { state: "live", reconnects: 0, last_event_at: Date.now() / 1000 - 30, last_error: "" },
signed_in: CLOUD_STATE.signed_in,
installs: Object.fromEntries(
githubState.installations.map((x) => [x.installation_id, { token_ok: true }]),
),
missed: {},
});
if (/\/v1\/connectors\/github\/installations\/[^/]+\/disconnect$/.test(p) && m === "POST") {
const iid = decodeURIComponent(p.split("/").slice(-2)[0]);
const i = githubState.installations.findIndex((x) => x.installation_id === iid);
if (i < 0) return json({ ok: false, error: "installation not connected" });
githubState.installations.splice(i, 1);
if (githubState.installations.length === 0) {
githubState.connected = false;
githubState.mode = "";
}
return json({ ok: true, remaining_installs: githubState.installations.length });
}
// Gmail multi-account management (M3.6 Step 3): per-account disconnect/default
// + the "Never show agents" filter lists.
if (/\/v1\/connectors\/gmail\/accounts\/[^/]+\/disconnect$/.test(p) && m === "POST") {
const email = decodeURIComponent(p.split("/").slice(-2)[0]);
const i = gmailState.accounts.findIndex((a) => a.email === email);
if (i < 0) return json({ ok: false, error: "account not connected" });
const wasDefault = gmailState.accounts[i].default;
gmailState.accounts.splice(i, 1);
if (wasDefault && gmailState.accounts[0]) gmailState.accounts[0].default = true;
return json({ ok: true, remaining_accounts: gmailState.accounts.length });
}
if (/\/v1\/connectors\/google_calendar\/accounts\/[^/]+\/disconnect$/.test(p) && m === "POST") {
const email = decodeURIComponent(p.split("/accounts/")[1].split("/")[0]);
const i = gcalState.accounts.findIndex((a) => a.email === email);
if (i < 0) return json({ ok: false, error: "account not connected" });
const wasDefault = gcalState.accounts[i].default;
gcalState.accounts.splice(i, 1);
if (wasDefault && gcalState.accounts[0]) gcalState.accounts[0].default = true;
return json({ ok: true, remaining_accounts: gcalState.accounts.length });
}
if (/\/v1\/connectors\/google_calendar\/accounts\/[^/]+\/default$/.test(p) && m === "POST") {
const email = decodeURIComponent(p.split("/accounts/")[1].split("/")[0]);
if (!gcalState.accounts.some((a) => a.email === email))
return json({ ok: false, error: "account not connected" });
for (const a of gcalState.accounts) a.default = a.email === email;
return json({ ok: true, default_account: email });
}
if (/\/v1\/connectors\/gmail\/accounts\/[^/]+\/default$/.test(p) && m === "POST") {
const email = decodeURIComponent(p.split("/").slice(-2)[0]);
if (!gmailState.accounts.some((a) => a.email === email))
return json({ ok: false, error: "account not connected" });
for (const a of gmailState.accounts) a.default = a.email === email;
return json({ ok: true, default_account: email });
}
if (p.endsWith("/v1/connectors/gmail/filters") && m === "PATCH") {
const b = req.postDataJSON() || {};
if (Array.isArray(b.senders)) gmailState.filters.senders = b.senders;
if (Array.isArray(b.labels)) gmailState.filters.labels = b.labels;
return json({ ok: true, filters: { ...gmailState.filters } });
}
// Generic multi-account management (accounts.py layer; notion in fixtures).
if (/\/v1\/connectors\/notion\/accounts\/[^/]+\/disconnect$/.test(p) && m === "POST") {
const id = decodeURIComponent(p.split("/accounts/")[1].split("/")[0]);
const i = notionState.accounts.findIndex((a) => a.account_id === id);
if (i < 0) return json({ ok: false, error: "account not connected" });
const wasDefault = notionState.accounts[i].default;
notionState.accounts.splice(i, 1);
if (wasDefault && notionState.accounts[0]) notionState.accounts[0].default = true;
return json({ ok: true, remaining_accounts: notionState.accounts.length });
}
if (/\/v1\/connectors\/notion\/accounts\/[^/]+\/default$/.test(p) && m === "POST") {
const id = decodeURIComponent(p.split("/accounts/")[1].split("/")[0]);
if (!notionState.accounts.some((a) => a.account_id === id))
return json({ ok: false, error: "account not connected" });
for (const a of notionState.accounts) a.default = a.account_id === id;
return json({ ok: true, default_account: id });
}
// HubSpot multi-portal management (M3.6 Step 4).
if (/\/v1\/connectors\/hubspot\/portals\/[^/]+\/disconnect$/.test(p) && m === "POST") {
const hub = decodeURIComponent(p.split("/").slice(-2)[0]);
const i = hubspotState.portals.findIndex((x) => x.hub_id === hub);
if (i < 0) return json({ ok: false, error: "portal not connected" });
const wasDefault = hubspotState.portals[i].default;
hubspotState.portals.splice(i, 1);
if (wasDefault && hubspotState.portals[0]) hubspotState.portals[0].default = true;
return json({ ok: true, remaining_portals: hubspotState.portals.length });
}
if (/\/v1\/connectors\/hubspot\/portals\/[^/]+\/default$/.test(p) && m === "POST") {
const hub = decodeURIComponent(p.split("/").slice(-2)[0]);
if (!hubspotState.portals.some((x) => x.hub_id === hub))
return json({ ok: false, error: "portal not connected" });
for (const x of hubspotState.portals) x.default = x.hub_id === hub;
return json({ ok: true, default_portal: hub });
}
if (p.endsWith("/v1/connectors/hubspot/hidden-fields") && m === "PATCH") {
const b = req.postDataJSON() || {};
if (Array.isArray(b.hidden_fields))
hubspotState.hidden_fields = b.hidden_fields.map((f: string) => f.trim().toLowerCase());
return json({ ok: true, hidden_fields: [...hubspotState.hidden_fields] });
}
if (p.endsWith("/v1/connectors"))
return json({
connectors: [
slackConnector(),
githubConnector(),
...CONNECTORS.connectors.map((c: any) =>
c.name === "gmail"
? gmailConnector()
: c.name === "google_calendar"
? gcalConnector()
: c.name === "hubspot"
? hubspotConnector()
: c.name === "notion"
? notionConnector()
: c.name === "outlook"
? outlookConnector()
: c.name === "monday" || c.name === "jira"
? mcpConnector(c.name)
: { ...c },
),
],
});
if (p.endsWith("/v1/cloud/status")) return json({ ...CLOUD_STATE });
if (p.endsWith("/v1/cloud/login") && m === "POST") {
Object.assign(CLOUD_STATE, { signed_in: true, account: "rohit@openworker.com", user_id: "usr_e2e" });
return json({ ok: true });
}
if (p.endsWith("/v1/cloud/telemetry") && m === "POST") {
CLOUD_STATE.telemetry_enabled = !!req.postDataJSON().enabled;
return json({ ok: true, telemetry_enabled: CLOUD_STATE.telemetry_enabled });
}
if (p.endsWith("/v1/cloud/logout") && m === "POST") {
Object.assign(CLOUD_STATE, { signed_in: false, account: "", user_id: "" });
return json({ ok: true, signed_in: false });
}
if (/\/v1\/connectors\/[^/]+\/mcp-connect$/.test(p) && m === "POST") {
// Local MCP OAuth flow — no cloud sign-in required; completes instantly here.
const name = p.match(/\/v1\/connectors\/([^/]+)\/mcp-connect$/)?.[1] as
| "monday"
| "jira";
if (name in mcpState) {
mcpState[name] = true;
return json({ ok: true, started: true });
}
return json({ ok: false, error: `${name} has no MCP connect path` });
}
if (/\/v1\/connectors\/[^/]+\/connect-managed$/.test(p) && m === "POST") {
if (!CLOUD_STATE.signed_in) return json({ ok: false, error: "not signed in" });
// Slack managed install = add a workspace. The real flow completes in the system
// browser; the mock installs instantly so the page's poll picks it up.
if (p.includes("/connectors/slack/")) {
slackState.workspaces.push({ team_id: "T3NEW", account: "new-workspace", domain: "new-workspace", allowed_users: ["U_ME"], allow_all: false, allowed_user_names: { U_ME: "Rohit Prasad" }, approval_owner_ids: ["U_ME"], approval_owner_names: { U_ME: "Rohit Prasad" }, installer_user_id: "U_ME", installer_name: "Rohit Prasad" });
slackState.connected = true;
slackState.mode = "relay";
}
// GitHub managed connect = install on the next account (instant, like Slack).
if (p.includes("/connectors/github/")) {
githubState.installations.push({
installation_id: "202", account_login: "hooli", account_type: "Organization",
repo_selection: "all", github_login: "rohit-dev", allowed_users: ["rohit-dev"], allow_all: false,
});
githubState.connected = true;
githubState.mode = "relay";
}
// Gmail managed connect = add the next mailbox; the first becomes default.
if (p.includes("/connectors/gmail/")) {
const email = GMAIL_NEXT[gmailState.accounts.length] || `acct${gmailState.accounts.length}@x.com`;
gmailState.accounts.push({
email, default: gmailState.accounts.length === 0, managed: true,
scopes: "gmail.readonly gmail.send", needs_reauth: false,
});
}
// Google Calendar managed connect = add the next account (gmail's flow).
if (p.includes("/connectors/google_calendar/")) {
const email = GCAL_NEXT[gcalState.accounts.length] || `acct${gcalState.accounts.length}@x.com`;
gcalState.accounts.push({
email, default: gcalState.accounts.length === 0, managed: true,
scopes: "calendar", needs_reauth: false,
});
}
// Outlook managed connect = add the next mailbox (email-keyed accounts).
if (p.includes("/connectors/outlook/")) {
outlookState.accounts.push({
account_id: `mbx${outlookState.accounts.length + 1}@openworker.com`,
name: `mbx${outlookState.accounts.length + 1}@openworker.com`,
default: outlookState.accounts.length === 0,
managed: true,
});
}
// Notion managed connect = add the next workspace (generic accounts layer).
if (p.includes("/connectors/notion/")) {
const next = NOTION_NEXT[notionState.accounts.length] || {
account_id: `ws-${notionState.accounts.length + 1}`, name: "extra",
};
notionState.accounts.push({
...next, default: notionState.accounts.length === 0, managed: true,
});
}
// HubSpot managed connect = add the next portal at the requested access tier.
if (p.includes("/connectors/hubspot/")) {
const access = (req.postDataJSON() || {}).access || "read";
const next = HUBSPOT_NEXT[hubspotState.portals.length] || {
hub_id: `9${hubspotState.portals.length}`, name: "extra", sandbox: false,
};
hubspotState.portals.push({
...next, default: hubspotState.portals.length === 0, managed: true, access,
});
}
return json({ ok: true });
}
if (p.endsWith("/v1/cloud/gallery")) {
return json(
CLOUD_STATE.signed_in
? { ok: true, personas: GALLERY_PERSONAS }
: { ok: false, error: "gallery requires cloud sign-in", personas: [] },
);
}
if (/\/v1\/cloud\/gallery\/[^/]+$/.test(p)) {
if (!CLOUD_STATE.signed_in) return json({ ok: false, error: "gallery requires cloud sign-in" });
const slug = p.split("/").pop();
const cardBase = GALLERY_PERSONAS.find((g) => g.slug === slug) ?? GALLERY_PERSONAS[0];
return json({
ok: true,
card: { ...cardBase, pitch_markdown: "**Walk into every call already knowing the account.**" },
capabilities: {
tools: ["files", "search", "todo"],
risk: [],
connectors: true,
mcp: [],
messaging: true,
recommended_mode: "interactive",
recommended_models: [],
},
recommends: [
{ kind: "connector", ref: "hubspot", reason: "read deals and contacts", tier: "core" },
],
});
}
// provider credential check (read-only) — an api_key containing "bad" fails, else ok.
if (p.endsWith("/v1/providers/verify") && m === "POST") {
const key = String(req.postDataJSON()?.fields?.api_key || "");
return /bad/i.test(key)
? json({ ok: false, error: "Invalid API key." })
: json({ ok: true });
}
// save a provider key — flips `configured`, stamps key_set_at (backend set_provider parity).
if (p.endsWith("/v1/providers") && m === "POST") {
const b = req.postDataJSON();
const prov = providers.find((x) => x.name === b.name);
if (!prov) return json({ ok: false, error: `unknown provider: ${b.name}` });
if (b.fields?.api_key) {
prov.configured = true;
prov.key_set_at = "2026-07-05";
}
// Backend parity: non-secret fields merge into `values` (empty clears them).
for (const [k, v] of Object.entries(b.fields || {})) {
if (k === "api_key") continue;
if (v) prov.values = { ...prov.values, [k]: v };
else if (prov.values) delete prov.values[k];
}
return json({ ok: true, provider: b.name, recommended_model: null });
}
// forget a provider's stored config (Settings ▸ Models "Remove key…").
if (/\/v1\/providers\/[^/]+$/.test(p) && m === "DELETE") {
const name = p.split("/").pop()!;
const prov = providers.find((x) => x.name === name);
if (!prov) return json({ ok: false, error: `unknown provider: ${name}` });
prov.configured = !prov.needs_key; // keyless (ollama) stays "configured"
prov.key_set_at = null;
return json({ ok: true, provider: name });
}
// Subscription OAuth sign-in (openai-codex): the flow "completes" instantly —
// signin flips the row to signed in; status mirrors it; signout clears it.
if (p.endsWith("/v1/providers/openai-codex/signin") && m === "POST") {
const prov = providers.find((x) => x.name === "openai-codex");
if (prov) {
prov.signed_in = true;
prov.configured = true;
prov.account = "rohit@example.com";
}
return json({ ok: true, started: true });
}
if (p.endsWith("/v1/providers/openai-codex/status")) {
const prov = providers.find((x) => x.name === "openai-codex");
return json({
signed_in: !!prov?.signed_in,
account: prov?.account || null,
authorizing: false,
last_error: null,
authorize_url: null,
});
}
if (p.endsWith("/v1/providers/openai-codex/signout") && m === "POST") {
const prov = providers.find((x) => x.name === "openai-codex");
if (prov) {
prov.signed_in = false;
prov.configured = false;
prov.account = null;
}
return json({ ok: true, had_tokens: true });
}
if (p.endsWith("/v1/providers")) return json(providers);
if (p.endsWith("/v1/channels/recent"))
return json({
channels: [
{ channel: "slack:C0AAA111", name: "ocw-test", last_from: "amy", last_text: "standup at 10" },
{ channel: "slack:C0BBB222", last_from: "bob", last_text: "deploy failed" },
],
});
// inbox: pending items + the outbound routing binding (inline Slack config)
if (/\/v1\/inbox\/[^/]+\/resolve$/.test(p) && m === "POST") {
const id = decodeURIComponent(p.split("/")[p.split("/").length - 2]);
const it = inbox.find((x) => x.id === id);
if (it) {
it.state = "resolved";
it.resolution = req.postDataJSON().resolution;
}
return json({ ok: true });
}
if (p.endsWith("/v1/inbox/routing/binding") && m === "POST") {
const b = req.postDataJSON();
routing.channel = b.channel;
routing.target = b.target;
return json({ ok: true, bindings: [{ ...routing }] });
}
if (p.endsWith("/v1/inbox/routing")) return json({ bindings: [{ ...routing }] });
if (p.endsWith("/v1/inbox")) {
const q = new URL(req.url()).searchParams;
const sid = q.get("session_id");
const state = q.get("state");
return json({
items: inbox.filter(
(i) => (!sid || i.session_id === sid) && (!state || i.state === state),
),
});
}
// automations: one scheduled task with a running run (drives the Automations detail page
// and the run-session banner + Back-to-runs flow). Mutable: Run now appends a run and opens
// its live session; the enable toggle (PATCH) and delete (DELETE) round-trip through the UI.
if (/\/v1\/automations\/[^/]+\/seen$/.test(p) && m === "POST") {
const id = p.split("/").slice(-2)[0];
const task = automations.find((t) => t.id === id);
if (task) {
task.unseen_runs = 0;
task.unseen_failed = false;
task.seen_runs_at = Math.floor(Date.now() / 1000);
}
return json({ ok: !!task });
}
if (/\/v1\/automations\/[^/]+\/run$/.test(p) && m === "POST") {
const id = p.split("/").slice(-2)[0];
const task = automations.find((t) => t.id === id);
if (!task) return json({ ok: false, error: "unknown task" });
const runId = `r${automationRuns.length + 1}`;
automationRuns.unshift({
run_id: runId,
task_id: id,
session_id: `__run__${runId}`,
started_at: Math.floor(Date.now() / 1000),
finished_at: null,
status: "running",
result_text: null,
artifacts: [],
error: null,
trigger: "manual",
});
return json({
ok: true,
run_id: runId,
session_id: `__run__${runId}`,
workspace: task.workspace,
agent: task.agent,
prompt: task.instructions,
});
}
if (/\/v1\/automations\/[^/]+$/.test(p) && m === "GET") {
const id = p.split("/").pop();
const task = automations.find((t) => t.id === id) ?? automations[0];
return json({ task, runs: automationRuns.filter((r) => r.task_id === task?.id) });
}
if (/\/v1\/automations\/[^/]+$/.test(p) && m === "PATCH") {
const id = p.split("/").pop();
const task = automations.find((t) => t.id === id);
const body = req.postDataJSON() ?? {};
if (task && body.revoke) {
// Standing-rule revocation (§25): remove the entry; `revoke` is a command,
// not a field to Object.assign onto the task.
task.always_allowed = (task.always_allowed || []).filter(
(r: any) => r.entry !== body.revoke,
);
return json({ ok: true, task });
}
if (task) Object.assign(task, body);
return json({ ok: true, task });
}
if (/\/v1\/automations\/[^/]+$/.test(p) && m === "DELETE") {
const id = p.split("/").pop();
const i = automations.findIndex((t) => t.id === id);
if (i >= 0) automations.splice(i, 1);
return json({ ok: true });
}
if (p.endsWith("/v1/automations") && m === "POST") {
// GUI/onboarding-recipe create (§24) — mirrors the server: title+instructions+cron
// required; §25 permissions become always_allowed entries (write grants only).
const body = req.postDataJSON() || {};
if (!body.title || !body.instructions || !(body.cron || body.fire_at))
return json({ ok: false, error: "missing fields" });
const grants = (body.permissions || [])
.filter((g: any) => g && g.access === "write" && g.tool && g.target)
.map((g: any) => ({ entry: `${g.tool} ${g.target}`, tool: g.tool, target: g.target }));
const task = {
...AUTOMATION,
id: `task-ob-${automations.length}`,
title: body.title,
instructions: body.instructions,
schedule: body.cron || body.fire_at,
always_allowed: grants,
run_count: 0,
};
automations.push(task);
return json({ ok: true, task });
}
if (p.endsWith("/v1/automations")) return json({ tasks: automations });
if (p.endsWith("/v1/settings/onboarded") && m === "POST") {
return json({ ok: true, onboarded: !!(req.postDataJSON() || {}).value });
}
// MCP servers — mutable so the OAuth quick-add (granola) flow reflects through the
// UI: add → needs_auth, connect → authorizing, next poll → connected (6 tools).
if (p.endsWith("/v1/mcp") && m === "GET") {
for (const s2 of mcpServers) {
if (s2.status === "authorizing" && s2._flip) {
// Servers named locked-* simulate a guarded remote: the anonymous
// probe 401s (→ needs sign-in) until the entry is switched to oauth.
if (s2.name.startsWith("locked") && s2.auth !== "oauth") {
s2.status = "error";
s2.auth_hint = true;
s2.last_error = "authentication required — sign in to connect";
} else {
s2.status = "connected";
s2.tool_count = 6;
s2.last_test_at = 1700000000; // the successful probe stamps the row
}
}
if (s2.status === "authorizing") s2._flip = true;
}
return json({ servers: mcpServers.map(({ _flip, ...s2 }) => s2) });
}
if (p.endsWith("/v1/mcp") && m === "POST") {
const b = req.postDataJSON();
mcpServers.push({
name: b.name,
enabled: true,
transport: b.config?.url ? "http" : "stdio",
requires_approval: true,
auth: b.config?.auth === "oauth" ? "oauth" : null,
status: b.config?.auth === "oauth" ? "needs_auth" : "configured",
auth_hint: false,
last_test_at: null,
last_error: null,
tool_count: null,
config: b.config || {},
});
return json({ ok: true, name: b.name });
}
{
const mc = p.match(/\/v1\/mcp\/([^/]+)\/connect$/);
if (mc && m === "POST") {
const s2 = mcpServers.find((x) => x.name === decodeURIComponent(mc[1]));
if (s2) {
s2.status = "authorizing";
s2.auth_hint = false;
s2.last_error = null;
s2._flip = false;
}
return json({ ok: true, started: true });
}
const ms = p.match(/\/v1\/mcp\/([^/]+)\/signout$/);
if (ms && m === "POST") {
const s2 = mcpServers.find((x) => x.name === decodeURIComponent(ms[1]));
if (s2) {
s2.status = "needs_auth";
s2.tool_count = null;
s2._flip = false;
}
return json({ ok: true });
}
const mp = p.match(/\/v1\/mcp\/([^/]+)$/);
if (mp && m === "PATCH") {
const s2 = mcpServers.find((x) => x.name === decodeURIComponent(mp[1]));
const b = req.postDataJSON() || {};
if (s2) {
if (b.enabled !== undefined) s2.enabled = b.enabled;
if (b.auth === "oauth") {
// The needs-sign-in fix: entry switches to oauth; the follow-up
// connect runs the browser flow.
s2.auth = "oauth";
s2.auth_hint = false;
s2.status = "needs_auth";
}
s2.config = { ...s2.config, ...b };
}
return json({ ok: !!s2, name: mp[1] });
}
const md = p.match(/\/v1\/mcp\/([^/]+)$/);
if (md && m === "DELETE") {
const i = mcpServers.findIndex((x) => x.name === decodeURIComponent(md[1]));
if (i >= 0) mcpServers.splice(i, 1);
return json({ ok: i >= 0 });
}
}
if (p.endsWith("/v1/unrouted")) return json([]);
// channel subscriptions — mutable so add/remove reflect through the UI
if (p.endsWith("/v1/subscriptions") && m === "GET") return json({ subscriptions });
if (p.endsWith("/v1/subscriptions") && m === "POST") {
const b = req.postDataJSON();
// Backend parity with resolve_channel: Copy-link URLs resolve to the id; bare #names
// can't be looked up and are rejected with the same hint the server gives.
const raw = String(b.channel || "").trim();
if (raw.startsWith("#"))
return json({
ok: false,
error:
"Channel names can't be looked up — paste the channel ID (channel name ▸ About) or the channel's Copy-link URL.",
});
const link = raw.match(/slack\.com\/archives\/([A-Za-z0-9]+)/);
const channel = link ? `slack:${link[1].toUpperCase()}` : raw;
subscriptions.push({ session_id: b.session_id, session_title: "", agent: "", channel, routing_target: null, collision: false });
return json({ ok: true, channel });
}
if (p.endsWith("/v1/subscriptions/remove") && m === "POST") {
const b = req.postDataJSON();
const i = subscriptions.findIndex((s) => s.session_id === b.session_id && s.channel === b.channel);
if (i >= 0) subscriptions.splice(i, 1);
return json({ ok: true });
}
// Anything else: an empty-but-valid body. GET list endpoints read `?? []`/`?? {}` fallbacks.
return json({});
});
}
/** Seed a replayed transcript for one session. The shared mock answers every
* GET /v1/sessions/{id}/messages with `[]`, so reopening a session always starts blank;
* this registers a LATER route (later routes win) that stages rich history for that one
* session replayed tool calls with results, connector-sourced messages, notices,
* reasoning so specs can assert the reopen path (itemsFromMessages) directly instead
* of driving every turn live through the fake agent. Call after the page has the mock
* (any time before the session is opened). */
export async function seedSessionMessages(
page: Page,
sessionId: string,
messages: Record<string, unknown>[],
): Promise<void> {
await page.route(new RegExp(`/v1/sessions/${sessionId}/messages$`), (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ messages }),
}),
);
}
// A `test` whose page has the API mocked before navigation.
export const test = base.extend({
page: async ({ page }, use) => {
await mockApi(page);
await use(page);
},
});
export { expect };