mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 06:47:00 +10:00
1372 lines
43 KiB
TypeScript
1372 lines
43 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ORPCError } from "@orpc/client";
|
|
|
|
const dbMock = {
|
|
select: vi.fn(),
|
|
insert: vi.fn(),
|
|
update: vi.fn(),
|
|
delete: vi.fn(),
|
|
transaction: vi.fn(async <T>(callback: (tx: typeof dbMock) => Promise<T>) => callback(dbMock)),
|
|
};
|
|
|
|
const clearActiveAgentRunIfCurrentMock = vi.fn();
|
|
const claimActiveAgentRunMock = vi.fn();
|
|
const storageServiceMock = {
|
|
delete: vi.fn(),
|
|
write: vi.fn(),
|
|
read: vi.fn(),
|
|
};
|
|
|
|
const resumeServiceMock = {
|
|
getById: vi.fn(),
|
|
patch: vi.fn(),
|
|
patchInTransaction: vi.fn(),
|
|
notifyResumePatched: vi.fn(),
|
|
};
|
|
|
|
const aiProvidersServiceMock = {
|
|
getRunnableById: vi.fn(),
|
|
getDefaultRunnable: vi.fn(),
|
|
markUsed: vi.fn(),
|
|
};
|
|
|
|
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
|
|
vi.mock("@reactive-resume/db/schema", () => ({
|
|
agentThread: {
|
|
id: "agent_threads.id",
|
|
userId: "agent_threads.user_id",
|
|
deletedAt: "agent_threads.deleted_at",
|
|
archivedAt: "agent_threads.archived_at",
|
|
status: "agent_threads.status",
|
|
activeRunId: "agent_threads.active_run_id",
|
|
activeStreamId: "agent_threads.active_stream_id",
|
|
activeRunStartedAt: "agent_threads.active_run_started_at",
|
|
aiProviderId: "agent_threads.ai_provider_id",
|
|
workingResumeId: "agent_threads.working_resume_id",
|
|
sourceResumeId: "agent_threads.source_resume_id",
|
|
title: "agent_threads.title",
|
|
lastMessageAt: "agent_threads.last_message_at",
|
|
createdAt: "agent_threads.created_at",
|
|
updatedAt: "agent_threads.updated_at",
|
|
},
|
|
agentMessage: {
|
|
id: "agent_messages.id",
|
|
threadId: "agent_messages.thread_id",
|
|
userId: "agent_messages.user_id",
|
|
role: "agent_messages.role",
|
|
status: "agent_messages.status",
|
|
sequence: "agent_messages.sequence",
|
|
uiMessage: "agent_messages.ui_message",
|
|
},
|
|
agentAction: {
|
|
id: "agent_actions.id",
|
|
threadId: "agent_actions.thread_id",
|
|
userId: "agent_actions.user_id",
|
|
resumeId: "agent_actions.resume_id",
|
|
kind: "agent_actions.kind",
|
|
status: "agent_actions.status",
|
|
appliedUpdatedAt: "agent_actions.applied_updated_at",
|
|
createdAt: "agent_actions.created_at",
|
|
},
|
|
agentAttachment: {
|
|
id: "agent_attachments.id",
|
|
threadId: "agent_attachments.thread_id",
|
|
userId: "agent_attachments.user_id",
|
|
messageId: "agent_attachments.message_id",
|
|
storageKey: "agent_attachments.storage_key",
|
|
filename: "agent_attachments.filename",
|
|
mediaType: "agent_attachments.media_type",
|
|
size: "agent_attachments.size",
|
|
createdAt: "agent_attachments.created_at",
|
|
},
|
|
resume: { name: "resume.name", id: "resume.id", userId: "resume.user_id", slug: "resume.slug" },
|
|
aiProvider: { label: "ai_provider.label", id: "ai_provider.id" },
|
|
}));
|
|
|
|
vi.mock("drizzle-orm", () => ({
|
|
and: (...conditions: unknown[]) => ({ type: "and", conditions }),
|
|
asc: (value: unknown) => ({ type: "asc", value }),
|
|
count: () => ({ type: "count" }),
|
|
desc: (value: unknown) => ({ type: "desc", value }),
|
|
eq: (left: unknown, right: unknown) => ({ type: "eq", left, right }),
|
|
gte: (left: unknown, right: unknown) => ({ type: "gte", left, right }),
|
|
inArray: (left: unknown, values: unknown[]) => ({ type: "inArray", left, values }),
|
|
isNull: (value: unknown) => ({ type: "isNull", value }),
|
|
max: (value: unknown) => ({ type: "max", value }),
|
|
sql: () => ({ type: "sql" }),
|
|
}));
|
|
|
|
vi.mock("ai", () => ({
|
|
convertToModelMessages: vi.fn(),
|
|
stepCountIs: vi.fn(),
|
|
ToolLoopAgent: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../ai/service", () => ({ getAgentModel: vi.fn() }));
|
|
vi.mock("../ai/credentials", () => ({ assertAgentEnvironment: vi.fn() }));
|
|
vi.mock("../ai-providers/service", () => ({ aiProvidersService: aiProvidersServiceMock }));
|
|
vi.mock("../resume/service", () => ({ resumeService: resumeServiceMock }));
|
|
vi.mock("../storage/service", () => ({
|
|
getStorageService: vi.fn(() => storageServiceMock),
|
|
inferContentType: vi.fn(),
|
|
}));
|
|
vi.mock("./resume", () => ({
|
|
buildAgentDraftResumeName: vi.fn(),
|
|
buildUniqueAgentDraftSlug: vi.fn(),
|
|
}));
|
|
vi.mock("./runs", () => ({
|
|
claimActiveAgentRun: claimActiveAgentRunMock,
|
|
clearActiveAgentRunIfCurrent: clearActiveAgentRunIfCurrentMock,
|
|
}));
|
|
vi.mock("./streams", () => ({
|
|
agentStreamLifecycle: { create: vi.fn(), resume: vi.fn() },
|
|
}));
|
|
vi.mock("./tools", () => ({
|
|
buildAgentInstructions: vi.fn(),
|
|
buildAgentTools: vi.fn(() => ({})),
|
|
}));
|
|
vi.mock("@reactive-resume/schema/resume/default", () => ({ defaultResumeData: {} }));
|
|
vi.mock("@reactive-resume/utils/string", () => ({ generateId: () => "test-id" }));
|
|
vi.mock("@orpc/server", () => ({ streamToEventIterator: vi.fn() }));
|
|
|
|
beforeEach(() => {
|
|
for (const mock of Object.values(dbMock)) mock.mockReset();
|
|
dbMock.transaction.mockImplementation(async <T>(callback: (tx: typeof dbMock) => Promise<T>) => callback(dbMock));
|
|
clearActiveAgentRunIfCurrentMock.mockReset();
|
|
claimActiveAgentRunMock.mockReset();
|
|
for (const mock of Object.values(storageServiceMock)) mock.mockReset();
|
|
for (const mock of Object.values(resumeServiceMock)) mock.mockReset();
|
|
for (const mock of Object.values(aiProvidersServiceMock)) mock.mockReset();
|
|
});
|
|
|
|
function buildArchivedThread(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "thread-1",
|
|
userId: "user-1",
|
|
aiProviderId: "provider-1",
|
|
workingResumeId: "resume-1",
|
|
sourceResumeId: null,
|
|
title: "Archived thread",
|
|
status: "archived",
|
|
activeRunId: null,
|
|
activeStreamId: null,
|
|
activeRunStartedAt: null,
|
|
lastMessageAt: new Date("2026-05-01T00:00:00.000Z"),
|
|
archivedAt: new Date("2026-05-02T00:00:00.000Z"),
|
|
deletedAt: null,
|
|
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-05-02T00:00:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildActiveThread(overrides: Record<string, unknown> = {}) {
|
|
return buildArchivedThread({
|
|
status: "active",
|
|
title: "Active thread",
|
|
activeRunId: null,
|
|
activeStreamId: null,
|
|
archivedAt: null,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function buildAttachment(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "attachment-1",
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
messageId: null,
|
|
storageKey: "uploads/user-1/agent/thread-1/attachment-1-note.txt",
|
|
filename: "note.txt",
|
|
mediaType: "text/plain",
|
|
size: 5,
|
|
createdAt: new Date("2026-05-01T00:00:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function selectLimitResult(rows: unknown[]) {
|
|
const limit = vi.fn(async () => rows);
|
|
const where = vi.fn(() => ({ limit }));
|
|
const from = vi.fn(() => ({ where }));
|
|
return { from };
|
|
}
|
|
|
|
function selectWhereResult(rows: unknown[]) {
|
|
const where = vi.fn(async () => rows);
|
|
const from = vi.fn(() => ({ where }));
|
|
return { from };
|
|
}
|
|
|
|
function selectOrderByResult(rows: unknown[]) {
|
|
const orderBy = vi.fn(async () => rows);
|
|
const where = vi.fn(() => ({ orderBy }));
|
|
const from = vi.fn(() => ({ where }));
|
|
return { from };
|
|
}
|
|
|
|
function selectWhereOrderByLimitResult(rows: unknown[]) {
|
|
const limit = vi.fn(async () => rows);
|
|
const orderBy = vi.fn(() => ({ limit }));
|
|
const where = vi.fn(() => ({ orderBy }));
|
|
const from = vi.fn(() => ({ where }));
|
|
return { from };
|
|
}
|
|
|
|
describe("agentService.threads.get", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("returns isReadOnly: true when the thread is archived, even if the resume and provider are present", async () => {
|
|
const archivedThread = buildArchivedThread();
|
|
|
|
// First select call is `getThread` (limit), then three select calls for messages/actions/attachments (orderBy).
|
|
const threadSelect = () => {
|
|
const limit = vi.fn(async () => [archivedThread]);
|
|
const where = vi.fn(() => ({ limit }));
|
|
const from = vi.fn(() => ({ where }));
|
|
return { from };
|
|
};
|
|
const emptyListSelect = () => {
|
|
const orderBy = vi.fn(async () => []);
|
|
const where = vi.fn(() => ({ orderBy }));
|
|
const from = vi.fn(() => ({ where }));
|
|
return { from };
|
|
};
|
|
|
|
dbMock.select
|
|
.mockImplementationOnce(threadSelect)
|
|
.mockImplementationOnce(emptyListSelect)
|
|
.mockImplementationOnce(emptyListSelect)
|
|
.mockImplementationOnce(emptyListSelect);
|
|
|
|
resumeServiceMock.getById.mockResolvedValue({
|
|
id: "resume-1",
|
|
name: "Resume",
|
|
data: {},
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
const result = await agentService.threads.get({ id: "thread-1", userId: "user-1" });
|
|
|
|
expect(result.isReadOnly).toBe(true);
|
|
expect(result.thread.status).toBe("archived");
|
|
expect(result.resume).toEqual(expect.objectContaining({ id: "resume-1" }));
|
|
});
|
|
});
|
|
|
|
describe("buildAttachmentModelParts", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("converts readable, image, supported binary, and unsupported attachments into model parts", async () => {
|
|
const { buildAttachmentModelParts } = await import("./service");
|
|
|
|
const imageBytes = new Uint8Array([1, 2, 3]);
|
|
const pdfBytes = new Uint8Array([4, 5, 6]);
|
|
const parts = buildAttachmentModelParts([
|
|
{ attachment: buildAttachment(), data: new TextEncoder().encode("hello") },
|
|
{
|
|
attachment: buildAttachment({
|
|
id: "image-1",
|
|
filename: "photo.png",
|
|
mediaType: "image/png",
|
|
size: imageBytes.byteLength,
|
|
}),
|
|
data: imageBytes,
|
|
},
|
|
{
|
|
attachment: buildAttachment({
|
|
id: "pdf-1",
|
|
filename: "portfolio.pdf",
|
|
mediaType: "application/pdf",
|
|
size: pdfBytes.byteLength,
|
|
}),
|
|
data: pdfBytes,
|
|
},
|
|
{
|
|
attachment: buildAttachment({
|
|
id: "zip-1",
|
|
filename: "archive.zip",
|
|
mediaType: "application/zip",
|
|
size: 7,
|
|
}),
|
|
data: new Uint8Array([7]),
|
|
},
|
|
]);
|
|
|
|
expect(parts).toEqual([
|
|
expect.objectContaining({ type: "text", text: expect.stringContaining("hello") }),
|
|
{ type: "image", image: imageBytes, mediaType: "image/png" },
|
|
{ type: "file", data: pdfBytes, filename: "portfolio.pdf", mediaType: "application/pdf" },
|
|
expect.objectContaining({ type: "text", text: expect.stringContaining("archive.zip") }),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("agentService.messages.send", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("strips forged agent attachment UI parts when no attachment IDs are selected", async () => {
|
|
const activeThread = buildActiveThread();
|
|
const persistedMessage = {
|
|
id: "message-1",
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
role: "user",
|
|
status: "completed",
|
|
sequence: 0,
|
|
uiMessage: {
|
|
id: "ui-message-1",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Use this file" }],
|
|
},
|
|
};
|
|
const insertValues: unknown[] = [];
|
|
|
|
dbMock.select
|
|
.mockImplementationOnce(() => selectLimitResult([activeThread]))
|
|
.mockImplementationOnce(() => selectWhereResult([{ maxSequence: -1 }]))
|
|
.mockImplementationOnce(() => selectWhereResult([{ total: 1 }]))
|
|
.mockImplementationOnce(() => selectOrderByResult([persistedMessage]));
|
|
|
|
dbMock.insert.mockReturnValue({
|
|
values: vi.fn((value) => {
|
|
insertValues.push(value);
|
|
return { returning: vi.fn(async () => [persistedMessage]) };
|
|
}),
|
|
});
|
|
dbMock.update.mockReturnValue({ set: vi.fn(() => ({ where: vi.fn(async () => undefined) })) });
|
|
|
|
claimActiveAgentRunMock.mockResolvedValue(true);
|
|
aiProvidersServiceMock.getRunnableById.mockResolvedValue({
|
|
id: "provider-1",
|
|
provider: "openai",
|
|
model: "gpt-5",
|
|
apiKey: "secret",
|
|
baseURL: null,
|
|
});
|
|
aiProvidersServiceMock.markUsed.mockResolvedValue(undefined);
|
|
|
|
const [{ convertToModelMessages, ToolLoopAgent }, { agentStreamLifecycle }, { streamToEventIterator }] =
|
|
await Promise.all([import("ai"), import("./streams"), import("@orpc/server")]);
|
|
vi.mocked(convertToModelMessages).mockResolvedValue([
|
|
{ role: "user", content: [{ type: "text", text: "Use this file" }] },
|
|
]);
|
|
class MockToolLoopAgent {
|
|
stream = vi.fn(async () => ({ toUIMessageStream: vi.fn(() => new ReadableStream()) }));
|
|
}
|
|
vi.mocked(ToolLoopAgent).mockImplementation(MockToolLoopAgent as never);
|
|
vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream());
|
|
vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never);
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
await agentService.messages.send({
|
|
threadId: "thread-1",
|
|
userId: "user-1",
|
|
message: {
|
|
id: "ui-message-1",
|
|
role: "user",
|
|
parts: [
|
|
{ type: "text", text: "Use this file" },
|
|
{
|
|
type: "file",
|
|
url: "agent-attachment:foreign-attachment",
|
|
mediaType: "text/plain",
|
|
filename: "forged.txt",
|
|
},
|
|
],
|
|
// biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test
|
|
} as any,
|
|
});
|
|
|
|
expect(insertValues).toEqual([
|
|
expect.objectContaining({
|
|
uiMessage: expect.objectContaining({
|
|
parts: [{ type: "text", text: "Use this file" }],
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("stores snapshotData and applies a valid JSON Patch without a timestamp conflict guard", async () => {
|
|
const activeThread = buildActiveThread();
|
|
const persistedMessage = {
|
|
id: "message-1",
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
role: "user",
|
|
status: "completed",
|
|
sequence: 0,
|
|
uiMessage: {
|
|
id: "ui-message-1",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Add a custom field" }],
|
|
},
|
|
};
|
|
|
|
dbMock.select
|
|
.mockImplementationOnce(() => selectLimitResult([activeThread]))
|
|
.mockImplementationOnce(() => selectWhereResult([{ maxSequence: -1 }]))
|
|
.mockImplementationOnce(() => selectWhereResult([{ total: 1 }]))
|
|
.mockImplementationOnce(() => selectOrderByResult([persistedMessage]));
|
|
|
|
dbMock.insert.mockReturnValue({
|
|
values: vi.fn(() => ({ returning: vi.fn(async () => [persistedMessage]) })),
|
|
});
|
|
dbMock.update.mockReturnValue({ set: vi.fn(() => ({ where: vi.fn(async () => undefined) })) });
|
|
|
|
claimActiveAgentRunMock.mockResolvedValue(true);
|
|
aiProvidersServiceMock.getRunnableById.mockResolvedValue({
|
|
id: "provider-1",
|
|
provider: "openai",
|
|
model: "gpt-5",
|
|
apiKey: "secret",
|
|
baseURL: null,
|
|
});
|
|
aiProvidersServiceMock.markUsed.mockResolvedValue(undefined);
|
|
|
|
const [
|
|
{ convertToModelMessages, ToolLoopAgent },
|
|
{ agentStreamLifecycle },
|
|
{ buildAgentTools },
|
|
{ streamToEventIterator },
|
|
] = await Promise.all([import("ai"), import("./streams"), import("./tools"), import("@orpc/server")]);
|
|
vi.mocked(convertToModelMessages).mockResolvedValue([
|
|
{ role: "user", content: [{ type: "text", text: "Add a custom field" }] },
|
|
]);
|
|
class MockToolLoopAgent {
|
|
stream = vi.fn(async () => ({ toUIMessageStream: vi.fn(() => new ReadableStream()) }));
|
|
}
|
|
vi.mocked(ToolLoopAgent).mockImplementation(MockToolLoopAgent as never);
|
|
vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream());
|
|
vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never);
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
await agentService.messages.send({
|
|
threadId: "thread-1",
|
|
userId: "user-1",
|
|
message: {
|
|
id: "ui-message-1",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Add a custom field" }],
|
|
// biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test
|
|
} as any,
|
|
});
|
|
|
|
const beforeData = { basics: { customFields: [] } };
|
|
const beforeUpdatedAt = new Date("2026-05-01T00:00:00.000Z");
|
|
const patchedUpdatedAt = new Date("2026-05-02T00:00:00.000Z");
|
|
const operations = [
|
|
{ op: "add", path: "/basics/customFields/-", value: { id: "field-1", icon: "phosphor", text: "x", link: "" } },
|
|
];
|
|
const insertValues: unknown[] = [];
|
|
|
|
resumeServiceMock.getById.mockResolvedValue({ data: beforeData, updatedAt: beforeUpdatedAt });
|
|
resumeServiceMock.patchInTransaction.mockResolvedValue({ id: "resume-1", updatedAt: patchedUpdatedAt });
|
|
dbMock.insert.mockReturnValue({
|
|
values: vi.fn((value) => {
|
|
insertValues.push(value);
|
|
return {
|
|
returning: vi.fn(async () => [
|
|
{
|
|
id: "action-1",
|
|
...value,
|
|
messageId: null,
|
|
revertedAt: null,
|
|
revertMessage: null,
|
|
createdAt: patchedUpdatedAt,
|
|
updatedAt: patchedUpdatedAt,
|
|
},
|
|
]),
|
|
};
|
|
}),
|
|
});
|
|
|
|
const toolConfig = vi.mocked(buildAgentTools).mock.calls.at(-1)?.[0];
|
|
// biome-ignore lint/suspicious/noExplicitAny: captured mocked tool config has intentionally loose handler types
|
|
const result = await (toolConfig as any).handlers.applyResumePatch({ title: "Append field", operations });
|
|
|
|
expect(resumeServiceMock.patchInTransaction).toHaveBeenCalledWith(dbMock, {
|
|
id: "resume-1",
|
|
userId: "user-1",
|
|
operations,
|
|
});
|
|
expect(insertValues).toContainEqual(
|
|
expect.objectContaining({
|
|
operations,
|
|
snapshotData: beforeData,
|
|
baseUpdatedAt: beforeUpdatedAt,
|
|
appliedUpdatedAt: patchedUpdatedAt,
|
|
}),
|
|
);
|
|
expect(result).toEqual(expect.objectContaining({ actionId: "action-1", resumeId: "resume-1" }));
|
|
});
|
|
|
|
it("persists canonical attachment UI parts, links selected attachments, and appends server-read model parts", async () => {
|
|
const activeThread = buildActiveThread();
|
|
const attachment = buildAttachment({
|
|
filename: "canonical.txt",
|
|
mediaType: "text/plain",
|
|
size: 5,
|
|
});
|
|
const persistedMessage = {
|
|
id: "message-1",
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
role: "user",
|
|
status: "completed",
|
|
sequence: 0,
|
|
uiMessage: {
|
|
id: "ui-message-1",
|
|
role: "user",
|
|
parts: [
|
|
{ type: "text", text: "Use this file" },
|
|
{
|
|
type: "file",
|
|
url: "agent-attachment:attachment-1",
|
|
mediaType: "text/plain",
|
|
filename: "canonical.txt",
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const insertValues: unknown[] = [];
|
|
const updateSets: unknown[] = [];
|
|
|
|
dbMock.select
|
|
.mockImplementationOnce(() => selectLimitResult([activeThread]))
|
|
.mockImplementationOnce(() => selectWhereResult([attachment]))
|
|
.mockImplementationOnce(() => selectWhereResult([{ maxSequence: -1 }]))
|
|
.mockImplementationOnce(() => selectWhereResult([{ total: 1 }]))
|
|
.mockImplementationOnce(() => selectOrderByResult([persistedMessage]));
|
|
|
|
dbMock.insert.mockReturnValue({
|
|
values: vi.fn((value) => {
|
|
insertValues.push(value);
|
|
return { returning: vi.fn(async () => [persistedMessage]) };
|
|
}),
|
|
});
|
|
dbMock.update.mockImplementation(() => ({
|
|
set: vi.fn((value) => {
|
|
updateSets.push(value);
|
|
return {
|
|
where: vi.fn(() => ({
|
|
returning: vi.fn(async () => [{ id: "attachment-1" }]),
|
|
})),
|
|
};
|
|
}),
|
|
}));
|
|
|
|
claimActiveAgentRunMock.mockResolvedValue(true);
|
|
aiProvidersServiceMock.getRunnableById.mockResolvedValue({
|
|
id: "provider-1",
|
|
provider: "openai",
|
|
model: "gpt-5",
|
|
apiKey: "secret",
|
|
baseURL: null,
|
|
});
|
|
aiProvidersServiceMock.markUsed.mockResolvedValue(undefined);
|
|
storageServiceMock.read.mockResolvedValue({ data: new TextEncoder().encode("hello"), contentType: "text/plain" });
|
|
|
|
const [{ convertToModelMessages, ToolLoopAgent }, { agentStreamLifecycle }, { streamToEventIterator }] =
|
|
await Promise.all([import("ai"), import("./streams"), import("@orpc/server")]);
|
|
const streamMock = vi.fn(async () => ({
|
|
toUIMessageStream: vi.fn(() => new ReadableStream()),
|
|
}));
|
|
vi.mocked(convertToModelMessages).mockResolvedValue([
|
|
{ role: "user", content: [{ type: "text", text: "Use this file" }] },
|
|
]);
|
|
class MockToolLoopAgent {
|
|
stream = streamMock;
|
|
}
|
|
vi.mocked(ToolLoopAgent).mockImplementation(MockToolLoopAgent as never);
|
|
vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream());
|
|
vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never);
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
await agentService.messages.send({
|
|
threadId: "thread-1",
|
|
userId: "user-1",
|
|
message: {
|
|
id: "ui-message-1",
|
|
role: "user",
|
|
parts: [
|
|
{ type: "text", text: "Use this file" },
|
|
{
|
|
type: "file",
|
|
url: "agent-attachment:attachment-1",
|
|
mediaType: "application/octet-stream",
|
|
filename: "forged-name.bin",
|
|
},
|
|
],
|
|
// biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test
|
|
} as any,
|
|
attachmentIds: ["attachment-1"],
|
|
});
|
|
|
|
expect(insertValues).toEqual([
|
|
expect.objectContaining({
|
|
uiMessage: expect.objectContaining({
|
|
parts: [
|
|
{ type: "text", text: "Use this file" },
|
|
{
|
|
type: "file",
|
|
url: "agent-attachment:attachment-1",
|
|
mediaType: "text/plain",
|
|
filename: "canonical.txt",
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
]);
|
|
expect(updateSets).toContainEqual({ messageId: "message-1" });
|
|
expect(streamMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Use this file" },
|
|
expect.objectContaining({ type: "text", text: expect.stringContaining("hello") }),
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("merges an answered ask-user-question tool result into the existing assistant message", async () => {
|
|
const activeThread = buildActiveThread();
|
|
const userMessage = {
|
|
id: "message-user-1",
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
role: "user",
|
|
status: "completed",
|
|
sequence: 0,
|
|
uiMessage: {
|
|
id: "ui-user-1",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Change the name" }],
|
|
},
|
|
};
|
|
const unansweredAssistantMessage = {
|
|
id: "message-assistant-1",
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
role: "assistant",
|
|
status: "completed",
|
|
sequence: 1,
|
|
uiMessage: {
|
|
id: "ui-assistant-1",
|
|
role: "assistant",
|
|
parts: [
|
|
{
|
|
type: "tool-ask_user_question",
|
|
toolCallId: "call-1",
|
|
state: "input-available",
|
|
input: {
|
|
question: "How broadly should I rename?",
|
|
choices: ["Only change the main resume header name"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const answeredAssistantMessage = {
|
|
...unansweredAssistantMessage,
|
|
uiMessage: {
|
|
...unansweredAssistantMessage.uiMessage,
|
|
parts: [
|
|
{
|
|
type: "tool-ask_user_question",
|
|
toolCallId: "call-1",
|
|
state: "output-available",
|
|
input: {
|
|
question: "How broadly should I rename?",
|
|
choices: ["Only change the main resume header name"],
|
|
},
|
|
output: "Only change the main resume header name",
|
|
callProviderMetadata: { openai: { itemId: "fc_duplicate_item" } },
|
|
resultProviderMetadata: { openai: { itemId: "fc_duplicate_item" } },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const answeredAssistantModelInput = {
|
|
...answeredAssistantMessage.uiMessage,
|
|
parts: [
|
|
{
|
|
type: "tool-ask_user_question",
|
|
toolCallId: "call-1",
|
|
state: "output-available",
|
|
input: {
|
|
question: "How broadly should I rename?",
|
|
choices: ["Only change the main resume header name"],
|
|
},
|
|
output: "Only change the main resume header name",
|
|
},
|
|
],
|
|
};
|
|
const updateSets: unknown[] = [];
|
|
|
|
dbMock.select
|
|
.mockImplementationOnce(() => selectLimitResult([activeThread]))
|
|
.mockImplementationOnce(() => selectOrderByResult([userMessage, unansweredAssistantMessage]))
|
|
.mockImplementationOnce(() => selectOrderByResult([userMessage, answeredAssistantMessage]));
|
|
|
|
dbMock.update.mockImplementation(() => ({
|
|
set: vi.fn((value) => {
|
|
updateSets.push(value);
|
|
return { where: vi.fn(async () => undefined) };
|
|
}),
|
|
}));
|
|
|
|
claimActiveAgentRunMock.mockResolvedValue(true);
|
|
aiProvidersServiceMock.getRunnableById.mockResolvedValue({
|
|
id: "provider-1",
|
|
provider: "openai",
|
|
model: "gpt-5",
|
|
apiKey: "secret",
|
|
baseURL: null,
|
|
});
|
|
aiProvidersServiceMock.markUsed.mockResolvedValue(undefined);
|
|
|
|
const [{ convertToModelMessages, ToolLoopAgent }, { agentStreamLifecycle }, { streamToEventIterator }] =
|
|
await Promise.all([import("ai"), import("./streams"), import("@orpc/server")]);
|
|
vi.mocked(convertToModelMessages).mockResolvedValue([
|
|
{ role: "user", content: [{ type: "text", text: "Change the name" }] },
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "tool-call", toolCallId: "call-1", toolName: "ask_user_question", input: {} }],
|
|
},
|
|
{
|
|
role: "tool",
|
|
content: [
|
|
{
|
|
type: "tool-result",
|
|
toolCallId: "call-1",
|
|
toolName: "ask_user_question",
|
|
output: "Only change the main resume header name",
|
|
},
|
|
],
|
|
},
|
|
] as never);
|
|
class MockToolLoopAgent {
|
|
stream = vi.fn(async () => ({ toUIMessageStream: vi.fn(() => new ReadableStream()) }));
|
|
}
|
|
vi.mocked(ToolLoopAgent).mockImplementation(MockToolLoopAgent as never);
|
|
vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream());
|
|
vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never);
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
await agentService.messages.send({
|
|
threadId: "thread-1",
|
|
userId: "user-1",
|
|
message: {
|
|
id: "ui-assistant-1",
|
|
role: "assistant",
|
|
parts: [
|
|
{
|
|
type: "tool-ask_user_question",
|
|
toolCallId: "call-1",
|
|
state: "output-available",
|
|
output: "Only change the main resume header name",
|
|
},
|
|
],
|
|
// biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test
|
|
} as any,
|
|
});
|
|
|
|
expect(dbMock.insert).not.toHaveBeenCalled();
|
|
expect(updateSets).toContainEqual(
|
|
expect.objectContaining({
|
|
uiMessage: expect.objectContaining({
|
|
parts: [
|
|
expect.objectContaining({
|
|
type: "tool-ask_user_question",
|
|
toolCallId: "call-1",
|
|
state: "output-available",
|
|
output: "Only change the main resume header name",
|
|
}),
|
|
],
|
|
}),
|
|
}),
|
|
);
|
|
expect(convertToModelMessages).toHaveBeenCalledWith([userMessage.uiMessage, answeredAssistantModelInput]);
|
|
});
|
|
|
|
it("repairs legacy user-answer messages that followed an unresolved ask-user-question tool call", async () => {
|
|
const activeThread = buildActiveThread();
|
|
const firstUserMessage = {
|
|
id: "message-user-1",
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
role: "user",
|
|
status: "completed",
|
|
sequence: 0,
|
|
uiMessage: {
|
|
id: "ui-user-1",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Change the name" }],
|
|
},
|
|
};
|
|
const unresolvedAssistantMessage = {
|
|
id: "message-assistant-1",
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
role: "assistant",
|
|
status: "completed",
|
|
sequence: 1,
|
|
uiMessage: {
|
|
id: "ui-assistant-1",
|
|
role: "assistant",
|
|
parts: [
|
|
{
|
|
type: "tool-ask_user_question",
|
|
toolCallId: "call-legacy",
|
|
state: "input-available",
|
|
input: {
|
|
question: "How broadly should I rename?",
|
|
choices: ["Only change the main resume header name"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const legacyAnswerMessage = {
|
|
id: "message-user-2",
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
role: "user",
|
|
status: "completed",
|
|
sequence: 2,
|
|
uiMessage: {
|
|
id: "ui-user-2",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Only change the main resume header name" }],
|
|
},
|
|
};
|
|
const retryMessage = {
|
|
id: "message-user-3",
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
role: "user",
|
|
status: "completed",
|
|
sequence: 3,
|
|
uiMessage: {
|
|
id: "ui-user-3",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Retry" }],
|
|
},
|
|
};
|
|
const updateSets: unknown[] = [];
|
|
|
|
dbMock.select
|
|
.mockImplementationOnce(() => selectLimitResult([activeThread]))
|
|
.mockImplementationOnce(() => selectWhereResult([{ maxSequence: 2 }]))
|
|
.mockImplementationOnce(() => selectWhereResult([{ total: 4 }]))
|
|
.mockImplementationOnce(() =>
|
|
selectOrderByResult([firstUserMessage, unresolvedAssistantMessage, legacyAnswerMessage, retryMessage]),
|
|
);
|
|
|
|
dbMock.insert.mockReturnValue({
|
|
values: vi.fn(() => ({ returning: vi.fn(async () => [retryMessage]) })),
|
|
});
|
|
dbMock.update.mockImplementation(() => ({
|
|
set: vi.fn((value) => {
|
|
updateSets.push(value);
|
|
return { where: vi.fn(async () => undefined) };
|
|
}),
|
|
}));
|
|
|
|
claimActiveAgentRunMock.mockResolvedValue(true);
|
|
aiProvidersServiceMock.getRunnableById.mockResolvedValue({
|
|
id: "provider-1",
|
|
provider: "openai",
|
|
model: "gpt-5",
|
|
apiKey: "secret",
|
|
baseURL: null,
|
|
});
|
|
aiProvidersServiceMock.markUsed.mockResolvedValue(undefined);
|
|
|
|
const [{ convertToModelMessages, ToolLoopAgent }, { agentStreamLifecycle }, { streamToEventIterator }] =
|
|
await Promise.all([import("ai"), import("./streams"), import("@orpc/server")]);
|
|
vi.mocked(convertToModelMessages).mockResolvedValue([{ role: "user", content: [{ type: "text", text: "Retry" }] }]);
|
|
class MockToolLoopAgent {
|
|
stream = vi.fn(async () => ({ toUIMessageStream: vi.fn(() => new ReadableStream()) }));
|
|
}
|
|
vi.mocked(ToolLoopAgent).mockImplementation(MockToolLoopAgent as never);
|
|
vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream());
|
|
vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never);
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
await agentService.messages.send({
|
|
threadId: "thread-1",
|
|
userId: "user-1",
|
|
// biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test
|
|
message: retryMessage.uiMessage as any,
|
|
});
|
|
|
|
expect(updateSets).toContainEqual(
|
|
expect.objectContaining({
|
|
uiMessage: expect.objectContaining({
|
|
parts: [
|
|
expect.objectContaining({
|
|
type: "tool-ask_user_question",
|
|
toolCallId: "call-legacy",
|
|
state: "output-available",
|
|
output: "Only change the main resume header name",
|
|
}),
|
|
],
|
|
}),
|
|
}),
|
|
);
|
|
expect(convertToModelMessages).toHaveBeenCalledWith([
|
|
firstUserMessage.uiMessage,
|
|
expect.objectContaining({
|
|
parts: [
|
|
expect.objectContaining({
|
|
toolCallId: "call-legacy",
|
|
state: "output-available",
|
|
output: "Only change the main resume header name",
|
|
}),
|
|
],
|
|
}),
|
|
legacyAnswerMessage.uiMessage,
|
|
retryMessage.uiMessage,
|
|
]);
|
|
});
|
|
|
|
it("rejects malformed attachment IDs before persisting a message", async () => {
|
|
dbMock.select.mockImplementationOnce(() => selectLimitResult([buildActiveThread()]));
|
|
aiProvidersServiceMock.getRunnableById.mockResolvedValue({
|
|
id: "provider-1",
|
|
provider: "openai",
|
|
model: "gpt-5",
|
|
apiKey: "secret",
|
|
baseURL: null,
|
|
});
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
const sending = agentService.messages.send({
|
|
threadId: "thread-1",
|
|
userId: "user-1",
|
|
// biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test
|
|
message: { id: "ui-message-1", role: "user", parts: [{ type: "text", text: "Use this file" }] } as any,
|
|
attachmentIds: [123],
|
|
});
|
|
|
|
await expect(sending).rejects.toBeInstanceOf(ORPCError);
|
|
await expect(sending).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
message: "Attachment IDs must be non-empty strings.",
|
|
});
|
|
expect(dbMock.insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects attachments that are missing, foreign, or already linked before persisting a message", async () => {
|
|
dbMock.select
|
|
.mockImplementationOnce(() => selectLimitResult([buildActiveThread()]))
|
|
.mockImplementationOnce(() => selectWhereResult([]));
|
|
|
|
aiProvidersServiceMock.getRunnableById.mockResolvedValue({
|
|
id: "provider-1",
|
|
provider: "openai",
|
|
model: "gpt-5",
|
|
apiKey: "secret",
|
|
baseURL: null,
|
|
});
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
const sending = agentService.messages.send({
|
|
threadId: "thread-1",
|
|
userId: "user-1",
|
|
// biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test
|
|
message: { id: "ui-message-1", role: "user", parts: [{ type: "text", text: "Use this file" }] } as any,
|
|
attachmentIds: ["foreign-or-linked-attachment"],
|
|
});
|
|
|
|
await expect(sending).rejects.toBeInstanceOf(ORPCError);
|
|
await expect(sending).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
message: "One or more attachments are unavailable or already linked to a message.",
|
|
});
|
|
expect(dbMock.insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws CONFLICT when the underlying thread is archived", async () => {
|
|
const archivedThread = buildArchivedThread();
|
|
|
|
dbMock.select.mockImplementation(() => {
|
|
const limit = vi.fn(async () => [archivedThread]);
|
|
const where = vi.fn(() => ({ limit }));
|
|
const from = vi.fn(() => ({ where }));
|
|
return { from };
|
|
});
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
const sending = agentService.messages.send({
|
|
threadId: "thread-1",
|
|
userId: "user-1",
|
|
// biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test
|
|
message: { id: "msg-1", role: "user", parts: [{ type: "text", text: "hi" }] } as any,
|
|
});
|
|
|
|
await expect(sending).rejects.toBeInstanceOf(ORPCError);
|
|
await expect(sending).rejects.toMatchObject({ code: "CONFLICT", message: "This thread is archived." });
|
|
|
|
// Ensure we never tried to claim a run or persist anything for an archived thread.
|
|
expect(aiProvidersServiceMock.getRunnableById).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("agentService.threads.archive", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("skips active-run cleanup when there is no active run", async () => {
|
|
const idleThread = buildArchivedThread({
|
|
status: "active",
|
|
activeRunId: null,
|
|
activeStreamId: null,
|
|
archivedAt: null,
|
|
});
|
|
|
|
dbMock.select.mockImplementation(() => {
|
|
const limit = vi.fn(async () => [idleThread]);
|
|
const where = vi.fn(() => ({ limit }));
|
|
const from = vi.fn(() => ({ where }));
|
|
return { from };
|
|
});
|
|
|
|
const updateWhere = vi.fn(async () => undefined);
|
|
const updateSet = vi.fn(() => ({ where: updateWhere }));
|
|
dbMock.update.mockReturnValue({ set: updateSet });
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
await agentService.threads.archive({ id: "thread-1", userId: "user-1" });
|
|
|
|
expect(clearActiveAgentRunIfCurrentMock).not.toHaveBeenCalled();
|
|
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ status: "archived" }));
|
|
expect(updateWhere).toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears active-run state when an active run is present, then flips status", async () => {
|
|
const activeThread = buildArchivedThread({
|
|
status: "active",
|
|
activeRunId: "run-1",
|
|
activeStreamId: "stream-1",
|
|
archivedAt: null,
|
|
});
|
|
|
|
dbMock.select.mockImplementation(() => {
|
|
const limit = vi.fn(async () => [activeThread]);
|
|
const where = vi.fn(() => ({ limit }));
|
|
const from = vi.fn(() => ({ where }));
|
|
return { from };
|
|
});
|
|
|
|
const updateWhere = vi.fn(async () => undefined);
|
|
const updateSet = vi.fn(() => ({ where: updateWhere }));
|
|
dbMock.update.mockReturnValue({ set: updateSet });
|
|
|
|
clearActiveAgentRunIfCurrentMock.mockResolvedValue(undefined);
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
await agentService.threads.archive({ id: "thread-1", userId: "user-1" });
|
|
|
|
expect(clearActiveAgentRunIfCurrentMock).toHaveBeenCalledWith({
|
|
threadId: "thread-1",
|
|
userId: "user-1",
|
|
runId: "run-1",
|
|
streamId: "stream-1",
|
|
});
|
|
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ status: "archived" }));
|
|
expect(updateWhere).toHaveBeenCalled();
|
|
});
|
|
|
|
it("still flips status when clearActiveAgentRunIfCurrent throws", async () => {
|
|
const activeThread = buildArchivedThread({
|
|
status: "active",
|
|
activeRunId: "run-2",
|
|
activeStreamId: "stream-2",
|
|
archivedAt: null,
|
|
});
|
|
|
|
dbMock.select.mockImplementation(() => {
|
|
const limit = vi.fn(async () => [activeThread]);
|
|
const where = vi.fn(() => ({ limit }));
|
|
const from = vi.fn(() => ({ where }));
|
|
return { from };
|
|
});
|
|
|
|
const updateWhere = vi.fn(async () => undefined);
|
|
const updateSet = vi.fn(() => ({ where: updateWhere }));
|
|
dbMock.update.mockReturnValue({ set: updateSet });
|
|
|
|
clearActiveAgentRunIfCurrentMock.mockRejectedValue(new Error("boom"));
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
await agentService.threads.archive({ id: "thread-1", userId: "user-1" });
|
|
|
|
expect(clearActiveAgentRunIfCurrentMock).toHaveBeenCalled();
|
|
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ status: "archived" }));
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("agentService.threads.delete", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("throws NOT_FOUND when the thread does not belong to the user and skips destructive work", async () => {
|
|
dbMock.select.mockImplementation(() => {
|
|
const limit = vi.fn(async () => []);
|
|
const where = vi.fn(() => ({ limit }));
|
|
const from = vi.fn(() => ({ where }));
|
|
return { from };
|
|
});
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
const deleting = agentService.threads.delete({ id: "thread-x", userId: "user-y" });
|
|
|
|
await expect(deleting).rejects.toBeInstanceOf(ORPCError);
|
|
await expect(deleting).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
|
|
expect(storageServiceMock.delete).not.toHaveBeenCalled();
|
|
expect(dbMock.delete).not.toHaveBeenCalled();
|
|
expect(dbMock.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("proceeds with cleanup when the thread is owned by the user", async () => {
|
|
const ownedThread = buildArchivedThread({
|
|
id: "thread-own",
|
|
userId: "user-own",
|
|
status: "active",
|
|
activeRunId: null,
|
|
activeStreamId: null,
|
|
archivedAt: null,
|
|
});
|
|
|
|
dbMock.select.mockImplementation(() => {
|
|
const limit = vi.fn(async () => [ownedThread]);
|
|
const where = vi.fn(() => ({ limit }));
|
|
const from = vi.fn(() => ({ where }));
|
|
return { from };
|
|
});
|
|
|
|
const deleteWhere = vi.fn(async () => undefined);
|
|
dbMock.delete.mockReturnValue({ where: deleteWhere });
|
|
|
|
const updateWhere = vi.fn(async () => undefined);
|
|
const updateSet = vi.fn(() => ({ where: updateWhere }));
|
|
dbMock.update.mockReturnValue({ set: updateSet });
|
|
|
|
storageServiceMock.delete.mockResolvedValue(undefined);
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
await agentService.threads.delete({ id: "thread-own", userId: "user-own" });
|
|
|
|
expect(storageServiceMock.delete).toHaveBeenCalledWith("uploads/user-own/agent/thread-own");
|
|
expect(dbMock.delete).toHaveBeenCalled();
|
|
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ status: "deleted" }));
|
|
});
|
|
});
|
|
|
|
describe("agentService.actions.revert", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function buildAction(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "action-1",
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
messageId: null,
|
|
resumeId: "resume-1",
|
|
kind: "resume_patch",
|
|
status: "applied",
|
|
title: "Tighten summary",
|
|
summary: null,
|
|
operations: [{ op: "replace", path: "/basics/name", value: "Bob" }],
|
|
snapshotData: { basics: { name: "Alice" } },
|
|
baseUpdatedAt: new Date("2026-05-01T00:00:00.000Z"),
|
|
appliedUpdatedAt: new Date("2026-05-02T00:00:00.000Z"),
|
|
revertedAt: null,
|
|
revertMessage: null,
|
|
createdAt: new Date("2026-05-02T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-05-02T00:00:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("rolls back an applied action by restoring its snapshot and marks later applied actions rolled_back", async () => {
|
|
const action = buildAction();
|
|
const laterAction = buildAction({
|
|
id: "action-2",
|
|
appliedUpdatedAt: new Date("2026-05-03T00:00:00.000Z"),
|
|
createdAt: new Date("2026-05-03T00:00:00.000Z"),
|
|
});
|
|
const updatedAction = { ...action, status: "rolled_back", revertedAt: new Date(), revertMessage: null };
|
|
|
|
dbMock.select
|
|
.mockImplementationOnce(() => selectLimitResult([action]))
|
|
.mockImplementationOnce(() => selectWhereOrderByLimitResult([laterAction]));
|
|
|
|
const updateReturning = vi.fn(async () => [updatedAction]);
|
|
const updateWhere = vi.fn(() => ({ returning: updateReturning }));
|
|
const updateSet = vi.fn(() => ({ where: updateWhere }));
|
|
dbMock.update.mockReturnValue({ set: updateSet });
|
|
|
|
resumeServiceMock.patchInTransaction.mockResolvedValue({
|
|
id: "resume-1",
|
|
updatedAt: new Date("2026-05-03T00:00:00.000Z"),
|
|
});
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
const result = await agentService.actions.revert({ id: "action-1", userId: "user-1" });
|
|
|
|
expect(resumeServiceMock.patchInTransaction).toHaveBeenCalledWith(dbMock, {
|
|
id: "resume-1",
|
|
userId: "user-1",
|
|
operations: [{ op: "replace", path: "", value: action.snapshotData }],
|
|
expectedUpdatedAt: laterAction.appliedUpdatedAt,
|
|
});
|
|
expect(updateSet).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
status: "rolled_back",
|
|
revertMessage: "This patch was rolled back when the resume was restored to an earlier state.",
|
|
appliedUpdatedAt: new Date("2026-05-03T00:00:00.000Z"),
|
|
}),
|
|
);
|
|
expect(result.status).toBe("rolled_back");
|
|
});
|
|
|
|
it("returns a conflicted action when snapshot restore throws RESUME_VERSION_CONFLICT", async () => {
|
|
const action = buildAction();
|
|
const latestAction = buildAction({
|
|
id: "action-2",
|
|
appliedUpdatedAt: new Date("2026-05-03T00:00:00.000Z"),
|
|
});
|
|
const conflictedAction = {
|
|
...action,
|
|
status: "conflicted",
|
|
revertMessage: "The resume changed after this action was applied.",
|
|
};
|
|
|
|
dbMock.select
|
|
.mockImplementationOnce(() => selectLimitResult([action]))
|
|
.mockImplementationOnce(() => selectWhereOrderByLimitResult([latestAction]));
|
|
|
|
const updateReturning = vi.fn(async () => [conflictedAction]);
|
|
const updateWhere = vi.fn(() => ({ returning: updateReturning }));
|
|
const updateSet = vi.fn(() => ({ where: updateWhere }));
|
|
dbMock.update.mockReturnValue({ set: updateSet });
|
|
|
|
resumeServiceMock.patchInTransaction.mockRejectedValue(new ORPCError("RESUME_VERSION_CONFLICT"));
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
const result = await agentService.actions.revert({ id: "action-1", userId: "user-1" });
|
|
|
|
expect(resumeServiceMock.patchInTransaction).toHaveBeenCalled();
|
|
expect(updateSet).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
status: "conflicted",
|
|
revertMessage: "The resume changed after this action was applied.",
|
|
}),
|
|
);
|
|
expect(updateWhere).toHaveBeenCalled();
|
|
expect(updateReturning).toHaveBeenCalled();
|
|
expect(result.status).toBe("conflicted");
|
|
expect(result.revertMessage).toBe("The resume changed after this action was applied.");
|
|
});
|
|
|
|
it("returns the existing action unchanged when its status is already rolled_back", async () => {
|
|
const action = buildAction({
|
|
status: "rolled_back",
|
|
revertedAt: new Date("2026-05-03T00:00:00.000Z"),
|
|
});
|
|
|
|
dbMock.select.mockImplementation(() => selectLimitResult([action]));
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
const result = await agentService.actions.revert({ id: "action-1", userId: "user-1" });
|
|
|
|
expect(resumeServiceMock.patch).not.toHaveBeenCalled();
|
|
expect(dbMock.update).not.toHaveBeenCalled();
|
|
expect(result.status).toBe("rolled_back");
|
|
expect(result.id).toBe("action-1");
|
|
});
|
|
|
|
it("throws BAD_REQUEST when an applied legacy action has no snapshotData", async () => {
|
|
const action = buildAction({ snapshotData: null });
|
|
|
|
dbMock.select.mockImplementation(() => selectLimitResult([action]));
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
const reverting = agentService.actions.revert({ id: "action-1", userId: "user-1" });
|
|
|
|
await expect(reverting).rejects.toBeInstanceOf(ORPCError);
|
|
await expect(reverting).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
|
expect(resumeServiceMock.patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws BAD_REQUEST when the action has no resumeId", async () => {
|
|
const action = buildAction({ resumeId: null });
|
|
|
|
dbMock.select.mockImplementation(() => selectLimitResult([action]));
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
const reverting = agentService.actions.revert({ id: "action-1", userId: "user-1" });
|
|
|
|
await expect(reverting).rejects.toBeInstanceOf(ORPCError);
|
|
await expect(reverting).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
|
expect(resumeServiceMock.patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws NOT_FOUND when no matching action is found", async () => {
|
|
dbMock.select.mockImplementation(() => selectLimitResult([]));
|
|
|
|
const { agentService } = await import("./service");
|
|
|
|
const reverting = agentService.actions.revert({ id: "missing-id", userId: "user-1" });
|
|
|
|
await expect(reverting).rejects.toBeInstanceOf(ORPCError);
|
|
await expect(reverting).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
expect(resumeServiceMock.patch).not.toHaveBeenCalled();
|
|
});
|
|
});
|