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 type { RouterOutput } from "@/libs/orpc/client";
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
@@ -43,6 +43,130 @@ import { getOrpcErrorMessage } from "@/libs/error-message";
|
|||||||
import { client, orpc, streamClient } from "@/libs/orpc/client";
|
import { client, orpc, streamClient } from "@/libs/orpc/client";
|
||||||
|
|
||||||
type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"];
|
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")({
|
export const Route = createFileRoute("/agent/$threadId")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
@@ -64,6 +188,15 @@ function textFromMessage(message: UIMessage) {
|
|||||||
.join("\n");
|
.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>) {
|
function parseAgentSseStream(stream: ReadableStream<string>) {
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
const eventBoundary = /\r?\n\r?\n/;
|
const eventBoundary = /\r?\n\r?\n/;
|
||||||
@@ -152,11 +285,13 @@ function MessagePart({
|
|||||||
onAnswer,
|
onAnswer,
|
||||||
onRevert,
|
onRevert,
|
||||||
isReverting,
|
isReverting,
|
||||||
|
actionsById,
|
||||||
}: {
|
}: {
|
||||||
part: UIMessage["parts"][number];
|
part: UIMessage["parts"][number];
|
||||||
onAnswer: (toolCallId: string, answer: string) => void;
|
onAnswer: (toolCallId: string, answer: string) => void;
|
||||||
onRevert: (actionId: string) => void;
|
onRevert: (actionId: string) => void;
|
||||||
isReverting: boolean;
|
isReverting: boolean;
|
||||||
|
actionsById: Map<string, AgentAction>;
|
||||||
}) {
|
}) {
|
||||||
if (part.type === "text") return <div className="whitespace-pre-wrap leading-relaxed">{part.text}</div>;
|
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>)
|
? (part.output as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
const actionId = typeof output?.actionId === "string" ? output.actionId : null;
|
const actionId = typeof output?.actionId === "string" ? output.actionId : null;
|
||||||
|
const action = actionId ? actionsById.get(actionId) : undefined;
|
||||||
|
|
||||||
return (
|
return <PatchToolCard part={part} action={action} onRevert={onRevert} isReverting={isReverting} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.type.startsWith("source-")) {
|
if (part.type === "source-url") {
|
||||||
const url = "url" in part && typeof part.url === "string" ? part.url : null;
|
const title = part.title?.trim() || null;
|
||||||
const title = "title" in part && typeof part.title === "string" && part.title.trim() ? part.title : null;
|
|
||||||
return url ? (
|
return (
|
||||||
<a className="block text-primary text-sm underline" href={url} target="_blank" rel="noreferrer">
|
<a className="block text-primary text-sm underline" href={part.url} target="_blank" rel="noreferrer">
|
||||||
{title ? (
|
{title ? (
|
||||||
<>
|
<>
|
||||||
<span className="block truncate">{title}</span>
|
<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>
|
</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;
|
return null;
|
||||||
@@ -263,11 +391,13 @@ function ChatMessage({
|
|||||||
onAnswer,
|
onAnswer,
|
||||||
onRevert,
|
onRevert,
|
||||||
isReverting,
|
isReverting,
|
||||||
|
actionsById,
|
||||||
}: {
|
}: {
|
||||||
message: UIMessage;
|
message: UIMessage;
|
||||||
onAnswer: (toolCallId: string, answer: string) => void;
|
onAnswer: (toolCallId: string, answer: string) => void;
|
||||||
onRevert: (actionId: string) => void;
|
onRevert: (actionId: string) => void;
|
||||||
isReverting: boolean;
|
isReverting: boolean;
|
||||||
|
actionsById: Map<string, AgentAction>;
|
||||||
}) {
|
}) {
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
|
|
||||||
@@ -286,6 +416,7 @@ function ChatMessage({
|
|||||||
onAnswer={onAnswer}
|
onAnswer={onAnswer}
|
||||||
onRevert={onRevert}
|
onRevert={onRevert}
|
||||||
isReverting={isReverting}
|
isReverting={isReverting}
|
||||||
|
actionsById={actionsById}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -299,26 +430,35 @@ function AgentChat({
|
|||||||
isReadOnly,
|
isReadOnly,
|
||||||
readOnlyReason,
|
readOnlyReason,
|
||||||
threadStatus,
|
threadStatus,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
initialMessages: UIMessage[];
|
initialMessages: UIMessage[];
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
readOnlyReason: "archived" | "missing" | null;
|
readOnlyReason: "archived" | "missing" | null;
|
||||||
threadStatus: string;
|
threadStatus: string;
|
||||||
|
actions: AgentAction[];
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
const [pendingAttachments, setPendingAttachments] = useState<
|
||||||
const [attachmentLabels, setAttachmentLabels] = useState<string[]>([]);
|
Array<Pick<AgentAttachment, "id" | "filename" | "mediaType">>
|
||||||
|
>([]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const revertMutation = useMutation(orpc.agent.actions.revert.mutationOptions());
|
const revertMutation = useMutation(orpc.agent.actions.revert.mutationOptions());
|
||||||
const archiveMutation = useMutation(orpc.agent.threads.archive.mutationOptions());
|
const archiveMutation = useMutation(orpc.agent.threads.archive.mutationOptions());
|
||||||
const deleteMutation = useMutation(orpc.agent.threads.delete.mutationOptions());
|
const deleteMutation = useMutation(orpc.agent.threads.delete.mutationOptions());
|
||||||
const isArchived = threadStatus === "archived";
|
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 = () => {
|
const handleArchive = () => {
|
||||||
archiveMutation.mutate(
|
archiveMutation.mutate(
|
||||||
{ id: threadId },
|
{ id: threadId },
|
||||||
@@ -361,13 +501,20 @@ function AgentChat({
|
|||||||
|
|
||||||
const transport = useMemo(
|
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);
|
const message = options.messages.at(-1);
|
||||||
if (!message) throw new Error("No message to send.");
|
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(
|
return parseAgentSseStream(
|
||||||
eventIteratorToUnproxiedDataStream(
|
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 send = () => {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
if (!text || isReadOnly || isStreaming) return;
|
if ((!text && pendingAttachments.length === 0) || isReadOnly || isStreaming) return;
|
||||||
|
|
||||||
const attachmentNote =
|
|
||||||
attachmentIds.length > 0
|
|
||||||
? `\n\nAttachments:\n${attachmentIds.map((id, index) => `- ${attachmentLabels[index]} (attachmentId: ${id})`).join("\n")}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
clearError();
|
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("");
|
setInput("");
|
||||||
setAttachmentIds([]);
|
setPendingAttachments([]);
|
||||||
setAttachmentLabels([]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFiles = async (files: FileList | null) => {
|
const uploadFiles = async (files: FileList | null) => {
|
||||||
@@ -421,8 +565,10 @@ function AgentChat({
|
|||||||
mediaType: file.type || "application/octet-stream",
|
mediaType: file.type || "application/octet-stream",
|
||||||
data: await fileToBase64(file),
|
data: await fileToBase64(file),
|
||||||
});
|
});
|
||||||
setAttachmentIds((current) => [...current, attachment.id]);
|
setPendingAttachments((current) => [
|
||||||
setAttachmentLabels((current) => [...current, file.name]);
|
...current,
|
||||||
|
{ id: attachment.id, filename: attachment.filename, mediaType: attachment.mediaType },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
toast.success(t`Attachment uploaded.`);
|
toast.success(t`Attachment uploaded.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -531,6 +677,7 @@ function AgentChat({
|
|||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
isReverting={revertMutation.isPending}
|
isReverting={revertMutation.isPending}
|
||||||
|
actionsById={actionsById}
|
||||||
onAnswer={(toolCallId, answer) => {
|
onAnswer={(toolCallId, answer) => {
|
||||||
addToolOutput({ tool: "ask_user_question", toolCallId, output: answer });
|
addToolOutput({ tool: "ask_user_question", toolCallId, output: answer });
|
||||||
sendMessage({ text: answer });
|
sendMessage({ text: answer });
|
||||||
@@ -539,8 +686,14 @@ function AgentChat({
|
|||||||
revertMutation.mutate(
|
revertMutation.mutate(
|
||||||
{ id: actionId },
|
{ id: actionId },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: (action) => {
|
||||||
toast.success(t`Patch reverted.`);
|
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({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: orpc.agent.threads.get.queryKey({ input: { id: threadId } }),
|
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">
|
<div className="mx-auto max-w-3xl space-y-2">
|
||||||
{attachmentLabels.length > 0 ? (
|
{pendingAttachments.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{attachmentLabels.map((label) => (
|
{pendingAttachments.map((attachment) => (
|
||||||
<Badge key={label} variant="secondary">
|
<Badge key={attachment.id} variant="secondary">
|
||||||
<FileIcon />
|
<FileIcon />
|
||||||
{label}
|
{attachment.filename}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -636,7 +789,11 @@ function AgentChat({
|
|||||||
<StopIcon />
|
<StopIcon />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button type="submit" size="icon" disabled={isReadOnly || !input.trim()}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon"
|
||||||
|
disabled={isReadOnly || (!input.trim() && pendingAttachments.length === 0)}
|
||||||
|
>
|
||||||
<PaperPlaneRightIcon />
|
<PaperPlaneRightIcon />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -740,6 +897,7 @@ function RouteComponent() {
|
|||||||
isReadOnly={data.isReadOnly}
|
isReadOnly={data.isReadOnly}
|
||||||
readOnlyReason={readOnlyReason}
|
readOnlyReason={readOnlyReason}
|
||||||
threadStatus={data.thread.status}
|
threadStatus={data.thread.status}
|
||||||
|
actions={data.actions}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableSeparator withHandle />
|
<ResizableSeparator withHandle />
|
||||||
@@ -777,6 +935,7 @@ function RouteComponent() {
|
|||||||
isReadOnly={data.isReadOnly}
|
isReadOnly={data.isReadOnly}
|
||||||
readOnlyReason={readOnlyReason}
|
readOnlyReason={readOnlyReason}
|
||||||
threadStatus={data.thread.status}
|
threadStatus={data.thread.status}
|
||||||
|
actions={data.actions}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{mobileTab === "resume" ? <ResumePane resume={data.resume} /> : 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 = {
|
const resumeServiceMock = {
|
||||||
getById: vi.fn(),
|
getById: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const aiProvidersServiceMock = {
|
const aiProvidersServiceMock = {
|
||||||
@@ -46,18 +47,29 @@ vi.mock("@reactive-resume/db/schema", () => ({
|
|||||||
updatedAt: "agent_threads.updated_at",
|
updatedAt: "agent_threads.updated_at",
|
||||||
},
|
},
|
||||||
agentMessage: {
|
agentMessage: {
|
||||||
|
id: "agent_messages.id",
|
||||||
threadId: "agent_messages.thread_id",
|
threadId: "agent_messages.thread_id",
|
||||||
userId: "agent_messages.user_id",
|
userId: "agent_messages.user_id",
|
||||||
|
role: "agent_messages.role",
|
||||||
|
status: "agent_messages.status",
|
||||||
sequence: "agent_messages.sequence",
|
sequence: "agent_messages.sequence",
|
||||||
|
uiMessage: "agent_messages.ui_message",
|
||||||
},
|
},
|
||||||
agentAction: {
|
agentAction: {
|
||||||
|
id: "agent_actions.id",
|
||||||
threadId: "agent_actions.thread_id",
|
threadId: "agent_actions.thread_id",
|
||||||
userId: "agent_actions.user_id",
|
userId: "agent_actions.user_id",
|
||||||
createdAt: "agent_actions.created_at",
|
createdAt: "agent_actions.created_at",
|
||||||
},
|
},
|
||||||
agentAttachment: {
|
agentAttachment: {
|
||||||
|
id: "agent_attachments.id",
|
||||||
threadId: "agent_attachments.thread_id",
|
threadId: "agent_attachments.thread_id",
|
||||||
userId: "agent_attachments.user_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",
|
createdAt: "agent_attachments.created_at",
|
||||||
},
|
},
|
||||||
resume: { name: "resume.name", id: "resume.id", userId: "resume.user_id", slug: "resume.slug" },
|
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" }),
|
count: () => ({ type: "count" }),
|
||||||
desc: (value: unknown) => ({ type: "desc", value }),
|
desc: (value: unknown) => ({ type: "desc", value }),
|
||||||
eq: (left: unknown, right: unknown) => ({ type: "eq", left, right }),
|
eq: (left: unknown, right: unknown) => ({ type: "eq", left, right }),
|
||||||
|
inArray: (left: unknown, values: unknown[]) => ({ type: "inArray", left, values }),
|
||||||
isNull: (value: unknown) => ({ type: "isNull", value }),
|
isNull: (value: unknown) => ({ type: "isNull", value }),
|
||||||
max: (value: unknown) => ({ type: "max", value }),
|
max: (value: unknown) => ({ type: "max", value }),
|
||||||
sql: () => ({ type: "sql" }),
|
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("@reactive-resume/utils/string", () => ({ generateId: () => "test-id" }));
|
||||||
vi.mock("@orpc/server", () => ({ streamToEventIterator: vi.fn() }));
|
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> = {}) {
|
function buildArchivedThread(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
id: "thread-1",
|
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", () => {
|
describe("agentService.threads.get", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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", () => {
|
describe("agentService.messages.send", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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.
|
// Ensure we never tried to claim a run or persist anything for an archived thread.
|
||||||
expect(aiProvidersServiceMock.getRunnableById).not.toHaveBeenCalled();
|
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", () => {
|
describe("agentService.threads.archive", () => {
|
||||||
@@ -367,3 +635,143 @@ describe("agentService.threads.delete", () => {
|
|||||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ status: "deleted" }));
|
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