mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 07:20:10 +00:00
Split-scene carousel on the Slack connector page teaching mentions, threads, and approvals. Post-it annotations per beat; static under prefers-reduced-motion. Co-authored-by: Devika <devikaverma11@gmail.com>
1411 lines
71 KiB
TypeScript
1411 lines
71 KiB
TypeScript
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",
|
||
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",
|
||
},
|
||
};
|
||
|
||
const PERSONAS = {
|
||
personas: [
|
||
{ id: "cowork", name: "OpenWorker", icon: "cowork", tagline: "Produce a deliverable — research, analysis, scripts", needs_workspace: true, builtin: true, family: "knowledge", workspace: "deliverable", tools: ["files", "search"], enabled: true, surfaced: true, default: true },
|
||
{ id: "code", name: "Code", icon: "code", tagline: "Work in a codebase — files, git, shell", needs_workspace: true, builtin: true, family: "code", workspace: "git", tools: ["code_files", "git"], enabled: true, surfaced: true, default: false },
|
||
{ id: "chat", name: "Chat", icon: "chat", tagline: "Quick questions — no workspace", needs_workspace: false, builtin: true, family: "knowledge", workspace: "none", tools: [], enabled: true, surfaced: false, default: false },
|
||
{ id: "ops", name: "Ops Coworker", icon: "wrench", tagline: "Operate and investigate — runbooks, logs, infrastructure", needs_workspace: true, builtin: true, family: "knowledge", workspace: "deliverable", tools: ["files", "shell"], enabled: true, surfaced: true, default: false },
|
||
// 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", needs_workspace: true, builtin: false, family: "knowledge", workspace: "deliverable", tools: ["files"], enabled: false, surfaced: false, default: false },
|
||
],
|
||
};
|
||
|
||
// 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: [],
|
||
};
|
||
|
||
// §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 Mac."], 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 },
|
||
// 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 },
|
||
];
|
||
|
||
/** Install the API + WebSocket mocks on a page. Returns handles for assertions/seed data. */
|
||
export async function mockApi(page: import("@playwright/test").Page) {
|
||
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)
|
||
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>, 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>, 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], 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).
|
||
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.
|
||
const sessions: any[] = [
|
||
{ ...PINNED_SESSION },
|
||
...EXTRA_SESSIONS.map((s) => ({ ...s })),
|
||
{ ...OPS_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> = {};
|
||
|
||
// 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 }));
|
||
send("ready");
|
||
let pendingTool = "run_shell"; // which proposal the next approval decision resolves
|
||
ws.onMessage((raw) => {
|
||
const msg = JSON.parse(String(raw));
|
||
if (msg.type === "user_message") {
|
||
send("turn_start", { input: msg.text });
|
||
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.",
|
||
});
|
||
return; // suspended on the approval
|
||
}
|
||
// §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 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. ";
|
||
const timer = setInterval(() => {
|
||
ticks += 1;
|
||
send("assistant_delta", { text: line.repeat(3) + "\n\n" });
|
||
if (ticks >= 40) {
|
||
clearInterval(timer);
|
||
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).
|
||
send("assistant_message", { text: `Echo: ${msg.text} [model=${msg.model || "none"}]` });
|
||
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");
|
||
}
|
||
});
|
||
});
|
||
|
||
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);
|
||
}
|
||
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 });
|
||
}
|
||
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);
|
||
}
|
||
|
||
if (p.endsWith("/v1/health")) return json(HEALTH);
|
||
if (p.endsWith("/v1/settings")) return json(SETTINGS);
|
||
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" });
|
||
}
|
||
// 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.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)) return json(PERSONA_DETAIL);
|
||
if (p.endsWith("/v1/personas")) return json({ 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 });
|
||
}
|
||
// 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", allowed_users: [], allow_all: false, allowed_user_names: {} });
|
||
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";
|
||
}
|
||
if (b.fields?.base_url) prov.values = { ...prov.values, base_url: b.fields.base_url };
|
||
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 });
|
||
}
|
||
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) {
|
||
s2.status = "connected";
|
||
s2.tool_count = 6;
|
||
}
|
||
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",
|
||
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";
|
||
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 });
|
||
}
|
||
}
|
||
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({});
|
||
});
|
||
}
|
||
|
||
// 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 };
|