import type { RealtimeFanout } from "@rakazo/adapter-kit"; import { describe, expect, it, vi } from "vitest"; import type { PrismaClient } from "./client.js"; import { answerRunInput, appendEvent, claimSteering, clearThread, completedRunBlocks, finalizeComputerControlRelease, finalizeRun, followThreadEvents, pauseRunForInput, pauseRunForTakeover, sendUserMessage, } from "./events.js"; import { RunHistoryWriteError } from "./messages.js"; class TestFanout implements RealtimeFanout { subscriber: ((payload: string) => void) | undefined; unsubscribed = false; describe() { return { id: "test", contractVersion: "1", adapterVersion: "1", capabilities: { distributed: false, push: true }, }; } async publish(_topic: string, payload: string) { this.subscriber?.(payload); } async subscribe(_topic: string, subscriber: (payload: string) => void) { this.subscriber = subscriber; return async () => { this.unsubscribed = true; this.subscriber = undefined; }; } async close() {} } function event(seq: number) { return { id: `event-${seq}`, spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1", seq, type: "run.started", payload: {}, runId: null, createdAt: new Date("2026-08-15T12:00:00.000Z"), }; } describe("finalizeRun", () => { it("stamps the final steps block with wall-clock run duration", () => { const blocks = [ { kind: "steps" as const, steps: [{ label: "Read file", count: 1 }] }, { kind: "text" as const, text: "Then I checked it." }, { kind: "steps" as const, steps: [{ label: "Run tests", count: 1 }] }, ]; expect( completedRunBlocks( blocks, new Date("2026-09-01T12:00:00.000Z"), new Date("2026-09-01T12:01:43.000Z"), ), ).toEqual([blocks[0], blocks[1], { ...blocks[2], durationMs: 103_000 }]); expect(completedRunBlocks(blocks, null, new Date())).toBe(blocks); }); it("retries a transaction conflict without duplicating the terminal event or notification", async () => { const conflict = Object.assign(new Error("serialization conflict"), { code: "P2034" }); const createEvent = vi.fn(async () => ({ threadId: "thread-1", seq: 0 })); const tx = { $queryRaw: vi.fn(async () => []), run: { findUnique: vi.fn(async () => ({ status: "running" })), findUniqueOrThrow: vi.fn(async () => ({ sourceMessage: null })), findFirst: vi.fn(async () => null), updateMany: vi.fn(async () => ({ count: 1 })), }, attempt: { updateMany: vi.fn(async () => ({ count: 1 })) }, task: { updateMany: vi.fn(async () => ({ count: 1 })) }, thread: { update: vi.fn(async () => ({ nextEventSeq: 1 })) }, event: { create: createEvent, deleteMany: vi.fn(async () => ({ count: 0 })) }, steeringMessage: { findMany: vi.fn(async () => []), updateMany: vi.fn(async () => ({ count: 0 })), }, bot: { update: vi.fn(async () => ({})) }, }; const transaction = vi .fn() .mockRejectedValueOnce(conflict) .mockImplementation(async (operation: (client: typeof tx) => unknown) => operation(tx)); const publish = vi.fn(async () => undefined); await expect( finalizeRun( { $transaction: transaction } as unknown as PrismaClient, { spaceId: "space-1", threadId: "thread-1", botId: "bot-1", runId: "run-1", taskId: "task-1", attemptId: "attempt-1", leaseOwner: "worker-1", leaseFence: 1, outcome: "failed", error: "failed", }, { publish } as never, ), ).resolves.toEqual({ continuationRunId: null }); expect(transaction).toHaveBeenCalledTimes(2); expect(createEvent).toHaveBeenCalledOnce(); expect(publish).toHaveBeenCalledOnce(); }); }); describe("followThreadEvents", () => { it("does not lose a notification that arrives while querying", async () => { const fanout = new TestFanout(); const findMany = vi .fn() .mockImplementationOnce(async () => { await fanout.publish("thread:thread-1", "wake"); return []; }) .mockResolvedValueOnce([event(0)]) .mockResolvedValue([]); const prisma = { event: { findMany } } as unknown as PrismaClient; const abort = new AbortController(); const stream = followThreadEvents(prisma, "thread-1", -1, fanout, abort.signal, 10_000); await expect(stream.next()).resolves.toMatchObject({ value: { seq: 0 }, done: false }); expect(findMany).toHaveBeenCalledTimes(2); abort.abort(); await stream.return(undefined); expect(fanout.unsubscribed).toBe(true); }); it("periodically catches up when a signal is missed", async () => { const findMany = vi .fn() .mockResolvedValueOnce([]) .mockResolvedValueOnce([event(0)]); const prisma = { event: { findMany } } as unknown as PrismaClient; const abort = new AbortController(); const stream = followThreadEvents(prisma, "thread-1", -1, undefined, abort.signal, 1); await expect(stream.next()).resolves.toMatchObject({ value: { seq: 0 }, done: false }); abort.abort(); await stream.return(undefined); }); }); describe("finalizeComputerControlRelease", () => { it("clears the matching lease and appends its release event in one transaction", async () => { const fanout = new TestFanout(); const publish = vi.spyOn(fanout, "publish"); const tx = { computer: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, bot: { findFirst: vi.fn().mockResolvedValue({ computerId: "computer-1", thread: { id: "thread-1" }, }), }, run: { findUnique: vi.fn().mockResolvedValue({ status: "waiting_takeover" }), updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 8 }) }, event: { create: vi.fn().mockResolvedValue({ ...event(7), type: "computer.takeover.released", payload: { holder: "none", leaseId: "lease-1", reason: "expired" }, }), }, }; const prisma = { message: { findUnique: vi.fn().mockResolvedValue(null) }, $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( finalizeComputerControlRelease( prisma, { spaceId: "workspace-1", computerId: "computer-1", botId: "bot-1", runId: "run-1", leaseId: "lease-1", holder: "none", reason: "expired", }, fanout, ), ).resolves.toEqual({ runId: "run-1" }); expect(tx.computer.updateMany).toHaveBeenCalledWith({ where: { id: "computer-1", spaceId: "workspace-1", controlBotId: "bot-1", controlLeaseId: "lease-1", controlRunId: "run-1", }, data: { controlHolder: "none", controlLeaseId: null, controlLeaseExpiresAt: null, controlBotId: null, controlRunId: null, }, }); expect(tx.run.updateMany).toHaveBeenCalledWith({ where: { id: "run-1", spaceId: "workspace-1", botId: "bot-1", status: "waiting_takeover", }, data: { status: "queued", checkpoint: "takeover-skipped" }, }); expect(tx.event.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ type: "computer.takeover.released", runId: "run-1", payload: { holder: "none", leaseId: "lease-1", reason: "expired" }, }), }), ); expect(publish).toHaveBeenCalledWith("thread:thread-1", JSON.stringify({ cursor: 7 })); }); it("stamps a takeover checkpoint on a steered running run without clearing its lease", async () => { const tx = { computer: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, bot: { findFirst: vi.fn().mockResolvedValue({ computerId: "computer-1", thread: { id: "thread-1" }, }), }, run: { findUnique: vi.fn().mockResolvedValue({ status: "running" }), updateMany: vi.fn().mockResolvedValueOnce({ count: 0 }).mockResolvedValueOnce({ count: 1 }), }, thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 8 }) }, event: { create: vi.fn().mockResolvedValue({ ...event(7), type: "computer.takeover.released", payload: { holder: "bot", leaseId: "lease-1", reason: "released" }, }), }, }; const prisma = { message: { findUnique: vi.fn().mockResolvedValue(null) }, $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( finalizeComputerControlRelease(prisma, { spaceId: "workspace-1", computerId: "computer-1", botId: "bot-1", runId: "run-1", leaseId: "lease-1", holder: "bot", reason: "released", }), ).resolves.toEqual({ runId: "run-1" }); expect(tx.run.updateMany).toHaveBeenNthCalledWith(1, { where: { id: "run-1", spaceId: "workspace-1", botId: "bot-1", status: "waiting_takeover", }, data: { status: "queued", checkpoint: "takeover" }, }); expect(tx.run.updateMany).toHaveBeenNthCalledWith(2, { where: { id: "run-1", spaceId: "workspace-1", botId: "bot-1", status: { in: ["leased", "running"] }, }, data: { checkpoint: "takeover" }, }); expect(tx.event.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ type: "computer.takeover.released", runId: "run-1", }), }), ); }); it("clears the lease even if its controlling bot was deleted", async () => { const tx = { computer: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, bot: { findFirst: vi.fn().mockResolvedValue(null) }, }; const prisma = { message: { findUnique: vi.fn().mockResolvedValue(null) }, $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( finalizeComputerControlRelease(prisma, { spaceId: "workspace-1", computerId: "computer-1", botId: "deleted-bot", runId: null, leaseId: "lease-1", holder: "none", reason: "expired", }), ).resolves.toEqual({ runId: null }); expect(tx.computer.updateMany).toHaveBeenCalledOnce(); }); }); describe("pauseRunForInput", () => { it("stores the paused run, prompt, and status event in one transaction", async () => { const fanout = new TestFanout(); const publish = vi.spyOn(fanout, "publish"); const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), run: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), findUnique: vi.fn().mockResolvedValue({ status: "running", createdAt: new Date("2026-08-16T12:00:00.000Z"), threadId: "thread-1", }), }, attempt: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, thread: { update: vi .fn() .mockResolvedValueOnce({ nextMessageSeq: 4 }) .mockResolvedValueOnce({ nextEventSeq: 8 }) .mockResolvedValueOnce({ nextEventSeq: 9 }), }, message: { create: vi.fn().mockResolvedValue({ id: "message-1" }) }, event: { create: vi.fn(async ({ data }: { data: { seq: number; type: string } }) => ({ ...event(data.seq), type: data.type, })), deleteMany: vi.fn().mockResolvedValue({ count: 1 }), findFirst: vi.fn().mockResolvedValue(null), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( pauseRunForInput( prisma, { spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1", runId: "run-1", attemptId: "attempt-1", leaseOwner: "worker-1", leaseFence: 3, blocks: [{ kind: "ask", text: "Which city?" }], }, fanout, ), ).resolves.toBe(true); expect(tx.message.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ botId: "bot-1", runId: "run-1", role: "bot", }), }), ); expect(tx.run.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: "running", leaseFence: 3 }), data: { status: "waiting_input", leaseOwner: null, leaseExpiresAt: null }, }), ); expect(tx.event.create.mock.calls.map(([input]) => input.data.type)).toEqual([ "thread.message.created", "run.waiting_input", ]); expect(publish).toHaveBeenCalledWith("thread:thread-1", JSON.stringify({ cursor: 8 })); }); it("stores offered choice actions on the run checkpoint for resume", async () => { const fanout = new TestFanout(); const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), run: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), findUnique: vi.fn().mockResolvedValue({ status: "running", createdAt: new Date("2026-08-16T12:00:00.000Z"), threadId: "thread-1", }), }, attempt: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, thread: { update: vi .fn() .mockResolvedValueOnce({ nextMessageSeq: 4 }) .mockResolvedValueOnce({ nextEventSeq: 8 }) .mockResolvedValueOnce({ nextEventSeq: 9 }), }, message: { create: vi.fn().mockResolvedValue({ id: "message-1" }) }, event: { create: vi.fn(async ({ data }: { data: { seq: number; type: string } }) => ({ ...event(data.seq), type: data.type, })), deleteMany: vi.fn().mockResolvedValue({ count: 1 }), findFirst: vi.fn().mockResolvedValue(null), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( pauseRunForInput( prisma, { spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1", runId: "run-1", attemptId: "attempt-1", leaseOwner: "worker-1", leaseFence: 3, blocks: [ { kind: "ask", text: "Which token?", status: "pending", actions: [ { id: "choice-1", label: "use [redacted]" }, { id: "choice-2", label: "use plain" }, ], }, ], offeredActions: [ { id: "choice-1", label: "use sk-live-choice-secret" }, { id: "choice-2", label: "use plain" }, ], }, fanout, ), ).resolves.toBe(true); expect(tx.run.updateMany).toHaveBeenCalledWith( expect.objectContaining({ data: { status: "waiting_input", leaseOwner: null, leaseExpiresAt: null, checkpoint: JSON.stringify({ kind: "choice_ask_v1", actions: [ { id: "choice-1", label: "use sk-live-choice-secret" }, { id: "choice-2", label: "use plain" }, ], }), }, }), ); }); }); describe("pauseRunForTakeover", () => { it("stores the paused run, attempt, computer takeover mark, and event in one transaction", async () => { const fanout = new TestFanout(); const publish = vi.spyOn(fanout, "publish"); const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), run: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), findUnique: vi.fn().mockResolvedValue({ status: "waiting_takeover" }), }, attempt: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, computer: { findFirst: vi.fn().mockResolvedValue({ controlHolder: "none", controlBotId: null, controlLeaseId: null, controlLeaseExpiresAt: null, }), updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 8 }) }, event: { create: vi.fn(async ({ data }: { data: { seq: number; type: string } }) => ({ ...event(data.seq), type: data.type, })), deleteMany: vi.fn().mockResolvedValue({ count: 1 }), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( pauseRunForTakeover( prisma, { spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1", runId: "run-1", attemptId: "attempt-1", leaseOwner: "worker-1", leaseFence: 3, reason: "Sign in", computerId: "computer-1", }, fanout, ), ).resolves.toBe(true); expect(tx.run.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: "running", leaseFence: 3 }), data: { status: "waiting_takeover", leaseOwner: null, leaseExpiresAt: null, checkpoint: null, }, }), ); expect(tx.attempt.updateMany).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: "waiting_takeover" }) }), ); expect(tx.computer.updateMany).toHaveBeenCalledWith({ where: { id: "computer-1", spaceId: "workspace-1" }, data: { state: "running", controlHolder: "none", controlLeaseId: null, controlLeaseExpiresAt: null, controlBotId: null, controlRunId: "run-1", }, }); expect(tx.event.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ type: "computer.takeover.requested", payload: { reason: "Sign in", takeoverRequested: true, retainedControl: false, }, }), }), ); expect(publish).toHaveBeenCalledWith("thread:thread-1", JSON.stringify({ cursor: 7 })); }); it("keeps an active user control lease and only binds controlRunId", async () => { const expiresAt = new Date(Date.now() + 60_000); const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), run: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), findUnique: vi.fn().mockResolvedValue({ status: "waiting_takeover" }), }, attempt: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, computer: { findFirst: vi.fn().mockResolvedValue({ controlHolder: "user", controlBotId: "bot-1", controlLeaseId: "lease-1", controlLeaseExpiresAt: expiresAt, }), updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 8 }) }, event: { create: vi.fn(async ({ data }: { data: { seq: number; type: string } }) => ({ ...event(data.seq), type: data.type, })), deleteMany: vi.fn().mockResolvedValue({ count: 1 }), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( pauseRunForTakeover(prisma, { spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1", runId: "run-1", attemptId: "attempt-1", leaseOwner: "worker-1", leaseFence: 3, reason: "Sign in", computerId: "computer-1", }), ).resolves.toBe(true); expect(tx.computer.updateMany).toHaveBeenCalledWith({ where: { id: "computer-1", spaceId: "workspace-1" }, data: { state: "running", controlRunId: "run-1" }, }); expect(tx.event.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ payload: expect.objectContaining({ retainedControl: true, takeoverRequested: true }), }), }), ); }); it("preserves another bot's active user lease without binding this run to it", async () => { const expiresAt = new Date(Date.now() + 60_000); const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "computer-1" }]), run: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), findUnique: vi.fn().mockResolvedValue({ status: "waiting_takeover" }), }, attempt: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, computer: { findFirst: vi.fn().mockResolvedValue({ controlHolder: "user", controlBotId: "other-bot", controlLeaseId: "lease-other", controlLeaseExpiresAt: expiresAt, }), updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 8 }) }, event: { create: vi.fn(async ({ data }: { data: { seq: number; type: string } }) => ({ ...event(data.seq), type: data.type, })), deleteMany: vi.fn().mockResolvedValue({ count: 1 }), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( pauseRunForTakeover(prisma, { spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1", runId: "run-1", attemptId: "attempt-1", leaseOwner: "worker-1", leaseFence: 3, reason: "Sign in", computerId: "computer-1", }), ).resolves.toBe(true); expect(tx.computer.updateMany).toHaveBeenCalledWith({ where: { id: "computer-1", spaceId: "workspace-1" }, data: { state: "running" }, }); expect(tx.event.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ payload: expect.objectContaining({ retainedControl: false, takeoverRequested: true }), }), }), ); }); }); describe("answerRunInput", () => { it("answers only the selected pending prompt and publishes its update", async () => { const fanout = new TestFanout(); const publish = vi.spyOn(fanout, "publish"); publish.mockRejectedValueOnce(new Error("realtime unavailable")); const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", text: "Which city?", status: "pending", actions: [ { id: "choice-1", label: "Berlin" }, { id: "choice-2", label: "Paris" }, ], }, ], }), update: vi.fn().mockResolvedValue({ id: "message-1" }), }, run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-2" }), updateMany: vi.fn().mockResolvedValue({ count: 1 }), findUnique: vi.fn().mockResolvedValue({ status: "queued", createdAt: new Date("2026-08-16T12:00:00.000Z"), threadId: "thread-1", }), }, task: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 10 }) }, event: { create: vi.fn(async ({ data }: { data: { seq: number; type: string } }) => ({ ...event(data.seq), type: data.type, })), findFirst: vi.fn().mockResolvedValue(null), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( answerRunInput( prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "user-1", answer: "choice-2", }, fanout, ), ).resolves.toBe(true); expect(tx.run.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: "waiting_input" }), data: { status: "queued", checkpoint: null }, }), ); expect(tx.message.update).toHaveBeenCalledWith({ where: { id: "message-1" }, data: { blocks: [ { kind: "ask", text: "Which city?", status: "answered", answer: "choice-2", actions: [ { id: "choice-1", label: "Berlin" }, { id: "choice-2", label: "Paris" }, ], }, ], }, }); expect(tx.task.updateMany).toHaveBeenCalledWith({ where: { runs: { some: { id: "run-1" } } }, data: { prompt: "Selected choice choice-2: Paris" }, }); expect(tx.event.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ type: "thread.message.updated", botId: "bot-2" }), }), ); expect(publish).toHaveBeenCalledWith("thread:thread-1", JSON.stringify({ cursor: 9 })); }); it("resumes with the offered choice label when the persisted label was redacted", async () => { const fanout = new TestFanout(); const secret = "sk-live-choice-secret"; const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", text: "Which token?", status: "pending", actions: [ { id: "choice-1", label: `use [redacted]` }, { id: "choice-2", label: "use plain" }, ], }, ], }), update: vi.fn().mockResolvedValue({ id: "message-1" }), }, run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-2", userId: "user-1", checkpoint: JSON.stringify({ kind: "choice_ask_v1", actions: [ { id: "choice-1", label: `use ${secret}` }, { id: "choice-2", label: "use plain" }, ], }), }), updateMany: vi.fn().mockResolvedValue({ count: 1 }), findUnique: vi.fn().mockResolvedValue({ status: "queued", createdAt: new Date("2026-08-16T12:00:00.000Z"), threadId: "thread-1", }), }, task: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 11 }) }, event: { create: vi.fn(async ({ data }: { data: { seq: number; type: string } }) => ({ ...event(data.seq), type: data.type, })), findFirst: vi.fn().mockResolvedValue(null), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( answerRunInput( prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "user-1", answer: "choice-1", }, fanout, ), ).resolves.toBe(true); expect(tx.task.updateMany).toHaveBeenCalledWith({ where: { runs: { some: { id: "run-1" } } }, data: { prompt: `Selected choice choice-1: use ${secret}` }, }); expect(tx.message.update).toHaveBeenCalledWith({ where: { id: "message-1" }, data: { blocks: [ { kind: "ask", text: "Which token?", status: "answered", answer: "choice-1", actions: [ { id: "choice-1", label: "use [redacted]" }, { id: "choice-2", label: "use plain" }, ], }, ], }, }); expect(tx.run.updateMany).toHaveBeenCalledWith( expect.objectContaining({ data: { status: "queued", checkpoint: null }, }), ); }); it("does not queue a run for a choice the card did not offer", async () => { const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", text: "Which city?", status: "pending", actions: [ { id: "Berlin", label: "Berlin" }, { id: "Seoul", label: "Seoul" }, ], }, ], }), }, run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-2", userId: "user-1" }), updateMany: vi.fn(), }, task: { updateMany: vi.fn() }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( answerRunInput(prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "user-1", answer: "Toronto", }), ).resolves.toBe(false); expect(tx.run.updateMany).not.toHaveBeenCalled(); expect(tx.task.updateMany).not.toHaveBeenCalled(); }); it("approves consequential actions without overwriting the task prompt", async () => { const fanout = new TestFanout(); const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", approvalEffectId: "effect-1", text: "Review before writing", status: "pending", actions: [ { id: "allow", label: "Allow once" }, { id: "deny", label: "Deny" }, ], }, ], }), update: vi.fn().mockResolvedValue({ id: "message-1" }), }, run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "user-1" }), updateMany: vi.fn().mockResolvedValue({ count: 1 }), findUnique: vi.fn().mockResolvedValue({ status: "queued" }), }, task: { updateMany: vi.fn() }, externalEffect: { findFirst: vi.fn().mockResolvedValue({ id: "effect-1", status: "intended" }), update: vi.fn().mockResolvedValue({ id: "effect-1" }), }, thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 10 }) }, event: { create: vi.fn(async ({ data }: { data: { seq: number; type: string } }) => ({ ...event(data.seq), type: data.type, })), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( answerRunInput( prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "user-1", answer: "allow", }, fanout, ), ).resolves.toBe(true); expect(tx.task.updateMany).not.toHaveBeenCalled(); expect(tx.externalEffect.findFirst).toHaveBeenCalledWith({ where: { id: "effect-1", spaceId: "workspace-1", runId: "run-1", status: "intended", }, }); expect(tx.externalEffect.update).toHaveBeenCalledWith({ where: { id: "effect-1" }, data: { status: "approved" }, }); }); it("approves and upserts always-allow without overwriting the task prompt", async () => { const fanout = new TestFanout(); const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", approvalEffectId: "effect-1", text: "Review before writing", status: "pending", actions: [ { id: "allow", label: "Allow once" }, { id: "always", label: "Always allow" }, { id: "deny", label: "Deny" }, ], }, ], }), update: vi.fn().mockResolvedValue({ id: "message-1" }), }, run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "user-1" }), updateMany: vi.fn().mockResolvedValue({ count: 1 }), findUnique: vi.fn().mockResolvedValue({ userId: "user-1", status: "queued" }), }, task: { updateMany: vi.fn() }, externalEffect: { findFirst: vi .fn() .mockResolvedValue({ id: "effect-1", status: "intended", kind: "destination.write" }), update: vi.fn().mockResolvedValue({ id: "effect-1" }), }, actionApprovalRule: { upsert: vi.fn().mockResolvedValue({ id: "rule-1" }), }, thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 10 }) }, event: { create: vi.fn(async ({ data }: { data: { seq: number; type: string } }) => ({ ...event(data.seq), type: data.type, })), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( answerRunInput( prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "user-1", answer: "always", }, fanout, ), ).resolves.toBe(true); expect(tx.task.updateMany).not.toHaveBeenCalled(); expect(tx.externalEffect.update).toHaveBeenCalledWith({ where: { id: "effect-1" }, data: { status: "approved" }, }); expect(tx.actionApprovalRule.upsert).toHaveBeenCalledWith({ where: { spaceId_createdByUserId_effect_matchKind_matchValue: { spaceId: "workspace-1", createdByUserId: "user-1", effect: "always_allow", matchKind: "tool", matchValue: "destination.write", }, }, create: { spaceId: "workspace-1", createdByUserId: "user-1", effect: "always_allow", matchKind: "tool", matchValue: "destination.write", }, update: {}, }); }); it("does not let another workspace member approve a consequential action with allow", async () => { const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", approvalEffectId: "effect-1", text: "Review before writing", status: "pending", actions: [ { id: "allow", label: "Allow once" }, { id: "deny", label: "Deny" }, ], }, ], }), }, run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "run-owner" }), updateMany: vi.fn(), }, externalEffect: { findFirst: vi.fn().mockResolvedValue({ id: "effect-1", status: "intended" }), update: vi.fn(), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( answerRunInput(prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "other-member", answer: "allow", }), ).resolves.toBe(false); expect(tx.externalEffect.update).not.toHaveBeenCalled(); expect(tx.run.updateMany).not.toHaveBeenCalled(); }); it("does not let another workspace member create an always-allow rule for the run owner", async () => { const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", approvalEffectId: "effect-1", text: "Review before writing", status: "pending", actions: [ { id: "allow", label: "Allow once" }, { id: "always", label: "Always allow" }, { id: "deny", label: "Deny" }, ], }, ], }), }, run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "run-owner" }), findUnique: vi.fn().mockResolvedValue({ userId: "run-owner" }), updateMany: vi.fn(), }, externalEffect: { findFirst: vi .fn() .mockResolvedValue({ id: "effect-1", status: "intended", kind: "destination.write" }), }, actionApprovalRule: { upsert: vi.fn() }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( answerRunInput(prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "other-member", answer: "always", }), ).resolves.toBe(false); expect(tx.run.updateMany).not.toHaveBeenCalled(); expect(tx.actionApprovalRule.upsert).not.toHaveBeenCalled(); }); it("does not queue a run when an approval card has no matching effect", async () => { const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", approvalEffectId: "missing-effect", text: "Review before writing", status: "pending", actions: [ { id: "allow", label: "Allow once" }, { id: "deny", label: "Deny" }, ], }, ], }), }, run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-1" }), updateMany: vi.fn(), }, externalEffect: { findFirst: vi.fn().mockResolvedValue(null) }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( answerRunInput(prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "user-1", answer: "allow", }), ).resolves.toBe(false); expect(tx.run.updateMany).not.toHaveBeenCalled(); }); it("does not queue a run for an action that the approval card did not offer", async () => { const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", approvalEffectId: "effect-1", text: "Review before writing", status: "pending", actions: [ { id: "allow", label: "Allow once" }, { id: "deny", label: "Deny" }, ], }, ], }), }, run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-1" }), updateMany: vi.fn(), }, externalEffect: { findFirst: vi.fn() }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( answerRunInput(prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "user-1", answer: "always", }), ).resolves.toBe(false); expect(tx.externalEffect.findFirst).not.toHaveBeenCalled(); expect(tx.run.updateMany).not.toHaveBeenCalled(); }); it("rejects an already answered prompt without queuing the run", async () => { const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [{ kind: "ask", text: "Which city?", status: "answered", answer: "Paris" }], }), }, run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-1" }), updateMany: vi.fn(), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( answerRunInput(prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "user-1", answer: "Rome", }), ).resolves.toBe(false); expect(tx.run.updateMany).not.toHaveBeenCalled(); }); it.each([ undefined, { name: "example_api", origin: "https://api.example.test", auth: { type: "bearer" } }, ])("stores secret asks without writing plaintext to the task prompt (%j)", async (credential) => { const fanout = new TestFanout(); const store = vi.fn().mockResolvedValue(undefined); const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", text: "Enter your code", input: "secret", purpose: "api_key", ...(credential ? { credential } : {}), status: "pending", }, ], }), update: vi.fn().mockResolvedValue({ id: "message-1" }), }, run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "user-1" }), findUnique: vi.fn().mockResolvedValue({ status: "queued" }), updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, task: { updateMany: vi.fn() }, externalEffect: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 10 }) }, event: { create: vi.fn(async ({ data }: { data: { seq: number; type: string } }) => ({ ...event(data.seq), type: data.type, })), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( answerRunInput( prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "user-1", answer: "123456", }, fanout, { store }, ), ).resolves.toBe(true); expect(store).toHaveBeenCalledWith( expect.objectContaining({ runId: "run-1", plaintext: "123456", botId: "bot-1", userId: "user-1", spaceId: "workspace-1", credential, }), ); expect(tx.task.updateMany).not.toHaveBeenCalled(); expect(JSON.stringify(tx.event.create.mock.calls)).not.toContain("123456"); expect(tx.externalEffect.updateMany).toHaveBeenCalledWith({ where: { runId: "run-1", spaceId: "workspace-1", kind: "request_secret", status: "intended", }, data: { status: "approved", ...(credential ? { result: { credentialSaved: credential } } : {}), }, }); expect(tx.message.update).toHaveBeenCalledWith({ where: { id: "message-1" }, data: { blocks: [ { kind: "ask", text: "Enter your code", input: "secret", purpose: "api_key", ...(credential ? { credential } : {}), status: "answered", answer: "", }, ], }, }); }); it("does not let another workspace member answer a generic secret ask for the run owner", async () => { const store = vi.fn(); const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "owner" }), updateMany: vi.fn(), }, message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", text: "Enter your code", input: "secret", status: "pending", }, ], }), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; expect( await answerRunInput( prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "other-member", answer: "123456", }, new TestFanout(), { store }, ), ).toBe(false); expect(store).not.toHaveBeenCalled(); expect(tx.run.updateMany).not.toHaveBeenCalled(); }); it("does not let another workspace member save a credential as the run owner", async () => { const store = vi.fn(); const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "owner" }), updateMany: vi.fn(), }, message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", text: "API key", input: "secret", status: "pending", credential: { name: "example_api", origin: "https://api.example.test", auth: { type: "bearer" }, }, }, ], }), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; expect( await answerRunInput( prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "other-member", answer: "fake-key", }, new TestFanout(), { store }, ), ).toBe(false); expect(store).not.toHaveBeenCalled(); expect(tx.run.updateMany).not.toHaveBeenCalled(); }); it("rejects secret asks when no run secret writer is configured", async () => { const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), message: { findFirst: vi.fn().mockResolvedValue({ id: "message-1", blocks: [ { kind: "ask", text: "Enter your code", input: "secret", status: "pending", }, ], }), }, run: { findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "user-1" }), updateMany: vi.fn(), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( answerRunInput(prisma, { spaceId: "workspace-1", threadId: "thread-1", runId: "run-1", messageId: "message-1", answeredByUserId: "user-1", answer: "123456", }), ).resolves.toBe(false); expect(tx.run.updateMany).not.toHaveBeenCalled(); }); }); describe("sendUserMessage", () => { it("creates the message, run, and event in one transaction and publishes once", async () => { const fanout = new TestFanout(); const publish = vi.spyOn(fanout, "publish"); const tx = { thread: { update: vi .fn() .mockResolvedValueOnce({ nextMessageSeq: 5 }) .mockResolvedValueOnce({ nextEventSeq: 9 }), }, message: { create: vi.fn().mockResolvedValue({ id: "message-1", seq: 4 }), update: vi.fn(), }, task: { create: vi.fn().mockResolvedValue({ id: "task-1" }) }, run: { create: vi.fn().mockResolvedValue({ id: "run-1" }), findFirst: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue({ status: "queued" }), }, event: { create: vi.fn(async ({ data }: { data: { seq: number; type: string } }) => ({ ...event(data.seq), type: data.type, runId: "run-1", })), }, }; const prisma = { message: { findUnique: vi.fn().mockResolvedValue(null) }, $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( sendUserMessage( prisma, { spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1", userId: "user-1", blocks: [{ kind: "text", text: "hello" }], prompt: "hello", trigger: "user", clientNonce: "nonce-1", linkMessageToRun: true, }, fanout, ), ).resolves.toEqual({ messageId: "message-1", seq: 4, taskId: "task-1", runId: "run-1" }); expect(tx.run.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ trigger: "user", clientNonce: "send:message-1", sourceMessageId: "message-1", }), }), ); expect(tx.message.update).toHaveBeenCalledWith({ where: { id: "message-1" }, data: { runId: "run-1" }, }); expect(tx.event.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ type: "thread.message.created", runId: "run-1" }), }), ); expect(publish).toHaveBeenCalledWith("thread:thread-1", JSON.stringify({ cursor: 8 })); }); it("persists steering instead of starting a parallel run when the bot is busy", async () => { const tx = { thread: { update: vi .fn() .mockResolvedValueOnce({ nextMessageSeq: 5 }) .mockResolvedValueOnce({ nextEventSeq: 9 }), }, message: { create: vi.fn().mockResolvedValue({ id: "message-1", seq: 4 }), update: vi.fn().mockResolvedValue({ id: "message-1" }), }, steeringMessage: { create: vi.fn() }, task: { create: vi.fn() }, run: { findFirst: vi.fn().mockResolvedValue({ id: "run-0", taskId: "task-0" }), findUnique: vi.fn().mockResolvedValue({ status: "running" }), create: vi.fn(), }, event: { create: vi.fn(async ({ data }: { data: { seq: number; type: string } }) => ({ ...event(data.seq), type: data.type, })), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( sendUserMessage(prisma, { spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1", userId: "user-1", blocks: [{ kind: "text", text: "hello" }], prompt: "hello", trigger: "follow_up", }), ).resolves.toEqual({ messageId: "message-1", seq: 4, taskId: null, runId: "run-0" }); expect(tx.task.create).not.toHaveBeenCalled(); expect(tx.run.create).not.toHaveBeenCalled(); expect(tx.message.update).toHaveBeenCalledWith({ where: { id: "message-1" }, data: { runId: "run-0" }, }); expect(tx.steeringMessage.create).toHaveBeenCalledWith({ data: { messageId: "message-1", botId: "bot-1", userId: "user-1", runId: "run-0" }, }); }); }); describe("claimSteering", () => { it("claims pending messages for the fenced run in message order", async () => { const tx = { $queryRaw: vi.fn(), run: { findFirst: vi.fn().mockResolvedValue({ id: "run-1" }) }, steeringMessage: { findMany: vi.fn().mockResolvedValue([ { id: "steer-1", messageId: "message-1", message: { seq: 5, blocks: [ { kind: "text", text: "First" }, { kind: "image", artifactId: "artifact-1", name: "chart.png", mimeType: "image/png", }, ], }, }, { id: "steer-2", messageId: "message-2", message: { seq: 6, blocks: [{ kind: "text", text: "Second" }] }, }, ]), updateMany: vi.fn().mockResolvedValue({ count: 2 }), }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( claimSteering(prisma, { threadId: "thread-1", botId: "bot-1", runId: "run-1", leaseOwner: "worker-1", leaseFence: 2, seenIds: [], }), ).resolves.toEqual([ { id: "steer-1", messageId: "message-1", text: "First\n[image: chart.png]", blocks: [ { kind: "text", text: "First" }, { kind: "image", artifactId: "artifact-1", name: "chart.png", mimeType: "image/png", }, ], }, { id: "steer-2", messageId: "message-2", text: "Second", blocks: [{ kind: "text", text: "Second" }], }, ]); expect(tx.run.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ leaseOwner: "worker-1", leaseFence: 2 }), }), ); expect(tx.steeringMessage.updateMany).toHaveBeenCalledWith({ where: { id: { in: ["steer-1", "steer-2"] }, claimedAt: null }, data: { runId: "run-1", claimedAt: expect.any(Date) }, }); }); }); describe("clearThread", () => { it("refuses to clear while a delegated bot outcome is still pending", async () => { const fanout = new TestFanout(); const tx = { thread: { update: vi .fn() .mockResolvedValueOnce({ nextMessageSeq: 4, historyCompactionGeneration: 0 }), }, run: { findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(1), }, attempt: { updateMany: vi.fn() }, task: { updateMany: vi.fn() }, computerExecutionLease: { updateMany: vi.fn() }, computer: { updateMany: vi.fn() }, message: { deleteMany: vi.fn() }, event: { deleteMany: vi.fn(), create: vi.fn() }, bot: { update: vi.fn() }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; const { ClearThreadPendingDelegatedReturnsError } = await import("./scope.js"); await expect( clearThread(prisma, { spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1" }, fanout), ).rejects.toBeInstanceOf(ClearThreadPendingDelegatedReturnsError); expect(tx.run.count).toHaveBeenCalledWith({ where: expect.objectContaining({ trigger: "bot_message", status: { in: ["completed", "failed"] }, botOutcomeReturnedAt: null, }), }); expect(tx.message.deleteMany).not.toHaveBeenCalled(); }); it("releases the computer execution lease without dropping the computer", async () => { const fanout = new TestFanout(); const publish = vi.spyOn(fanout, "publish"); const tx = { thread: { update: vi .fn() .mockResolvedValueOnce({ nextMessageSeq: 42, historyCompactionGeneration: 0 }) .mockResolvedValue({ nextEventSeq: 1 }), }, run: { findMany: vi.fn().mockResolvedValue([{ id: "run-1", taskId: "task-1" }]), count: vi.fn().mockResolvedValue(0), updateMany: vi.fn(), }, attempt: { updateMany: vi.fn() }, task: { updateMany: vi.fn() }, computerExecutionLease: { updateMany: vi.fn() }, computer: { updateMany: vi.fn() }, message: { deleteMany: vi.fn() }, event: { deleteMany: vi.fn(), create: vi.fn().mockResolvedValue({ ...event(0), type: "thread.cleared", }), }, bot: { update: vi.fn() }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( clearThread(prisma, { spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1" }, fanout), ).resolves.toMatchObject({ event: { type: "thread.cleared" }, cancelledRunIds: ["run-1"], }); expect(tx.computerExecutionLease.updateMany).toHaveBeenCalledWith({ where: { runId: { in: ["run-1"] } }, data: { expiresAt: new Date(0) }, }); expect(tx.computer.updateMany).toHaveBeenCalledWith({ where: { executionRunId: { in: ["run-1"] } }, data: { executionRunId: null, executionBotId: null, executionLeaseExpiresAt: null, }, }); // Every deleted message counts as compacted, so compaction cannot summarize cleared history. expect(tx.thread.update).toHaveBeenCalledWith({ where: { id: "thread-1" }, data: { historyCompactedUpToSeq: 41, historyCompactionSummary: null, historyCompactionGeneration: { increment: 1 }, }, }); expect(publish).toHaveBeenCalledWith("thread:thread-1", JSON.stringify({ cursor: 0 })); }); it("scopes group clear lease cleanup to cancelled run ids", async () => { const fanout = new TestFanout(); const tx = { thread: { update: vi .fn() .mockResolvedValueOnce({ nextMessageSeq: 3, historyCompactionGeneration: 0 }) .mockResolvedValue({ nextEventSeq: 1 }), }, run: { findMany: vi.fn().mockResolvedValue([ { id: "group-run-1", taskId: "task-1" }, { id: "group-run-2", taskId: "task-2" }, ]), count: vi.fn().mockResolvedValue(0), updateMany: vi.fn(), }, attempt: { updateMany: vi.fn() }, task: { updateMany: vi.fn() }, computerExecutionLease: { updateMany: vi.fn() }, computer: { updateMany: vi.fn() }, message: { deleteMany: vi.fn() }, event: { deleteMany: vi.fn(), create: vi.fn().mockResolvedValue({ ...event(0), type: "thread.cleared", }), }, chatGroup: { update: vi.fn() }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( clearThread( prisma, { spaceId: "workspace-1", threadId: "thread-group", botId: "bot-1", groupId: "group-1", }, fanout, ), ).resolves.toMatchObject({ cancelledRunIds: ["group-run-1", "group-run-2"] }); expect(tx.run.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ threadId: "thread-group", spaceId: "workspace-1", }), }), ); expect(tx.computerExecutionLease.updateMany).toHaveBeenCalledWith({ where: { runId: { in: ["group-run-1", "group-run-2"] } }, data: { expiresAt: new Date(0) }, }); expect(tx.computer.updateMany).toHaveBeenCalledWith({ where: { executionRunId: { in: ["group-run-1", "group-run-2"] } }, data: { executionRunId: null, executionBotId: null, executionLeaseExpiresAt: null, }, }); expect(tx.computerExecutionLease.updateMany).not.toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ botId: expect.anything() }) }), ); }); }); describe("appendEvent", () => { it("does not persist output from a cancelled run after history was cleared", async () => { const fanout = new TestFanout(); const publish = vi.spyOn(fanout, "publish"); const tx = { thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 8 }) }, run: { findUnique: vi.fn().mockResolvedValue({ status: "cancelled" }) }, event: { create: vi.fn() }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( appendEvent( prisma, { spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1", type: "thread.progress", runId: "run-1", payload: { text: "stale" }, }, fanout, ), ).rejects.toThrow(RunHistoryWriteError); expect(tx.event.create).not.toHaveBeenCalled(); expect(publish).not.toHaveBeenCalled(); }); it("lets a new active run write after the thread was cleared", async () => { const fanout = new TestFanout(); const publish = vi.spyOn(fanout, "publish"); const created = { ...event(8), type: "thread.progress", runId: "run-2", }; const tx = { thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 9 }) }, run: { findUnique: vi.fn().mockResolvedValue({ status: "running" }) }, event: { create: vi.fn().mockResolvedValue(created) }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; await expect( appendEvent( prisma, { spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1", type: "thread.progress", runId: "run-2", payload: { text: "fresh" }, }, fanout, ), ).resolves.toMatchObject({ type: "thread.progress", runId: "run-2" }); expect(tx.event.create).toHaveBeenCalled(); expect(publish).toHaveBeenCalled(); }); it("does not persist a split-emoji high surrogate that would crash JSON insert", async () => { const fanout = new TestFanout(); const created = { ...event(3), type: "thread.progress", runId: "run-3", payload: { delta: "hello \uFFFD", streaming: true }, }; const tx = { thread: { update: vi.fn().mockResolvedValue({ nextEventSeq: 4 }) }, run: { findUnique: vi.fn().mockResolvedValue({ status: "running" }) }, event: { create: vi.fn().mockResolvedValue(created) }, }; const prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; // Orphaned UTF-16 high surrogate (e.g. 😀 split across stream chunks as \uD83D alone). const orphanedHigh = "hello \uD83D"; await expect( appendEvent( prisma, { spaceId: "workspace-1", threadId: "thread-1", botId: "bot-1", type: "thread.progress", runId: "run-3", payload: { delta: orphanedHigh, streaming: true }, }, fanout, ), ).resolves.toMatchObject({ type: "thread.progress", runId: "run-3" }); expect(tx.event.create).toHaveBeenCalledWith({ data: expect.objectContaining({ payload: { delta: "hello \uFFFD", streaming: true }, }), }); const persisted = tx.event.create.mock.calls[0]![0].data.payload as { delta: string }; // Postgres rejects unpaired surrogates in json; the sanitized form must not contain any. expect(persisted.delta).not.toMatch(/[\uD800-\uDFFF]/); expect(() => JSON.stringify(persisted)).not.toThrow(); }); });