Agents only received the documents of a turn on their computer and could attach workspace files up to 10 MiB, so they could neither cut a clip a user sent nor post the video they rendered. - Current-turn images, video and audio are copied into the bot's workspace (Team Computers under bots/<botId>/) like documents. Remote computers move files as base64 JSON, so anything above their 12 MiB transfer cap stays in storage and the agent is told it by artifact id instead of failing the turn; local computers copy up to the kind cap. - attach_file accepts video and audio by extension and attaches up to the kind cap when the store streams (still bounded by the transfer cap of a remote computer), stored through the shared artifact helper with a real SHA-256. - The render_plot fallback that attaches a chart PNG now records the group, so the chart opens in group conversations. - Agent history names image artifacts by id, like video and audio, so a later turn can refer to them. - artifacts.get refuses files above 25 MiB: base64 reads hold the whole file in memory, and larger media is served by the artifact route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2096 lines
65 KiB
TypeScript
2096 lines
65 KiB
TypeScript
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 (artifact artifact-1)]",
|
|
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) },
|
|
});
|
|
expect(tx.steeringMessage.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ claimedAt: null }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns nothing when steering was already claimed by another caller", 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: "Already claimed" }] },
|
|
},
|
|
]),
|
|
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
};
|
|
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([]);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|