From 73ef1acca05c0ef0f73d9a331d0edc9c803268da Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Wed, 13 May 2026 22:52:55 +0200 Subject: [PATCH] feat(agent): display patch diffs and surface revert conflicts Render apply_resume_patch tool messages with a status-aware card (applied/ reverted/conflicted), expandable operation list, and a Revert button that correctly handles RESUME_VERSION_CONFLICT responses. Adds unit tests for the inverse-patch builder and the agentService.actions.revert flow. --- apps/web/src/routes/agent/$threadId.tsx | 257 ++++++++--- .../api/src/services/agent-patches.test.ts | 131 ++++++ packages/api/src/services/agent.test.ts | 408 ++++++++++++++++++ 3 files changed, 747 insertions(+), 49 deletions(-) create mode 100644 packages/api/src/services/agent-patches.test.ts diff --git a/apps/web/src/routes/agent/$threadId.tsx b/apps/web/src/routes/agent/$threadId.tsx index 1529699be..74666af8a 100644 --- a/apps/web/src/routes/agent/$threadId.tsx +++ b/apps/web/src/routes/agent/$threadId.tsx @@ -1,4 +1,4 @@ -import type { UIMessage, UIMessageChunk } from "ai"; +import type { FileUIPart, UIMessage, UIMessageChunk } from "ai"; import type { RouterOutput } from "@/libs/orpc/client"; import { useChat } from "@ai-sdk/react"; import { t } from "@lingui/core/macro"; @@ -43,6 +43,130 @@ import { getOrpcErrorMessage } from "@/libs/error-message"; import { client, orpc, streamClient } from "@/libs/orpc/client"; type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"]; +type AgentAction = AgentThreadDetail["actions"][number]; +type AgentAttachment = AgentThreadDetail["attachments"][number]; +type PatchOperation = AgentAction["operations"][number]; + +function truncate(str: string, max = 200) { + return str.length > max ? `${str.slice(0, max)}...` : str; +} + +function PatchToolCard({ + part, + action, + onRevert, + isReverting, +}: { + part: UIMessage["parts"][number]; + action: AgentAction | undefined; + onRevert: (actionId: string) => void; + isReverting: boolean; +}) { + const output = + "output" in part && typeof part.output === "object" && part.output + ? (part.output as Record) + : null; + const actionId = action?.id ?? (typeof output?.actionId === "string" ? output.actionId : null); + + const title = action?.title ?? (typeof output?.title === "string" ? output.title : t`Resume patch`); + const operations: PatchOperation[] = + action?.operations ?? (Array.isArray(output?.operations) ? (output.operations as PatchOperation[]) : []); + const status = action?.status ?? "applied"; + const revertMessage = action?.revertMessage ?? null; + + const containerClass = + status === "reverted" + ? "border-muted bg-muted/30 text-foreground" + : status === "conflicted" + ? "border-amber-300 bg-amber-50 text-amber-950 dark:bg-amber-950/20 dark:text-amber-100" + : "border-emerald-200 bg-emerald-50 text-emerald-950 dark:border-emerald-900 dark:bg-emerald-950/20 dark:text-emerald-100"; + + const statusBadge = + status === "reverted" ? ( + + Reverted + + ) : status === "conflicted" ? ( + + Conflicted + + ) : ( + + Applied + + ); + + const revertDisabled = isReverting || status === "reverted" || status === "conflicted"; + + return ( +
+
+
+
+ {title} + {statusBadge} +
+ {status === "conflicted" && revertMessage ? ( +

{revertMessage}

+ ) : ( +

+ {status === "reverted" ? ( + Reverted from the working resume. + ) : ( + Applied to the working resume. + )} +

+ )} +
+ {actionId ? ( + + ) : null} +
+ + {operations.length > 0 ? ( +
+ + Show changes + +
    + {operations.map((op, index) => { + const opKey = `${op.op}-${op.path}-${index}`; + const indicator = + op.op === "add" ? ( + + Add + ) : op.op === "replace" ? ( + ~ Replace + ) : op.op === "remove" ? ( + − Remove + ) : ( + {op.op} + ); + + const value = op.op === "add" || op.op === "replace" ? truncate(JSON.stringify(op.value, null, 2)) : null; + + return ( +
  • +
    + {indicator} + {op.path} +
    + {value !== null ? ( +
    +											{value}
    +										
    + ) : null} +
  • + ); + })} +
+
+ ) : null} +
+ ); +} export const Route = createFileRoute("/agent/$threadId")({ component: RouteComponent, @@ -64,6 +188,15 @@ function textFromMessage(message: UIMessage) { .join("\n"); } +function attachmentToFilePart(attachment: Pick): FileUIPart { + return { + type: "file", + url: `agent-attachment:${attachment.id}`, + mediaType: attachment.mediaType, + filename: attachment.filename, + }; +} + function parseAgentSseStream(stream: ReadableStream) { let buffer = ""; const eventBoundary = /\r?\n\r?\n/; @@ -152,11 +285,13 @@ function MessagePart({ onAnswer, onRevert, isReverting, + actionsById, }: { part: UIMessage["parts"][number]; onAnswer: (toolCallId: string, answer: string) => void; onRevert: (actionId: string) => void; isReverting: boolean; + actionsById: Map; }) { if (part.type === "text") return
{part.text}
; @@ -217,42 +352,35 @@ function MessagePart({ ? (part.output as Record) : null; const actionId = typeof output?.actionId === "string" ? output.actionId : null; + const action = actionId ? actionsById.get(actionId) : undefined; - return ( -
-
-
-
{typeof output?.title === "string" ? output.title : t`Resume patch`}
-

- Applied to the working resume. -

-
- {actionId ? ( - - ) : null} -
-
- ); + return ; } - if (part.type.startsWith("source-")) { - const url = "url" in part && typeof part.url === "string" ? part.url : null; - const title = "title" in part && typeof part.title === "string" && part.title.trim() ? part.title : null; - return url ? ( - + if (part.type === "source-url") { + const title = part.title?.trim() || null; + + return ( + {title ? ( <> {title} - {url} + {part.url} ) : ( - {url} + {part.url} )} - ) : null; + ); + } + + if (part.type === "file") { + return ( +
+ + {part.filename ?? part.url} +
+ ); } return null; @@ -263,11 +391,13 @@ function ChatMessage({ onAnswer, onRevert, isReverting, + actionsById, }: { message: UIMessage; onAnswer: (toolCallId: string, answer: string) => void; onRevert: (actionId: string) => void; isReverting: boolean; + actionsById: Map; }) { const isUser = message.role === "user"; @@ -286,6 +416,7 @@ function ChatMessage({ onAnswer={onAnswer} onRevert={onRevert} isReverting={isReverting} + actionsById={actionsById} /> ))} @@ -299,26 +430,35 @@ function AgentChat({ isReadOnly, readOnlyReason, threadStatus, + actions, }: { threadId: string; initialMessages: UIMessage[]; isReadOnly: boolean; readOnlyReason: "archived" | "missing" | null; threadStatus: string; + actions: AgentAction[]; }) { const queryClient = useQueryClient(); const navigate = useNavigate(); const confirm = useConfirm(); const fileInputRef = useRef(null); const [input, setInput] = useState(""); - const [attachmentIds, setAttachmentIds] = useState([]); - const [attachmentLabels, setAttachmentLabels] = useState([]); + const [pendingAttachments, setPendingAttachments] = useState< + Array> + >([]); const [isUploading, setIsUploading] = useState(false); const revertMutation = useMutation(orpc.agent.actions.revert.mutationOptions()); const archiveMutation = useMutation(orpc.agent.threads.archive.mutationOptions()); const deleteMutation = useMutation(orpc.agent.threads.delete.mutationOptions()); const isArchived = threadStatus === "archived"; + const actionsById = useMemo(() => { + const map = new Map(); + for (const action of actions) map.set(action.id, action); + return map; + }, [actions]); + const handleArchive = () => { archiveMutation.mutate( { id: threadId }, @@ -361,13 +501,20 @@ function AgentChat({ const transport = useMemo( () => ({ - async sendMessages(options: { messages: UIMessage[]; abortSignal?: AbortSignal }) { + async sendMessages(options: { messages: UIMessage[]; abortSignal?: AbortSignal; body?: object }) { const message = options.messages.at(-1); if (!message) throw new Error("No message to send."); + const attachmentIds = + options.body && "attachmentIds" in options.body && Array.isArray(options.body.attachmentIds) + ? options.body.attachmentIds.filter((id): id is string => typeof id === "string") + : undefined; return parseAgentSseStream( eventIteratorToUnproxiedDataStream( - await streamClient.agent.messages.send({ threadId, message }, { signal: options.abortSignal }), + await streamClient.agent.messages.send( + { threadId, message, attachmentIds }, + { signal: options.abortSignal }, + ), ), ); }, @@ -395,18 +542,15 @@ function AgentChat({ const send = () => { const text = input.trim(); - if (!text || isReadOnly || isStreaming) return; - - const attachmentNote = - attachmentIds.length > 0 - ? `\n\nAttachments:\n${attachmentIds.map((id, index) => `- ${attachmentLabels[index]} (attachmentId: ${id})`).join("\n")}` - : ""; + if ((!text && pendingAttachments.length === 0) || isReadOnly || isStreaming) return; clearError(); - sendMessage({ text: `${text}${attachmentNote}` }); + const files = pendingAttachments.map(attachmentToFilePart); + sendMessage(text ? { text, ...(files.length > 0 ? { files } : {}) } : { files }, { + body: { attachmentIds: pendingAttachments.map((attachment) => attachment.id) }, + }); setInput(""); - setAttachmentIds([]); - setAttachmentLabels([]); + setPendingAttachments([]); }; const uploadFiles = async (files: FileList | null) => { @@ -421,8 +565,10 @@ function AgentChat({ mediaType: file.type || "application/octet-stream", data: await fileToBase64(file), }); - setAttachmentIds((current) => [...current, attachment.id]); - setAttachmentLabels((current) => [...current, file.name]); + setPendingAttachments((current) => [ + ...current, + { id: attachment.id, filename: attachment.filename, mediaType: attachment.mediaType }, + ]); } toast.success(t`Attachment uploaded.`); } catch (error) { @@ -531,6 +677,7 @@ function AgentChat({ key={message.id} message={message} isReverting={revertMutation.isPending} + actionsById={actionsById} onAnswer={(toolCallId, answer) => { addToolOutput({ tool: "ask_user_question", toolCallId, output: answer }); sendMessage({ text: answer }); @@ -539,8 +686,14 @@ function AgentChat({ revertMutation.mutate( { id: actionId }, { - onSuccess: () => { - toast.success(t`Patch reverted.`); + onSuccess: (action) => { + if (action.status === "conflicted") { + toast.error( + action.revertMessage ?? t`Cannot revert; the resume has changed since this edit was applied.`, + ); + } else if (action.status === "reverted") { + toast.success(t`Patch reverted.`); + } void queryClient.invalidateQueries({ queryKey: orpc.agent.threads.get.queryKey({ input: { id: threadId } }), }); @@ -591,12 +744,12 @@ function AgentChat({ }} >
- {attachmentLabels.length > 0 ? ( + {pendingAttachments.length > 0 ? (
- {attachmentLabels.map((label) => ( - + {pendingAttachments.map((attachment) => ( + - {label} + {attachment.filename} ))}
@@ -636,7 +789,11 @@ function AgentChat({ ) : ( - )} @@ -740,6 +897,7 @@ function RouteComponent() { isReadOnly={data.isReadOnly} readOnlyReason={readOnlyReason} threadStatus={data.thread.status} + actions={data.actions} /> @@ -777,6 +935,7 @@ function RouteComponent() { isReadOnly={data.isReadOnly} readOnlyReason={readOnlyReason} threadStatus={data.thread.status} + actions={data.actions} /> ) : null} {mobileTab === "resume" ? : null} diff --git a/packages/api/src/services/agent-patches.test.ts b/packages/api/src/services/agent-patches.test.ts new file mode 100644 index 000000000..ddacbd66e --- /dev/null +++ b/packages/api/src/services/agent-patches.test.ts @@ -0,0 +1,131 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch"; +import { describe, expect, it } from "vitest"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { createInverseResumePatches } from "./agent-patches"; + +function buildFixture(): ResumeData { + const clone = JSON.parse(JSON.stringify(defaultResumeData)) as ResumeData; + clone.basics.name = "Alice"; + clone.basics.email = "alice@example.com"; + clone.basics.customFields = [ + { id: "field-1", icon: "phosphor", text: "first", link: "" }, + { id: "field-2", icon: "phosphor", text: "second", link: "" }, + ]; + return clone; +} + +describe("createInverseResumePatches", () => { + it("inverts a single replace into a replace back to the original value", () => { + const data = buildFixture(); + const operations: JsonPatchOperation[] = [{ op: "replace", path: "/basics/name", value: "Bob" }]; + + const inverse = createInverseResumePatches(data, operations); + + expect(inverse).toEqual([{ op: "replace", path: "/basics/name", value: "Alice" }]); + }); + + it("inverts a single remove into an add carrying the original value", () => { + const data = buildFixture(); + const operations: JsonPatchOperation[] = [{ op: "remove", path: "/basics/customFields/0" }]; + + const inverse = createInverseResumePatches(data, operations); + + expect(inverse).toEqual([ + { + op: "add", + path: "/basics/customFields/0", + value: { id: "field-1", icon: "phosphor", text: "first", link: "" }, + }, + ]); + }); + + it("inverts a single add into a remove at the same path", () => { + const data = buildFixture(); + const operations: JsonPatchOperation[] = [ + { + op: "add", + path: "/basics/customFields/2", + value: { id: "field-3", icon: "phosphor", text: "third", link: "" }, + }, + ]; + + const inverse = createInverseResumePatches(data, operations); + + expect(inverse).toEqual([{ op: "remove", path: "/basics/customFields/2" }]); + }); + + it("composes inverses in reverse order with each original value", () => { + const data = buildFixture(); + const operations: JsonPatchOperation[] = [ + { op: "replace", path: "/basics/name", value: "Bob" }, + { op: "replace", path: "/basics/email", value: "bob@example.com" }, + ]; + + const inverse = createInverseResumePatches(data, operations); + + expect(inverse).toEqual([ + { op: "replace", path: "/basics/email", value: "alice@example.com" }, + { op: "replace", path: "/basics/name", value: "Alice" }, + ]); + }); + + it("reads downstream pointers against the working copy after upstream removals", () => { + // Removing /basics/customFields/1 does not affect /basics/name. + // The inverse builder reads /basics/name from the working copy after the + // removal has been applied; that value must still be the original "Alice". + const data = buildFixture(); + const operations: JsonPatchOperation[] = [ + { op: "remove", path: "/basics/customFields/1" }, + { op: "replace", path: "/basics/name", value: "Bob" }, + ]; + + const inverse = createInverseResumePatches(data, operations); + + expect(inverse).toEqual([ + { op: "replace", path: "/basics/name", value: "Alice" }, + { + op: "add", + path: "/basics/customFields/1", + value: { id: "field-2", icon: "phosphor", text: "second", link: "" }, + }, + ]); + }); + + it("throws INVERTIBLE_PATCH_REQUIRED when a path ends with /- (array append)", () => { + const data = buildFixture(); + const operations: JsonPatchOperation[] = [ + { op: "add", path: "/basics/customFields/-", value: { id: "x", icon: "phosphor", text: "x", link: "" } }, + ]; + + expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED"); + }); + + it("throws INVERTIBLE_PATCH_REQUIRED for move operations", () => { + const data = buildFixture(); + const operations: JsonPatchOperation[] = [{ op: "move", path: "/basics/email", from: "/basics/name" }]; + + expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED"); + }); + + it("throws INVERTIBLE_PATCH_REQUIRED for copy operations", () => { + const data = buildFixture(); + const operations: JsonPatchOperation[] = [{ op: "copy", path: "/basics/email", from: "/basics/name" }]; + + expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED"); + }); + + it("throws INVERTIBLE_PATCH_REQUIRED for test operations", () => { + const data = buildFixture(); + const operations: JsonPatchOperation[] = [{ op: "test", path: "/basics/name", value: "Alice" }]; + + expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED"); + }); + + it("throws INVALID_PATCH_OPERATIONS when reading a non-existent path", () => { + const data = buildFixture(); + const operations: JsonPatchOperation[] = [{ op: "replace", path: "/does/not/exist", value: "x" }]; + + expect(() => createInverseResumePatches(data, operations)).toThrow("INVALID_PATCH_OPERATIONS"); + }); +}); diff --git a/packages/api/src/services/agent.test.ts b/packages/api/src/services/agent.test.ts index 31903bcc5..8aa91ee04 100644 --- a/packages/api/src/services/agent.test.ts +++ b/packages/api/src/services/agent.test.ts @@ -18,6 +18,7 @@ const storageServiceMock = { const resumeServiceMock = { getById: vi.fn(), + patch: vi.fn(), }; const aiProvidersServiceMock = { @@ -46,18 +47,29 @@ vi.mock("@reactive-resume/db/schema", () => ({ 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", 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" }, @@ -70,6 +82,7 @@ vi.mock("drizzle-orm", () => ({ count: () => ({ type: "count" }), desc: (value: unknown) => ({ type: "desc", value }), eq: (left: unknown, right: unknown) => ({ type: "eq", 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" }), @@ -107,6 +120,15 @@ 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(); + 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 = {}) { return { id: "thread-1", @@ -128,6 +150,52 @@ function buildArchivedThread(overrides: Record = {}) { }; } +function buildActiveThread(overrides: Record = {}) { + return buildArchivedThread({ + status: "active", + title: "Active thread", + activeRunId: null, + activeStreamId: null, + archivedAt: null, + ...overrides, + }); +} + +function buildAttachment(overrides: Record = {}) { + 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 }; +} + describe("agentService.threads.get", () => { beforeEach(() => { vi.clearAllMocks(); @@ -173,6 +241,59 @@ describe("agentService.threads.get", () => { }); }); +describe("buildAttachmentModelParts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("converts text, image, supported binary, and unsupported attachments into model parts", async () => { + const { buildAttachmentModelParts } = await import("./agent"); + + 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(); @@ -203,6 +324,153 @@ describe("agentService.messages.send", () => { // Ensure we never tried to claim a run or persist anything for an archived thread. expect(aiProvidersServiceMock.getRunnableById).not.toHaveBeenCalled(); }); + + it("persists the user message with file UI parts and links selected attachments to it", async () => { + const activeThread = buildActiveThread(); + const attachment = buildAttachment(); + 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: "note.txt", + }, + ], + }, + }; + const updateSets: unknown[] = []; + const insertValues: 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 } = await import("ai"); + const { agentStreamLifecycle } = await import("./agent-streams"); + const { streamToEventIterator } = await 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" }] }, + ]); + vi.mocked(ToolLoopAgent).mockImplementation((() => ({ stream: streamMock })) as never); + vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream()); + vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never); + + const { agentService } = await import("./agent"); + + await 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: ["attachment-1"], + }); + + expect(insertValues).toEqual([ + expect.objectContaining({ + uiMessage: expect.objectContaining({ + parts: expect.arrayContaining([ + { + type: "file", + url: "agent-attachment:attachment-1", + mediaType: "text/plain", + filename: "note.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("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, + }); + claimActiveAgentRunMock.mockResolvedValue(true); + + const { agentService } = await import("./agent"); + + 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(); + }); }); describe("agentService.threads.archive", () => { @@ -367,3 +635,143 @@ describe("agentService.threads.delete", () => { expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ status: "deleted" })); }); }); + +describe("agentService.actions.revert", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function buildAction(overrides: Record = {}) { + 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" }], + inverseOperations: [{ op: "replace", path: "/basics/name", value: "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("reverts an applied action, calls resumeService.patch with the inverse operations, and updates the DB row", async () => { + const action = buildAction(); + const updatedAction = { ...action, status: "reverted", revertedAt: new Date(), revertMessage: null }; + + dbMock.select.mockImplementation(() => selectLimitResult([action])); + + const updateReturning = vi.fn(async () => [updatedAction]); + const updateWhere = vi.fn(() => ({ returning: updateReturning })); + const updateSet = vi.fn(() => ({ where: updateWhere })); + dbMock.update.mockReturnValue({ set: updateSet }); + + resumeServiceMock.patch.mockResolvedValue({ updatedAt: new Date("2026-05-03T00:00:00.000Z") }); + + const { agentService } = await import("./agent"); + + const result = await agentService.actions.revert({ id: "action-1", userId: "user-1" }); + + expect(resumeServiceMock.patch).toHaveBeenCalledWith({ + id: "resume-1", + userId: "user-1", + operations: action.inverseOperations, + expectedUpdatedAt: action.appliedUpdatedAt, + }); + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + status: "reverted", + revertMessage: null, + appliedUpdatedAt: new Date("2026-05-03T00:00:00.000Z"), + }), + ); + expect(result.status).toBe("reverted"); + }); + + it("returns a conflicted action when resumeService.patch throws RESUME_VERSION_CONFLICT", async () => { + const action = buildAction(); + const conflictedAction = { + ...action, + status: "conflicted", + revertMessage: "The resume changed after this action was applied.", + }; + + dbMock.select.mockImplementation(() => selectLimitResult([action])); + + const updateReturning = vi.fn(async () => [conflictedAction]); + const updateWhere = vi.fn(() => ({ returning: updateReturning })); + const updateSet = vi.fn(() => ({ where: updateWhere })); + dbMock.update.mockReturnValue({ set: updateSet }); + + resumeServiceMock.patch.mockRejectedValue(new ORPCError("RESUME_VERSION_CONFLICT")); + + const { agentService } = await import("./agent"); + + const result = await agentService.actions.revert({ id: "action-1", userId: "user-1" }); + + expect(resumeServiceMock.patch).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 reverted", async () => { + const action = buildAction({ + status: "reverted", + revertedAt: new Date("2026-05-03T00:00:00.000Z"), + }); + + dbMock.select.mockImplementation(() => selectLimitResult([action])); + + const { agentService } = await import("./agent"); + + 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("reverted"); + expect(result.id).toBe("action-1"); + }); + + 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("./agent"); + + 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("./agent"); + + 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(); + }); +});