mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user