mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-25 07:42:20 +10:00
feat(agent): adopt AI SDK v7 — crash safety, context pruning, HITL approvals (#3362)
* docs(adr): propose agent AI SDK v7 adoption plan * fix(ai): bind analyzeResume through aiService in service test The test destructured analyzeResume as a named export that does not exist; main was red. * test(agent): keep pure ai helpers real via spread-actual mock factory * feat(agent): add run guards, patch version guard, run wall-clock timeout * feat(agent): validate UI messages at the send boundary * feat(agent): crash-safe draft-row persistence and server-side cancellation * feat(agent): reap stale run claims at boot, on send, and on thread open * feat(agent): fresh-document patch output and tiered context pruning * feat(ai): shared agent tool contracts and message metadata schema * feat(agent): add per-thread review-patches setting with update endpoint * feat(agent): gate resume patches behind hmac-signed tool approval * feat(agent): merge question answers and approval decisions before run claim * feat(agent): approval ui with composed auto-send and fixture-driven tests * feat(agent): usage metadata, tool activity cards, smoother streaming * feat(agent): tool-call repair, input examples, structured step logging * chore(i18n): translate new agent workspace strings across all locales * fix(agent): gate stale-run draft cancellation on winning the claim clear Snapshot streaming drafts before the conditional clear and skip the flip entirely when another reaper or a replacement run already cleared the claim. Also address review nits in eleven locale catalogs. * fix(agent): flip reaped drafts only when their snapshotted state is unchanged * fix(agent): address review findings across run lifecycle, context budget, and approval flow - bind patches to the revision the model read via signed baseUpdatedAt - claim the run before consuming a continuation; recorded-but-unexecuted approvals retry as pending continuations - keep run ownership on stop() until cancellation persists; preserve the claim for the reaper when final persistence fails - estimate tokens without serializing binary attachments (tokenx) and enforce the budget by dropping oldest whole turns - mark crash-recovered patch results as snapshot boundaries; strip /data prefixes at execution time - retry failed continuations without regenerate; mount a single AgentChat; disable response controls on read-only threads; freeze review toggle during runs (client+server) - accumulate usage across continuations and match the SDK's nested usage shape; label-form token strings; reorderable source label; accessible note field; state-neutral web-search label * chore(i18n): translate revised agent strings across all locales * fix(agent): harden baseUpdatedAt validation and address review follow-ups - bundle tokenx in the server runtime dependencies (e2e boot failure) - strict ISO schema for baseUpdatedAt plus loud executor rejection of unparseable values - it-IT source label consistency (Fonte) - prove penultimate-turn retention in the context pruning test * chore(deps): exempt tokenx from knip for the externalized server bundle
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// Backend-free approval flow: a scripted ChatTransport from @shadcn/helpers drives useChat through
|
||||
// halt (approval-requested) → user decision → composed sendAutomaticallyWhen → continuation.
|
||||
import type { AgentUIMessage } from "@reactive-resume/ai/tools/agent-tool-contracts";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { createChat } from "@shadcn/helpers/ai-sdk";
|
||||
import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
|
||||
function buildScriptedChat() {
|
||||
return createChat<AgentUIMessage>()
|
||||
.user("Tighten my summary")
|
||||
.assistant(({ writer }) => {
|
||||
writer.text("I would like to apply this edit.", { mode: "instant" });
|
||||
writer.tool("apply_resume_patch", {
|
||||
toolCallId: "call-1",
|
||||
approvalId: "approval-1",
|
||||
needsApproval: true,
|
||||
input: {
|
||||
title: "Tighten summary",
|
||||
operations: [{ op: "replace", path: "/sections/summary/content", value: "Impact-driven engineer" }],
|
||||
},
|
||||
// With needsApproval, output means "stream this after approval"; denial streams
|
||||
// tool-output-denied automatically.
|
||||
output: {
|
||||
actionId: "action-1",
|
||||
resumeId: "resume-1",
|
||||
title: "Tighten summary",
|
||||
operations: [{ op: "replace", path: "/sections/summary/content", value: "Impact-driven engineer" }],
|
||||
appliedUpdatedAt: "2026-08-20T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
})
|
||||
.assistant(({ writer, toolCall }) => {
|
||||
writer.text(toolCall?.denied ? "Understood, I left the resume unchanged." : "Done — the edit is applied.", {
|
||||
mode: "instant",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type HarnessProps = {
|
||||
chat: ReturnType<typeof buildScriptedChat>;
|
||||
decision: { approved: boolean; reason?: string };
|
||||
};
|
||||
|
||||
// Minimal stand-in for AgentChat's wiring: same composed auto-send, same approval response call.
|
||||
// AgentChat itself is deliberately not refactored to accept a transport prop for tests.
|
||||
function ApprovalHarness({ chat, decision }: HarnessProps) {
|
||||
const { messages, sendMessage, addToolApprovalResponse } = useChat<AgentUIMessage>({
|
||||
transport: chat.transport({ delayMs: undefined }),
|
||||
sendAutomaticallyWhen: (options) =>
|
||||
lastAssistantMessageIsCompleteWithToolCalls(options) ||
|
||||
lastAssistantMessageIsCompleteWithApprovalResponses(options),
|
||||
});
|
||||
|
||||
const pendingApproval = messages
|
||||
.flatMap((message) => message.parts)
|
||||
.find((part) => "state" in part && part.state === "approval-requested") as
|
||||
| { approval?: { id: string } }
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => sendMessage({ text: "Tighten my summary" })}>
|
||||
send
|
||||
</button>
|
||||
{pendingApproval?.approval ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void addToolApprovalResponse({ id: pendingApproval.approval?.id ?? "", ...decision })}
|
||||
>
|
||||
respond
|
||||
</button>
|
||||
) : null}
|
||||
<output>
|
||||
{messages
|
||||
.flatMap((message) => message.parts)
|
||||
.map((part) => ("state" in part && typeof part.state === "string" ? part.state : part.type))
|
||||
.join(",")}
|
||||
</output>
|
||||
<pre>
|
||||
{messages.map((message) => message.parts.map((part) => ("text" in part ? part.text : "")).join(" ")).join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("agent approval flow (scripted transport)", () => {
|
||||
it("halts on approval-requested, then approves and streams the continuation with the tool output", async () => {
|
||||
render(<ApprovalHarness chat={buildScriptedChat()} decision={{ approved: true }} />);
|
||||
|
||||
screen.getByRole("button", { name: "send" }).click();
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("approval-requested"));
|
||||
|
||||
screen.getByRole("button", { name: "respond" }).click();
|
||||
|
||||
// Composed sendAutomaticallyWhen fires the continuation without a manual send.
|
||||
await waitFor(() => expect(screen.getByText(/Done — the edit is applied/)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("output-available"));
|
||||
});
|
||||
|
||||
it("denies: the call ends output-denied and the continuation reflects the denial", async () => {
|
||||
render(<ApprovalHarness chat={buildScriptedChat()} decision={{ approved: false, reason: "Wrong section" }} />);
|
||||
|
||||
screen.getByRole("button", { name: "send" }).click();
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("approval-requested"));
|
||||
|
||||
screen.getByRole("button", { name: "respond" }).click();
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/left the resume unchanged/)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("output-denied"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user