mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 15:22: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:
@@ -6,6 +6,7 @@
|
||||
"exports": {
|
||||
"./types": "./src/types.ts",
|
||||
"./prompts": "./src/prompts.ts",
|
||||
"./tools/agent-tool-contracts": "./src/tools/agent-tool-contracts.ts",
|
||||
"./tools/resume-tool-contracts": "./src/tools/resume-tool-contracts.ts",
|
||||
"./tools/patch-proposal": "./src/tools/patch-proposal.ts",
|
||||
"./resume/extraction-template": "./src/resume/extraction-template.ts",
|
||||
@@ -28,6 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"ai": "^7.0.66",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
agentMessageMetadataSchema,
|
||||
applyResumePatchInputSchema,
|
||||
applyResumePatchOutputSchema,
|
||||
askUserQuestionInputSchema,
|
||||
} from "./agent-tool-contracts";
|
||||
|
||||
describe("askUserQuestionInputSchema", () => {
|
||||
it("accepts a question with up to four choices", () => {
|
||||
expect(
|
||||
askUserQuestionInputSchema.safeParse({ question: "Which tone?", choices: ["Formal", "Casual"] }).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty question and too many choices", () => {
|
||||
expect(askUserQuestionInputSchema.safeParse({ question: " " }).success).toBe(false);
|
||||
expect(askUserQuestionInputSchema.safeParse({ question: "?", choices: ["a", "b", "c", "d", "e"] }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyResumePatchInputSchema", () => {
|
||||
it("requires a title and at least one operation", () => {
|
||||
expect(
|
||||
applyResumePatchInputSchema.safeParse({
|
||||
title: "Edit",
|
||||
operations: [{ op: "replace", path: "/basics/name", value: "Bob" }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(applyResumePatchInputSchema.safeParse({ title: "Edit", operations: [] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a strict ISO baseUpdatedAt and rejects malformed values", () => {
|
||||
const operations = [{ op: "replace", path: "/basics/name", value: "Bob" }];
|
||||
|
||||
expect(
|
||||
applyResumePatchInputSchema.safeParse({
|
||||
title: "Edit",
|
||||
baseUpdatedAt: "2026-08-20T10:15:00.000Z",
|
||||
operations,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
applyResumePatchInputSchema.safeParse({ title: "Edit", baseUpdatedAt: "yesterday", operations }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyResumePatchOutputSchema", () => {
|
||||
const base = {
|
||||
actionId: "action-1",
|
||||
resumeId: "resume-1",
|
||||
title: "Edit",
|
||||
summary: null,
|
||||
operations: [{ op: "remove" as const, path: "/sections/experience/items/0" }],
|
||||
appliedUpdatedAt: "2026-08-20T00:00:00.000Z",
|
||||
};
|
||||
|
||||
it("accepts legacy outputs without changedPaths or resume", () => {
|
||||
expect(applyResumePatchOutputSchema.safeParse(base).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts fresh outputs carrying the post-patch document", () => {
|
||||
expect(
|
||||
applyResumePatchOutputSchema.safeParse({ ...base, changedPaths: ["/basics/name"], resume: { basics: {} } })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentMessageMetadataSchema", () => {
|
||||
it("accepts missing metadata, empty metadata, and unknown extra fields", () => {
|
||||
expect(agentMessageMetadataSchema.safeParse(undefined).success).toBe(true);
|
||||
expect(agentMessageMetadataSchema.safeParse({}).success).toBe(true);
|
||||
expect(
|
||||
agentMessageMetadataSchema.safeParse({ model: "gpt-5", usage: { totalTokens: 12, custom: true }, extra: 1 })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// Shared typed contracts for the /agent workspace tools. Zod is the only runtime import — the
|
||||
// "ai" package is a devDependency used with `import type` only, so this file stays
|
||||
// runtime-universal (consumed by both the API tool definitions and the web chat UI).
|
||||
import type { UIDataTypes, UIMessage } from "ai";
|
||||
import z from "zod";
|
||||
import { jsonPatchOperationSchema } from "@reactive-resume/resume/patch";
|
||||
|
||||
export const askUserQuestionInputSchema = z.object({
|
||||
question: z.string().trim().min(1),
|
||||
choices: z.array(z.string().trim().min(1)).min(1).max(4).optional(),
|
||||
recommendedChoice: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export const applyResumePatchInputSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
summary: z.string().trim().optional(),
|
||||
// The `updatedAt` of the read_resume / apply_resume_patch result the operations were built
|
||||
// against. Execution rejects the patch when the resume has changed since, so index-based
|
||||
// operations can never silently target different items (e.g. after a user edit while an
|
||||
// approval was pending). Optional for weaker models; strict ISO when present, so a malformed
|
||||
// value is rejected at the schema (SDK re-asks) instead of silently skipping the check.
|
||||
baseUpdatedAt: z.iso.datetime().optional(),
|
||||
operations: z.array(jsonPatchOperationSchema).min(1),
|
||||
});
|
||||
|
||||
// Loose on purpose: legacy persisted outputs predate changedPaths/resume.
|
||||
export const applyResumePatchOutputSchema = z.looseObject({
|
||||
actionId: z.string(),
|
||||
resumeId: z.string(),
|
||||
title: z.string(),
|
||||
summary: z.string().nullish(),
|
||||
operations: z.array(jsonPatchOperationSchema),
|
||||
appliedUpdatedAt: z.string(),
|
||||
changedPaths: z.array(z.string()).optional(),
|
||||
resume: z.unknown().optional(),
|
||||
});
|
||||
|
||||
// All-optional and loose: legacy rows have no metadata and must keep rendering.
|
||||
// The usage shape mirrors the AI SDK's LanguageModelUsage (nested token details).
|
||||
export const agentMessageMetadataSchema = z
|
||||
.looseObject({
|
||||
model: z.string().optional(),
|
||||
usage: z
|
||||
.looseObject({
|
||||
inputTokens: z.number().optional(),
|
||||
outputTokens: z.number().optional(),
|
||||
totalTokens: z.number().optional(),
|
||||
inputTokenDetails: z
|
||||
.looseObject({
|
||||
noCacheTokens: z.number().optional(),
|
||||
cacheReadTokens: z.number().optional(),
|
||||
cacheWriteTokens: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
outputTokenDetails: z
|
||||
.looseObject({
|
||||
textTokens: z.number().optional(),
|
||||
reasoningTokens: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export type AskUserQuestionInput = z.infer<typeof askUserQuestionInputSchema>;
|
||||
export type ApplyResumePatchInput = z.infer<typeof applyResumePatchInputSchema>;
|
||||
export type ApplyResumePatchOutput = z.infer<typeof applyResumePatchOutputSchema>;
|
||||
export type AgentMessageMetadata = z.infer<typeof agentMessageMetadataSchema>;
|
||||
|
||||
export type AgentTools = {
|
||||
ask_user_question: { input: AskUserQuestionInput; output: string };
|
||||
read_resume: { input: Record<string, never>; output: unknown };
|
||||
read_attachment: { input: { attachmentId: string }; output: unknown };
|
||||
apply_resume_patch: { input: ApplyResumePatchInput; output: ApplyResumePatchOutput };
|
||||
// Provider-native web search (OpenAI Responses); input/output shapes are provider-owned.
|
||||
web_search: { input: unknown; output: unknown };
|
||||
};
|
||||
|
||||
export type AgentUIMessage = UIMessage<AgentMessageMetadata, UIDataTypes, AgentTools>;
|
||||
Reference in New Issue
Block a user