import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { ComposioEmulator, cancelScheduleFromTool, createScheduleFromTool, DesktopSandboxProvider, FakeSandboxProvider, handoffToGroupBot, listSchedulesFromTool, ManagedSandboxEmulator, resolveScheduleOwner, toComputerRef, } from "@rakazo/adapters"; import { ACTIVE_RUN_STATUSES, ONCE_ROUTINE_CRON } from "@rakazo/core"; import { appendEvent, computerHomeKey, createRepos, createThreadEvents, createThreadMessage, ensureComputerRecord, ensureMainBot, joinMainConversation, RunHistoryWriteError, } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { createApp } from "../../../apps/api/src/app.ts"; import { sessionCookieHeader } from "./index.js"; type App = { request: (input: string, init?: RequestInit) => Promise }; process.env.WAKEUP_DRIVER = "memory"; process.env.SANDBOX_PROVIDER = "fake"; process.env.AGENT_RUNTIME = "scripted"; const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); const describeJourneys = hasDb ? describe : describe.skip; describeJourneys("required product journeys", () => { let app: App; let stop: () => Promise; let prisma: Awaited>["prisma"]; let connector: Awaited>["connector"]; let executor: Awaited>["executor"]; let jobs: Awaited>["jobs"]; let sandbox: Awaited>["sandbox"]; const stamp = Date.now(); const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-journey-")); async function sendAndWait(app: App, cookie: string, botId: string, text: string) { const { runId } = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId, text }); let terminal: { status: string; error: string | null } | null = null; await waitForDatabase(async () => { terminal = await prisma.run.findUnique({ where: { id: runId }, select: { status: true, error: true }, }); return Boolean(terminal && ["completed", "failed", "cancelled"].includes(terminal.status)); }); if (!terminal) throw new Error(`run ${runId} was not found after completion`); if (terminal.status !== "completed") { throw new Error( `run ${runId} ended ${terminal.status}: ${terminal.error ?? "unknown error"}`, ); } return { ...(await rpc(app, cookie, "threads/get", { botId })), run: { id: runId, status: terminal.status }, }; } async function sendGroupAndWait( app: App, cookie: string, groupId: string, text: string, waitForBotId?: string, ) { const { runIds, runId } = await rpc<{ runId: string; runIds?: string[] }>( app, cookie, "threads/send", { groupId, text, }, ); let targets = runIds ?? [runId]; if (waitForBotId) { const runs = await prisma.run.findMany({ where: { id: { in: targets }, botId: waitForBotId }, select: { id: true }, }); targets = runs.map((run) => run.id); if (targets.length === 0) { throw new Error(`no run scheduled for bot ${waitForBotId}`); } } for (const runId of targets) { let terminal: { status: string; error: string | null } | null = null; await waitForDatabase(async () => { terminal = await prisma.run.findUnique({ where: { id: runId }, select: { status: true, error: true }, }); return Boolean(terminal && ["completed", "failed", "cancelled"].includes(terminal.status)); }); if (!terminal) throw new Error(`run ${runId} was not found after completion`); if (terminal.status !== "completed") { throw new Error( `run ${runId} ended ${terminal.status}: ${terminal.error ?? "unknown error"}`, ); } } return rpc(app, cookie, "threads/get", { groupId }); } beforeAll(async () => { const { createApp } = await import("../../../apps/api/src/app.ts"); const handles = await createApp({ databaseUrl: process.env.DATABASE_URL!, dataDir, sandboxProvider: "fake", agentRuntime: "scripted", composio: new ComposioEmulator(), }); app = handles.app; stop = handles.stop; prisma = handles.prisma; connector = handles.connector; executor = handles.executor; jobs = handles.jobs; sandbox = handles.sandbox; }); afterAll(async () => { await stop?.(); }); it("computer updates preserve the workspace and reserve the shared computer until completion", async () => { const cookie = await signup(app, `maintenance-${stamp}@rakazo.test`, "Maintenance"); const outsider = await signup(app, `maintenance-other-${stamp}@rakazo.test`, "Other workspace"); const bot = await rpc(app, cookie, "bots/create", { name: "Writer", title: "Writer", description: "Writes files", instructions: "Write files.", }); await waitForDatabase( async () => (await prisma.run.count({ where: { botId: bot.id, status: { in: [...ACTIVE_RUN_STATUSES] } }, })) === 0, ); await rpc(app, cookie, "computer/boot", { botId: bot.id }); const original = await prisma.bot.findUniqueOrThrow({ where: { id: bot.id }, include: { computer: true }, }); const ctx = { operationId: "fixture", traceId: "fixture", spaceId: original.spaceId, userId: original.userId, botId: bot.id, signal: new AbortController().signal, }; await sandbox.writeFile( toComputerRef(original.computer!), { path: "notes.txt", content: new TextEncoder().encode("durable work") }, ctx, ); let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); const destroyed = vi.spyOn(sandbox, "destroy"); const exportWorkspace = sandbox.exportWorkspace.bind(sandbox); const spy = vi .spyOn(sandbox, "exportWorkspace") .mockImplementation(async function* (ref, context) { await gate; yield* exportWorkspace(ref, context); }); try { const update = await rpc<{ id: string }>(app, cookie, "computer/update", { botId: bot.id }); await waitForDatabase( async () => (await prisma.computerUpdate.findUniqueOrThrow({ where: { id: update.id } })).stage === "saving", ); expect(await rpc(app, outsider, "computer/updates")).toEqual([]); const duplicate = await app.request("/rpc/computer/update", { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ json: { botId: bot.id } }), }); expect(duplicate.status).toBe(409); const stopped = await app.request("/rpc/computer/stop", { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ json: { botId: bot.id } }), }); expect(stopped.status).toBe(409); const switched = await app.request("/rpc/bots/setComputer", { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ json: { botId: bot.id, mode: "dedicated" } }), }); expect(switched.status).toBe(409); release(); await waitForDatabase( async () => (await prisma.computerUpdate.findUniqueOrThrow({ where: { id: update.id } })).status === "completed", ); const replacement = await prisma.computer.findUniqueOrThrow({ where: { id: original.computerId! }, }); expect(replacement.maintenanceId).toBeNull(); expect(destroyed).toHaveBeenCalledWith( expect.objectContaining({ providerRef: original.computer!.providerRef }), expect.anything(), ); expect( new TextDecoder().decode( await sandbox.readFile(toComputerRef(replacement), "notes.txt", ctx), ), ).toBe("durable work"); } finally { release(); spy.mockRestore(); destroyed.mockRestore(); } }); it("1+2: users are isolated and workspace bots share the Team Computer", async () => { const ada = await signup(app, `ada-j-${stamp}@rakazo.test`, "Ada Journey"); const bob = await signup(app, `bob-j-${stamp}@rakazo.test`, "Bob Journey"); const adaMe = await rpc(app, ada, "me"); const bobMe = await rpc(app, bob, "me"); expect(adaMe.spaceId).not.toBe(bobMe.spaceId); const chief = await rpc(app, ada, "bots/create", { name: "Chief", title: "Chief of staff", description: "Keeps work moving", instructions: "", notifyOnFinish: true, }); const coder = await rpc(app, ada, "bots/create", { name: "Coder", title: "Engineer", description: "Writes code", instructions: "", notifyOnFinish: true, }); const bobBot = await rpc(app, bob, "bots/create", { name: "Chief", title: "Chief of staff", description: "Bob's bot", instructions: "", notifyOnFinish: true, }); const [chiefRecord, coderRecord, bobRecord] = await Promise.all( [chief.id, coder.id, bobBot.id].map((id) => prisma.bot.findUniqueOrThrow({ where: { id }, include: { computer: true } }), ), ); expect(chief.computerMode).toBe("team"); expect(coder.computerMode).toBe("team"); expect(chiefRecord.computerId).toBe(coderRecord.computerId); expect(chiefRecord.computerId).not.toBe(bobRecord.computerId); const bobList = await rpc(app, bob, "bots/list"); expect(bobList.map((b) => b.id)).not.toContain(chief.id); const forbidden = await raw(app, bob, "bots/get", { botId: chief.id }); expect(forbidden.status).toBeGreaterThanOrEqual(400); const pinned = await rpc(app, ada, "bots/update", { botId: coder.id, pinned: true }); expect(pinned.pinned).toBe(true); const [section, concurrentSection] = await Promise.all([ rpc<{ id: string; name: string }>(app, ada, "botSections/create", { botId: chief.id, name: "Planning", }), rpc<{ id: string; name: string }>(app, ada, "botSections/create", { botId: coder.id, name: "Planning", }), ]); expect(section.name).toBe("Planning"); expect(concurrentSection.id).toBe(section.id); expect(await rpc>(app, ada, "botSections/list")).toEqual([ expect.objectContaining({ id: section.id }), ]); expect( (await rpc(app, ada, "bots/list")).find((bot) => bot.id === chief.id)?.sectionId, ).toBe(section.id); expect( (await rpc(app, ada, "bots/list")).find((bot) => bot.id === coder.id)?.sectionId, ).toBe(section.id); const foreignSection = await raw(app, bob, "bots/update", { botId: bobBot.id, sectionId: section.id, }); expect(foreignSection.status).toBeGreaterThanOrEqual(400); expect(await rpc(app, bob, "botSections/list")).toEqual([]); const renamed = await rpc<{ id: string; name: string }>(app, ada, "botSections/update", { sectionId: section.id, name: "Delivery", }); expect(renamed).toMatchObject({ id: section.id, name: "Delivery" }); expect(await rpc>(app, ada, "botSections/list")).toEqual([ expect.objectContaining({ id: section.id, name: "Delivery" }), ]); await rpc<{ id: string; name: string }>(app, ada, "botSections/create", { botId: coder.id, name: "Archive", }); const renameClash = await raw(app, ada, "botSections/update", { sectionId: section.id, name: "Archive", }); expect(renameClash.status).toBe(409); const foreignRename = await raw(app, bob, "botSections/update", { sectionId: section.id, name: "Stolen", }); expect(foreignRename.status).toBeGreaterThanOrEqual(400); expect(await rpc>(app, ada, "botSections/list")).toEqual( expect.arrayContaining([expect.objectContaining({ id: section.id, name: "Delivery" })]), ); const duplicate = await rpc(app, ada, "bots/duplicate", { botId: chief.id }); expect(duplicate).toMatchObject({ name: "Chief copy", title: chief.title, description: chief.description, instructions: chief.instructions, notifyOnFinish: chief.notifyOnFinish, color: chief.color, pinned: false, sectionId: null, unread: false, }); expect(duplicate.id).not.toBe(chief.id); expect((await rpc(app, ada, "bots/list"))[0]?.id).toBe(coder.id); await sendAndWait( app, ada, chief.id, "write a file in your home called notes/result.txt that says isolation-ok", ); expect( (await rpc(app, ada, "bots/list")).find((bot) => bot.id === chief.id)?.unread, ).toBe(true); await rpc(app, ada, "threads/markRead", { botId: chief.id }); expect( (await rpc(app, ada, "bots/list")).find((bot) => bot.id === chief.id)?.unread, ).toBe(false); await rpc(app, ada, "threads/markUnread", { botId: chief.id }); expect( (await rpc(app, ada, "bots/list")).find((bot) => bot.id === chief.id)?.unread, ).toBe(true); await sendAndWait(app, ada, coder.id, "remember that coder prefers rust"); const chiefFile = await rpc<{ path: string; content: string }>(app, ada, "computer/readFile", { botId: chief.id, path: "notes/result.txt", }); expect(chiefFile.content).toContain("isolation-ok"); const computer = await rpc<{ state: string }>(app, ada, "computer/status", { botId: chief.id }); expect(computer.state).toBe("running"); await rpc(app, ada, "computer/stop", { botId: chief.id }); const persisted = await rpc<{ content: string }>(app, ada, "computer/readFile", { botId: chief.id, path: "notes/result.txt", }); expect(persisted.content).toContain("isolation-ok"); const coderMem = await rpc>(app, ada, "memory/list", { botId: coder.id, }); expect(coderMem.some((m) => m.content.toLowerCase().includes("rust"))).toBe(true); // Remembering appends a dated note below the seeded heading instead of replacing it. expect(coderMem.map((m) => m.content)).toContainEqual( expect.stringMatching(/^# Coder\n\n- \d{4}-\d{2}-\d{2}: remember that coder prefers rust\n$/), ); const dedicated = await rpc(app, ada, "bots/setComputer", { botId: coder.id, mode: "dedicated", }); expect(dedicated.computerMode).toBe("dedicated"); const dedicatedRecord = await prisma.bot.findUniqueOrThrow({ where: { id: coder.id }, include: { computer: true }, }); expect(dedicatedRecord.computerId).not.toBe(chiefRecord.computerId); await rpc(app, ada, "bots/setComputer", { botId: coder.id, mode: "team" }); const backOnTeam = await prisma.bot.findUniqueOrThrow({ where: { id: coder.id } }); expect(backOnTeam.computerId).toBe(chiefRecord.computerId); await rpc(app, ada, "bots/setComputer", { botId: coder.id, mode: "dedicated" }); const reusedDedicated = await prisma.bot.findUniqueOrThrow({ where: { id: coder.id } }); expect(reusedDedicated.computerId).toBe(dedicatedRecord.computerId); expect(bobBot.id).not.toBe(chief.id); }); it("clears a conversation without removing the bot, computer, memory, or routines", async () => { const cookie = await signup(app, `clear-j-${stamp}@rakazo.test`, "Clear Journey"); const bot = await rpc(app, cookie, "bots/create", { name: "Keeper", title: "Keeps its setup", description: "", instructions: "", notifyOnFinish: true, }); await sendAndWait( app, cookie, bot.id, "write a file in your home called notes/result.txt that says kept-after-clear", ); const memories = await rpc>(app, cookie, "memory/list", { botId: bot.id, }); await rpc(app, cookie, "memory/update", { documentId: memories[0]!.id, content: "# Keeper\n\nRemember this after clearing.", }); const routine = await rpc<{ id: string }>(app, cookie, "routines/create", { botId: bot.id, name: "Kept routine", prompt: "Check in", crons: ["0 9 * * 1"], timezone: "UTC", notify: false, active: false, }); const thread = await prisma.thread.findUniqueOrThrow({ where: { botId: bot.id } }); const task = await prisma.task.create({ data: { spaceId: thread.spaceId, userId: thread.userId, botId: bot.id, threadId: thread.id, prompt: "queued before clear", status: "queued", }, }); const run = await prisma.run.create({ data: { spaceId: thread.spaceId, userId: thread.userId, botId: bot.id, threadId: thread.id, taskId: task.id, status: "queued", trigger: "user", }, }); await prisma.thread.update({ where: { id: thread.id }, data: { historyCompactedUpToSeq: 0, historyCompactionSummary: "summary that must be invalidated", historyCompactionGeneration: 3, }, }); await rpc(app, cookie, "threads/clear", { botId: bot.id }); const snap = await rpc(app, cookie, "threads/get", { botId: bot.id }); expect(snap.messages).toEqual([]); expect(snap.run).toBeNull(); expect(await prisma.message.count({ where: { threadId: thread.id } })).toBe(0); expect(await prisma.event.findMany({ where: { threadId: thread.id } })).toMatchObject([ { type: "thread.cleared" }, ]); // The deleted messages all count as compacted, so compaction cannot summarize them and // recall cannot treat the fresh conversation as having uncompacted history. const clearedThread = await prisma.thread.findUniqueOrThrow({ where: { id: thread.id } }); expect(clearedThread.historyCompactedUpToSeq).toBe(clearedThread.nextMessageSeq - 1); expect(clearedThread.historyCompactionSummary).toBeNull(); expect(clearedThread.historyCompactionGeneration).toBe(4); expect(await prisma.run.findUniqueOrThrow({ where: { id: run.id } })).toMatchObject({ status: "cancelled", }); expect(await prisma.task.findUniqueOrThrow({ where: { id: task.id } })).toMatchObject({ status: "cancelled", }); expect( (await rpc(app, cookie, "bots/list")).find((item) => item.id === bot.id), ).toMatchObject({ preview: "", unread: false, }); expect( await rpc(app, cookie, "computer/readFile", { botId: bot.id, path: "notes/result.txt" }), ).toMatchObject({ content: expect.stringContaining("kept-after-clear") }); expect(await rpc(app, cookie, "memory/list", { botId: bot.id })).toEqual( expect.arrayContaining([ expect.objectContaining({ content: expect.stringContaining("Remember this") }), ]), ); expect(await rpc(app, cookie, "routines/list", { botId: bot.id })).toEqual( expect.arrayContaining([expect.objectContaining({ id: routine.id })]), ); await expect( appendEvent(prisma, { spaceId: thread.spaceId, threadId: thread.id, botId: bot.id, type: "thread.progress", runId: run.id, payload: { text: "stale output after clear" }, }), ).rejects.toThrow(RunHistoryWriteError); await expect( createThreadMessage(prisma, { threadId: thread.id, role: "bot", blocks: [{ kind: "text", text: "stale output after clear" }], runId: run.id, }), ).rejects.toThrow(RunHistoryWriteError); expect(await prisma.message.count({ where: { threadId: thread.id } })).toBe(0); expect(await prisma.event.findMany({ where: { threadId: thread.id } })).toMatchObject([ { type: "thread.cleared" }, ]); const after = await sendAndWait( app, cookie, bot.id, "write a file in your home called notes/after-clear.txt that says hello-after-clear", ); expect(after.messages.length).toBeGreaterThan(0); expect(await prisma.message.count({ where: { threadId: thread.id } })).toBeGreaterThan(0); }); it("2b: two Team bots send at once on distinct screens", async () => { const cookie = await signup(app, `parallel-j-${stamp}@rakazo.test`, "Parallel"); const writer = await rpc(app, cookie, "bots/create", { name: "Writer", title: "", description: "", instructions: "", notifyOnFinish: true, }); const researcher = await rpc(app, cookie, "bots/create", { name: "Researcher", title: "", description: "", instructions: "", notifyOnFinish: true, }); const [writerSnap, researcherSnap] = await Promise.all([ sendAndWait(app, cookie, writer.id, "observe your screen and type writer-desk"), sendAndWait(app, cookie, researcher.id, "observe your screen and type researcher-desk"), ]); expect(writerSnap.run?.status).toBe("completed"); expect(researcherSnap.run?.status).toBe("completed"); expect( ( await rpc<{ busyBotName: string | null }>(app, cookie, "computer/status", { botId: writer.id, }) ).busyBotName, ).toBeNull(); expect( ( await rpc<{ busyBotName: string | null }>(app, cookie, "computer/status", { botId: researcher.id, }) ).busyBotName, ).toBeNull(); const writerScreen = await rpc<{ url: string | null }>(app, cookie, "computer/screenUrl", { botId: writer.id, }); const researcherScreen = await rpc<{ url: string | null }>(app, cookie, "computer/screenUrl", { botId: researcher.id, }); expect(writerScreen.url).toContain(writer.id); expect(researcherScreen.url).toContain(researcher.id); expect(writerScreen.url).not.toBe(researcherScreen.url); }); it("3: disconnect and reconnect from a cursor reconstructs the thread", async () => { const cookie = await signup(app, `cursor-j-${stamp}@rakazo.test`, "Cursor"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); await sendAndWait( app, cookie, bot.id, "write a file in your home called notes/result.txt that says reconnect-ok", ); const snap = await rpc(app, cookie, "threads/get", { botId: bot.id }); expect(snap.messages.map((m) => m.seq)).toEqual( [...snap.messages].map((m) => m.seq).sort((a, b) => a - b), ); expect(snap.messages.some((m) => JSON.stringify(m.blocks).includes("reconnect-ok"))).toBe(true); const again = await rpc(app, cookie, "threads/get", { botId: bot.id }); expect(again.messages.length).toBe(snap.messages.length); }); it("4: takeover login then resume without exposing credentials", async () => { const cookie = await signup(app, `takeover-j-${stamp}@rakazo.test`, "Takeover"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, cookie, "threads/send", { botId: bot.id, text: "install the gsc cli and sign in", }); const waiting = await waitFor( app, cookie, bot.id, (snap) => snap.run?.status === "waiting_takeover", ); expect(JSON.stringify(waiting.messages)).not.toMatch(/password|secret|token/i); await rpc(app, cookie, "computer/boot", { botId: bot.id }); await rpc(app, cookie, "computer/takeover", { botId: bot.id }); await rpc(app, cookie, "computer/release", { botId: bot.id }); const done = await waitFor( app, cookie, bot.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); expect(JSON.stringify(done.messages).toLowerCase()).toMatch(/signed in|session/); expect(done.run?.status ?? "completed").not.toBe("waiting_takeover"); const releaseEvents = await prisma.event.count({ where: { botId: bot.id, type: "computer.takeover.released" }, }); await rpc(app, cookie, "computer/release", { botId: bot.id }); expect( await prisma.event.count({ where: { botId: bot.id, type: "computer.takeover.released" }, }), ).toBe(releaseEvents); }); it("4d: skipping takeover resumes without treating login as done", async () => { const cookie = await signup(app, `takeover-skip-j-${stamp}@rakazo.test`, "Skip Takeover"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, cookie, "threads/send", { botId: bot.id, text: "install the gsc cli and sign in", }); const waiting = await waitFor( app, cookie, bot.id, (snap) => snap.run?.status === "waiting_takeover", ); await rpc(app, cookie, "computer/boot", { botId: bot.id }); await rpc(app, cookie, "computer/takeover", { botId: bot.id }); await rpc(app, cookie, "computer/release", { botId: bot.id, reason: "skipped" }); const done = await waitFor( app, cookie, bot.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); expect(JSON.stringify(done.messages).toLowerCase()).toMatch(/skipped/); expect(JSON.stringify(done.messages).toLowerCase()).not.toMatch(/signed in/); expect(done.run?.status ?? "completed").not.toBe("waiting_takeover"); const released = await prisma.event.findFirst({ where: { botId: bot.id, type: "computer.takeover.released" }, orderBy: { createdAt: "desc" }, }); expect(released).toMatchObject({ runId: waiting.run?.id, payload: { reason: "skipped" }, }); }); it("4b: an expired takeover denies input and reconciles API and database state", async () => { const previousTakeoverTtl = process.env.COMPUTER_TAKEOVER_TTL_MS; process.env.COMPUTER_TAKEOVER_TTL_MS = "1000"; try { const cookie = await signup(app, `takeover-expiry-j-${stamp}@rakazo.test`, "Expiry"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, cookie, "threads/send", { botId: bot.id, text: "install the gsc cli and sign in", }); const waiting = await waitFor( app, cookie, bot.id, (snap) => snap.run?.status === "waiting_takeover", ); await rpc(app, cookie, "computer/boot", { botId: bot.id }); const lease = await rpc<{ leaseId: string; expiresAt: string }>( app, cookie, "computer/takeover", { botId: bot.id }, ); expect(new Date(lease.expiresAt).getTime() - Date.now()).toBeLessThanOrEqual(1000); expect( (await rpc<{ url: string | null }>(app, cookie, "computer/screenUrl", { botId: bot.id })) .url, ).toContain("view_only=false"); await rpc(app, cookie, "computer/input", { botId: bot.id, kind: "key", payload: { key: "a" }, }); await waitForDatabase(async () => { const storedBot = await prisma.bot.findUniqueOrThrow({ where: { id: bot.id }, include: { computer: true }, }); const computer = storedBot.computer!; return computer.controlLeaseId === null && computer.controlHolder === "none"; }); expect( (await rpc<{ controlHolder: string }>(app, cookie, "computer/status", { botId: bot.id })) .controlHolder, ).toBe("none"); expect( (await rpc<{ url: string | null }>(app, cookie, "computer/screenUrl", { botId: bot.id })) .url, ).toContain("view_only=true"); expect( ( await raw(app, cookie, "computer/input", { botId: bot.id, kind: "key", payload: { key: "b" }, }) ).status, ).toBeGreaterThanOrEqual(400); const done = await waitFor( app, cookie, bot.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); expect(JSON.stringify(done.messages).toLowerCase()).toMatch(/skipped/); expect(JSON.stringify(done.messages).toLowerCase()).not.toMatch(/signed in/); expect(done.run?.status ?? "completed").not.toBe("waiting_takeover"); expect( await prisma.event.findFirst({ where: { runId: waiting.run?.id, type: "computer.takeover.released" }, orderBy: { createdAt: "desc" }, }), ).toMatchObject({ payload: { reason: "expired" } }); } finally { if (previousTakeoverTtl === undefined) delete process.env.COMPUTER_TAKEOVER_TTL_MS; else process.env.COMPUTER_TAKEOVER_TTL_MS = previousTakeoverTtl; } }); it("4c: a takeover authorizes input only on the controlled bot screen", async () => { const cookie = await signup(app, `takeover-scope-j-${stamp}@rakazo.test`, "Takeover Scope"); const writer = await rpc(app, cookie, "bots/create", { name: "Writer", title: "", description: "", instructions: "", notifyOnFinish: true, }); const researcher = await rpc(app, cookie, "bots/create", { name: "Researcher", title: "", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, cookie, "computer/boot", { botId: writer.id }); await rpc(app, cookie, "computer/takeover", { botId: writer.id }); expect( ( await raw(app, cookie, "computer/input", { botId: researcher.id, kind: "key", payload: { key: "A" }, }) ).status, ).toBeGreaterThanOrEqual(400); await rpc(app, cookie, "computer/release", { botId: writer.id }); }); it("4d: a stale Team release cannot clear a newer bot takeover", async () => { const cookie = await signup( app, `takeover-release-fence-j-${stamp}@rakazo.test`, "Release Fence", ); const writer = await rpc(app, cookie, "bots/create", { name: "Writer", title: "", description: "", instructions: "", notifyOnFinish: true, }); const researcher = await rpc(app, cookie, "bots/create", { name: "Researcher", title: "", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, cookie, "computer/boot", { botId: writer.id }); await rpc(app, cookie, "threads/send", { botId: researcher.id, text: "install the gsc cli and sign in", }); await waitFor(app, cookie, researcher.id, (snap) => snap.run?.status === "waiting_takeover"); const writerLease = await rpc<{ leaseId: string; expiresAt: string }>( app, cookie, "computer/takeover", { botId: writer.id }, ); const releaseEventsBefore = await prisma.event.count({ where: { botId: researcher.id, type: "computer.takeover.released" }, }); const originalSetScreenControl = sandbox.setScreenControl; let signalReleaseRevocation!: () => void; let allowReleaseRevocation!: () => void; const releaseRevocationReached = new Promise((resolve) => { signalReleaseRevocation = resolve; }); const releaseRevocationAllowed = new Promise((resolve) => { allowReleaseRevocation = resolve; }); const revokedLeases: string[] = []; sandbox.setScreenControl = async (_computer, interactive, context, controlToken) => { expect(interactive).toBe(false); revokedLeases.push(controlToken ?? ""); if (revokedLeases.length !== 1) return; expect(context.botId).toBe(writer.id); expect(controlToken).toBe(writerLease.leaseId); signalReleaseRevocation(); await releaseRevocationAllowed; }; let staleRelease: Promise<{ ok: true }> | undefined; let researcherLease!: { leaseId: string; expiresAt: string }; try { // Pause Release after it captures Writer's lease but before database finalization. The // replacement takeover then commits first, so the resumed Release must lose its lease CAS. staleRelease = rpc<{ ok: true }>(app, cookie, "computer/release", { botId: writer.id }); await releaseRevocationReached; researcherLease = await rpc<{ leaseId: string; expiresAt: string }>( app, cookie, "computer/takeover", { botId: researcher.id }, ); allowReleaseRevocation(); await staleRelease; } finally { allowReleaseRevocation(); await staleRelease?.catch(() => undefined); sandbox.setScreenControl = originalSetScreenControl; } expect(researcherLease.leaseId).not.toBe(writerLease.leaseId); expect(revokedLeases).toEqual([writerLease.leaseId, writerLease.leaseId]); const writerRecord = await prisma.bot.findUniqueOrThrow({ where: { id: writer.id }, include: { computer: true }, }); const researcherRecord = await prisma.bot.findUniqueOrThrow({ where: { id: researcher.id }, include: { computer: true }, }); const computerId = writerRecord.computer!.id; expect(researcherRecord.computer!.id).toBe(computerId); const afterStaleRelease = await prisma.computer.findUniqueOrThrow({ where: { id: computerId }, }); expect(afterStaleRelease.controlHolder).toBe("user"); expect(afterStaleRelease.controlBotId).toBe(researcher.id); expect(afterStaleRelease.controlLeaseId).toBe(researcherLease.leaseId); expect(afterStaleRelease.controlLeaseExpiresAt?.toISOString()).toBe(researcherLease.expiresAt); expect( await prisma.event.count({ where: { botId: researcher.id, type: "computer.takeover.released" }, }), ).toBe(releaseEventsBefore); expect( ( await prisma.run.findFirstOrThrow({ where: { botId: researcher.id, status: "waiting_takeover" }, }) ).status, ).toBe("waiting_takeover"); await rpc(app, cookie, "computer/release", { botId: researcher.id }); const afterOwnerRelease = await prisma.computer.findUniqueOrThrow({ where: { id: computerId }, }); expect(afterOwnerRelease).toMatchObject({ controlHolder: "bot", controlBotId: null, controlLeaseId: null, controlLeaseExpiresAt: null, }); await waitFor( app, cookie, researcher.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); expect( await prisma.event.count({ where: { botId: researcher.id, type: "computer.takeover.released" }, }), ).toBe(releaseEventsBefore + 1); const releaseEvent = await prisma.event.findFirstOrThrow({ where: { botId: researcher.id, type: "computer.takeover.released" }, orderBy: { seq: "desc" }, }); expect(releaseEvent.payload).toMatchObject({ holder: "bot", leaseId: researcherLease.leaseId, reason: "released", }); await rpc(app, cookie, "computer/release", { botId: researcher.id }); expect( await prisma.event.count({ where: { botId: researcher.id, type: "computer.takeover.released" }, }), ).toBe(releaseEventsBefore + 1); }); it("4e: concurrent Team takeovers never return another bot's lease", async () => { const cookie = await signup( app, `takeover-owner-race-j-${stamp}@rakazo.test`, "Takeover Owner Race", ); const writer = await rpc(app, cookie, "bots/create", { name: "Writer", title: "", description: "", instructions: "", notifyOnFinish: true, }); const researcher = await rpc(app, cookie, "bots/create", { name: "Researcher", title: "", description: "", instructions: "", notifyOnFinish: true, }); const analyst = await rpc(app, cookie, "bots/create", { name: "Analyst", title: "", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, cookie, "computer/boot", { botId: writer.id }); const writerLease = await rpc<{ leaseId: string }>(app, cookie, "computer/takeover", { botId: writer.id, }); const originalSetScreenControl = sandbox.setScreenControl; let revocations = 0; let releaseRevocations!: () => void; const bothRevocationsReached = new Promise((resolve) => { releaseRevocations = resolve; }); const barrierTimeout = setTimeout(releaseRevocations, 1_000); sandbox.setScreenControl = async (_computer, interactive, _context, controlToken) => { expect(interactive).toBe(false); expect(controlToken).toBe(writerLease.leaseId); revocations += 1; if (revocations === 2) releaseRevocations(); await bothRevocationsReached; }; let responses: Response[] = []; try { responses = await Promise.all([ raw(app, cookie, "computer/takeover", { botId: researcher.id }), raw(app, cookie, "computer/takeover", { botId: analyst.id }), ]); } finally { clearTimeout(barrierTimeout); releaseRevocations(); sandbox.setScreenControl = originalSetScreenControl; } expect(revocations).toBe(2); expect(responses.map((response) => response.status).sort()).toEqual([200, 409]); const computer = await prisma.computer.findFirstOrThrow({ where: { bots: { some: { id: writer.id } } }, select: { controlBotId: true, controlLeaseId: true }, }); const winner = computer.controlBotId === researcher.id ? researcher : analyst; const successfulResponse = responses.find((response) => response.status === 200)!; await expect(successfulResponse.clone().json()).resolves.toMatchObject({ json: { leaseId: computer.controlLeaseId }, }); await rpc(app, cookie, "computer/release", { botId: winner.id }); }); it("5: a routine wakes the bot and posts into the existing thread", async () => { const cookie = await signup(app, `routine-j-${stamp}@rakazo.test`, "Routine"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); const routine = await rpc<{ id: string; nextRunAt: string | null }>( app, cookie, "routines/create", { botId: bot.id, name: "Monday briefing", prompt: "write a file in your home called notes/result.txt that says routine-ok", crons: ["0 9 * * 1"], timezone: "UTC", notify: true, active: true, }, ); expect(routine.nextRunAt).toBeTruthy(); const dueAt = new Date(Date.now() - 1_000); await prisma.routine.update({ where: { id: routine.id }, data: { nextRunAt: dueAt }, }); await jobs.enqueue({ name: "routine.wakeup", payload: { routineId: routine.id, scheduledFor: dueAt.toISOString() }, }); await jobs.enqueue({ name: "routine.wakeup", payload: { routineId: routine.id, scheduledFor: dueAt.toISOString() }, }); const snap = await waitFor(app, cookie, bot.id, (s) => s.messages.some( (m) => JSON.stringify(m.blocks).includes("routine-ok") || JSON.stringify(m.blocks).includes("writing"), ), ); expect(snap.messages.length).toBeGreaterThan(0); const routineRuns = await prisma.run.count({ where: { botId: bot.id, trigger: "routine" }, }); expect(routineRuns).toBe(1); const advanced = await prisma.routine.findUniqueOrThrow({ where: { id: routine.id } }); expect(advanced.nextRunAt?.getTime()).toBeGreaterThan(dueAt.getTime()); const legacyRunsBefore = await prisma.run.count({ where: { botId: bot.id, trigger: "routine" }, }); const legacyDueAt = new Date(Date.now() - 1_000); const legacy = await prisma.routine.create({ data: { spaceId: advanced.spaceId, userId: advanced.userId, botId: bot.id, name: "Legacy schedule", prompt: "Run the legacy schedule once", crons: ["0 0 9 * * *"], timezone: "UTC", notify: false, active: true, nextRunAt: legacyDueAt, }, }); await jobs.enqueue({ name: "routine.wakeup", payload: { routineId: legacy.id, scheduledFor: legacyDueAt.toISOString() }, }); await waitForDatabase(async () => { const stored = await prisma.routine.findUnique({ where: { id: legacy.id } }); return stored?.active === false && stored.nextRunAt === null; }); expect(await prisma.run.count({ where: { botId: bot.id, trigger: "routine" } })).toBe( legacyRunsBefore + 1, ); }); it("5b: tool-created schedules wake in the creating group or 1:1 thread", async () => { const cookie = await signup(app, `schedule-dest-j-${stamp}@rakazo.test`, "Schedule Dest"); const me = await rpc(app, cookie, "me"); const bot = await rpc(app, cookie, "bots/create", { name: "Scheduler", title: "", description: "", instructions: "", notifyOnFinish: true, }); const peer = await rpc(app, cookie, "bots/create", { name: "Peer", title: "", description: "", instructions: "", notifyOnFinish: false, }); const dmThread = await prisma.thread.findUniqueOrThrow({ where: { botId: bot.id } }); const group = await rpc<{ id: string; threadId: string }>(app, cookie, "groups/create", { name: "Schedule room", botIds: [bot.id, peer.id], }); const events = createThreadEvents(prisma); const scheduleDeps = { prisma, events, jobs }; const groupCreated = await createScheduleFromTool(scheduleDeps, { spaceId: me.spaceId, botId: bot.id, userId: me.userId, threadId: group.threadId, name: "Group ping", prompt: "say group-reminder-ok", schedule: { delaySeconds: 30 }, }); expect(groupCreated).toMatchObject({ ok: true }); if (!("ok" in groupCreated) || !groupCreated.ok) throw new Error("group schedule create failed"); const groupRoutine = await prisma.routine.findUniqueOrThrow({ where: { id: groupCreated.routineId }, }); expect(groupRoutine.threadId).toBe(group.threadId); expect(groupRoutine.crons).toEqual([ONCE_ROUTINE_CRON]); const groupDueAt = new Date(Date.now() - 1_000); await prisma.routine.update({ where: { id: groupRoutine.id }, data: { nextRunAt: groupDueAt }, }); await executor.wakeRoutine(groupRoutine.id, groupDueAt.toISOString()); await waitForDatabase(async () => { const run = await prisma.run.findFirst({ where: { routineId: groupRoutine.id, trigger: "routine" }, }); return run?.threadId === group.threadId; }); const groupRun = await prisma.run.findFirstOrThrow({ where: { routineId: groupRoutine.id, trigger: "routine" }, }); expect(groupRun.threadId).toBe(group.threadId); expect(groupRun.threadId).not.toBe(dmThread.id); expect( await prisma.event.count({ where: { threadId: group.threadId, type: "routine.fired", runId: groupRun.id, }, }), ).toBe(1); expect( await prisma.event.count({ where: { threadId: dmThread.id, type: "routine.fired", runId: groupRun.id, }, }), ).toBe(0); const dmCreated = await createScheduleFromTool(scheduleDeps, { spaceId: me.spaceId, botId: bot.id, userId: me.userId, threadId: dmThread.id, name: "DM ping", prompt: "say dm-reminder-ok", schedule: { delaySeconds: 30 }, }); expect(dmCreated).toMatchObject({ ok: true }); if (!("ok" in dmCreated) || !dmCreated.ok) throw new Error("dm schedule create failed"); const dmRoutine = await prisma.routine.findUniqueOrThrow({ where: { id: dmCreated.routineId }, }); expect(dmRoutine.threadId).toBe(dmThread.id); const dmDueAt = new Date(Date.now() - 1_000); await prisma.routine.update({ where: { id: dmRoutine.id }, data: { nextRunAt: dmDueAt }, }); await executor.wakeRoutine(dmRoutine.id, dmDueAt.toISOString()); await waitForDatabase(async () => { const run = await prisma.run.findFirst({ where: { routineId: dmRoutine.id, trigger: "routine" }, }); return run?.threadId === dmThread.id; }); const dmRun = await prisma.run.findFirstOrThrow({ where: { routineId: dmRoutine.id, trigger: "routine" }, }); expect(dmRun.threadId).toBe(dmThread.id); expect( await prisma.event.count({ where: { threadId: dmThread.id, type: "routine.fired", runId: dmRun.id, }, }), ).toBe(1); }); it("5c: the main bot schedules for a specialist in its own space or in the main conversation", async () => { const cookie = await signup(app, `schedule-behalf-j-${stamp}@rakazo.test`, "Schedule Behalf"); const me = await rpc(app, cookie, "me"); const main = await ensureMainBot(prisma, me.userId); const writer = await rpc(app, cookie, "bots/create", { name: "Writer", title: "", description: "", instructions: "", notifyOnFinish: false, }); const lab = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Lab" }); const scout = await createRepos(prisma).createBot( { userId: me.userId, spaceId: lab.id, email: "", isDeploymentOwner: false }, { name: "Scout", title: "", description: "", instructions: "", notifyOnFinish: false }, ); const scheduleDeps = { prisma, events: createThreadEvents(prisma), jobs }; const creator = { botId: main.id, kind: "main", spaceId: main.spaceId, userId: me.userId, threadId: main.threadId, }; async function scheduleAndWake(botName: string, routineName: string) { const owner = await resolveScheduleOwner(prisma, creator, botName); if ("error" in owner) throw new Error(owner.error); const created = await createScheduleFromTool(scheduleDeps, { ...owner, name: routineName, prompt: `say ${routineName}-ok`, schedule: { delaySeconds: 30 }, }); if (!("ok" in created)) throw new Error(created.error); expect(created.bot).toBe(botName); const routine = await prisma.routine.findUniqueOrThrow({ where: { id: created.routineId } }); const dueAt = new Date(Date.now() - 1_000); await prisma.routine.update({ where: { id: routine.id }, data: { nextRunAt: dueAt } }); await executor.wakeRoutine(routine.id, dueAt.toISOString()); await waitForDatabase(async () => Boolean(await prisma.run.findFirst({ where: { routineId: routine.id } })), ); const run = await prisma.run.findFirstOrThrow({ where: { routineId: routine.id } }); return { routine, run }; } const inLab = await scheduleAndWake("Scout", "lab-digest"); expect(inLab.routine).toMatchObject({ botId: scout.id, spaceId: lab.id, threadId: scout.threadId, createdByBotId: main.id, }); expect(inLab.run).toMatchObject({ botId: scout.id, spaceId: lab.id, threadId: scout.threadId, trigger: "routine", }); await joinMainConversation(prisma, { userId: me.userId, botIds: [writer.id] }); // Lab has a main conversation of its own; the home one is where Writer joined. const conversation = await prisma.chatGroup.findFirstOrThrow({ where: { userId: me.userId, spaceId: main.spaceId, kind: "main" }, include: { thread: true }, }); const inMain = await scheduleAndWake("Writer", "main-digest"); expect(inMain.routine).toMatchObject({ botId: writer.id, threadId: conversation.thread?.id, createdByBotId: main.id, }); expect(inMain.run).toMatchObject({ botId: writer.id, threadId: conversation.thread?.id }); const listed = await listSchedulesFromTool(scheduleDeps, { spaceId: main.spaceId, botId: main.id, userId: me.userId, threadId: conversation.thread?.id, }); expect(listed.routines.map((routine) => [routine.name, routine.bot])).toEqual( expect.arrayContaining([ ["lab-digest", "Scout"], ["main-digest", "Writer"], ]), ); expect( await listSchedulesFromTool(scheduleDeps, { spaceId: lab.id, botId: scout.id, userId: me.userId, }), ).toMatchObject({ routines: [{ name: "lab-digest" }] }); expect( await cancelScheduleFromTool(scheduleDeps, { spaceId: main.spaceId, botId: main.id, userId: me.userId, name: "lab-digest", }), ).toMatchObject({ ok: true, routineId: inLab.routine.id }); expect(await prisma.routine.findUnique({ where: { id: inLab.routine.id } })).toBeNull(); // Another specialist neither owns nor scheduled the Writer's routine. expect( await cancelScheduleFromTool(scheduleDeps, { spaceId: lab.id, botId: scout.id, userId: me.userId, name: "main-digest", }), ).toEqual({ error: "Schedule not found." }); expect(await prisma.routine.findUnique({ where: { id: inMain.routine.id } })).not.toBeNull(); const retired = await rpc(app, cookie, "bots/create", { name: "Retired", title: "", description: "", instructions: "", notifyOnFinish: false, }); await rpc(app, cookie, "bots/archive", { botId: retired.id }); expect(await resolveScheduleOwner(prisma, creator, "Retired")).toEqual({ error: "No bot named Retired.", }); expect( await resolveScheduleOwner( prisma, { ...creator, botId: scout.id, kind: "specialist" }, "Writer", ), ).toEqual({ error: "Only the main agent can schedule for another bot." }); }); it("5d: a bot's builtin tool allowlist holds in its runs and in bots it creates", async () => { const cookie = await signup(app, `allowlist-j-${stamp}@rakazo.test`, "Allowlist"); const bot = await rpc( app, cookie, "bots/create", { name: "Reader", title: "", description: "", instructions: "", notifyOnFinish: false, tags: ["research"], restrictBuiltinTools: true, allowedBuiltinTools: ["read_file"], }, ); expect(bot).toMatchObject({ tags: ["research"], restrictBuiltinTools: true, allowedBuiltinTools: ["read_file"], }); expect(await rpc(app, cookie, "bots/builtinTools", { botId: bot.id })).toEqual( expect.arrayContaining(["read_file", "write_file", "schedule_create"]), ); await sendAndWait( app, cookie, bot.id, "write a file in your home called notes/blocked.txt that says blocked", ); await expect( rpc(app, cookie, "computer/readFile", { botId: bot.id, path: "notes/blocked.txt" }), ).rejects.toThrow(); await expect( rpc(app, cookie, "bots/update", { botId: bot.id, allowedBuiltinTools: ["write_fiel"] }), ).rejects.toThrow(/Unknown tools: write_fiel/); await rpc(app, cookie, "bots/update", { botId: bot.id, allowedBuiltinTools: ["write_file", "spawn_bot"], }); await sendAndWait( app, cookie, bot.id, "write a file in your home called notes/allowed.txt that says allowed-ok", ); expect( ( await rpc<{ content: string }>(app, cookie, "computer/readFile", { botId: bot.id, path: "notes/allowed.txt", }) ).content, ).toContain("allowed-ok"); await sendAndWait(app, cookie, bot.id, "create a bot named Junior"); const child = await prisma.bot.findFirstOrThrow({ where: { parentBotId: bot.id } }); expect(child).toMatchObject({ name: "Junior", restrictBuiltinTools: true, allowedBuiltinTools: ["write_file", "spawn_bot"], }); const copy = await rpc<{ restrictBuiltinTools: boolean; allowedBuiltinTools: string[] }>( app, cookie, "bots/duplicate", { botId: bot.id }, ); expect(copy).toMatchObject({ restrictBuiltinTools: true, allowedBuiltinTools: ["write_file", "spawn_bot"], }); }); it("allocates event and message cursors atomically under concurrent writes", async () => { const cookie = await signup(app, `sequence-j-${stamp}@rakazo.test`, "Sequence"); const actor = await rpc(app, cookie, "me"); const bot = await rpc(app, cookie, "bots/create", { name: "Sequencer", title: "", description: "", instructions: "", notifyOnFinish: false, }); const thread = await prisma.thread.findUniqueOrThrow({ where: { botId: bot.id } }); await Promise.all( Array.from({ length: 40 }, (_, index) => appendEvent(prisma, { spaceId: actor.spaceId, threadId: thread.id, botId: bot.id, type: "thread.progress", payload: { delta: String(index) }, }), ), ); await Promise.all( Array.from({ length: 40 }, (_, index) => createThreadMessage(prisma, { threadId: thread.id, role: "system", blocks: [{ kind: "meta", text: String(index) }], }), ), ); const [events, messages] = await Promise.all([ prisma.event.findMany({ where: { threadId: thread.id }, orderBy: { seq: "asc" } }), prisma.message.findMany({ where: { threadId: thread.id }, orderBy: { seq: "asc" } }), ]); expect(events.map((row) => row.seq)).toEqual(Array.from({ length: 40 }, (_, i) => i)); expect(messages.map((row) => row.seq)).toEqual(Array.from({ length: 40 }, (_, i) => i)); }); it("6: fake, managed-sandbox emulator, and desktop executor run the same graphical task", async () => { const ctx = { operationId: "1", traceId: "1", spaceId: "w", userId: "u", signal: new AbortController().signal, }; const fake = new FakeSandboxProvider(); const managed = new ManagedSandboxEmulator(); const desktop = new DesktopSandboxProvider(); const a = await fake.provision({ botId: "ja", homePath: "/tmp/ja" }, ctx); const b = await managed.provision({ botId: "jb", homePath: "/tmp/jb" }, ctx); const c = await desktop.provision({ botId: "jc", homePath: "/tmp/jc" }, ctx); let out = ""; for await (const event of fake.execute(a, { argv: ["echo", "same-task"] }, ctx)) { if (event.type === "stdout") out += event.data; } for await (const event of managed.execute(b, { argv: ["echo", "same-task"] }, ctx)) { if (event.type === "stdout") out += event.data; } for await (const event of desktop.execute(c, { argv: ["echo", "same-task"] }, ctx)) { if (event.type === "stdout") out += event.data; } expect(out.match(/same-task/g)?.length).toBe(3); await desktop.destroy(c, ctx); }); it("7: destination write is independently inspectable and credentials stay out of the thread", async () => { const cookie = await signup(app, `dest-j-${stamp}@rakazo.test`, "Dest"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); const before = connector.records.length; const secret = "test-openrouter-key-not-a-real-secret"; await rpc(app, cookie, "models/connect", { provider: "openrouter", apiKey: secret, label: "test", modelId: "scripted", }); await sendAndWait(app, cookie, bot.id, "write this to the destination crm as a note"); expect(connector.records.length).toBeGreaterThan(before); const snap = await rpc(app, cookie, "threads/get", { botId: bot.id }); expect(JSON.stringify(snap)).not.toContain(secret); const inspect = await fetch(`http://127.0.0.1:${connector.port}/records`); const records = (await inspect.json()) as unknown[]; expect(records.length).toBeGreaterThan(0); }); it("8: retrying a completed effect does not duplicate the destination write", async () => { const cookie = await signup(app, `crash-j-${stamp}@rakazo.test`, "Crash"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); const before = connector.records.length; const sent = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId: bot.id, text: "write this to the destination crm as a note", }); await waitFor( app, cookie, bot.id, (s) => !s.run || ["completed", "failed", "cancelled"].includes(s.run.status), ); const afterFirst = connector.records.length; expect(afterFirst).toBeGreaterThan(before); await prisma.run.update({ where: { id: sent.runId }, data: { status: "running", completedAt: null }, }); await executor.continueRun(sent.runId, "retry"); expect(connector.records.length).toBe(afterFirst); }); it("9: export includes memory and files but not secrets or browser sessions", async () => { const cookie = await signup(app, `export-j-${stamp}@rakazo.test`, "Export"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "Be useful", notifyOnFinish: true, }); const secret = "test-openrouter-key-not-a-real-secret"; await rpc(app, cookie, "models/connect", { provider: "openrouter", apiKey: secret, label: "hidden", modelId: "scripted", }); await sendAndWait( app, cookie, bot.id, "write a file in your home called notes/result.txt that says export-ok", ); const manifest = await rpc>(app, cookie, "export/bot", { botId: bot.id, }); const rawJson = JSON.stringify(manifest); expect(rawJson).toContain("export-ok"); expect(rawJson).toContain("Be useful"); expect(rawJson).not.toContain(secret); expect(rawJson).not.toMatch(/browserProfile|ciphertext|sessionCookie/i); }); it("9b: an export restores as a new bot with memory, files and paused routines", async () => { const cookie = await signup(app, `import-j-${stamp}@rakazo.test`, "Import"); const source = await rpc(app, cookie, "bots/create", { name: "Scout", title: "Researcher", description: "Finds sources", instructions: "Cite everything", notifyOnFinish: true, }); await sendAndWait( app, cookie, source.id, "write a file in your home called notes/result.txt that says import-ok", ); await rpc(app, cookie, "routines/create", { botId: source.id, name: "Morning brief", prompt: "Summarize the news", crons: ["0 9 * * *"], timezone: "UTC", active: true, }); const manifest = await rpc<{ memory: Array<{ path: string; content: string }>; files: Array<{ path: string; content: string }>; }>(app, cookie, "export/bot", { botId: source.id }); expect(manifest.files.some((file) => file.content.includes("import-ok"))).toBe(true); const imported = await rpc(app, cookie, "bots/import", manifest); expect(imported.id).not.toBe(source.id); expect(imported).toMatchObject({ name: "Scout", title: "Researcher", description: "Finds sources", instructions: "Cite everything", }); const roundTrip = await rpc<{ memory: Array<{ path: string; content: string }>; files: Array<{ path: string; content: string }>; }>(app, cookie, "export/bot", { botId: imported.id }); expect(roundTrip.files.map((file) => file.content)).toContain( manifest.files.find((file) => file.content.includes("import-ok"))?.content, ); expect(roundTrip.files.every((file) => !file.path.includes(source.id))).toBe(true); for (const doc of manifest.memory) { expect(roundTrip.memory).toContainEqual(doc); } const routines = await prisma.routine.findMany({ where: { botId: imported.id } }); expect(routines).toHaveLength(1); expect(routines[0]).toMatchObject({ name: "Morning brief", active: false, nextRunAt: null }); const other = await signup(app, `import-other-j-${stamp}@rakazo.test`, "Other"); const unsafe = await raw(app, other, "bots/import", { ...manifest, files: [{ path: "../escape.txt", content: "x" }], }); expect(unsafe.status).toBe(400); const otherBots = await rpc(app, other, "bots/list"); expect(otherBots.some((bot) => bot.name === "Scout")).toBe(false); }); it("10: bots can be archived safely and deleted with or without their memories", async () => { const ada = await signup(app, `delete-j-${stamp}@rakazo.test`, "Delete Ada"); const bob = await signup(app, `delete-bob-j-${stamp}@rakazo.test`, "Delete Bob"); const keep = await rpc(app, ada, "bots/create", { name: "Keep", title: "", description: "", instructions: "", notifyOnFinish: true, }); const gone = await rpc(app, ada, "bots/create", { name: "Gone", title: "", description: "", instructions: "", notifyOnFinish: true, computerMode: "dedicated", }); const forget = await rpc(app, ada, "bots/create", { name: "Forget", title: "", description: "", instructions: "", notifyOnFinish: true, }); const goneMemory = await prisma.memoryDocument.findFirstOrThrow({ where: { botId: gone.id } }); const forgetMemory = await prisma.memoryDocument.findFirstOrThrow({ where: { botId: forget.id }, }); await prisma.memoryDocument.update({ where: { id: goneMemory.id }, data: { content: "important retained context" }, }); await sendAndWait( app, ada, gone.id, "write a file in your home called notes/result.txt that says delete-ok", ); const goneArtifact = await rpc<{ id: string }>(app, ada, "artifacts/create", { botId: gone.id, name: "delete-me.txt", mimeType: "text/plain", contentBase64: Buffer.from("fake artifact content").toString("base64"), }); const home = path.join(dataDir, "homes", gone.id); expect(existsSync(home)).toBe(true); // Spend that really happened. The scripted runtime emits no usage event, so the row is // written directly, the same way this file creates runs and tasks elsewhere. const goneBotRow = await prisma.bot.findUniqueOrThrow({ where: { id: gone.id }, select: { spaceId: true, userId: true }, }); const goneSpend = await prisma.usageRecord.create({ data: { spaceId: goneBotRow.spaceId, botId: gone.id, userId: goneBotRow.userId, provider: "anthropic", model: "claude-sonnet-4-5", inputTokens: 1200, outputTokens: 340, }, }); const stolen = await raw(app, bob, "bots/archive", { botId: gone.id }); expect(stolen.status).toBeGreaterThanOrEqual(400); expect((await rpc(app, ada, "bots/list")).map((bot) => bot.id)).toContain(gone.id); await rpc(app, ada, "bots/archive", { botId: gone.id }); expect((await rpc(app, ada, "bots/list")).map((bot) => bot.id)).not.toContain(gone.id); expect((await rpc(app, ada, "bots/listArchived")).map((bot) => bot.id)).toContain( gone.id, ); expect(await prisma.artifact.findUnique({ where: { id: goneArtifact.id } })).not.toBeNull(); expect(existsSync(home)).toBe(true); await rpc(app, ada, "bots/restore", { botId: gone.id }); expect((await rpc(app, ada, "bots/list")).map((bot) => bot.id)).toContain(gone.id); await rpc(app, ada, "bots/remove", { botId: gone.id, deleteMemories: false }); const list = await rpc(app, ada, "bots/list"); expect(list.map((bot) => bot.id)).toEqual(expect.arrayContaining([keep.id, forget.id])); expect((await raw(app, ada, "bots/get", { botId: gone.id })).status).toBeGreaterThanOrEqual( 400, ); expect( await prisma.memoryDocument.findUniqueOrThrow({ where: { id: goneMemory.id } }), ).toMatchObject({ botId: null, scope: "user", content: "important retained context", }); // The name is the half that makes a detached usage row readable: bot_deletions is keyed by // the bot id, so a per-bot spend report can still label spend that belongs to a bot that // no longer exists. expect(await prisma.botDeletion.findUniqueOrThrow({ where: { id: gone.id } })).toMatchObject({ name: "Gone", memoriesPreserved: true, }); expect(await prisma.artifact.findUnique({ where: { id: goneArtifact.id } })).toBeNull(); expect(existsSync(home)).toBe(false); // Deleting a bot must not erase what it cost, nor which bot cost it. botId has no foreign // key, so it outlives the bot and stays joinable against bot_deletions for the name. expect( await prisma.usageRecord.findUniqueOrThrow({ where: { id: goneSpend.id } }), ).toMatchObject({ botId: gone.id, inputTokens: 1200, outputTokens: 340 }); await rpc(app, ada, "bots/remove", { botId: forget.id, deleteMemories: true }); expect(await prisma.memoryDocument.findUnique({ where: { id: forgetMemory.id } })).toBeNull(); }); it("11: deleting an account removes the user and personal workspace data", async () => { const email = `account-delete-j-${stamp}@rakazo.test`; const cookie = await signup(app, email, "Delete Account"); const me = await rpc(app, cookie, "me"); const bot = await rpc(app, cookie, "bots/create", { name: "Temporary", title: "", description: "", instructions: "", notifyOnFinish: true, }); const homeKey = computerHomeKey("team", bot.spaceId); const teamComputer = await ensureComputerRecord(prisma, { mode: "team", spaceId: bot.spaceId, userId: me.userId, kind: "fake", }); const teamHomePath = path.join(dataDir, "homes", homeKey); mkdirSync(path.join(teamHomePath, "account-delete-marker"), { recursive: true }); const fakeSandbox = sandbox as FakeSandboxProvider; const teamRef = await fakeSandbox.provision( { botId: homeKey, homePath: teamHomePath }, { operationId: "journey-11-seed", traceId: "journey-11-seed", spaceId: bot.spaceId, userId: me.userId, signal: AbortSignal.timeout(15_000), }, ); await prisma.computer.update({ where: { id: teamComputer.id }, data: { providerRef: teamRef.id, state: "running" }, }); const deleted = await app.request("/api/auth/delete-user", { method: "POST", headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173", }, body: JSON.stringify({ password: "password12" }), }); expect(deleted.status).toBe(200); expect(await prisma.user.findUnique({ where: { id: me.userId } })).toBeNull(); expect(await prisma.organization.findUnique({ where: { id: me.spaceId } })).toBeNull(); expect(await prisma.bot.findUnique({ where: { id: bot.id } })).toBeNull(); expect((await raw(app, cookie, "me")).status).toBeGreaterThanOrEqual(400); expect(existsSync(teamHomePath)).toBe(false); expect(fakeSandbox.boxes.has(teamRef.id)).toBe(false); }); it("12: a bot can spawn a regular bot and must confirm the name to delete it", async () => { const cookie = await signup(app, `spawn-j-${stamp}@rakazo.test`, "Spawn"); const parent = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); await sendAndWait(app, cookie, parent.id, "spawn a bot named Scout to research venues"); const listed = await rpc(app, cookie, "bots/list"); const scout = listed.find((bot) => bot.name === "Scout"); expect(scout).toBeTruthy(); expect(listed.map((bot) => bot.name).sort()).toEqual(["Chief", "Scout"]); await waitFor( app, cookie, scout!.id, (s) => !s.run || ["completed", "failed", "cancelled"].includes(s.run.status), ); const snap = await rpc(app, cookie, "threads/get", { botId: parent.id }); expect(JSON.stringify(snap.messages)).toMatch(/child_bot|Scout/); await sendAndWait(app, cookie, scout!.id, "spawn a bot named Nested"); const afterNested = await rpc(app, cookie, "bots/list"); const nested = afterNested.find((bot) => bot.name === "Nested"); expect(nested).toBeTruthy(); expect(afterNested.map((bot) => bot.name).sort()).toEqual(["Chief", "Nested", "Scout"]); await waitFor( app, cookie, nested!.id, (s) => !s.run || ["completed", "failed", "cancelled"].includes(s.run.status), ); await sendAndWait(app, cookie, parent.id, "delete the bot named Nested"); expect((await rpc(app, cookie, "bots/list")).some((bot) => bot.id === nested!.id)).toBe( true, ); await sendAndWait(app, cookie, parent.id, "delete the bot named WrongName"); expect((await rpc(app, cookie, "bots/list")).some((bot) => bot.id === scout!.id)).toBe( true, ); await sendAndWait(app, cookie, parent.id, "delete the bot named Scout"); const afterScout = await rpc(app, cookie, "bots/list"); expect(afterScout.some((bot) => bot.id === scout!.id)).toBe(false); expect(afterScout.some((bot) => bot.id === nested!.id)).toBe(true); await rpc(app, cookie, "bots/remove", { botId: parent.id }); expect((await rpc(app, cookie, "bots/list")).map((bot) => bot.name)).toEqual(["Nested"]); }); it("13: a subagent shows up in the parent thread without creating a bot", async () => { const cookie = await signup(app, `subagent-j-${stamp}@rakazo.test`, "Subagent"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); const before = (await rpc(app, cookie, "bots/list")).length; const snap = await sendAndWait(app, cookie, bot.id, "run a subagent to summarize the notes"); expect(JSON.stringify(snap.messages)).toMatch(/subagent|helper/); expect(await rpc(app, cookie, "bots/list")).toHaveLength(before); }); it("11: compose backup docs and dump tooling exist", async () => { expect(existsSync(path.resolve("docs/self-host.md"))).toBe(true); expect(existsSync(path.resolve("infra/compose/docker-compose.yml"))).toBe(true); expect(existsSync(path.resolve("scripts/backup.sh"))).toBe(true); expect(existsSync(path.resolve("scripts/restore.sh"))).toBe(true); const docs = readFileSync(path.resolve("docs/self-host.md"), "utf8"); expect(docs).toMatch(/pg_dump/); expect(docs).toMatch(/Restore/); }); it("14: this-mac is refused unless the sandbox is docker", async () => { const cookie = await signup(app, `host-j-${stamp}@rakazo.test`, "Host"); const me = await rpc(app, cookie, "me"); expect(me.canChooseHostComputer).toBe(false); await prisma.deploymentSettings.update({ where: { id: "default" }, data: { ownerUserId: me.userId }, }); const res = await raw(app, cookie, "deployment/update", { computerHost: "this-mac" }); const text = await res.text(); expect(res.status).toBeGreaterThanOrEqual(400); expect(text).toMatch(/This Mac mode is only available/i); }); it("15: ask, answer, stop, follow-up, and clientNonce stay consistent", async () => { const cookie = await signup(app, `ask-j-${stamp}@rakazo.test`, "Ask"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, cookie, "bots/update", { botId: bot.id, title: "Updated chief" }); expect((await rpc(app, cookie, "bots/get", { botId: bot.id })).title).toBe( "Updated chief", ); const asked = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId: bot.id, text: "ask me which city to use", }); const waiting = await waitFor( app, cookie, bot.id, (snap) => snap.run?.status === "waiting_input", ); expect(JSON.stringify(waiting.messages)).toMatch(/which city/i); const askMessage = waiting.messages.find((message) => message.blocks.some((block) => block.kind === "ask"), ); expect(askMessage).toBeTruthy(); await rpc(app, cookie, "threads/answer", { botId: bot.id, runId: asked.runId, messageId: askMessage!.id, answer: "Paris", }); const answered = await waitFor( app, cookie, bot.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); expect(answered.run?.status ?? "completed").toBe("completed"); await rpc(app, cookie, "threads/send", { botId: bot.id, text: "keep working until I stop you", }); await waitFor(app, cookie, bot.id, (snap) => ["queued", "leased", "running"].includes(snap.run?.status ?? ""), ); const hanging = await prisma.run.findFirstOrThrow({ where: { botId: bot.id }, orderBy: { createdAt: "desc" }, }); await rpc(app, cookie, "threads/stop", { botId: bot.id }); await waitFor(app, cookie, bot.id, (snap) => !snap.run); expect((await prisma.run.findUniqueOrThrow({ where: { id: hanging.id } })).status).toBe( "cancelled", ); await rpc(app, cookie, "threads/followUp", { botId: bot.id, text: "write a file in your home called notes/result.txt that says followup-ok", }); await waitFor( app, cookie, bot.id, (s) => !s.run || ["completed", "failed", "cancelled"].includes(s.run.status), ); expect( ( await rpc<{ content: string }>(app, cookie, "computer/readFile", { botId: bot.id, path: "notes/result.txt", }) ).content, ).toContain("followup-ok"); const first = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId: bot.id, text: "write a file in your home called notes/result.txt that says nonce-ok", clientNonce: `nonce-${stamp}`, }); const second = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId: bot.id, text: "write a file in your home called notes/result.txt that says nonce-dup", clientNonce: `nonce-${stamp}`, }); expect(second.runId).toBe(first.runId); await waitFor( app, cookie, bot.id, (s) => !s.run || ["completed", "failed", "cancelled"].includes(s.run.status), ); const file = await rpc<{ content: string }>(app, cookie, "computer/readFile", { botId: bot.id, path: "notes/result.txt", }); expect(file.content).toContain("nonce-ok"); expect(file.content).not.toContain("nonce-dup"); }); it("16: routine test-run and plugin connect/revoke", async () => { const ada = await signup(app, `plug-j-${stamp}@rakazo.test`, "Plug Ada"); const bob = await signup(app, `plug-bob-j-${stamp}@rakazo.test`, "Plug Bob"); const bot = await rpc(app, ada, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); const routine = await rpc<{ id: string }>(app, ada, "routines/create", { botId: bot.id, name: "Test now", prompt: "write a file in your home called notes/result.txt that says testrun-ok", crons: ["0 9 * * 1"], timezone: "UTC", notify: false, active: false, }); const tested = await rpc<{ runId: string }>(app, ada, "routines/testRun", { routineId: routine.id, }); expect(tested.runId).toBeTruthy(); await waitFor( app, ada, bot.id, (s) => !s.run || ["completed", "failed", "cancelled"].includes(s.run.status), ); const file = await rpc<{ content: string }>(app, ada, "computer/readFile", { botId: bot.id, path: "notes/result.txt", }); expect(file.content).toContain("testrun-ok"); const started = await rpc<{ connectionId: string; authorizationUrl: string | null }>( app, ada, "connections/begin", { provider: "gmail", displayName: "Gmail" }, ); expect(started.authorizationUrl).toBeNull(); const connected = await rpc<{ status: string }>(app, ada, "connections/complete", { connectionId: started.connectionId, }); expect(connected.status).toBe("connected"); await rpc(app, bob, "connections/revoke", { connectionId: started.connectionId }); expect( (await rpc>(app, ada, "connections/list")).find( (row) => row.id === started.connectionId, )?.status, ).toBe("connected"); await rpc(app, ada, "connections/revoke", { connectionId: started.connectionId }); expect( (await rpc>(app, ada, "connections/list")).find( (row) => row.id === started.connectionId, )?.status, ).toBe("revoked"); }); it("54: group chats share one transcript with mentions and handoffs", async () => { const ada = await signup(app, `ada-g-${stamp}@rakazo.test`, "Ada Groups"); const adaMe = await rpc(app, ada, "me"); const botA = await rpc(app, ada, "bots/create", { name: "BotA", title: "Researcher", description: "", instructions: "", notifyOnFinish: true, }); const botB = await rpc(app, ada, "bots/create", { name: "BotB", title: "Writer", description: "", instructions: "", notifyOnFinish: true, }); const botC = await rpc(app, ada, "bots/create", { name: "Writer", title: "Editor", description: "", instructions: "", notifyOnFinish: true, }); const botD = await rpc(app, ada, "bots/create", { name: "Research Writer", title: "Analyst", description: "", instructions: "", notifyOnFinish: true, }); const group = await rpc<{ id: string; threadId: string; members: Array<{ botId: string }> }>( app, ada, "groups/create", { name: "Research squad", botIds: [botA.id, botB.id, botC.id, botD.id] }, ); const listed = await rpc>(app, ada, "groups/list"); expect(listed.some((row) => row.id === group.id)).toBe(true); expect((await rpc(app, ada, "bots/list")).map((b) => b.id)).toEqual( expect.arrayContaining([botA.id, botB.id, botC.id, botD.id]), ); await sendGroupAndWait(app, ada, group.id, "@BotA gather sources. @BotB summarize."); const mentioned = await rpc(app, ada, "threads/get", { groupId: group.id, }); expect(new Set(mentioned.messages.map((m) => m.seq)).size).toBeGreaterThan(0); const runsAfterMention = await prisma.run.findMany({ where: { threadId: group.threadId, botId: { in: [botA.id, botB.id] } }, orderBy: { createdAt: "desc" }, take: 4, }); expect(runsAfterMention.some((run) => run.botId === botA.id)).toBe(true); expect(runsAfterMention.some((run) => run.botId === botB.id)).toBe(true); const countUserRuns = async (botId: string) => prisma.run.count({ where: { threadId: group.threadId, trigger: "user", botId }, }); const botABefore = await countUserRuns(botA.id); const botBBefore = await countUserRuns(botB.id); const botCBefore = await countUserRuns(botC.id); const botDBefore = await countUserRuns(botD.id); await sendGroupAndWait(app, ada, group.id, "hello team"); expect( (await countUserRuns(botA.id)) + (await countUserRuns(botB.id)) + (await countUserRuns(botC.id)) + (await countUserRuns(botD.id)), ).toBe(botABefore + botBBefore + botCBefore + botDBefore + 1); await sendGroupAndWait(app, ada, group.id, "@BotA hand this to Writer for the draft", botA.id); const handoffSnap = await rpc(app, ada, "threads/get", { groupId: group.id }); expect( handoffSnap.messages.some((message) => (message.blocks as Array<{ kind?: string }>).some((block) => block.kind === "handoff"), ), ).toBe(true); const groupAsk = await rpc<{ runId: string }>(app, ada, "threads/send", { groupId: group.id, text: "@Research Writer ask me which city to use", }); await waitForDatabase(async () => { const run = await prisma.run.findUnique({ where: { id: groupAsk.runId } }); return run?.status === "waiting_input"; }); const groupsWithActiveMember = await rpc< Array<{ id: string; members: Array<{ botId: string; status?: string }> }> >(app, ada, "groups/list"); expect( groupsWithActiveMember .find((listedGroup) => listedGroup.id === group.id) ?.members.find((member) => member.botId === botD.id)?.status, ).toBe("waiting_input"); const activeGroupSnapshot = await rpc<{ members?: Array<{ botId: string; status?: string }>; }>(app, ada, "threads/get", { groupId: group.id }); expect(activeGroupSnapshot.members?.find((member) => member.botId === botD.id)?.status).toBe( "waiting_input", ); const concurrentTask = await prisma.task.create({ data: { spaceId: botA.spaceId, botId: botA.id, threadId: group.threadId, userId: adaMe.userId, prompt: "concurrent work", status: "running", }, }); const concurrentRun = await prisma.run.create({ data: { spaceId: botA.spaceId, botId: botA.id, threadId: group.threadId, taskId: concurrentTask.id, userId: adaMe.userId, status: "running", trigger: "user", createdAt: new Date(Date.now() + 1_000), }, }); const askSnapshot = await rpc(app, ada, "threads/get", { groupId: group.id }); // Waiting asks win the headline run even when a newer busy run exists. expect(askSnapshot.run?.id).toBe(groupAsk.runId); expect(askSnapshot.activeRuns?.some((run) => run.id === concurrentRun.id)).toBe(true); expect(askSnapshot.activeRuns?.some((run) => run.id === groupAsk.runId)).toBe(true); const askMessage = askSnapshot.messages.find( (message) => message.runId === groupAsk.runId && message.blocks.some((block) => block.kind === "ask" && block.status !== "answered"), ); expect(askMessage).toBeTruthy(); await rpc(app, ada, "threads/answer", { groupId: group.id, runId: groupAsk.runId, messageId: askMessage!.id, answer: "Paris", }); await waitForDatabase(async () => { const run = await prisma.run.findUnique({ where: { id: groupAsk.runId } }); return run?.status === "completed"; }); const answerEvent = await prisma.event.findFirstOrThrow({ where: { threadId: group.threadId, runId: groupAsk.runId, type: "thread.message.updated", }, orderBy: { seq: "desc" }, }); expect(answerEvent.botId).toBe(botD.id); await prisma.$transaction([ prisma.run.update({ where: { id: concurrentRun.id }, data: { status: "cancelled", completedAt: new Date() }, }), prisma.task.update({ where: { id: concurrentTask.id }, data: { status: "cancelled" } }), ]); const staleTask = await prisma.task.create({ data: { spaceId: botB.spaceId, botId: botB.id, threadId: group.threadId, userId: adaMe.userId, prompt: "stale handoff", status: "running", }, }); const staleRun = await prisma.run.create({ data: { spaceId: botB.spaceId, botId: botB.id, threadId: group.threadId, taskId: staleTask.id, userId: adaMe.userId, status: "running", trigger: "user", }, }); const replayNonce = `group-replay-${stamp}`; const firstSend = await rpc<{ runId: string; runIds?: string[] }>(app, ada, "threads/send", { groupId: group.id, text: "@BotA gather updates. @BotB compare them.", clientNonce: replayNonce, }); await rpc(app, ada, "groups/update", { groupId: group.id, botIds: [botA.id, botC.id, botD.id], }); expect((await prisma.run.findUniqueOrThrow({ where: { id: staleRun.id } })).status).toBe( "cancelled", ); const replayedSend = await rpc<{ runId: string; runIds?: string[] }>(app, ada, "threads/send", { groupId: group.id, text: "this changed text must not create another message", clientNonce: replayNonce, }); expect(replayedSend.runId).toBe(firstSend.runId); expect(replayedSend.runIds).toEqual(firstSend.runIds); const replayMessage = await prisma.message.findUniqueOrThrow({ where: { threadId_clientNonce: { threadId: group.threadId, clientNonce: replayNonce } }, include: { sourceRuns: true }, }); expect(replayMessage.sourceRuns).toHaveLength(1); await expect( prisma.steeringMessage.findUniqueOrThrow({ where: { messageId_botId: { messageId: replayMessage.id, botId: botB.id } }, }), ).resolves.toMatchObject({ runId: staleRun.id, claimedAt: null }); expect( await prisma.message.count({ where: { threadId: group.threadId, clientNonce: replayNonce } }), ).toBe(1); const messageEvents = await prisma.event.findMany({ where: { threadId: group.threadId, type: "thread.message.created" }, select: { payload: true }, }); expect( messageEvents.filter( (event) => (event.payload as { messageId?: string } | null)?.messageId === replayMessage.id, ), ).toHaveLength(1); const staleHandoff = await handoffToGroupBot( { prisma, events: createThreadEvents(prisma), jobs }, { id: staleRun.id, spaceId: botB.spaceId, threadId: group.threadId, botId: botB.id, userId: staleTask.userId, }, group.id, { bot_id: botC.id, message: "should be rejected" }, ); expect(staleHandoff).toEqual({ error: "source run is no longer active" }); const crossThread = await rpc<{ runId: string }>(app, ada, "threads/send", { botId: botC.id, text: "the same nonce is valid in a different thread", clientNonce: replayNonce, }); expect(crossThread.runId).not.toBe(firstSend.runId); const attachmentText = "group attachment content"; const artifact = await rpc<{ id: string }>(app, ada, "artifacts/create", { groupId: group.id, name: "group-note.txt", mimeType: "text/plain", contentBase64: Buffer.from(attachmentText).toString("base64"), }); const attached = await rpc<{ runId: string }>(app, ada, "threads/send", { groupId: group.id, text: "@Research Writer inspect the attachment", artifactIds: [artifact.id], }); await waitForDatabase(async () => { const run = await prisma.run.findUnique({ where: { id: attached.runId } }); return Boolean(run && ["completed", "failed", "cancelled"].includes(run.status)); }); expect(await prisma.run.findUniqueOrThrow({ where: { id: attached.runId } })).toMatchObject({ botId: botD.id, status: "completed", error: null, }); const downloaded = await rpc<{ contentBase64: string }>(app, ada, "artifacts/get", { groupId: group.id, artifactId: artifact.id, }); expect(Buffer.from(downloaded.contentBase64, "base64").toString()).toBe(attachmentText); const artifactOwnerId = ( await prisma.artifact.findUniqueOrThrow({ where: { id: artifact.id } }) ).botId; if (!artifactOwnerId) throw new Error("Group artifact is missing its uploader"); const remainingGroupBotIds = [botA.id, botC.id, botD.id].filter( (botId) => botId !== artifactOwnerId, ); expect(remainingGroupBotIds).toHaveLength(2); await rpc(app, ada, "groups/update", { groupId: group.id, botIds: remainingGroupBotIds, }); await rpc(app, ada, "bots/remove", { botId: artifactOwnerId, deleteMemories: true }); expect(await prisma.artifact.findUniqueOrThrow({ where: { id: artifact.id } })).toMatchObject({ botId: null, groupId: group.id, }); const downloadedAfterUploaderRemoval = await rpc<{ contentBase64: string }>( app, ada, "artifacts/get", { groupId: group.id, artifactId: artifact.id }, ); expect(Buffer.from(downloadedAfterUploaderRemoval.contentBase64, "base64").toString()).toBe( attachmentText, ); const archiveMember = await rpc(app, ada, "bots/create", { name: "Archive Member", title: "", description: "", instructions: "", notifyOnFinish: true, }); const archivePartner = await rpc(app, ada, "bots/create", { name: "Archive Partner", title: "", description: "", instructions: "", notifyOnFinish: true, }); const archiveGroup = await rpc<{ id: string }>(app, ada, "groups/create", { name: "Archive invariant", botIds: [archiveMember.id, archivePartner.id], }); const archiveThread = await prisma.thread.findUniqueOrThrow({ where: { groupId: archiveGroup.id }, select: { id: true }, }); await rpc(app, ada, "bots/archive", { botId: archiveMember.id }); expect( await prisma.chatGroup.findUniqueOrThrow({ where: { id: archiveGroup.id }, include: { members: true, thread: { select: { id: true } } }, }), ).toMatchObject({ members: expect.arrayContaining([ expect.objectContaining({ botId: archiveMember.id }), expect.objectContaining({ botId: archivePartner.id }), ]), thread: archiveThread, }); const groupsWhileUndersized = await rpc>(app, ada, "groups/list"); expect(groupsWhileUndersized.some((row) => row.id === archiveGroup.id)).toBe(false); await expect(rpc(app, ada, "threads/get", { groupId: archiveGroup.id })).rejects.toThrow(); await expect( rpc(app, ada, "threads/send", { groupId: archiveGroup.id, text: "This hidden group must not run with one active member", }), ).rejects.toThrow(); await rpc(app, ada, "bots/restore", { botId: archiveMember.id }); const restoredArchiveGroup = await rpc< Array<{ id: string; members: Array<{ botId: string }> }> >(app, ada, "groups/list"); expect(restoredArchiveGroup.find((row) => row.id === archiveGroup.id)?.members).toEqual( expect.arrayContaining([ expect.objectContaining({ botId: archiveMember.id }), expect.objectContaining({ botId: archivePartner.id }), ]), ); const archiveThird = await rpc(app, ada, "bots/create", { name: "Archive Third", title: "", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, ada, "groups/update", { groupId: archiveGroup.id, botIds: [archiveMember.id, archivePartner.id, archiveThird.id], }); await rpc(app, ada, "bots/archive", { botId: archiveMember.id }); const dissolvingTask = await prisma.task.create({ data: { spaceId: archivePartner.spaceId, botId: archivePartner.id, threadId: archiveThread.id, userId: adaMe.userId, prompt: "fake active work while deleting a group member", status: "running", }, }); const dissolvingRun = await prisma.run.create({ data: { spaceId: archivePartner.spaceId, botId: archivePartner.id, threadId: archiveThread.id, taskId: dissolvingTask.id, userId: adaMe.userId, status: "running", trigger: "user", }, }); await rpc(app, ada, "bots/remove", { botId: archiveThird.id, deleteMemories: true }); expect(await prisma.chatGroup.findUnique({ where: { id: archiveGroup.id } })).toBeNull(); expect(await prisma.run.findUnique({ where: { id: dissolvingRun.id } })).toBeNull(); await rpc(app, ada, "bots/restore", { botId: archiveMember.id }); expect(await prisma.chatGroup.findUnique({ where: { id: archiveGroup.id } })).toBeNull(); const deletionPartner = await rpc(app, ada, "bots/create", { name: "Deletion Partner", title: "", description: "", instructions: "", notifyOnFinish: true, }); const deletionGroup = await rpc<{ id: string }>(app, ada, "groups/create", { name: "Deletion invariant", botIds: [botB.id, deletionPartner.id], }); await expect(prisma.bot.delete({ where: { id: botB.id } })).rejects.toThrow(); expect(await prisma.chatGroup.findUnique({ where: { id: deletionGroup.id } })).not.toBeNull(); await rpc(app, ada, "bots/remove", { botId: botB.id, deleteMemories: true }); expect(await prisma.chatGroup.findUnique({ where: { id: deletionGroup.id } })).toBeNull(); await rpc(app, ada, "groups/remove", { groupId: group.id }); expect(await prisma.artifact.findUnique({ where: { id: artifact.id } })).toBeNull(); const remainingBotIds = (await rpc(app, ada, "bots/list")).map((bot) => bot.id); expect(remainingBotIds).toEqual( expect.arrayContaining([ ...remainingGroupBotIds, deletionPartner.id, archiveMember.id, archivePartner.id, ]), ); expect(remainingBotIds).not.toContain(artifactOwnerId); }); it("55: the main bot leads the main conversation and addressed bots join it", async () => { const cookie = await signup(app, `main-conversation-${stamp}@rakazo.test`, "Main Conversation"); const me = await rpc(app, cookie, "me"); type MainRef = { botId: string; groupId: string; spaceId: string }; // Reading navigation must not create a main bot before onboarding asks for one. expect((await rpc<{ main: MainRef | null }>(app, cookie, "spaces/list")).main).toBeNull(); const mainBot = await rpc(app, cookie, "bots/ensureMain"); expect(mainBot.kind).toBe("main"); expect((await rpc(app, cookie, "bots/ensureMain")).id).toBe(mainBot.id); const { main } = await rpc<{ main: MainRef | null }>(app, cookie, "bootstrap", {}); expect(main).toMatchObject({ botId: mainBot.id, spaceId: me.spaceId }); expect((await rpc<{ main: MainRef | null }>(app, cookie, "spaces/list")).main).toEqual(main); const groupId = main!.groupId; const { id: threadId } = await prisma.thread.findUniqueOrThrow({ where: { groupId }, select: { id: true }, }); const analyst = await rpc(app, cookie, "bots/create", { name: "Analyst", title: "", description: "", instructions: "", notifyOnFinish: true, }); const userRunBots = async () => ( await prisma.run.findMany({ where: { threadId, trigger: "user" }, orderBy: { createdAt: "asc" }, select: { botId: true }, }) ).map((run) => run.botId); // The lead answers when nobody is addressed, even with a single member. await sendGroupAndWait(app, cookie, groupId, "hello there"); expect(await userRunBots()).toEqual([mainBot.id]); // Addressing a same-space bot makes it join the conversation and answer there. await sendGroupAndWait(app, cookie, groupId, "@Analyst check the numbers", analyst.id); expect(await userRunBots()).toEqual([mainBot.id, analyst.id]); const joined = await rpc<{ leadBotId?: string; members: Array<{ botId: string }> }>( app, cookie, "threads/get", { groupId }, ); expect(joined.leadBotId).toBe(mainBot.id); expect(joined.members.map((member) => member.botId).sort()).toEqual( [mainBot.id, analyst.id].sort(), ); expect(await prisma.event.count({ where: { threadId, type: "group.updated" } })).toBe(1); expect( await prisma.message.count({ where: { threadId, role: "bot", botId: analyst.id } }), ).toBeGreaterThan(0); // A chip for a member routes only to that member. await rpc(app, cookie, "threads/send", { groupId, text: "and the totals", mentions: [{ kind: "bot", id: analyst.id }], }); await waitForDatabase(async () => (await userRunBots()).length === 3); expect((await userRunBots()).at(-1)).toBe(analyst.id); for (const [procedure, input] of [ ["groups/update", { groupId, botIds: [mainBot.id, analyst.id] }], ["groups/archive", { groupId }], ["groups/remove", { groupId }], ["bots/archive", { botId: mainBot.id }], ["bots/remove", { botId: mainBot.id }], ] as const) { expect((await raw(app, cookie, procedure, input)).status, procedure).toBe(400); } await rpc(app, cookie, "groups/update", { groupId, pinned: true }); await rpc(app, cookie, "bots/update", { botId: mainBot.id, name: "Ada" }); expect((await prisma.chatGroup.findUniqueOrThrow({ where: { id: groupId } })).name).toBe("Ada"); // A member leaving never dissolves the main conversation. await waitForDatabase( async () => (await prisma.run.count({ where: { threadId, status: { in: [...ACTIVE_RUN_STATUSES] } }, })) === 0, ); await rpc(app, cookie, "bots/remove", { botId: analyst.id, deleteMemories: true }); const afterLeave = await rpc<{ members: Array<{ botId: string }> }>( app, cookie, "threads/get", { groupId, }, ); expect(afterLeave.members.map((member) => member.botId)).toEqual([mainBot.id]); await rpc(app, cookie, "threads/clear", { groupId }); const cleared = await prisma.event.findFirstOrThrow({ where: { threadId, type: "thread.cleared" }, orderBy: { seq: "desc" }, }); expect(cleared.botId).toBe(mainBot.id); }); it("17: teach a task end to end", async () => { const cookie = await signup(app, `teach-j-${stamp}@rakazo.test`, "Teach Ada"); const bot = await rpc(app, cookie, "bots/create", { name: "Teacher", title: "", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, cookie, "computer/boot", { botId: bot.id }); await rpc(app, cookie, "computer/takeover", { botId: bot.id }); void rpc(app, cookie, "threads/send", { botId: bot.id, text: "keep working until I stop you", }); await new Promise((resolve) => setTimeout(resolve, 100)); const skill = await rpc<{ id: string; status: string; playbook: { steps: string[] }; recording: { events: Array<{ kind: string }> }; }>(app, cookie, "skills/start", { botId: bot.id, goal: "Export weekly CRM list", }); expect(skill.status).toBe("recording"); await rpc(app, cookie, "computer/input", { botId: bot.id, kind: "pointer", payload: { x: 120, y: 40, button: "left", type: "click" }, }); await rpc(app, cookie, "computer/input", { botId: bot.id, kind: "key", payload: { key: "x" }, }); const blocked = await raw(app, cookie, "threads/send", { botId: bot.id, text: "please act now", }); expect(blocked.status).toBeGreaterThanOrEqual(400); const stopped = await rpc<{ status: string; playbook: { steps: string[] }; recording: { events: Array<{ kind: string }>; snapshots: Array<{ summary: string }> }; }>(app, cookie, "skills/stop", { skillId: skill.id }); expect(stopped.status).toBe("draft"); expect(stopped.playbook.steps.join(" ")).toMatch(/Click|120|40|x/i); expect(stopped.recording.events.some((event) => event.kind === "pointer")).toBe(true); expect(stopped.recording.snapshots.length).toBeGreaterThanOrEqual(2); const computerAfterStop = await rpc<{ controlHolder: string; controlBotId: string | null }>( app, cookie, "computer/status", { botId: bot.id }, ); expect(computerAfterStop.controlHolder).toBe("bot"); expect(computerAfterStop.controlBotId).toBeNull(); await rpc(app, cookie, "skills/updateDraft", { skillId: skill.id, name: "Export weekly CRM list", playbook: stopped.playbook, }); const saved = await rpc<{ status: string; name: string }>(app, cookie, "skills/save", { skillId: skill.id, name: "Export weekly CRM list", }); expect(saved.status).toBe("saved"); const listed = await rpc>(app, cookie, "skills/list", { botId: bot.id, }); expect(listed.some((row) => row.id === skill.id)).toBe(true); const testRun = await rpc<{ runId: string }>(app, cookie, "skills/testRun", { skillId: skill.id, }); expect(testRun.runId).toBeTruthy(); await waitFor( app, cookie, bot.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); await sendAndWait(app, cookie, bot.id, "run Export weekly CRM list"); const messages = (await rpc(app, cookie, "threads/get", { botId: bot.id })).messages; const botText = JSON.stringify(messages); expect(botText.toLowerCase()).toContain("taught skill"); }); it("18: teaching expiry auto-stops recording", async () => { const previousTtl = process.env.TEACH_RECORDING_TTL_MS; process.env.TEACH_RECORDING_TTL_MS = "1000"; try { const cookie = await signup(app, `teach-exp-j-${stamp}@rakazo.test`, "Teach Exp Ada"); const bot = await rpc(app, cookie, "bots/create", { name: "Timer", title: "", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, cookie, "computer/boot", { botId: bot.id }); await rpc(app, cookie, "computer/takeover", { botId: bot.id }); const skill = await rpc<{ id: string; status: string }>(app, cookie, "skills/start", { botId: bot.id, goal: "Timed demo", }); await new Promise((resolve) => setTimeout(resolve, 1_500)); const current = await rpc<{ status: string }>(app, cookie, "skills/get", { skillId: skill.id, }); expect(["draft", "drafting"].includes(current.status)).toBe(true); } finally { if (previousTtl === undefined) delete process.env.TEACH_RECORDING_TTL_MS; else process.env.TEACH_RECORDING_TTL_MS = previousTtl; } }); it("19: destination writes pause for approval before side effects", async () => { const cookie = await signup(app, `approval-j-${stamp}@rakazo.test`, "Approval"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); const recordsBefore = connector.records.length; await rpc(app, cookie, "approvalRules/set", { effect: "require_approval", matchKind: "tool", matchValue: "destination.write", }); const sent = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId: bot.id, text: "write this to the destination crm as a note", }); const waiting = await waitFor( app, cookie, bot.id, (snap) => snap.run?.status === "waiting_input", ); expect(JSON.stringify(waiting.messages)).toMatch(/allow once|review before/i); expect(connector.records).toHaveLength(recordsBefore); await answerPendingApproval(app, cookie, bot.id, sent.runId, "allow", waiting); await waitFor( app, cookie, bot.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); expect(connector.records.length).toBeGreaterThan(recordsBefore); const task = await prisma.task.findFirstOrThrow({ where: { runs: { some: { id: sent.runId } } }, }); expect(task.prompt).toBe("write this to the destination crm as a note"); const recordsAfterAllow = connector.records.length; const second = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId: bot.id, text: "write this to the destination crm as a note again", }); const waitingAgain = await waitFor( app, cookie, bot.id, (snap) => snap.run?.status === "waiting_input", ); await answerPendingApproval(app, cookie, bot.id, second.runId, "deny", waitingAgain); const denied = await waitFor( app, cookie, bot.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); expect(connector.records).toHaveLength(recordsAfterAllow); expect(denied.run?.status ?? "completed").toBe("completed"); const deniedEffect = await prisma.externalEffect.findFirst({ where: { runId: second.runId, kind: "destination.write" }, }); expect(deniedEffect?.status).toBe("denied"); }); it("20: actions run by default and specific exceptions override broad review rules", async () => { const cookie = await signup(app, `always-j-${stamp}@rakazo.test`, "Always"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); const prompt = "write this to the destination crm as a note"; const recordsBefore = connector.records.length; await sendAndWait(app, cookie, bot.id, prompt); expect(connector.records.length).toBeGreaterThan(recordsBefore); const recordsAfterDefault = connector.records.length; await rpc(app, cookie, "approvalRules/set", { effect: "require_approval", matchKind: "connector", matchValue: "destination.write", }); const second = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId: bot.id, text: "write this to the destination crm as a note again", }); const waiting = await waitFor( app, cookie, bot.id, (snap) => snap.run?.status === "waiting_input", ); await answerPendingApproval(app, cookie, bot.id, second.runId, "always", waiting); await waitFor( app, cookie, bot.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); expect(connector.records.length).toBe(recordsAfterDefault + 1); await sendAndWait(app, cookie, bot.id, "write this to the destination crm once more"); expect(connector.records.length).toBe(recordsAfterDefault + 2); await rpc(app, cookie, "approvalRules/set", { effect: "require_approval", matchKind: "tool", matchValue: "destination.write", }); const fourth = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId: bot.id, text: "write this to the destination crm one final time", }); const waitingAgain = await waitFor( app, cookie, bot.id, (snap) => snap.run?.status === "waiting_input", ); expect(connector.records.length).toBe(recordsAfterDefault + 2); await answerPendingApproval(app, cookie, bot.id, fourth.runId, "allow", waitingAgain); await waitFor( app, cookie, bot.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); expect(connector.records.length).toBe(recordsAfterDefault + 3); }); it("21: routine destination writes pause on the same approval card", async () => { const cookie = await signup(app, `routine-approval-j-${stamp}@rakazo.test`, "Routine Approval"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, cookie, "approvalRules/set", { effect: "require_approval", matchKind: "tool", matchValue: "destination.write", }); const routine = await rpc<{ id: string }>(app, cookie, "routines/create", { botId: bot.id, name: "Send note", prompt: "write this to the destination crm as a note", crons: ["0 9 * * 1"], timezone: "UTC", notify: false, active: false, }); const recordsBefore = connector.records.length; const tested = await rpc<{ runId: string }>(app, cookie, "routines/testRun", { routineId: routine.id, }); const waiting = await waitFor( app, cookie, bot.id, (snap) => snap.run?.status === "waiting_input", ); expect(connector.records).toHaveLength(recordsBefore); await answerPendingApproval(app, cookie, bot.id, tested.runId, "allow", waiting); await waitFor( app, cookie, bot.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); expect(connector.records.length).toBeGreaterThan(recordsBefore); }); it("22: a routine schedule with any malformed or mixed one-shot cron is rejected", async () => { const cookie = await signup(app, `routine-crons-j-${stamp}@rakazo.test`, "Routine Crons"); const bot = await rpc(app, cookie, "bots/create", { name: "Scheduler", title: "", description: "", instructions: "", notifyOnFinish: true, }); const partiallyInvalid = await raw(app, cookie, "routines/create", { botId: bot.id, name: "Bad schedule", prompt: "check the fixture", crons: ["0 9 * * *", "not-a-cron"], timezone: "UTC", notify: false, active: true, }); expect(partiallyInvalid.status).toBeGreaterThanOrEqual(400); const mixedOneShot = await raw(app, cookie, "routines/create", { botId: bot.id, name: "Mixed schedule", prompt: "check the fixture", crons: ["@once", "0 9 * * *"], timezone: "UTC", notify: false, active: false, }); expect(mixedOneShot.status).toBeGreaterThanOrEqual(400); }); it("23: never-run one-shot templates can be armed with a future runAt", async () => { const cookie = await signup(app, `once-arm-j-${stamp}@rakazo.test`, "Once Arm"); const me = await rpc(app, cookie, "me"); const bot = await rpc(app, cookie, "bots/create", { name: "Once Bot", title: "", description: "", instructions: "", notifyOnFinish: true, }); const routine = await prisma.routine.create({ data: { spaceId: me.spaceId, userId: me.userId, botId: bot.id, name: "Remind once", prompt: "Ping me once", crons: ["@once"], timezone: "UTC", notify: true, active: false, nextRunAt: null, }, }); const withoutTime = await raw(app, cookie, "routines/update", { routineId: routine.id, active: true, }); expect(withoutTime.status).toBeGreaterThanOrEqual(400); const runAt = new Date(Date.now() + 120_000).toISOString(); const armed = await rpc<{ active: boolean; nextRunAt: string | null }>( app, cookie, "routines/update", { routineId: routine.id, active: true, runAt, }, ); expect(armed.active).toBe(true); expect(armed.nextRunAt).toBe(runAt); await prisma.routine.update({ where: { id: routine.id }, data: { active: false, nextRunAt: null, lastRunAt: new Date() }, }); const afterFire = await raw(app, cookie, "routines/update", { routineId: routine.id, active: true, runAt: new Date(Date.now() + 180_000).toISOString(), }); expect(afterFire.status).toBeGreaterThanOrEqual(400); }); it("24: chat creates a space only after explicit approval", async () => { const cookie = await signup(app, `space-chat-j-${stamp}@rakazo.test`, "Space Chat"); const me = await rpc(app, cookie, "me"); const bot = await rpc(app, cookie, "bots/create", { name: "Chief", title: "", description: "", instructions: "", notifyOnFinish: true, }); const membershipsBefore = await prisma.spaceMember.count({ where: { userId: me.userId } }); const sent = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId: bot.id, text: "create a space named Customer support", }); const waiting = await waitFor( app, cookie, bot.id, (snap) => snap.run?.id === sent.runId && snap.run.status === "waiting_input", ); const approval = JSON.stringify(waiting.messages); expect(approval).toContain("Create space"); expect(approval).toContain("Customer support"); expect(approval).toContain("Cancel"); expect(approval).not.toContain("Always allow this tool"); expect(await prisma.spaceMember.count({ where: { userId: me.userId } })).toBe( membershipsBefore, ); await answerPendingApproval(app, cookie, bot.id, sent.runId, "allow", waiting); await waitFor( app, cookie, bot.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); const navigation = await rpc<{ spaces: Array<{ name: string }> }>(app, cookie, "spaces/list"); expect(navigation.spaces.map((space) => space.name)).toContain("Customer support"); expect(await prisma.spaceMember.count({ where: { userId: me.userId } })).toBe( membershipsBefore + 1, ); const denied = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId: bot.id, text: "create a space named Finance", }); const deniedWaiting = await waitFor( app, cookie, bot.id, (snap) => snap.run?.id === denied.runId && snap.run.status === "waiting_input", ); await answerPendingApproval(app, cookie, bot.id, denied.runId, "deny", deniedWaiting); await waitFor( app, cookie, bot.id, (snap) => !snap.run || ["completed", "failed", "cancelled"].includes(snap.run.status), ); expect(await prisma.spaceMember.count({ where: { userId: me.userId } })).toBe( membershipsBefore + 1, ); }); }); type Me = { spaceId: string; userId: string; canChooseHostComputer: boolean }; type Bot = { id: string; name: string; title: string; description: string; instructions: string; notifyOnFinish: boolean; color: string; pinned: boolean; sectionId: string | null; unread: boolean; computerMode: "team" | "dedicated"; parentBotId?: string | null; }; type Snap = { messages: Array<{ id: string; seq: number; runId?: string | null; blocks: Array<{ kind?: string; status?: string; actions?: unknown[] }>; }>; run: { id: string; status: string } | null; activeRuns?: Array<{ id: string; status: string }>; }; async function signup(app: App, email: string, name: string) { const res = await app.request("/api/auth/sign-up/email", { method: "POST", headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173", }, body: JSON.stringify({ email, password: "password12", name }), }); if (res.status >= 400) { throw new Error(`signup failed ${res.status}: ${await res.text()}`); } return sessionCookieHeader(res); } async function raw(app: App, cookie: string, proc: string, body: unknown = {}) { return app.request(`/rpc/${proc}`, { method: "POST", headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173", }, body: JSON.stringify({ json: body }), }); } async function rpc(app: App, cookie: string, proc: string, body: unknown = {}): Promise { const res = await raw(app, cookie, proc, body); const text = await res.text(); let parsed: { json?: T; error?: { message?: string } }; try { parsed = JSON.parse(text) as { json?: T; error?: { message?: string } }; } catch { throw new Error(`${proc} ${res.status}: ${text}`); } if (res.status >= 400 || parsed.error) { throw new Error(`${proc} ${res.status}: ${parsed.error?.message ?? text}`); } return parsed.json as T; } async function answerPendingApproval( app: App, cookie: string, botId: string, runId: string, answer: "allow" | "always" | "deny", current?: Snap, ) { const waiting = current ?? (await waitFor( app, cookie, botId, (snap) => snap.run?.id === runId && snap.run.status === "waiting_input", )); const message = [...waiting.messages] .reverse() .find( (candidate) => candidate.runId === runId && candidate.blocks.some( (block) => typeof block === "object" && block !== null && "kind" in block && block.kind === "ask" && "approvalEffectId" in block && typeof block.approvalEffectId === "string" && "actions" in block && Array.isArray(block.actions), ), ); if (!message) throw new Error(`run ${runId} did not expose an approval card`); await rpc(app, cookie, "threads/answer", { botId, runId, messageId: message.id, answer, }); } async function waitFor(app: App, cookie: string, botId: string, pred: (snap: Snap) => boolean) { const start = Date.now(); let last: Snap | null = null; while (Date.now() - start < 20_000) { last = await rpc(app, cookie, "threads/get", { botId }); if (pred(last)) return last; await new Promise((r) => setTimeout(r, 100)); } throw new Error(`timeout waiting for thread: ${JSON.stringify(last)}`); } async function waitForDatabase(pred: () => Promise) { const start = Date.now(); while (Date.now() - start < 10_000) { if (await pred()) return; await new Promise((resolve) => setTimeout(resolve, 25)); } throw new Error("timeout waiting for database state"); }