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.
This commit is contained in:
Amruth Pillai
2026-05-13 22:52:55 +02:00
parent 66417b9f9b
commit 73ef1acca0
3 changed files with 747 additions and 49 deletions
+208 -49
View File
@@ -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<string, unknown>)
: 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" ? (
<Badge variant="secondary">
<Trans>Reverted</Trans>
</Badge>
) : status === "conflicted" ? (
<Badge variant="destructive">
<Trans>Conflicted</Trans>
</Badge>
) : (
<Badge variant="outline">
<Trans>Applied</Trans>
</Badge>
);
const revertDisabled = isReverting || status === "reverted" || status === "conflicted";
return (
<div className={cn("space-y-3 rounded-md border p-3", containerClass)}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">{title}</span>
{statusBadge}
</div>
{status === "conflicted" && revertMessage ? (
<p className="text-sm opacity-80">{revertMessage}</p>
) : (
<p className="text-sm opacity-80">
{status === "reverted" ? (
<Trans>Reverted from the working resume.</Trans>
) : (
<Trans>Applied to the working resume.</Trans>
)}
</p>
)}
</div>
{actionId ? (
<Button size="sm" variant="outline" disabled={revertDisabled} onClick={() => onRevert(actionId)}>
<ClockCounterClockwiseIcon />
<Trans>Revert</Trans>
</Button>
) : null}
</div>
{operations.length > 0 ? (
<details className="rounded border bg-background/40 px-2 py-1 text-sm">
<summary className="cursor-pointer text-muted-foreground">
<Trans>Show changes</Trans>
</summary>
<ul className="mt-2 space-y-2">
{operations.map((op, index) => {
const opKey = `${op.op}-${op.path}-${index}`;
const indicator =
op.op === "add" ? (
<span className="text-emerald-600 dark:text-emerald-400">+ Add</span>
) : op.op === "replace" ? (
<span className="text-amber-600 dark:text-amber-400">~ Replace</span>
) : op.op === "remove" ? (
<span className="text-rose-600 dark:text-rose-400"> Remove</span>
) : (
<span className="text-muted-foreground">{op.op}</span>
);
const value = op.op === "add" || op.op === "replace" ? truncate(JSON.stringify(op.value, null, 2)) : null;
return (
<li key={opKey} className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<span className="font-medium">{indicator}</span>
<span className="font-mono text-xs">{op.path}</span>
</div>
{value !== null ? (
<pre className="whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-xs">
{value}
</pre>
) : null}
</li>
);
})}
</ul>
</details>
) : null}
</div>
);
}
export const Route = createFileRoute("/agent/$threadId")({
component: RouteComponent,
@@ -64,6 +188,15 @@ function textFromMessage(message: UIMessage) {
.join("\n");
}
function attachmentToFilePart(attachment: Pick<AgentAttachment, "id" | "filename" | "mediaType">): FileUIPart {
return {
type: "file",
url: `agent-attachment:${attachment.id}`,
mediaType: attachment.mediaType,
filename: attachment.filename,
};
}
function parseAgentSseStream(stream: ReadableStream<string>) {
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<string, AgentAction>;
}) {
if (part.type === "text") return <div className="whitespace-pre-wrap leading-relaxed">{part.text}</div>;
@@ -217,42 +352,35 @@ function MessagePart({
? (part.output as Record<string, unknown>)
: null;
const actionId = typeof output?.actionId === "string" ? output.actionId : null;
const action = actionId ? actionsById.get(actionId) : undefined;
return (
<div className="space-y-3 rounded-md border bg-emerald-50 p-3 text-emerald-950 dark:bg-emerald-950/20 dark:text-emerald-100">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium">{typeof output?.title === "string" ? output.title : t`Resume patch`}</div>
<p className="text-sm opacity-80">
<Trans>Applied to the working resume.</Trans>
</p>
</div>
{actionId ? (
<Button size="sm" variant="outline" disabled={isReverting} onClick={() => onRevert(actionId)}>
<ClockCounterClockwiseIcon />
<Trans>Revert</Trans>
</Button>
) : null}
</div>
</div>
);
return <PatchToolCard part={part} action={action} onRevert={onRevert} isReverting={isReverting} />;
}
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 ? (
<a className="block text-primary text-sm underline" href={url} target="_blank" rel="noreferrer">
if (part.type === "source-url") {
const title = part.title?.trim() || null;
return (
<a className="block text-primary text-sm underline" href={part.url} target="_blank" rel="noreferrer">
{title ? (
<>
<span className="block truncate">{title}</span>
<span className="block truncate text-muted-foreground">{url}</span>
<span className="block truncate text-muted-foreground">{part.url}</span>
</>
) : (
<span className="block truncate">{url}</span>
<span className="block truncate">{part.url}</span>
)}
</a>
) : null;
);
}
if (part.type === "file") {
return (
<div className="flex max-w-full items-center gap-2 rounded-md border bg-background/20 px-2 py-1 text-sm">
<FileIcon className="shrink-0" />
<span className="truncate">{part.filename ?? part.url}</span>
</div>
);
}
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<string, AgentAction>;
}) {
const isUser = message.role === "user";
@@ -286,6 +416,7 @@ function ChatMessage({
onAnswer={onAnswer}
onRevert={onRevert}
isReverting={isReverting}
actionsById={actionsById}
/>
))}
</div>
@@ -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<HTMLInputElement>(null);
const [input, setInput] = useState("");
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const [attachmentLabels, setAttachmentLabels] = useState<string[]>([]);
const [pendingAttachments, setPendingAttachments] = useState<
Array<Pick<AgentAttachment, "id" | "filename" | "mediaType">>
>([]);
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<string, AgentAction>();
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({
}}
>
<div className="mx-auto max-w-3xl space-y-2">
{attachmentLabels.length > 0 ? (
{pendingAttachments.length > 0 ? (
<div className="flex flex-wrap gap-2">
{attachmentLabels.map((label) => (
<Badge key={label} variant="secondary">
{pendingAttachments.map((attachment) => (
<Badge key={attachment.id} variant="secondary">
<FileIcon />
{label}
{attachment.filename}
</Badge>
))}
</div>
@@ -636,7 +789,11 @@ function AgentChat({
<StopIcon />
</Button>
) : (
<Button type="submit" size="icon" disabled={isReadOnly || !input.trim()}>
<Button
type="submit"
size="icon"
disabled={isReadOnly || (!input.trim() && pendingAttachments.length === 0)}
>
<PaperPlaneRightIcon />
</Button>
)}
@@ -740,6 +897,7 @@ function RouteComponent() {
isReadOnly={data.isReadOnly}
readOnlyReason={readOnlyReason}
threadStatus={data.thread.status}
actions={data.actions}
/>
</ResizablePanel>
<ResizableSeparator withHandle />
@@ -777,6 +935,7 @@ function RouteComponent() {
isReadOnly={data.isReadOnly}
readOnlyReason={readOnlyReason}
threadStatus={data.thread.status}
actions={data.actions}
/>
) : null}
{mobileTab === "resume" ? <ResumePane resume={data.resume} /> : null}
@@ -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");
});
});
+408
View File
@@ -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<string, unknown> = {}) {
return {
id: "thread-1",
@@ -128,6 +150,52 @@ function buildArchivedThread(overrides: Record<string, unknown> = {}) {
};
}
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 };
}
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<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" }],
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();
});
});