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:
Amruth Pillai
2026-05-13 22:23:16 +02:00
parent ca111d5ef0
commit e5ed570f0c
3 changed files with 1774 additions and 0 deletions
+787
View File
@@ -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>
);
}
+198
View File
@@ -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();
});
});
+789
View File
@@ -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;
}
},
},
};