Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src/components/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import React, { useState } from "react";
import type { MessageResponse } from "@/types/message";
import { ChevronDownIcon, ChevronRightIcon, CopyIcon, CheckIcon } from "lucide-react";
import { getToolName } from "@/services/messageUtils";
import { ToolResult } from "./toolRenderers";
import { ToolResult, renderersFor } from "./toolRenderers";
import { Chart } from "./charts/Chart";
import type { ChartPayload } from "./charts/types";

/** The artifact is `unknown` off the wire, so check the shape before drawing it. */
const isChartPayload = (v: unknown): v is ChartPayload => {
const p = v as ChartPayload | undefined;
return !!p && typeof p === "object" && !!p.spec && Array.isArray(p.rows);
};

interface ToolMessageProps {
message: MessageResponse;
Expand Down Expand Up @@ -46,6 +54,11 @@ export const ToolMessage = ({ message }: ToolMessageProps) => {
const toolName = getToolName(message);
const content = getContentAsString(message.data?.content);
const summary = summarize(content);
// A chart is the answer, not a detail of it, so it renders outside the collapsed disclosure —
// the receipt stays inside for inspection.
const artifact = (message.data as { artifact?: unknown })?.artifact;
const chart =
renderersFor(toolName).result === "chart" && isChartPayload(artifact) ? artifact : null;

const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
Expand All @@ -58,6 +71,22 @@ export const ToolMessage = ({ message }: ToolMessageProps) => {
}
};

if (chart) {
return (
<div className="space-y-2">
<Chart payload={chart} />
<details className="border-border bg-muted/30 rounded-lg border">
<summary className="text-muted-foreground cursor-pointer px-4 py-2 font-mono text-xs">
{toolName ?? "tool"} · result
</summary>
<div className="border-border border-t px-4 py-3.5">
<ToolResult toolName={toolName} content={content} />
</div>
</details>
</div>
);
}

return (
<div className="border-border bg-muted/30 rounded-lg border">
<button
Expand Down
10 changes: 2 additions & 8 deletions src/components/charts/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,8 @@ import { buildModel, ticks, yScale } from "./geometry";
import { seriesColor, type ChartPayload } from "./types";

/**
* A chart, drawn as plain SVG from a frozen payload.
*
* No charting library: the mark specs (capped bar width, rounded data-end, 2px surface gaps,
* surface rings, recessive hairline axes) are specific enough that wrapping a library to obey them
* is more code than drawing them, and a library brings its own styling system to fight the tokens.
*
* Colors come from --chart-N, which are CATEGORICAL (one hue per slot, fixed order). They are
* deliberately not amber: amber marks the approval boundary in this app and nothing else.
* Draws a ChartPayload as SVG. Series colors come from the categorical --chart-N tokens; never
* use amber here, which this app reserves for the approval boundary.
*/

const H = 200; // plot height
Expand Down
7 changes: 1 addition & 6 deletions src/components/charts/geometry.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import type { ChartPayload, ChartRow } from "./types";

/**
* Turning a payload into coordinates. Pure and DOM-free, so the interesting decisions — scale
* domains, series grouping, empty and single-point handling — are unit-testable without a browser.
*
* The component draws what these return and adds nothing of its own.
*/
/** Turns a payload into coordinates: series grouping, scale domains, ticks. Pure and DOM-free. */

export interface Series {
key: string;
Expand Down
22 changes: 7 additions & 15 deletions src/components/charts/types.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
/**
* The payload a chart renders from.
*
* A ZERO-IMPORT leaf: the client renderer and the server tool both name this shape, and the
* renderer must not drag anything server-side into the browser bundle.
*
* This is the FROZEN snapshot. `rows` are the numbers as they were when the chart was made — a
* chart in a thread is a record of what was said, so reopening a month later must not re-query and
* silently restate history with today's data.
* The payload a chart renders from — shared by the server tool that builds it and the client
* component that draws it. Keep this a ZERO-IMPORT leaf so the renderer pulls in nothing
* server-side.
*/

/** The two forms we render. See docs — pie and table are deliberate omissions, not gaps. */
export type ChartType = "bar" | "line";

export interface ChartSpec {
Expand All @@ -30,17 +23,16 @@ export type ChartRow = Record<string, unknown>;
export interface ChartPayload {
spec: ChartSpec;
columns: string[];
/** The rows as queried. Frozen: never re-fetched when a thread is reopened. */
rows: ChartRow[];
/** ISO timestamp — a chart is a snapshot, and a snapshot says when. */
generatedAt: string;
}

/** Categorical slots, in FIXED order. Never cycle, never reorder: the order is the CVD-safety
* mechanism (validated adjacent-pair separation), not a style choice. */
/** Number of categorical color slots. Their ORDER is validated for colorblind separation —
* re-run the dataviz palette validator before reordering or changing a value. */
export const SERIES_SLOTS = 8;

/** Resolve a series index to its CSS custom property. Past the 8th slot, callers fold to "Other"
* rather than inventing a hue. */
/** The CSS custom property for a series index, wrapping past the last slot. */
export function seriesColor(index: number): string {
return `var(--chart-${(index % SERIES_SLOTS) + 1})`;
}
4 changes: 3 additions & 1 deletion src/components/toolRenderers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/

export type ArgsRenderer = "sql" | "filters" | "file" | "expense" | "fields" | "csvPlan" | "json";
export type ResultRenderer = "table" | "receipt" | "json";
export type ResultRenderer = "table" | "receipt" | "chart" | "json";

export interface ToolRenderers {
/** null = the call has no arguments worth showing; render the name alone. */
Expand All @@ -36,6 +36,8 @@ export const TOOL_RENDERERS: Record<string, ToolRenderers> = {
// The result is a skill's markdown instructions — no purpose-built view yet, so it shows as
// JSON. Listed anyway because an unlisted registered tool is what config.test.ts guards against.
load_skill: { args: "fields", result: "json" },
// The rows arrive as an artifact, not in the result content — see ToolMessage.
render_chart: { args: "sql", result: "chart" },
};

const FALLBACK: ToolRenderers = { args: "json", result: "json" };
Expand Down
1 change: 1 addition & 0 deletions src/lib/agent/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe("listCapabilities", () => {
"analyticsTools",
"categoryTools",
"skillTools",
"chartTools",
];
for (const g of groups) {
expect(builtin, `${g} is not spread into builtin`).toContain(`...${g}`);
Expand Down
2 changes: 2 additions & 0 deletions src/lib/agent/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { financeTools } from "./tools/finance";
import { analyticsTools } from "./tools/analytics";
import { categoryTools } from "./tools/categories";
import { configTools } from "./tools/config";
import { chartTools } from "./tools/charts";
import { MUTATING_TOOL_NAMES as MUTATING_TOOL_NAMES_LOCAL } from "./mutatingTools";

/**
Expand Down Expand Up @@ -54,6 +55,7 @@ const GROUPS: { label: string; tools: { name: string; description: string }[] }[
{ label: "CSV import", tools: CSV_IMPORT_CAPABILITIES },
{ label: "Analysis", tools: analyticsTools },
{ label: "Settings", tools: configTools },
{ label: "Charts", tools: chartTools },
{ label: "Skills", tools: SKILL_CAPABILITIES },
];

Expand Down
2 changes: 2 additions & 0 deletions src/lib/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { csvImportTools } from "./tools/csvImport";
import { analyticsTools } from "./tools/analytics";
import { categoryTools } from "./tools/categories";
import { configTools as settingsTools } from "./tools/config";
import { chartTools } from "./tools/charts";
import { skillTools } from "./tools/skills";
import { listSkills } from "@/lib/skills/registry";
import { createAgent, humanInTheLoopMiddleware } from "langchain";
Expand Down Expand Up @@ -50,6 +51,7 @@ async function buildAgent(cfg?: AgentConfigOptions) {
...categoryTools,
...settingsTools,
...skillTools,
...chartTools,
];
const builtinTools = (provider === "google"
? builtin.map((t) => sanitizeTool(t as unknown as DynamicStructuredTool))
Expand Down
214 changes: 214 additions & 0 deletions src/lib/agent/tools/charts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { callToolWithArtifact } from "./testing";

/**
* Contract tests for render_chart — repository stubbed. See docs/TESTING.md.
*
* Success cases assert on BOTH halves: content and artifact are different payloads, so checking
* one leaves the other unverified.
*/

vi.mock("@/lib/repositories/analyticsRepository", () => ({ runReadOnlyQuery: vi.fn() }));

const repo = await import("@/lib/repositories/analyticsRepository");
const { renderChart, MAX_CHART_ROWS } = await import("./charts");

const ARGS = {
query: "SELECT c.name AS category, SUM(t.amount_minor) AS total FROM transaction t GROUP BY 1",
chartType: "bar",
x: "category",
y: "total",
};

function result(
rows: Record<string, unknown>[],
columns = ["category", "total"],
truncated = false,
) {
return { columns, rows, rowCount: rows.length, truncated };
}

const TWO_ROWS = [
{ category: "Dining", total: 28100 },
{ category: "Groceries", total: 42350 },
];

beforeEach(() => {
vi.resetAllMocks();
});

describe("render_chart", () => {
it("returns a receipt to the model and the rows to the client", async () => {
vi.mocked(repo.runReadOnlyQuery).mockResolvedValue(result(TWO_ROWS));

const { content, artifact } = await callToolWithArtifact(renderChart, ARGS);

expect(content).toMatchObject({ ok: true, chartType: "bar", points: 2, x: "category" });
expect(artifact.rows).toEqual(TWO_ROWS);
expect(artifact.spec).toMatchObject({ chartType: "bar", x: "category", y: "total" });
expect(artifact.columns).toEqual(["category", "total"]);
});

/**
* The whole reason this tool exists. If rows leaked into content the agent would pay for data it
* never reads, which is the cost the artifact split removes.
*/
it("keeps row data out of the model's payload entirely", async () => {
const many = Array.from({ length: 50 }, (_, i) => ({ category: `cat-${i}`, total: i * 100 }));
vi.mocked(repo.runReadOnlyQuery).mockResolvedValue(result(many));

const { content, artifact } = await callToolWithArtifact(renderChart, ARGS);

expect(JSON.stringify(content)).not.toContain("cat-49");
expect(JSON.stringify(content).length).toBeLessThan(150);
expect(artifact.rows).toHaveLength(50);
});

it("stamps the artifact with a generation time", async () => {
vi.mocked(repo.runReadOnlyQuery).mockResolvedValue(result(TWO_ROWS));

const { artifact } = await callToolWithArtifact(renderChart, ARGS);

expect(Number.isNaN(Date.parse(artifact.generatedAt))).toBe(false);
});

it("carries optional series and titles into the spec", async () => {
vi.mocked(repo.runReadOnlyQuery).mockResolvedValue(
result(
[{ month: "2026-07", total: 100, category: "Dining" }],
["month", "total", "category"],
),
);

const { artifact } = await callToolWithArtifact(renderChart, {
...ARGS,
chartType: "line",
x: "month",
series: "category",
title: "Spending over time",
subtitle: "Last 3 months",
});

expect(artifact.spec).toMatchObject({
chartType: "line",
series: "category",
title: "Spending over time",
subtitle: "Last 3 months",
});
});

it("omits absent optional fields rather than setting them undefined", async () => {
vi.mocked(repo.runReadOnlyQuery).mockResolvedValue(result(TWO_ROWS));

const { artifact } = await callToolWithArtifact(renderChart, ARGS);

expect("series" in artifact.spec).toBe(false);
expect("title" in artifact.spec).toBe(false);
});
});

describe("render_chart failures", () => {
/**
* The agent writes both the SQL and the encoding, so naming a column its own query didn't
* select is the likely slip. Undefined values would draw as empty bars — a chart that looks
* fine and shows nothing — so this must fail loudly and name the real columns.
*/
it.each([
["x", { x: "categorie" }],
["y", { y: "amount" }],
["series", { series: "bucket" }],
])("rejects a %s column the query does not return", async (_label, override) => {
vi.mocked(repo.runReadOnlyQuery).mockResolvedValue(result(TWO_ROWS));

const { content, artifact } = await callToolWithArtifact(renderChart, { ...ARGS, ...override });

expect(content.ok).toBe(false);
expect(content.availableColumns).toEqual(["category", "total"]);
expect(artifact).toBeNull();
});

it("names every missing column at once, not just the first", async () => {
vi.mocked(repo.runReadOnlyQuery).mockResolvedValue(result(TWO_ROWS));

const { content } = await callToolWithArtifact(renderChart, {
...ARGS,
x: "nope",
y: "alsonope",
});

expect(content.error).toContain("nope");
expect(content.error).toContain("alsonope");
});

/**
* A chart of a capped result looks complete — the reader cannot see the missing rows — so it
* misstates the total. Refuse rather than draw a lie. This is the same defect class as the
* truncation bug, handled differently because a chart has no room for a caveat.
*/
it("refuses to chart a truncated result", async () => {
vi.mocked(repo.runReadOnlyQuery).mockResolvedValue(
result(TWO_ROWS, ["category", "total"], true),
);

const { content, artifact } = await callToolWithArtifact(renderChart, ARGS);

expect(content.ok).toBe(false);
expect(content.error).toMatch(/partial|complete/i);
expect(artifact).toBeNull();
});

it("refuses more rows than a chart can show", async () => {
const many = Array.from({ length: MAX_CHART_ROWS + 1 }, (_, i) => ({
category: `cat-${i}`,
total: i,
}));
vi.mocked(repo.runReadOnlyQuery).mockResolvedValue(result(many));

const { content } = await callToolWithArtifact(renderChart, ARGS);

expect(content.ok).toBe(false);
expect(content.error).toContain(String(MAX_CHART_ROWS));
});

it("accepts exactly the maximum", async () => {
const many = Array.from({ length: MAX_CHART_ROWS }, (_, i) => ({
category: `cat-${i}`,
total: i,
}));
vi.mocked(repo.runReadOnlyQuery).mockResolvedValue(result(many));

const { content } = await callToolWithArtifact(renderChart, ARGS);

expect(content.ok).toBe(true);
});

/** An empty result is byte-identical whether the category exists or is misspelled. */
it("warns that an empty result may be a misspelling, not missing data", async () => {
vi.mocked(repo.runReadOnlyQuery).mockResolvedValue(result([]));

const { content, artifact } = await callToolWithArtifact(renderChart, ARGS);

expect(content.ok).toBe(false);
expect(content.error).toMatch(/spelling/i);
expect(artifact).toBeNull();
});

it("rejects a non-SELECT before touching the database", async () => {
const { content } = await callToolWithArtifact(renderChart, {
...ARGS,
query: "DELETE FROM transaction",
});

expect(content.ok).toBe(false);
expect(repo.runReadOnlyQuery).not.toHaveBeenCalled();
});

it("surfaces the database error so the agent can fix its query", async () => {
vi.mocked(repo.runReadOnlyQuery).mockRejectedValue(new Error('column "bogus" does not exist'));

const { content } = await callToolWithArtifact(renderChart, ARGS);

expect(content.ok).toBe(false);
expect(content.error).toContain("bogus");
});
});
Loading