feat: add read-only savings agent tools
This commit is contained in:
@@ -1,14 +1,58 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { convexTest } from "convex-test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { generateText } from "ai";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { buildToolTraceFromSteps } from "./savingsChat";
|
||||
import schema from "./schema";
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>();
|
||||
return {
|
||||
...actual,
|
||||
generateText: vi.fn(async (options: {
|
||||
tools: {
|
||||
get_transactions: { execute: (input: unknown) => Promise<unknown> };
|
||||
summarize_spending: { 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 transactionOutput = await options.tools.get_transactions.execute(transactionInput);
|
||||
const summaryOutput = await options.tools.summarize_spending.execute(summaryInput);
|
||||
|
||||
return {
|
||||
text: "Agenten-Antwort",
|
||||
steps: [
|
||||
{
|
||||
toolResults: [
|
||||
{
|
||||
toolName: "get_transactions",
|
||||
input: transactionInput,
|
||||
output: transactionOutput,
|
||||
},
|
||||
{
|
||||
toolName: "summarize_spending",
|
||||
input: summaryInput,
|
||||
output: summaryOutput,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
delete modules["./savingsChat.test.ts"];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("savingsChat.getContext", () => {
|
||||
test("counts and sums every matching transaction before applying prompt limits", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
@@ -192,3 +236,479 @@ describe("savingsChat.getContext", () => {
|
||||
expect(context.transactionLines.join("\n")).not.toContain("Other account should not appear");
|
||||
});
|
||||
});
|
||||
|
||||
describe("savingsChat read-only agent tools", () => {
|
||||
test("getTransactionsTool applies account scope, exact totals, limits, and sanitizes rows", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Tool User",
|
||||
email: "tool@example.com",
|
||||
});
|
||||
const categoryId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Lebensmittel",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#22c55e",
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
});
|
||||
const giroAccountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const savingsAccountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Tagesgeld",
|
||||
type: "savings",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
categoryId,
|
||||
bookingDate: "2026-01-05",
|
||||
valueDate: "2026-01-05",
|
||||
description: "Supermarkt",
|
||||
counterparty: "Markt GmbH",
|
||||
amount: -100,
|
||||
isPending: false,
|
||||
rawText: "RAW BANK PAYLOAD",
|
||||
notes: "private note",
|
||||
dedupHash: "secret-hash",
|
||||
externalRef: "external-ref",
|
||||
effectiveMonth: "2026-01",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
bookingDate: "2026-01-07",
|
||||
valueDate: "2026-01-07",
|
||||
description: "Baecker",
|
||||
amount: -15,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-01",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
bookingDate: "2026-01-03",
|
||||
valueDate: "2026-01-03",
|
||||
description: "Kaffee",
|
||||
amount: -5,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-01",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: savingsAccountId,
|
||||
bookingDate: "2026-01-08",
|
||||
valueDate: "2026-01-08",
|
||||
description: "Other account",
|
||||
amount: 999,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-01",
|
||||
});
|
||||
|
||||
return { userId, giroAccountId };
|
||||
});
|
||||
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const result = await asUser.query(internal.savingsChat.getTransactionsTool, {
|
||||
scope: {
|
||||
from: "2026-01-01",
|
||||
to: "2026-01-31",
|
||||
accountId: seeded.giroAccountId as Id<"accounts">,
|
||||
basis: "booking",
|
||||
},
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
expect(result.totalCount).toBe(3);
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.totals).toEqual({
|
||||
transactionCount: 3,
|
||||
income: 0,
|
||||
expenses: -120,
|
||||
balance: -120,
|
||||
});
|
||||
expect(result.rows[0].accountName).toBe("Girokonto");
|
||||
expect(result.rows.find((row) => row.description === "Supermarkt")?.categoryName).toBe(
|
||||
"Lebensmittel",
|
||||
);
|
||||
expect(Object.keys(result.rows[0])).not.toContain("rawText");
|
||||
expect(Object.keys(result.rows[0])).not.toContain("notes");
|
||||
expect(Object.keys(result.rows[0])).not.toContain("dedupHash");
|
||||
expect(Object.keys(result.rows[0])).not.toContain("externalRef");
|
||||
expect(Object.keys(result.rows[0])).not.toContain("userId");
|
||||
});
|
||||
|
||||
test("getTransactionsTool includes legacy effective-month rows via booking fallback", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Legacy User",
|
||||
email: "legacy@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-03-12",
|
||||
valueDate: "2026-03-12",
|
||||
description: "Legacy no effective month",
|
||||
amount: -30,
|
||||
isPending: false,
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-03-20",
|
||||
valueDate: "2026-03-20",
|
||||
description: "Modern effective month",
|
||||
amount: -20,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-03",
|
||||
});
|
||||
return { userId, accountId };
|
||||
});
|
||||
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const result = await asUser.query(internal.savingsChat.getTransactionsTool, {
|
||||
scope: {
|
||||
from: "2026-03-01",
|
||||
to: "2026-03-31",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
},
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.totalCount).toBe(2);
|
||||
expect(result.totals.expenses).toBe(-50);
|
||||
expect(result.rows.map((row) => row.description)).toContain("Legacy no effective month");
|
||||
});
|
||||
|
||||
test("summarizeSpendingTool computes exact monthly and category aggregates", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Summary User",
|
||||
email: "summary@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
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 foodId = 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-01-01", description: "Gehalt Januar", amount: 3000, categoryId: salaryId },
|
||||
{ date: "2026-01-02", description: "Miete Januar", amount: -1000, categoryId: rentId },
|
||||
{ date: "2026-01-10", description: "Supermarkt Januar", amount: -200, categoryId: foodId },
|
||||
{ date: "2026-02-01", description: "Gehalt Februar", amount: 3000, categoryId: salaryId },
|
||||
{ date: "2026-02-02", description: "Sonstiges Februar", amount: -50, categoryId: undefined },
|
||||
]) {
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
categoryId: tx.categoryId,
|
||||
bookingDate: tx.date,
|
||||
valueDate: tx.date,
|
||||
description: tx.description,
|
||||
amount: tx.amount,
|
||||
isPending: false,
|
||||
effectiveMonth: tx.date.slice(0, 7),
|
||||
});
|
||||
}
|
||||
|
||||
return { userId, accountId };
|
||||
});
|
||||
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const result = await asUser.query(internal.savingsChat.summarizeSpendingTool, {
|
||||
scope: {
|
||||
from: "2026-01-01",
|
||||
to: "2026-02-28",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.totals).toEqual({
|
||||
transactionCount: 5,
|
||||
income: 6000,
|
||||
expenses: -1250,
|
||||
balance: 4750,
|
||||
});
|
||||
expect(result.fixedCosts).toBe(-1000);
|
||||
expect(result.variableCosts).toBe(-200);
|
||||
expect(result.monthlyTrend).toEqual([
|
||||
{ month: "2026-01", income: 3000, expenses: -1200, balance: 1800 },
|
||||
{ month: "2026-02", income: 3000, expenses: -50, balance: 2950 },
|
||||
]);
|
||||
expect(result.categoryBreakdown.map((entry) => [entry.name, entry.amount])).toEqual([
|
||||
["Miete", -1000],
|
||||
["Lebensmittel", -200],
|
||||
["Ohne Kategorie", -50],
|
||||
]);
|
||||
});
|
||||
|
||||
test("forecastCashflowTool excludes partial current month from the baseline", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Forecast User",
|
||||
email: "forecast@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
|
||||
for (const tx of [
|
||||
{ date: "2026-04-01", description: "Gehalt April", amount: 3000 },
|
||||
{ date: "2026-04-10", description: "Kosten April", amount: -2000 },
|
||||
{ date: "2026-05-01", description: "Gehalt Mai", amount: 3200 },
|
||||
{ date: "2026-05-10", description: "Kosten Mai", amount: -2200 },
|
||||
{ date: "2026-06-01", description: "Gehalt Juni", amount: 3000 },
|
||||
{ date: "2026-06-10", description: "Teilkosten Juni", amount: -500 },
|
||||
]) {
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: tx.date,
|
||||
valueDate: tx.date,
|
||||
description: tx.description,
|
||||
amount: tx.amount,
|
||||
isPending: false,
|
||||
effectiveMonth: tx.date.slice(0, 7),
|
||||
});
|
||||
}
|
||||
|
||||
return { userId, accountId };
|
||||
});
|
||||
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const result = await asUser.query(internal.savingsChat.forecastCashflowTool, {
|
||||
scope: {
|
||||
from: "2026-04-01",
|
||||
to: "2026-06-30",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
},
|
||||
horizonMonths: 3,
|
||||
asOf: "2026-06-15",
|
||||
});
|
||||
|
||||
expect(result.baselineMonths).toEqual(["2026-04", "2026-05"]);
|
||||
expect(result.excludedPartialMonth).toBe("2026-06");
|
||||
expect(result.monthlyAverage).toEqual({ income: 3100, expenses: -2100, balance: 1000 });
|
||||
expect(result.projection).toEqual([
|
||||
{ month: "2026-07", income: 3100, expenses: -2100, balance: 1000 },
|
||||
{ month: "2026-08", income: 3100, expenses: -2100, balance: 1000 },
|
||||
{ month: "2026-09", income: 3100, expenses: -2100, balance: 1000 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildToolTraceFromSteps returns compact summaries without raw tool output", () => {
|
||||
const trace = buildToolTraceFromSteps([
|
||||
{
|
||||
toolResults: [
|
||||
{
|
||||
toolName: "get_transactions",
|
||||
input: { from: "2026-01-01", to: "2026-01-31", limit: 50 },
|
||||
output: {
|
||||
totalCount: 2,
|
||||
hasMore: false,
|
||||
totals: { income: 0, expenses: -115, balance: -115, transactionCount: 2 },
|
||||
rows: [
|
||||
{ description: "Supermarkt", amount: -100 },
|
||||
{ description: "Baecker", amount: -15 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(trace).toEqual([
|
||||
{
|
||||
name: "get_transactions",
|
||||
inputSummary: "2026-01-01 bis 2026-01-31, Limit 50",
|
||||
resultSummary: "2 Umsätze, Saldo -115.00€, vollständig",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("ask returns compact tool traces from executed read-only tools", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const previousKey = process.env.OPENAI_API_KEY;
|
||||
const previousModel = process.env.SAVINGS_CHAT_MODEL;
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Ask User",
|
||||
email: "ask@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-02-01",
|
||||
valueDate: "2026-02-01",
|
||||
description: "Gehalt",
|
||||
amount: 3000,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-02",
|
||||
rawText: "RAW PAYLOAD SHOULD NOT LEAK",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-02-10",
|
||||
valueDate: "2026-02-10",
|
||||
description: "Supermarkt",
|
||||
amount: -120,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-02",
|
||||
notes: "private note should not leak",
|
||||
});
|
||||
return { userId, accountId };
|
||||
});
|
||||
|
||||
try {
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
delete process.env.SAVINGS_CHAT_MODEL;
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const result = await asUser.action(api.savingsChat.ask, {
|
||||
messages: [{ role: "user", content: "Wie sieht Februar aus?" }],
|
||||
from: "2026-02-01",
|
||||
to: "2026-02-28",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
});
|
||||
|
||||
expect(result.answer).toBe("Agenten-Antwort");
|
||||
expect(result.model).toBe("gpt-5.4-mini");
|
||||
expect(result.usedTransactions).toBe(2);
|
||||
expect(result.usedBalance).toEqual({ income: 3000, expenses: -120, balance: 2880 });
|
||||
expect(result.toolTrace).toEqual([
|
||||
{
|
||||
name: "get_transactions",
|
||||
inputSummary: "2026-02-01 bis 2026-02-28, Limit 2",
|
||||
resultSummary: "2 Umsätze, Saldo 2880.00€, vollständig",
|
||||
},
|
||||
{
|
||||
name: "summarize_spending",
|
||||
inputSummary: "2026-02-01 bis 2026-02-28",
|
||||
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(result.toolTrace)).not.toContain("RAW PAYLOAD");
|
||||
expect(JSON.stringify(result.toolTrace)).not.toContain("private note");
|
||||
|
||||
expect(generateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
get_transactions: expect.any(Object),
|
||||
summarize_spending: expect.any(Object),
|
||||
forecast_cashflow: expect.any(Object),
|
||||
}),
|
||||
stopWhen: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (previousKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousKey;
|
||||
}
|
||||
if (previousModel === undefined) {
|
||||
delete process.env.SAVINGS_CHAT_MODEL;
|
||||
} else {
|
||||
process.env.SAVINGS_CHAT_MODEL = previousModel;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user