mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 23:02:17 +10:00
* 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
116 lines
4.6 KiB
TypeScript
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"));
|
|
});
|
|
});
|