Files
Reactive-Resume/apps/web/src/routes/agent/-components/agent-approval-flow.test.tsx
T
Amruth Pillai c8081ac2fe 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
2026-08-20 08:06:53 +02:00

116 lines
4.6 KiB
TypeScript

// @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"));
});
});