mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 06:47:00 +10:00
feat(agent): archive/delete actions and read-only state for agent threads
- Backend: mark archived threads as read-only in threads.get and reject messages.send with CONFLICT when the thread is archived. - Frontend: render archived threads in the sidebar with muted styling and an Archived badge; add a per-thread dropdown menu in the chat header with Archive (non-destructive) and Delete (with confirmation); show a read-only banner above the message list that disambiguates archived vs. missing-resource causes; suppress the Retry and Stop buttons in read-only mode. - Tests: new packages/api/src/services/agent.test.ts covering the archived-thread isReadOnly flag and the archived-thread send refusal.
This commit is contained in:
@@ -0,0 +1,787 @@
|
||||
import type { UIMessage, UIMessageChunk } from "ai";
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { eventIteratorToUnproxiedDataStream } from "@orpc/client";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArrowClockwiseIcon,
|
||||
ChatCircleDotsIcon,
|
||||
ClockCounterClockwiseIcon,
|
||||
CopyIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
FileIcon,
|
||||
PaperPlaneRightIcon,
|
||||
PlusIcon,
|
||||
SidebarSimpleIcon,
|
||||
SparkleIcon,
|
||||
SquaresFourIcon,
|
||||
StopIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { Textarea } from "@reactive-resume/ui/components/textarea";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { ResumePreview } from "@/components/resume/preview";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { client, orpc, streamClient } from "@/libs/orpc/client";
|
||||
|
||||
type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"];
|
||||
|
||||
export const Route = createFileRoute("/agent/$threadId")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function textFromMessage(message: UIMessage) {
|
||||
return message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function parseAgentSseStream(stream: ReadableStream<string>) {
|
||||
let buffer = "";
|
||||
const eventBoundary = /\r?\n\r?\n/;
|
||||
|
||||
return stream.pipeThrough(
|
||||
new TransformStream<string, UIMessageChunk>({
|
||||
transform(chunk, controller) {
|
||||
buffer += chunk;
|
||||
|
||||
let boundary = eventBoundary.exec(buffer);
|
||||
while (boundary) {
|
||||
const event = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary[0].length);
|
||||
|
||||
for (const line of event.split(/\r?\n/)) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
|
||||
const data = line.slice("data:".length).trimStart();
|
||||
if (!data || data === "[DONE]") continue;
|
||||
|
||||
controller.enqueue(JSON.parse(data) as UIMessageChunk);
|
||||
}
|
||||
|
||||
boundary = eventBoundary.exec(buffer);
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadSidebar({ activeThreadId }: { activeThreadId: string }) {
|
||||
const { data: threads } = useQuery(orpc.agent.threads.list.queryOptions());
|
||||
|
||||
return (
|
||||
<aside className="flex h-full flex-col border-e bg-muted/30">
|
||||
<div className="flex h-14 items-center justify-between gap-2 border-b px-3">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<ChatCircleDotsIcon />
|
||||
<Trans>Agent</Trans>
|
||||
</div>
|
||||
<Button size="icon-sm" variant="ghost" nativeButton={false} render={<Link to="/agent" />}>
|
||||
<PlusIcon />
|
||||
<span className="sr-only">
|
||||
<Trans>New thread</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{threads?.map((thread) => {
|
||||
const isArchived = thread.status === "archived";
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={thread.id}
|
||||
to="/agent/$threadId"
|
||||
params={{ threadId: thread.id }}
|
||||
className={cn(
|
||||
"block rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent",
|
||||
thread.id === activeThreadId && "bg-accent",
|
||||
isArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate font-medium">{thread.title}</div>
|
||||
{isArchived ? (
|
||||
<Badge variant="secondary">
|
||||
<Trans>Archived</Trans>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{thread.resumeName ?? thread.providerLabel}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function MessagePart({
|
||||
part,
|
||||
onAnswer,
|
||||
onRevert,
|
||||
isReverting,
|
||||
}: {
|
||||
part: UIMessage["parts"][number];
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
onRevert: (actionId: string) => void;
|
||||
isReverting: boolean;
|
||||
}) {
|
||||
if (part.type === "text") return <div className="whitespace-pre-wrap leading-relaxed">{part.text}</div>;
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
return (
|
||||
<details className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
<Trans>Thinking</Trans>
|
||||
</summary>
|
||||
<div className="mt-2 whitespace-pre-wrap">{part.text}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "tool-ask_user_question") {
|
||||
const input =
|
||||
"input" in part && typeof part.input === "object" && part.input ? (part.input as Record<string, unknown>) : {};
|
||||
const choices = Array.isArray(input.choices)
|
||||
? input.choices.filter((choice): choice is string => typeof choice === "string")
|
||||
: [];
|
||||
const question = typeof input.question === "string" ? input.question : t`The agent needs your input.`;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border bg-card p-3">
|
||||
<div className="font-medium">{question}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{choices.map((choice) => (
|
||||
<Button key={choice} size="sm" variant="outline" onClick={() => onAnswer(part.toolCallId, choice)}>
|
||||
{choice}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "tool-fetch_url") {
|
||||
const output =
|
||||
"output" in part && typeof part.output === "object" && part.output
|
||||
? (part.output as Record<string, unknown>)
|
||||
: null;
|
||||
return (
|
||||
<details className="rounded-md border bg-card p-3 text-sm">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
<Trans>Fetched URL</Trans>
|
||||
</summary>
|
||||
<div className="mt-2 space-y-1 text-muted-foreground">
|
||||
<p>{typeof output?.url === "string" ? output.url : t`Waiting for fetch result...`}</p>
|
||||
{typeof output?.title === "string" ? <p>{output.title}</p> : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "tool-apply_resume_patch") {
|
||||
const output =
|
||||
"output" in part && typeof part.output === "object" && part.output
|
||||
? (part.output as Record<string, unknown>)
|
||||
: null;
|
||||
const actionId = typeof output?.actionId === "string" ? output.actionId : null;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
{title ? (
|
||||
<>
|
||||
<span className="block truncate">{title}</span>
|
||||
<span className="block truncate text-muted-foreground">{url}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="block truncate">{url}</span>
|
||||
)}
|
||||
</a>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ChatMessage({
|
||||
message,
|
||||
onAnswer,
|
||||
onRevert,
|
||||
isReverting,
|
||||
}: {
|
||||
message: UIMessage;
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
onRevert: (actionId: string) => void;
|
||||
isReverting: boolean;
|
||||
}) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
return (
|
||||
<div className={cn("flex", isUser ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[86%] space-y-3 rounded-md px-4 py-3 text-sm",
|
||||
isUser ? "bg-primary text-primary-foreground" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
{message.parts.map((part, index) => (
|
||||
<MessagePart
|
||||
key={`${message.id}-${index}`}
|
||||
part={part}
|
||||
onAnswer={onAnswer}
|
||||
onRevert={onRevert}
|
||||
isReverting={isReverting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentChat({
|
||||
threadId,
|
||||
initialMessages,
|
||||
isReadOnly,
|
||||
readOnlyReason,
|
||||
threadStatus,
|
||||
}: {
|
||||
threadId: string;
|
||||
initialMessages: UIMessage[];
|
||||
isReadOnly: boolean;
|
||||
readOnlyReason: "archived" | "missing" | null;
|
||||
threadStatus: string;
|
||||
}) {
|
||||
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 [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 handleArchive = () => {
|
||||
archiveMutation.mutate(
|
||||
{ id: threadId },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
toast.success(t`Thread archived.`);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() }),
|
||||
queryClient.invalidateQueries({ queryKey: orpc.agent.threads.get.queryKey({ input: { id: threadId } }) }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to archive thread.` }));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmation = await confirm(t`Delete this agent thread?`, {
|
||||
description: t`This action cannot be undone. Messages and the working draft will be removed.`,
|
||||
});
|
||||
|
||||
if (!confirmation) return;
|
||||
|
||||
deleteMutation.mutate(
|
||||
{ id: threadId },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
toast.success(t`Thread deleted.`);
|
||||
await queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() });
|
||||
void navigate({ to: "/agent" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to delete thread.` }));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const transport = useMemo(
|
||||
() => ({
|
||||
async sendMessages(options: { messages: UIMessage[]; abortSignal?: AbortSignal }) {
|
||||
const message = options.messages.at(-1);
|
||||
if (!message) throw new Error("No message to send.");
|
||||
|
||||
return parseAgentSseStream(
|
||||
eventIteratorToUnproxiedDataStream(
|
||||
await streamClient.agent.messages.send({ threadId, message }, { signal: options.abortSignal }),
|
||||
),
|
||||
);
|
||||
},
|
||||
async reconnectToStream() {
|
||||
return parseAgentSseStream(
|
||||
eventIteratorToUnproxiedDataStream(await streamClient.agent.messages.resume({ threadId })),
|
||||
);
|
||||
},
|
||||
}),
|
||||
[threadId],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, regenerate, setMessages, status, error, clearError, addToolOutput } = useChat({
|
||||
id: threadId,
|
||||
messages: initialMessages,
|
||||
resume: true,
|
||||
transport,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMessages(initialMessages);
|
||||
}, [initialMessages, setMessages]);
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
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")}`
|
||||
: "";
|
||||
|
||||
clearError();
|
||||
sendMessage({ text: `${text}${attachmentNote}` });
|
||||
setInput("");
|
||||
setAttachmentIds([]);
|
||||
setAttachmentLabels([]);
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const attachment = await client.agent.attachments.create({
|
||||
threadId,
|
||||
filename: file.name,
|
||||
mediaType: file.type || "application/octet-stream",
|
||||
data: await fileToBase64(file),
|
||||
});
|
||||
setAttachmentIds((current) => [...current, attachment.id]);
|
||||
setAttachmentLabels((current) => [...current, file.name]);
|
||||
}
|
||||
toast.success(t`Attachment uploaded.`);
|
||||
} catch (error) {
|
||||
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to upload attachment.` }));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const stopRun = async () => {
|
||||
const last = messages.at(-1);
|
||||
await client.agent.messages.stop({
|
||||
threadId,
|
||||
...(last?.role === "assistant" ? { partialMessage: last } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col bg-background">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
<Trans>Chat</Trans>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<Trans>Use URLs, files, and direct instructions to refine the draft.</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(messages.map(textFromMessage).join("\n\n"));
|
||||
toast.success(t`Conversation copied.`);
|
||||
}}
|
||||
>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon-sm" variant="ghost">
|
||||
<DotsThreeVerticalIcon />
|
||||
<span className="sr-only">
|
||||
<Trans>Thread actions</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
{!isArchived ? (
|
||||
<DropdownMenuItem disabled={archiveMutation.isPending} onClick={handleArchive}>
|
||||
<ArchiveIcon />
|
||||
<Trans>Archive</Trans>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
<TrashIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isReadOnly ? (
|
||||
<div className="border-amber-300 border-b bg-amber-50 px-4 py-2 text-amber-950 text-sm dark:bg-amber-950/20 dark:text-amber-200">
|
||||
{readOnlyReason === "archived" ? (
|
||||
<Trans>This thread is archived. New messages cannot be sent.</Trans>
|
||||
) : (
|
||||
<Trans>This thread is read-only because the working resume or AI provider is unavailable.</Trans>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-4 p-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="grid gap-3 py-12 text-center">
|
||||
<SparkleIcon className="mx-auto size-8 text-primary" />
|
||||
<h2 className="font-semibold text-2xl">
|
||||
<Trans>What should this resume target?</Trans>
|
||||
</h2>
|
||||
<div className="mx-auto flex max-w-xl flex-wrap justify-center gap-2">
|
||||
{[
|
||||
t`Tailor this resume to a product manager job description.`,
|
||||
t`Find weak bullets and rewrite them with stronger outcomes.`,
|
||||
t`Compare this resume against a role URL and update keywords.`,
|
||||
].map((prompt) => (
|
||||
<Button key={prompt} variant="outline" onClick={() => setInput(prompt)}>
|
||||
{prompt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{messages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isReverting={revertMutation.isPending}
|
||||
onAnswer={(toolCallId, answer) => {
|
||||
addToolOutput({ tool: "ask_user_question", toolCallId, output: answer });
|
||||
sendMessage({ text: answer });
|
||||
}}
|
||||
onRevert={(actionId) =>
|
||||
revertMutation.mutate(
|
||||
{ id: actionId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t`Patch reverted.`);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: orpc.agent.threads.get.queryKey({ input: { id: threadId } }),
|
||||
});
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(getOrpcErrorMessage(error, { fallback: t`Could not revert this patch.` })),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isStreaming ? (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-md bg-muted px-4 py-3 text-muted-foreground text-sm">
|
||||
<Trans>Working...</Trans>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-rose-300 bg-rose-50 p-3 text-rose-950 text-sm dark:bg-rose-950/20 dark:text-rose-200">
|
||||
<span>{error.message}</span>
|
||||
{!isReadOnly ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearError();
|
||||
void regenerate();
|
||||
}}
|
||||
>
|
||||
<ArrowClockwiseIcon />
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<form
|
||||
className="border-t p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-2">
|
||||
{attachmentLabels.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachmentLabels.map((label) => (
|
||||
<Badge key={label} variant="secondary">
|
||||
<FileIcon />
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end gap-2 rounded-md border bg-card p-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => void uploadFiles(event.target.files)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={isReadOnly || isUploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{isUploading ? <ArrowClockwiseIcon className="animate-spin" /> : <PlusIcon />}
|
||||
</Button>
|
||||
<Textarea
|
||||
value={input}
|
||||
disabled={isReadOnly || isStreaming}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
send();
|
||||
}}
|
||||
placeholder={isReadOnly ? t`This thread is read-only` : t`Ask anything about this resume`}
|
||||
className="max-h-40 min-h-11 resize-none border-0 bg-transparent shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
{isStreaming && !isReadOnly ? (
|
||||
<Button type="button" size="icon" variant="outline" onClick={() => void stopRun()}>
|
||||
<StopIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" size="icon" disabled={isReadOnly || !input.trim()}>
|
||||
<PaperPlaneRightIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumePane({ resume }: { resume: AgentThreadDetail["resume"] }) {
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col bg-muted/30">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
<Trans>Resume</Trans>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{resume?.name ?? t`Missing working resume`}</div>
|
||||
</div>
|
||||
{resume ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link to="/builder/$resumeId" params={{ resumeId: resume.id }} />}
|
||||
>
|
||||
<Trans>Open Builder</Trans>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{resume ? (
|
||||
<ResumePreview
|
||||
data={resume.data}
|
||||
pageLayout="vertical"
|
||||
pageScale={0.62}
|
||||
pageGap={28}
|
||||
showPageNumbers
|
||||
className="mx-auto"
|
||||
pageClassName="shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed p-6 text-center text-muted-foreground">
|
||||
<Trans>The working resume was deleted. This thread is read-only.</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { threadId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const [mobileTab, setMobileTab] = useState("chat");
|
||||
const { data, isLoading, error } = useQuery(orpc.agent.threads.get.queryOptions({ input: { id: threadId } }));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid h-svh place-items-center bg-background text-muted-foreground">
|
||||
<Trans>Loading agent workspace...</Trans>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="grid h-svh place-items-center bg-background p-6 text-center">
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>This agent thread could not be opened.</Trans>
|
||||
</p>
|
||||
<Button onClick={() => void navigate({ to: "/agent" })}>
|
||||
<Trans>Start a new thread</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const readOnlyReason: "archived" | "missing" | null = data.isReadOnly
|
||||
? data.thread.status === "archived"
|
||||
? "archived"
|
||||
: "missing"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="h-svh bg-background">
|
||||
<div className="hidden h-full lg:block">
|
||||
<ResizableGroup orientation="horizontal" className="h-full">
|
||||
<ResizablePanel id="threads" defaultSize="18%" minSize="240px" maxSize="360px">
|
||||
<ThreadSidebar activeThreadId={threadId} />
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel id="chat" defaultSize="52%" minSize="420px">
|
||||
<AgentChat
|
||||
threadId={threadId}
|
||||
initialMessages={data.messages}
|
||||
isReadOnly={data.isReadOnly}
|
||||
readOnlyReason={readOnlyReason}
|
||||
threadStatus={data.thread.status}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel id="resume" defaultSize="30%" minSize="340px">
|
||||
<ResumePane resume={data.resume} />
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col lg:hidden">
|
||||
<div className="border-b p-2">
|
||||
<Tabs value={mobileTab} onValueChange={setMobileTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="threads">
|
||||
<SidebarSimpleIcon />
|
||||
<Trans>Threads</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chat">
|
||||
<ChatCircleDotsIcon />
|
||||
<Trans>Chat</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="resume">
|
||||
<SquaresFourIcon />
|
||||
<Trans>Resume</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
{mobileTab === "threads" ? <ThreadSidebar activeThreadId={threadId} /> : null}
|
||||
{mobileTab === "chat" ? (
|
||||
<AgentChat
|
||||
threadId={threadId}
|
||||
initialMessages={data.messages}
|
||||
isReadOnly={data.isReadOnly}
|
||||
readOnlyReason={readOnlyReason}
|
||||
threadStatus={data.thread.status}
|
||||
/>
|
||||
) : null}
|
||||
{mobileTab === "resume" ? <ResumePane resume={data.resume} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
|
||||
const dbMock = {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
const resumeServiceMock = {
|
||||
getById: vi.fn(),
|
||||
};
|
||||
|
||||
const aiProvidersServiceMock = {
|
||||
getRunnableById: vi.fn(),
|
||||
getDefaultRunnable: vi.fn(),
|
||||
markUsed: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
|
||||
vi.mock("@reactive-resume/db/schema", () => ({
|
||||
agentThread: {
|
||||
id: "agent_threads.id",
|
||||
userId: "agent_threads.user_id",
|
||||
deletedAt: "agent_threads.deleted_at",
|
||||
archivedAt: "agent_threads.archived_at",
|
||||
status: "agent_threads.status",
|
||||
activeRunId: "agent_threads.active_run_id",
|
||||
activeStreamId: "agent_threads.active_stream_id",
|
||||
activeRunStartedAt: "agent_threads.active_run_started_at",
|
||||
aiProviderId: "agent_threads.ai_provider_id",
|
||||
workingResumeId: "agent_threads.working_resume_id",
|
||||
sourceResumeId: "agent_threads.source_resume_id",
|
||||
title: "agent_threads.title",
|
||||
lastMessageAt: "agent_threads.last_message_at",
|
||||
createdAt: "agent_threads.created_at",
|
||||
updatedAt: "agent_threads.updated_at",
|
||||
},
|
||||
agentMessage: {
|
||||
threadId: "agent_messages.thread_id",
|
||||
userId: "agent_messages.user_id",
|
||||
sequence: "agent_messages.sequence",
|
||||
},
|
||||
agentAction: {
|
||||
threadId: "agent_actions.thread_id",
|
||||
userId: "agent_actions.user_id",
|
||||
createdAt: "agent_actions.created_at",
|
||||
},
|
||||
agentAttachment: {
|
||||
threadId: "agent_attachments.thread_id",
|
||||
userId: "agent_attachments.user_id",
|
||||
createdAt: "agent_attachments.created_at",
|
||||
},
|
||||
resume: { name: "resume.name", id: "resume.id", userId: "resume.user_id", slug: "resume.slug" },
|
||||
aiProvider: { label: "ai_provider.label", id: "ai_provider.id" },
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
and: (...conditions: unknown[]) => ({ type: "and", conditions }),
|
||||
asc: (value: unknown) => ({ type: "asc", value }),
|
||||
count: () => ({ type: "count" }),
|
||||
desc: (value: unknown) => ({ type: "desc", value }),
|
||||
eq: (left: unknown, right: unknown) => ({ type: "eq", left, right }),
|
||||
isNull: (value: unknown) => ({ type: "isNull", value }),
|
||||
max: (value: unknown) => ({ type: "max", value }),
|
||||
sql: () => ({ type: "sql" }),
|
||||
}));
|
||||
|
||||
vi.mock("ai", () => ({
|
||||
convertToModelMessages: vi.fn(),
|
||||
stepCountIs: vi.fn(),
|
||||
ToolLoopAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./ai", () => ({ getAgentModel: vi.fn() }));
|
||||
vi.mock("./ai-credentials", () => ({ assertAgentEnvironment: vi.fn() }));
|
||||
vi.mock("./ai-providers", () => ({ aiProvidersService: aiProvidersServiceMock }));
|
||||
vi.mock("./resume", () => ({ resumeService: resumeServiceMock }));
|
||||
vi.mock("./storage", () => ({ getStorageService: vi.fn(), inferContentType: vi.fn() }));
|
||||
vi.mock("./agent-patches", () => ({ createInverseResumePatches: vi.fn() }));
|
||||
vi.mock("./agent-resume", () => ({
|
||||
buildAgentDraftResumeName: vi.fn(),
|
||||
buildUniqueAgentDraftSlug: vi.fn(),
|
||||
}));
|
||||
vi.mock("./agent-run-state", () => ({
|
||||
claimActiveAgentRun: vi.fn(),
|
||||
clearActiveAgentRunIfCurrent: vi.fn(),
|
||||
}));
|
||||
vi.mock("./agent-streams", () => ({
|
||||
agentStreamLifecycle: { create: vi.fn(), resume: vi.fn() },
|
||||
}));
|
||||
vi.mock("./agent-tools", () => ({
|
||||
buildAgentInstructions: vi.fn(),
|
||||
buildAgentTools: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("./agent-url", () => ({ fetchUrlForAgent: vi.fn() }));
|
||||
vi.mock("@reactive-resume/schema/resume/default", () => ({ defaultResumeData: {} }));
|
||||
vi.mock("@reactive-resume/utils/string", () => ({ generateId: () => "test-id" }));
|
||||
vi.mock("@orpc/server", () => ({ streamToEventIterator: vi.fn() }));
|
||||
|
||||
function buildArchivedThread(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "thread-1",
|
||||
userId: "user-1",
|
||||
aiProviderId: "provider-1",
|
||||
workingResumeId: "resume-1",
|
||||
sourceResumeId: null,
|
||||
title: "Archived thread",
|
||||
status: "archived",
|
||||
activeRunId: null,
|
||||
activeStreamId: null,
|
||||
activeRunStartedAt: null,
|
||||
lastMessageAt: new Date("2026-05-01T00:00:00.000Z"),
|
||||
archivedAt: new Date("2026-05-02T00:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-02T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("agentService.threads.get", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns isReadOnly: true when the thread is archived, even if the resume and provider are present", async () => {
|
||||
const archivedThread = buildArchivedThread();
|
||||
|
||||
// First select call is `getThread` (limit), then three select calls for messages/actions/attachments (orderBy).
|
||||
const threadSelect = () => {
|
||||
const limit = vi.fn(async () => [archivedThread]);
|
||||
const where = vi.fn(() => ({ limit }));
|
||||
const from = vi.fn(() => ({ where }));
|
||||
return { from };
|
||||
};
|
||||
const emptyListSelect = () => {
|
||||
const orderBy = vi.fn(async () => []);
|
||||
const where = vi.fn(() => ({ orderBy }));
|
||||
const from = vi.fn(() => ({ where }));
|
||||
return { from };
|
||||
};
|
||||
|
||||
dbMock.select
|
||||
.mockImplementationOnce(threadSelect)
|
||||
.mockImplementationOnce(emptyListSelect)
|
||||
.mockImplementationOnce(emptyListSelect)
|
||||
.mockImplementationOnce(emptyListSelect);
|
||||
|
||||
resumeServiceMock.getById.mockResolvedValue({
|
||||
id: "resume-1",
|
||||
name: "Resume",
|
||||
data: {},
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const { agentService } = await import("./agent");
|
||||
|
||||
const result = await agentService.threads.get({ id: "thread-1", userId: "user-1" });
|
||||
|
||||
expect(result.isReadOnly).toBe(true);
|
||||
expect(result.thread.status).toBe("archived");
|
||||
expect(result.resume).toEqual(expect.objectContaining({ id: "resume-1" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentService.messages.send", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("throws CONFLICT when the underlying thread is archived", async () => {
|
||||
const archivedThread = buildArchivedThread();
|
||||
|
||||
dbMock.select.mockImplementation(() => {
|
||||
const limit = vi.fn(async () => [archivedThread]);
|
||||
const where = vi.fn(() => ({ limit }));
|
||||
const from = vi.fn(() => ({ where }));
|
||||
return { from };
|
||||
});
|
||||
|
||||
const { agentService } = await import("./agent");
|
||||
|
||||
const sending = agentService.messages.send({
|
||||
threadId: "thread-1",
|
||||
userId: "user-1",
|
||||
// biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test
|
||||
message: { id: "msg-1", role: "user", parts: [{ type: "text", text: "hi" }] } as any,
|
||||
});
|
||||
|
||||
await expect(sending).rejects.toBeInstanceOf(ORPCError);
|
||||
await expect(sending).rejects.toMatchObject({ code: "CONFLICT", message: "This thread is archived." });
|
||||
|
||||
// Ensure we never tried to claim a run or persist anything for an archived thread.
|
||||
expect(aiProvidersServiceMock.getRunnableById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,789 @@
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch";
|
||||
import type { UIMessage } from "ai";
|
||||
import type { getModel } from "./ai";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { streamToEventIterator } from "@orpc/server";
|
||||
import { convertToModelMessages, stepCountIs, ToolLoopAgent } from "ai";
|
||||
import { and, asc, count, desc, eq, isNull, max, sql } from "drizzle-orm";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { createInverseResumePatches } from "./agent-patches";
|
||||
import { buildAgentDraftResumeName, buildUniqueAgentDraftSlug } from "./agent-resume";
|
||||
import { claimActiveAgentRun, clearActiveAgentRunIfCurrent } from "./agent-run-state";
|
||||
import { agentStreamLifecycle } from "./agent-streams";
|
||||
import { buildAgentInstructions, buildAgentTools } from "./agent-tools";
|
||||
import { fetchUrlForAgent } from "./agent-url";
|
||||
import { getAgentModel } from "./ai";
|
||||
import { assertAgentEnvironment } from "./ai-credentials";
|
||||
import { aiProvidersService } from "./ai-providers";
|
||||
import { resumeService } from "./resume";
|
||||
import { getStorageService, inferContentType } from "./storage";
|
||||
|
||||
const MAX_AGENT_STEPS = 30;
|
||||
const MAX_ATTACHMENTS_PER_MESSAGE = 10;
|
||||
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
||||
const MAX_THREAD_ATTACHMENT_BYTES = 100 * 1024 * 1024;
|
||||
const READABLE_ATTACHMENT_TYPES = new Set(["text/plain", "text/markdown", "application/json"]);
|
||||
|
||||
const activeRunControllers = new Map<string, AbortController>();
|
||||
const canceledRunsWithPersistedPartial = new Set<string>();
|
||||
|
||||
type AgentThreadRecord = typeof schema.agentThread.$inferSelect;
|
||||
type AgentMessageRecord = typeof schema.agentMessage.$inferSelect;
|
||||
type AgentActionRecord = typeof schema.agentAction.$inferSelect;
|
||||
type AgentAttachmentRecord = typeof schema.agentAttachment.$inferSelect;
|
||||
|
||||
type CreateThreadInput = {
|
||||
userId: string;
|
||||
locale: Locale;
|
||||
aiProviderId?: string;
|
||||
sourceResumeId?: string;
|
||||
};
|
||||
|
||||
type SendMessageInput = {
|
||||
userId: string;
|
||||
threadId: string;
|
||||
message: UIMessage;
|
||||
};
|
||||
|
||||
type CreateAttachmentInput = {
|
||||
userId: string;
|
||||
threadId: string;
|
||||
filename: string;
|
||||
mediaType: string;
|
||||
data: Uint8Array;
|
||||
};
|
||||
|
||||
function cloneResumeData<T>(data: T): T {
|
||||
return structuredClone(data);
|
||||
}
|
||||
|
||||
function toThreadSummary(row: AgentThreadRecord & { resumeName?: string | null; providerLabel?: string | null }) {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
sourceResumeId: row.sourceResumeId,
|
||||
workingResumeId: row.workingResumeId,
|
||||
aiProviderId: row.aiProviderId,
|
||||
resumeName: row.resumeName ?? null,
|
||||
providerLabel: row.providerLabel ?? null,
|
||||
activeRunId: row.activeRunId,
|
||||
lastMessageAt: row.lastMessageAt,
|
||||
archivedAt: row.archivedAt,
|
||||
deletedAt: row.deletedAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toMessage(row: AgentMessageRecord): UIMessage {
|
||||
return row.uiMessage as unknown as UIMessage;
|
||||
}
|
||||
|
||||
function toAction(row: AgentActionRecord) {
|
||||
return {
|
||||
id: row.id,
|
||||
threadId: row.threadId,
|
||||
messageId: row.messageId,
|
||||
resumeId: row.resumeId,
|
||||
kind: row.kind,
|
||||
status: row.status,
|
||||
title: row.title,
|
||||
summary: row.summary,
|
||||
operations: row.operations,
|
||||
inverseOperations: row.inverseOperations,
|
||||
baseUpdatedAt: row.baseUpdatedAt,
|
||||
appliedUpdatedAt: row.appliedUpdatedAt,
|
||||
revertedAt: row.revertedAt,
|
||||
revertMessage: row.revertMessage,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toAttachment(row: AgentAttachmentRecord) {
|
||||
return {
|
||||
id: row.id,
|
||||
threadId: row.threadId,
|
||||
messageId: row.messageId,
|
||||
filename: row.filename,
|
||||
mediaType: row.mediaType,
|
||||
size: row.size,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function getExistingResumeSlugs(userId: string) {
|
||||
const rows = await db
|
||||
.select({ slug: schema.resume.slug })
|
||||
.from(schema.resume)
|
||||
.where(eq(schema.resume.userId, userId));
|
||||
return new Set(rows.map((row) => row.slug));
|
||||
}
|
||||
|
||||
async function createWorkingResume(input: CreateThreadInput) {
|
||||
if (input.sourceResumeId) {
|
||||
const source = await resumeService.getById({ id: input.sourceResumeId, userId: input.userId });
|
||||
const existingSlugs = await getExistingResumeSlugs(input.userId);
|
||||
const name = buildAgentDraftResumeName(source.name);
|
||||
const slug = buildUniqueAgentDraftSlug(source.name, existingSlugs);
|
||||
|
||||
const id = await resumeService.create({
|
||||
userId: input.userId,
|
||||
name,
|
||||
slug,
|
||||
tags: [...source.tags],
|
||||
locale: input.locale,
|
||||
data: cloneResumeData(source.data),
|
||||
});
|
||||
|
||||
return { id, source, title: name };
|
||||
}
|
||||
|
||||
const existingSlugs = await getExistingResumeSlugs(input.userId);
|
||||
const name = "AI Draft";
|
||||
const id = await resumeService.create({
|
||||
userId: input.userId,
|
||||
name,
|
||||
slug: buildUniqueAgentDraftSlug(name, existingSlugs),
|
||||
tags: [],
|
||||
locale: input.locale,
|
||||
data: cloneResumeData(defaultResumeData),
|
||||
});
|
||||
|
||||
return { id, source: null, title: name };
|
||||
}
|
||||
|
||||
async function getThread(input: { id: string; userId: string }) {
|
||||
const [thread] = await db
|
||||
.select()
|
||||
.from(schema.agentThread)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.agentThread.id, input.id),
|
||||
eq(schema.agentThread.userId, input.userId),
|
||||
isNull(schema.agentThread.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!thread) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
async function getNextMessageSequence(threadId: string) {
|
||||
const [row] = await db
|
||||
.select({ maxSequence: max(schema.agentMessage.sequence) })
|
||||
.from(schema.agentMessage)
|
||||
.where(eq(schema.agentMessage.threadId, threadId));
|
||||
|
||||
return (row?.maxSequence ?? -1) + 1;
|
||||
}
|
||||
|
||||
async function persistMessage(input: {
|
||||
userId: string;
|
||||
threadId: string;
|
||||
message: UIMessage;
|
||||
status?: string;
|
||||
sequence?: number;
|
||||
}) {
|
||||
const sequence = input.sequence ?? (await getNextMessageSequence(input.threadId));
|
||||
const [message] = await db
|
||||
.insert(schema.agentMessage)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
threadId: input.threadId,
|
||||
role: input.message.role,
|
||||
status: input.status ?? "completed",
|
||||
sequence,
|
||||
uiMessage: input.message as unknown as Record<string, unknown>,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db
|
||||
.update(schema.agentThread)
|
||||
.set({ lastMessageAt: new Date() })
|
||||
.where(and(eq(schema.agentThread.id, input.threadId), eq(schema.agentThread.userId, input.userId)));
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async function cleanupActiveRun(input: {
|
||||
threadId: string;
|
||||
userId: string;
|
||||
runId: string;
|
||||
streamId: string;
|
||||
primaryError?: unknown;
|
||||
}) {
|
||||
activeRunControllers.delete(input.runId);
|
||||
canceledRunsWithPersistedPartial.delete(input.runId);
|
||||
|
||||
try {
|
||||
await clearActiveAgentRunIfCurrent(input);
|
||||
} catch (error) {
|
||||
if (!input.primaryError) throw error;
|
||||
console.error("[agent] Failed to clear active run after run error", error);
|
||||
}
|
||||
}
|
||||
|
||||
function messageText(message: UIMessage) {
|
||||
return message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildThreadTitle(message: UIMessage, fallback: string) {
|
||||
const text = messageText(message);
|
||||
if (!text) return fallback;
|
||||
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
||||
}
|
||||
|
||||
async function listThreadMessages(input: { threadId: string; userId: string }) {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.agentMessage)
|
||||
.where(and(eq(schema.agentMessage.threadId, input.threadId), eq(schema.agentMessage.userId, input.userId)))
|
||||
.orderBy(asc(schema.agentMessage.sequence));
|
||||
}
|
||||
|
||||
async function readAttachment(input: { id: string; threadId: string; userId: string }) {
|
||||
const [attachment] = await db
|
||||
.select()
|
||||
.from(schema.agentAttachment)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.agentAttachment.id, input.id),
|
||||
eq(schema.agentAttachment.threadId, input.threadId),
|
||||
eq(schema.agentAttachment.userId, input.userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!attachment) throw new Error("ATTACHMENT_NOT_FOUND");
|
||||
|
||||
const stored = await getStorageService().read(attachment.storageKey);
|
||||
if (!stored) throw new Error("ATTACHMENT_NOT_FOUND");
|
||||
|
||||
if (!READABLE_ATTACHMENT_TYPES.has(attachment.mediaType)) {
|
||||
return {
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
mediaType: attachment.mediaType,
|
||||
size: attachment.size,
|
||||
content: null,
|
||||
note: "This attachment is available to the model as metadata only in this version.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
mediaType: attachment.mediaType,
|
||||
size: attachment.size,
|
||||
content: new TextDecoder().decode(stored.data).slice(0, 40_000),
|
||||
};
|
||||
}
|
||||
|
||||
async function applyResumePatch(input: {
|
||||
userId: string;
|
||||
threadId: string;
|
||||
resumeId: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
operations: JsonPatchOperation[];
|
||||
}) {
|
||||
const before = await resumeService.getById({ id: input.resumeId, userId: input.userId });
|
||||
const inverseOperations = createInverseResumePatches(before.data, input.operations);
|
||||
const patched = await resumeService.patch({
|
||||
id: input.resumeId,
|
||||
userId: input.userId,
|
||||
operations: input.operations,
|
||||
expectedUpdatedAt: before.updatedAt,
|
||||
});
|
||||
|
||||
const [action] = await db
|
||||
.insert(schema.agentAction)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
threadId: input.threadId,
|
||||
resumeId: input.resumeId,
|
||||
kind: "resume_patch",
|
||||
status: "applied",
|
||||
title: input.title,
|
||||
...(input.summary !== undefined ? { summary: input.summary } : {}),
|
||||
operations: input.operations,
|
||||
inverseOperations,
|
||||
baseUpdatedAt: before.updatedAt,
|
||||
appliedUpdatedAt: patched.updatedAt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!action) throw new Error("AGENT_ACTION_CREATE_FAILED");
|
||||
|
||||
return {
|
||||
actionId: action.id,
|
||||
resumeId: input.resumeId,
|
||||
title: action.title,
|
||||
summary: action.summary,
|
||||
operations: action.operations,
|
||||
appliedUpdatedAt: action.appliedUpdatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function createAgent(input: {
|
||||
userId: string;
|
||||
threadId: string;
|
||||
resumeId: string;
|
||||
provider: {
|
||||
provider: Parameters<typeof getModel>[0]["provider"];
|
||||
model: string;
|
||||
apiKey: string;
|
||||
baseURL?: string;
|
||||
};
|
||||
model: ReturnType<typeof getModel>;
|
||||
}) {
|
||||
const tools = buildAgentTools({
|
||||
provider: input.provider,
|
||||
handlers: {
|
||||
fetchUrl: fetchUrlForAgent,
|
||||
readResume: async () => {
|
||||
const resume = await resumeService.getById({ id: input.resumeId, userId: input.userId });
|
||||
return {
|
||||
id: resume.id,
|
||||
name: resume.name,
|
||||
updatedAt: resume.updatedAt.toISOString(),
|
||||
data: resume.data,
|
||||
};
|
||||
},
|
||||
readAttachment: (attachmentId) =>
|
||||
readAttachment({ id: attachmentId, threadId: input.threadId, userId: input.userId }),
|
||||
applyResumePatch: ({ title, summary, operations }) =>
|
||||
applyResumePatch({
|
||||
userId: input.userId,
|
||||
threadId: input.threadId,
|
||||
resumeId: input.resumeId,
|
||||
title,
|
||||
...(summary !== undefined ? { summary } : {}),
|
||||
operations,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return new ToolLoopAgent({
|
||||
model: input.model,
|
||||
instructions: buildAgentInstructions({ hasProviderNativeSearch: "web_search" in tools }),
|
||||
stopWhen: stepCountIs(MAX_AGENT_STEPS),
|
||||
tools,
|
||||
});
|
||||
}
|
||||
|
||||
export const agentService = {
|
||||
threads: {
|
||||
list: async (input: { userId: string }) => {
|
||||
assertAgentEnvironment();
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.agentThread.id,
|
||||
userId: schema.agentThread.userId,
|
||||
aiProviderId: schema.agentThread.aiProviderId,
|
||||
sourceResumeId: schema.agentThread.sourceResumeId,
|
||||
workingResumeId: schema.agentThread.workingResumeId,
|
||||
title: schema.agentThread.title,
|
||||
status: schema.agentThread.status,
|
||||
activeRunId: schema.agentThread.activeRunId,
|
||||
activeStreamId: schema.agentThread.activeStreamId,
|
||||
activeRunStartedAt: schema.agentThread.activeRunStartedAt,
|
||||
lastMessageAt: schema.agentThread.lastMessageAt,
|
||||
archivedAt: schema.agentThread.archivedAt,
|
||||
deletedAt: schema.agentThread.deletedAt,
|
||||
createdAt: schema.agentThread.createdAt,
|
||||
updatedAt: schema.agentThread.updatedAt,
|
||||
resumeName: schema.resume.name,
|
||||
providerLabel: schema.aiProvider.label,
|
||||
})
|
||||
.from(schema.agentThread)
|
||||
.leftJoin(schema.resume, eq(schema.agentThread.workingResumeId, schema.resume.id))
|
||||
.leftJoin(schema.aiProvider, eq(schema.agentThread.aiProviderId, schema.aiProvider.id))
|
||||
.where(and(eq(schema.agentThread.userId, input.userId), isNull(schema.agentThread.deletedAt)))
|
||||
.orderBy(desc(schema.agentThread.lastMessageAt));
|
||||
|
||||
return rows.map(toThreadSummary);
|
||||
},
|
||||
|
||||
create: async (input: CreateThreadInput) => {
|
||||
assertAgentEnvironment();
|
||||
|
||||
const selectedProvider = input.aiProviderId
|
||||
? await aiProvidersService.getRunnableById({ id: input.aiProviderId, userId: input.userId })
|
||||
: await aiProvidersService.getDefaultRunnable({ userId: input.userId });
|
||||
|
||||
if (!selectedProvider) throw new ORPCError("BAD_REQUEST", { message: "No tested AI provider is available." });
|
||||
|
||||
const working = await createWorkingResume(input);
|
||||
const [thread] = await db
|
||||
.insert(schema.agentThread)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
aiProviderId: selectedProvider.id,
|
||||
sourceResumeId: input.sourceResumeId ?? null,
|
||||
workingResumeId: working.id,
|
||||
title: working.title,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!thread) throw new Error("AGENT_THREAD_CREATE_FAILED");
|
||||
|
||||
return toThreadSummary({
|
||||
...thread,
|
||||
resumeName: working.title,
|
||||
providerLabel: selectedProvider.label,
|
||||
});
|
||||
},
|
||||
|
||||
get: async (input: { id: string; userId: string }) => {
|
||||
assertAgentEnvironment();
|
||||
|
||||
const thread = await getThread(input);
|
||||
const [messages, actions, attachments, resume] = await Promise.all([
|
||||
listThreadMessages({ threadId: input.id, userId: input.userId }),
|
||||
db
|
||||
.select()
|
||||
.from(schema.agentAction)
|
||||
.where(and(eq(schema.agentAction.threadId, input.id), eq(schema.agentAction.userId, input.userId)))
|
||||
.orderBy(desc(schema.agentAction.createdAt)),
|
||||
db
|
||||
.select()
|
||||
.from(schema.agentAttachment)
|
||||
.where(and(eq(schema.agentAttachment.threadId, input.id), eq(schema.agentAttachment.userId, input.userId)))
|
||||
.orderBy(asc(schema.agentAttachment.createdAt)),
|
||||
thread.workingResumeId
|
||||
? resumeService.getById({ id: thread.workingResumeId, userId: input.userId }).catch(() => null)
|
||||
: null,
|
||||
]);
|
||||
|
||||
return {
|
||||
thread: toThreadSummary(thread),
|
||||
messages: messages.map(toMessage),
|
||||
actions: actions.map(toAction),
|
||||
attachments: attachments.map(toAttachment),
|
||||
resume,
|
||||
isReadOnly: thread.status === "archived" || !thread.workingResumeId || !thread.aiProviderId || !resume,
|
||||
};
|
||||
},
|
||||
|
||||
archive: async (input: { id: string; userId: string }) => {
|
||||
assertAgentEnvironment();
|
||||
|
||||
await db
|
||||
.update(schema.agentThread)
|
||||
.set({ status: "archived", archivedAt: new Date() })
|
||||
.where(and(eq(schema.agentThread.id, input.id), eq(schema.agentThread.userId, input.userId)));
|
||||
},
|
||||
|
||||
delete: async (input: { id: string; userId: string }) => {
|
||||
assertAgentEnvironment();
|
||||
|
||||
await getStorageService().delete(`uploads/${input.userId}/agent/${input.id}`);
|
||||
await db.delete(schema.agentAttachment).where(eq(schema.agentAttachment.threadId, input.id));
|
||||
await db
|
||||
.update(schema.agentThread)
|
||||
.set({ status: "deleted", deletedAt: new Date() })
|
||||
.where(and(eq(schema.agentThread.id, input.id), eq(schema.agentThread.userId, input.userId)));
|
||||
},
|
||||
},
|
||||
|
||||
messages: {
|
||||
send: async (input: SendMessageInput) => {
|
||||
assertAgentEnvironment();
|
||||
|
||||
const thread = await getThread({ id: input.threadId, userId: input.userId });
|
||||
if (thread.status === "archived") {
|
||||
throw new ORPCError("CONFLICT", { message: "This thread is archived." });
|
||||
}
|
||||
if (thread.activeRunId) {
|
||||
throw new ORPCError("CONFLICT", { message: "This thread already has an active run." });
|
||||
}
|
||||
if (!thread.workingResumeId || !thread.aiProviderId) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "This thread is read-only." });
|
||||
}
|
||||
|
||||
const runnableProvider = await aiProvidersService.getRunnableById({
|
||||
id: thread.aiProviderId,
|
||||
userId: input.userId,
|
||||
});
|
||||
const runId = generateId();
|
||||
const streamId = generateId();
|
||||
const controller = new AbortController();
|
||||
activeRunControllers.set(runId, controller);
|
||||
|
||||
const claimed = await claimActiveAgentRun({ threadId: input.threadId, userId: input.userId, runId, streamId });
|
||||
if (!claimed) {
|
||||
activeRunControllers.delete(runId);
|
||||
throw new ORPCError("CONFLICT", { message: "This thread already has an active run." });
|
||||
}
|
||||
|
||||
try {
|
||||
const sequence = await getNextMessageSequence(input.threadId);
|
||||
await persistMessage({ userId: input.userId, threadId: input.threadId, message: input.message, sequence });
|
||||
|
||||
const [messageCount] = await db
|
||||
.select({ total: count() })
|
||||
.from(schema.agentMessage)
|
||||
.where(eq(schema.agentMessage.threadId, input.threadId));
|
||||
|
||||
if ((messageCount?.total ?? 0) === 1) {
|
||||
await db
|
||||
.update(schema.agentThread)
|
||||
.set({ title: buildThreadTitle(input.message, thread.title) })
|
||||
.where(and(eq(schema.agentThread.id, input.threadId), eq(schema.agentThread.userId, input.userId)));
|
||||
}
|
||||
|
||||
await aiProvidersService.markUsed({ id: runnableProvider.id, userId: input.userId });
|
||||
|
||||
const messages = (await listThreadMessages({ threadId: input.threadId, userId: input.userId })).map(toMessage);
|
||||
const agent = createAgent({
|
||||
userId: input.userId,
|
||||
threadId: input.threadId,
|
||||
resumeId: thread.workingResumeId,
|
||||
provider: {
|
||||
provider: runnableProvider.provider,
|
||||
model: runnableProvider.model,
|
||||
apiKey: runnableProvider.apiKey,
|
||||
baseURL: runnableProvider.baseURL ?? "",
|
||||
},
|
||||
model: getAgentModel({
|
||||
provider: runnableProvider.provider,
|
||||
model: runnableProvider.model,
|
||||
apiKey: runnableProvider.apiKey,
|
||||
baseURL: runnableProvider.baseURL ?? "",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await agent.stream({
|
||||
messages: await convertToModelMessages(messages),
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
return streamToEventIterator(
|
||||
await agentStreamLifecycle.create(streamId, () =>
|
||||
result.toUIMessageStream({
|
||||
originalMessages: messages,
|
||||
generateMessageId: generateId,
|
||||
sendSources: true,
|
||||
onFinish: async ({ responseMessage, isAborted }) => {
|
||||
let persistError: unknown;
|
||||
try {
|
||||
if (!(isAborted && canceledRunsWithPersistedPartial.has(runId))) {
|
||||
await persistMessage({
|
||||
userId: input.userId,
|
||||
threadId: input.threadId,
|
||||
message: responseMessage,
|
||||
status: isAborted ? "canceled" : "completed",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
persistError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
await cleanupActiveRun({
|
||||
threadId: input.threadId,
|
||||
userId: input.userId,
|
||||
runId,
|
||||
streamId,
|
||||
primaryError: persistError,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => (error instanceof Error ? error.message : "Agent run failed."),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
await cleanupActiveRun({
|
||||
threadId: input.threadId,
|
||||
userId: input.userId,
|
||||
runId,
|
||||
streamId,
|
||||
primaryError: error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
stop: async (input: { userId: string; threadId: string; partialMessage?: UIMessage }) => {
|
||||
assertAgentEnvironment();
|
||||
|
||||
const thread = await getThread({ id: input.threadId, userId: input.userId });
|
||||
const activeRunId = thread.activeRunId;
|
||||
const activeStreamId = thread.activeStreamId;
|
||||
|
||||
let persistError: unknown;
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
if (input.partialMessage) {
|
||||
await persistMessage({
|
||||
userId: input.userId,
|
||||
threadId: input.threadId,
|
||||
message: input.partialMessage,
|
||||
status: "canceled",
|
||||
});
|
||||
if (activeRunId) canceledRunsWithPersistedPartial.add(activeRunId);
|
||||
}
|
||||
} catch (error) {
|
||||
persistError = error;
|
||||
} finally {
|
||||
if (activeRunId) {
|
||||
activeRunControllers.get(activeRunId)?.abort("USER_STOPPED");
|
||||
activeRunControllers.delete(activeRunId);
|
||||
try {
|
||||
await clearActiveAgentRunIfCurrent({
|
||||
threadId: input.threadId,
|
||||
userId: input.userId,
|
||||
runId: activeRunId,
|
||||
streamId: activeStreamId,
|
||||
});
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
if (persistError) console.error("[agent] Failed to clear active run after stop persistence error", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (persistError) throw persistError;
|
||||
if (cleanupError) throw cleanupError;
|
||||
},
|
||||
resume: async (input: { userId: string; threadId: string }) => {
|
||||
assertAgentEnvironment();
|
||||
const thread = await getThread({ id: input.threadId, userId: input.userId });
|
||||
return streamToEventIterator(await agentStreamLifecycle.resume(thread.activeStreamId));
|
||||
},
|
||||
},
|
||||
|
||||
attachments: {
|
||||
create: async (input: CreateAttachmentInput) => {
|
||||
assertAgentEnvironment();
|
||||
await getThread({ id: input.threadId, userId: input.userId });
|
||||
|
||||
const [aggregate] = await db
|
||||
.select({ totalBytes: sql<number>`coalesce(sum(${schema.agentAttachment.size}), 0)` })
|
||||
.from(schema.agentAttachment)
|
||||
.where(
|
||||
and(eq(schema.agentAttachment.threadId, input.threadId), eq(schema.agentAttachment.userId, input.userId)),
|
||||
);
|
||||
|
||||
const [attachmentCount] = await db
|
||||
.select({ total: count() })
|
||||
.from(schema.agentAttachment)
|
||||
.where(
|
||||
and(eq(schema.agentAttachment.threadId, input.threadId), eq(schema.agentAttachment.userId, input.userId)),
|
||||
);
|
||||
|
||||
if ((attachmentCount?.total ?? 0) >= MAX_ATTACHMENTS_PER_MESSAGE) throw new ORPCError("BAD_REQUEST");
|
||||
if (input.data.byteLength > MAX_ATTACHMENT_BYTES) throw new ORPCError("BAD_REQUEST");
|
||||
if ((aggregate?.totalBytes ?? 0) + input.data.byteLength > MAX_THREAD_ATTACHMENT_BYTES) {
|
||||
throw new ORPCError("BAD_REQUEST");
|
||||
}
|
||||
|
||||
const mediaType = input.mediaType || inferContentType(input.filename);
|
||||
const id = generateId();
|
||||
const key = `uploads/${input.userId}/agent/${input.threadId}/${id}-${input.filename}`;
|
||||
|
||||
await getStorageService().write({ key, data: input.data, contentType: mediaType });
|
||||
const [attachment] = await db
|
||||
.insert(schema.agentAttachment)
|
||||
.values({
|
||||
id,
|
||||
userId: input.userId,
|
||||
threadId: input.threadId,
|
||||
storageKey: key,
|
||||
filename: input.filename,
|
||||
mediaType,
|
||||
size: input.data.byteLength,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!attachment) throw new Error("AGENT_ATTACHMENT_CREATE_FAILED");
|
||||
|
||||
return toAttachment(attachment);
|
||||
},
|
||||
|
||||
delete: async (input: { id: string; userId: string }) => {
|
||||
assertAgentEnvironment();
|
||||
|
||||
const [attachment] = await db
|
||||
.select()
|
||||
.from(schema.agentAttachment)
|
||||
.where(and(eq(schema.agentAttachment.id, input.id), eq(schema.agentAttachment.userId, input.userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!attachment) return;
|
||||
|
||||
await getStorageService().delete(attachment.storageKey);
|
||||
await db
|
||||
.delete(schema.agentAttachment)
|
||||
.where(and(eq(schema.agentAttachment.id, input.id), eq(schema.agentAttachment.userId, input.userId)));
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
revert: async (input: { id: string; userId: string }) => {
|
||||
assertAgentEnvironment();
|
||||
|
||||
const [action] = await db
|
||||
.select()
|
||||
.from(schema.agentAction)
|
||||
.where(and(eq(schema.agentAction.id, input.id), eq(schema.agentAction.userId, input.userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!action) throw new ORPCError("NOT_FOUND");
|
||||
if (action.status === "reverted") return toAction(action);
|
||||
if (!action.resumeId) throw new ORPCError("BAD_REQUEST", { message: "The edited resume no longer exists." });
|
||||
|
||||
try {
|
||||
const reverted = await resumeService.patch({
|
||||
id: action.resumeId,
|
||||
userId: input.userId,
|
||||
operations: action.inverseOperations,
|
||||
expectedUpdatedAt: action.appliedUpdatedAt,
|
||||
});
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.agentAction)
|
||||
.set({
|
||||
status: "reverted",
|
||||
revertedAt: new Date(),
|
||||
revertMessage: null,
|
||||
appliedUpdatedAt: reverted.updatedAt,
|
||||
})
|
||||
.where(and(eq(schema.agentAction.id, input.id), eq(schema.agentAction.userId, input.userId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
return toAction(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof ORPCError && error.code === "RESUME_VERSION_CONFLICT") {
|
||||
const [updated] = await db
|
||||
.update(schema.agentAction)
|
||||
.set({ status: "conflicted", revertMessage: "The resume changed after this action was applied." })
|
||||
.where(and(eq(schema.agentAction.id, input.id), eq(schema.agentAction.userId, input.userId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
return toAction(updated);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user