feat: add guarded savings agent tools

This commit is contained in:
Matthias
2026-06-23 21:13:02 +02:00
parent 6e687dc9b5
commit b7dae31fe1
22 changed files with 3510 additions and 73 deletions

View File

@@ -25,12 +25,19 @@ vi.mock("ai", async (importOriginal) => {
compare_periods: { execute: (input: unknown) => Promise<unknown> };
forecast_fixed_costs: { execute: (input: unknown) => Promise<unknown> };
explain_savings_rate: { execute: (input: unknown) => Promise<unknown> };
inspect_category_health: { execute: (input: unknown) => Promise<unknown> };
suggest_category_for_transactions: { execute: (input: unknown) => Promise<unknown> };
preview_month_end_spending: { execute: (input: unknown) => Promise<unknown> };
propose_bulk_recategory: { execute: (input: unknown) => Promise<unknown> };
propose_category_changes: { execute: (input: unknown) => Promise<unknown> };
};
}) => {
const transactionInput = { from: "2026-02-01", to: "2026-02-28", limit: 2 };
const summaryInput = { from: "2026-02-01", to: "2026-02-28" };
const previewInput = { today: "2026-02-20" };
const transactionOutput = await options.tools.get_transactions.execute(transactionInput);
const summaryOutput = await options.tools.summarize_spending.execute(summaryInput);
const previewOutput = await options.tools.preview_month_end_spending.execute(previewInput);
return {
text: "Agenten-Antwort",
@@ -47,6 +54,11 @@ vi.mock("ai", async (importOriginal) => {
input: summaryInput,
output: summaryOutput,
},
{
toolName: "preview_month_end_spending",
input: previewInput,
output: previewOutput,
},
],
},
],
@@ -67,6 +79,16 @@ const historyApi = {
};
const sendMessageAction = makeFunctionReference<"action">("savingsChat:sendMessage");
const previewMonthEndSpendingTool = makeFunctionReference<"query">("savingsChat:previewMonthEndSpendingTool");
const inspectCategoryHealthTool = makeFunctionReference<"query">("savingsChat:inspectCategoryHealthTool");
const suggestCategoryForTransactionsTool = makeFunctionReference<"query">("savingsChat:suggestCategoryForTransactionsTool");
const proposeBulkRecategoryTool = makeFunctionReference<"mutation">("savingsChat:proposeBulkRecategoryTool");
const proposeCategoryChangesTool = makeFunctionReference<"mutation">("savingsChat:proposeCategoryChangesTool");
const actionPlanApi = {
listPendingActionPlans: makeFunctionReference<"query">("savingsChatActionPlans:listPendingActionPlans"),
applyActionPlan: makeFunctionReference<"mutation">("savingsChatActionPlans:applyActionPlan"),
dismissActionPlan: makeFunctionReference<"mutation">("savingsChatActionPlans:dismissActionPlan"),
};
const paginationOpts = { cursor: null, numItems: 20 };
@@ -454,10 +476,11 @@ describe("savingsChat.sendMessage", () => {
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
today: "2026-02-20",
});
expect(result.answer).toBe("Agenten-Antwort [1] [2]");
expect(result.toolTrace).toHaveLength(2);
expect(result.answer).toBe("Agenten-Antwort [1] [2] [3]");
expect(result.toolTrace).toHaveLength(3);
expect(result.sources).toEqual([
{
id: "tool-1",
@@ -469,10 +492,16 @@ describe("savingsChat.sendMessage", () => {
title: "summarize_spending",
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
id: "tool-3",
title: "preview_month_end_spending",
description: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
]);
expect(result.citations).toEqual([
{ marker: "1", sourceId: "tool-1" },
{ marker: "2", sourceId: "tool-2" },
{ marker: "3", sourceId: "tool-3" },
]);
const generateCall = vi.mocked(generateText).mock.calls[0][0] as {
@@ -493,7 +522,7 @@ describe("savingsChat.sendMessage", () => {
{ role: "user", content: "Wie sieht Februar aus?" },
{
role: "assistant",
content: "Agenten-Antwort [1] [2]",
content: "Agenten-Antwort [1] [2] [3]",
toolTrace: [
{
name: "get_transactions",
@@ -505,6 +534,11 @@ describe("savingsChat.sendMessage", () => {
inputSummary: "2026-02-01 bis 2026-02-28",
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
name: "preview_month_end_spending",
inputSummary: "Monatsvorschau für 2026-02-20",
resultSummary: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
],
sources: [
{
@@ -517,10 +551,16 @@ describe("savingsChat.sendMessage", () => {
title: "summarize_spending",
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
id: "tool-3",
title: "preview_month_end_spending",
description: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
],
citations: [
{ marker: "1", sourceId: "tool-1" },
{ marker: "2", sourceId: "tool-2" },
{ marker: "3", sourceId: "tool-3" },
],
},
]);
@@ -567,6 +607,7 @@ describe("savingsChat.sendMessage", () => {
from: "2026-02-01",
to: "2026-02-28",
basis: "effective",
today: "2026-02-20",
}),
).rejects.toThrow("KI-Anfrage fehlgeschlagen");
@@ -1428,9 +1469,10 @@ describe("savingsChat read-only agent tools", () => {
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
today: "2026-02-20",
});
expect(result.answer).toBe("Agenten-Antwort [1] [2]");
expect(result.answer).toBe("Agenten-Antwort [1] [2] [3]");
expect(result.model).toBe("gpt-5.4-mini");
expect(result.usedTransactions).toBe(2);
expect(result.usedBalance).toEqual({ income: 3000, expenses: -120, balance: 2880 });
@@ -1445,6 +1487,11 @@ describe("savingsChat read-only agent tools", () => {
inputSummary: "2026-02-01 bis 2026-02-28",
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
name: "preview_month_end_spending",
inputSummary: "Monatsvorschau für 2026-02-20",
resultSummary: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
]);
expect(result.sources).toEqual([
{
@@ -1457,10 +1504,16 @@ describe("savingsChat read-only agent tools", () => {
title: "summarize_spending",
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
id: "tool-3",
title: "preview_month_end_spending",
description: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
]);
expect(result.citations).toEqual([
{ marker: "1", sourceId: "tool-1" },
{ marker: "2", sourceId: "tool-2" },
{ marker: "3", sourceId: "tool-3" },
]);
expect(JSON.stringify(result.toolTrace)).not.toContain("RAW PAYLOAD");
expect(JSON.stringify(result.toolTrace)).not.toContain("private note");
@@ -1479,6 +1532,11 @@ describe("savingsChat read-only agent tools", () => {
compare_periods: expect.any(Object),
forecast_fixed_costs: expect.any(Object),
explain_savings_rate: expect.any(Object),
inspect_category_health: expect.any(Object),
suggest_category_for_transactions: expect.any(Object),
preview_month_end_spending: expect.any(Object),
propose_bulk_recategory: expect.any(Object),
propose_category_changes: expect.any(Object),
}),
stopWhen: expect.any(Function),
}),
@@ -1644,6 +1702,665 @@ describe("savingsChat read-only agent tools", () => {
expect(JSON.stringify(result)).not.toContain("PRIVATE NOTE SHOULD NOT LEAK");
});
test("previewMonthEndSpendingTool forecasts remaining current-month expenses for the selected account", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Preview User",
email: "preview@example.com",
});
const giroAccountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const otherAccountId = await ctx.db.insert("accounts", {
userId,
name: "Tagesgeld",
type: "savings",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const rentId = await ctx.db.insert("categories", {
userId,
name: "Miete",
kind: "ausgabe",
block: "wiederkehrend",
color: "#64748b",
sortOrder: 1,
isSystem: false,
});
const groceriesId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 2,
isSystem: false,
});
const salaryId = await ctx.db.insert("categories", {
userId,
name: "Gehalt",
kind: "einnahme",
color: "#0ea5e9",
sortOrder: 3,
isSystem: false,
});
for (const tx of [
{ date: "2026-04-28", description: "Miete April", amount: -1000, categoryId: rentId },
{ date: "2026-05-28", description: "Miete Mai", amount: -1000, categoryId: rentId },
{ date: "2026-04-05", description: "Supermarkt April", amount: -120, categoryId: groceriesId },
{ date: "2026-05-05", description: "Supermarkt Mai", amount: -150, categoryId: groceriesId },
{ date: "2026-06-01", description: "Gehalt Juni", amount: 3000, categoryId: salaryId },
{ date: "2026-06-05", description: "Supermarkt Juni", amount: -100, categoryId: groceriesId },
{ date: "2026-06-10", description: "Drogerie Juni", amount: -50, categoryId: groceriesId },
]) {
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
categoryId: tx.categoryId,
bookingDate: tx.date,
valueDate: tx.date,
description: tx.description,
amount: tx.amount,
isPending: false,
effectiveMonth: tx.date.slice(0, 7),
});
}
await ctx.db.insert("transactions", {
userId,
accountId: otherAccountId,
bookingDate: "2026-06-07",
valueDate: "2026-06-07",
description: "Other account should not affect preview",
amount: -999,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, giroAccountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|preview`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(previewMonthEndSpendingTool, {
scope: {
from: "2026-01-01",
to: "2026-12-31",
accountId: seeded.giroAccountId as Id<"accounts">,
basis: "booking",
},
today: "2026-06-10",
});
expect(result).toMatchObject({
month: "2026-06",
today: "2026-06-10",
accountName: "Girokonto",
actualIncome: 3000,
actualExpenses: -150,
predictedRemainingFixed: -1000,
predictedRemainingVariable: -300,
predictedRemainingExpenses: -1300,
expectedMonthExpenses: -1450,
projectedMonthEndBalance: 1550,
confidence: "medium",
});
expect(result.drivers.map((driver: { label: string }) => driver.label)).toEqual([
"Noch erwartete Fixkosten: Miete",
"Variable Ausgaben auf Basis des laufenden Monats",
]);
});
test("inspectCategoryHealthTool reports uncategorized usage, unused categories, and similar names", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Category Health User",
email: "category-health@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const groceriesId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 1,
isSystem: false,
});
await ctx.db.insert("categories", {
userId,
name: "Lebensmittel & Supermarkt",
kind: "ausgabe",
block: "variabel",
color: "#16a34a",
sortOrder: 2,
isSystem: false,
});
await ctx.db.insert("categories", {
userId,
name: "Ungenutzt",
kind: "ausgabe",
block: "variabel",
color: "#f97316",
sortOrder: 3,
isSystem: false,
});
await ctx.db.insert("transactions", {
userId,
accountId,
categoryId: groceriesId,
bookingDate: "2026-06-01",
description: "Supermarkt",
amount: -40,
isPending: false,
effectiveMonth: "2026-06",
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-02",
description: "Mystery Shop",
counterparty: "Mystery GmbH",
amount: -30,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|health`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(inspectCategoryHealthTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
});
expect(result.totalCategories).toBe(3);
expect(result.uncategorizedCount).toBe(1);
expect(result.uncategorizedAmount).toBe(-30);
expect(result.topUncategorizedCounterparties).toEqual([
{ name: "Mystery GmbH", count: 1, amount: -30 },
]);
expect(result.unusedCategories.map((category: { name: string }) => category.name)).toContain("Ungenutzt");
expect(result.similarCategories).toEqual([
{ names: ["Lebensmittel", "Lebensmittel & Supermarkt"], reason: "Ähnliche Kategoriebegriffe" },
]);
});
test("suggestCategoryForTransactionsTool proposes categories without leaking raw private fields", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Suggestion User",
email: "suggestion@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const subscriptionsId = await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "wiederkehrend",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
await ctx.db.insert("transactions", {
userId,
accountId,
categoryId: subscriptionsId,
bookingDate: "2026-05-03",
description: "Netflix",
counterparty: "Netflix",
amount: -15,
isPending: false,
effectiveMonth: "2026-05",
rawText: "RAW SHOULD NOT LEAK",
notes: "PRIVATE NOTE SHOULD NOT LEAK",
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-03",
description: "Netflix",
counterparty: "Netflix",
amount: -16,
isPending: false,
effectiveMonth: "2026-06",
rawText: "RAW SHOULD NOT LEAK",
notes: "PRIVATE NOTE SHOULD NOT LEAK",
});
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|suggestion`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(suggestCategoryForTransactionsTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
search: "Netflix",
onlyUncategorized: true,
limit: 10,
});
expect(result.suggestions).toEqual([
{
categoryName: "Abos",
confidence: "high",
matchCount: 1,
amount: -16,
examples: [
{
date: "2026-06-03",
description: "Netflix",
counterparty: "Netflix",
amount: -16,
accountName: "Girokonto",
},
],
reason: "Ähnliche frühere Umsätze waren bereits dieser Kategorie zugeordnet.",
},
]);
expect(JSON.stringify(result)).not.toContain("RAW SHOULD NOT LEAK");
expect(JSON.stringify(result)).not.toContain("PRIVATE NOTE SHOULD NOT LEAK");
});
test("proposeBulkRecategoryTool creates a pending plan and applyActionPlan revalidates before changing transactions", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Plan User",
email: "plan@example.com",
});
const otherUserId = await ctx.db.insert("users", {
name: "Other Plan User",
email: "other-plan@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const categoryId = await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "wiederkehrend",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
const txId = await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-03",
description: "Netflix",
counterparty: "Netflix",
amount: -16,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, otherUserId, accountId, categoryId, txId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|plan`,
tokenIdentifier: `test:${seeded.userId}`,
});
const asOtherUser = t.withIdentity({
subject: `${seeded.otherUserId}|plan`,
tokenIdentifier: `test:${seeded.otherUserId}`,
});
const session = await asUser.mutation(historyApi.createSession, { title: "Plan Chat" });
const proposed = await asUser.mutation(proposeBulkRecategoryTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
sessionId: session.sessionId,
targetCategoryName: "Abos",
search: "Netflix",
onlyUncategorized: true,
limit: 10,
now: 1_800_000_000_000,
});
expect(proposed).toMatchObject({
kind: "bulk_recategory",
status: "pending",
affectedCount: 1,
summary: "1 Umsatz zur Kategorie Abos zuordnen",
});
let tx = await t.run(async (ctx) => await ctx.db.get(seeded.txId));
expect(tx?.categoryId).toBeUndefined();
const otherPlans = await asOtherUser.query(actionPlanApi.listPendingActionPlans, {
sessionId: session.sessionId,
});
expect(otherPlans).toEqual([]);
await expect(
asOtherUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_001,
}),
).rejects.toThrow("Nicht autorisiert");
const pending = await asUser.query(actionPlanApi.listPendingActionPlans, {
sessionId: session.sessionId,
});
expect(pending).toHaveLength(1);
expect(pending[0]).toMatchObject({
_id: proposed.planId,
kind: "bulk_recategory",
affectedCount: 1,
previewRows: [
{
description: "Netflix",
amount: -16,
currentCategoryName: "Ohne Kategorie",
targetCategoryName: "Abos",
},
],
});
const applied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_002,
});
expect(applied).toEqual({
appliedCount: 1,
skippedCount: 0,
status: "applied",
summary: "1 Änderung angewendet, 0 übersprungen",
});
tx = await t.run(async (ctx) => await ctx.db.get(seeded.txId));
expect(tx?.categoryId).toBe(seeded.categoryId);
});
test("proposeCategoryChangesTool applies category creates and updates but does not support deletes", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Category Plan User",
email: "category-plan@example.com",
});
const subscriptionId = await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "variabel",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
return { userId, subscriptionId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|category-plan`,
tokenIdentifier: `test:${seeded.userId}`,
});
const session = await asUser.mutation(historyApi.createSession, { title: "Category Plan Chat" });
const proposed = await asUser.mutation(proposeCategoryChangesTool, {
sessionId: session.sessionId,
creates: [
{
name: "Versicherungen",
kind: "ausgabe",
block: "wiederkehrend",
color: "#0f766e",
sortOrder: 10,
},
],
updates: [
{
name: "Abos",
block: "wiederkehrend",
},
],
now: 1_800_000_000_000,
});
expect(proposed).toMatchObject({
kind: "category_changes",
status: "pending",
affectedCount: 2,
summary: "2 Kategorieänderungen vorbereiten",
});
const applied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_001,
});
expect(applied.appliedCount).toBe(2);
const categories = await asUser.query(api.categories.list, {});
expect(categories.find((category) => category.name === "Versicherungen")).toMatchObject({
kind: "ausgabe",
block: "wiederkehrend",
isSystem: false,
});
expect(categories.find((category) => category.name === "Abos")).toMatchObject({
block: "wiederkehrend",
});
});
test("category action plans skip empty names and duplicate renames", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Guarded Category User",
email: "guarded-category@example.com",
});
await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "wiederkehrend",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
await ctx.db.insert("categories", {
userId,
name: "Versicherungen",
kind: "ausgabe",
block: "wiederkehrend",
color: "#0f766e",
sortOrder: 2,
isSystem: false,
});
return { userId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|guarded-category`,
tokenIdentifier: `test:${seeded.userId}`,
});
const session = await asUser.mutation(historyApi.createSession, { title: "Guarded Category Chat" });
await expect(
asUser.mutation(proposeCategoryChangesTool, {
sessionId: session.sessionId,
creates: [
{
name: " ",
kind: "ausgabe",
block: "variabel",
color: "#f97316",
sortOrder: 3,
},
],
updates: [{ name: "Abos", newName: " " }],
now: 1_800_000_000_000,
}),
).rejects.toThrow("Keine Kategorieänderungen vorbereitet");
const proposed = await asUser.mutation(proposeCategoryChangesTool, {
sessionId: session.sessionId,
updates: [{ name: "Abos", newName: "Versicherungen" }],
now: 1_800_000_000_000,
});
const applied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_001,
});
expect(applied).toEqual({
appliedCount: 0,
skippedCount: 1,
status: "applied",
summary: "0 Änderungen angewendet, 1 übersprungen",
});
const categories = await asUser.query(api.categories.list, {});
expect(categories.map((category) => category.name).sort()).toEqual(["Abos", "Versicherungen"]);
});
test("applyActionPlan skips stale category assignments and expires old plans", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Stale Plan User",
email: "stale-plan@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const subscriptionsId = await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "wiederkehrend",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
const groceriesId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 2,
isSystem: false,
});
const txId = await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-03",
description: "Netflix",
counterparty: "Netflix",
amount: -16,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, accountId, subscriptionsId, groceriesId, txId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|stale-plan`,
tokenIdentifier: `test:${seeded.userId}`,
});
const session = await asUser.mutation(historyApi.createSession, { title: "Stale Plan Chat" });
const proposed = await asUser.mutation(proposeBulkRecategoryTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
sessionId: session.sessionId,
targetCategoryName: "Abos",
search: "Netflix",
onlyUncategorized: true,
limit: 10,
now: 1_800_000_000_000,
});
await t.run(async (ctx) => {
await ctx.db.patch(seeded.txId, { categoryId: seeded.groceriesId });
});
const staleApplied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_001,
});
expect(staleApplied).toEqual({
appliedCount: 0,
skippedCount: 1,
status: "applied",
summary: "0 Änderungen angewendet, 1 übersprungen",
});
const tx = await t.run(async (ctx) => await ctx.db.get(seeded.txId));
expect(tx?.categoryId).toBe(seeded.groceriesId);
const expired = await asUser.mutation(proposeBulkRecategoryTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
sessionId: session.sessionId,
targetCategoryName: "Abos",
search: "Netflix",
limit: 10,
now: 1_800_000_000_000,
});
const expiredApplied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: expired.planId,
now: 1_800_086_400_001,
});
expect(expiredApplied).toEqual({
appliedCount: 0,
skippedCount: 1,
status: "expired",
summary: "Vorschlag ist abgelaufen",
});
});
test("comparePeriodsTool computes totals and category deltas", async () => {
const { asUser, seeded } = await seedSavingsInsightFixture();