feat(odin): Phase 2a read-only agentic assistant + tool-trace UI

- agentic chat loop (max 6 round-trips, budget re-checked between iterations)
  over 10 read-only, permission-delegated tools in src/services/ai-tools.ts
- persist assistant tool trace in ai_chat_messages.content_json (+ migration)
- consulted-tools chips in OdinThread; header now shows month spend only
  (budget cap hidden from the UI, server-side 402 guard unchanged)
- seed: ai.use permission; Settings exposes the ai permission module
- docs: REVIEW consolidation; deployment-guide.md superseded by docs/release.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 23:31:50 +02:00
parent 2dbacc3bec
commit 674b44d047
26 changed files with 13728 additions and 2935 deletions

View File

@@ -0,0 +1,205 @@
import { describe, it, expect, vi, afterAll } from "vitest";
import prisma from "../config/database";
import {
toolDefinitionsFor,
executeTool,
ctxCan,
TOOL_LABELS,
type AiAuthCtx,
} from "../services/ai-tools";
// The agentic loop talks to a true external (Anthropic API) — mock the SDK
// module; everything below it (tools, services, DB) runs for real.
const { createMock } = vi.hoisted(() => ({ createMock: vi.fn() }));
vi.mock("@anthropic-ai/sdk", () => ({
default: class MockAnthropic {
messages = { create: createMock };
},
}));
// Imported AFTER the mock so ai.service's `new Anthropic()` gets the mock.
import { agenticChat } from "../services/ai.service";
const ADMIN: AiAuthCtx = {
userId: 999999993,
roleName: "admin",
permissions: [],
};
const VIEWER_INVOICES: AiAuthCtx = {
userId: 999999994,
roleName: "viewer",
permissions: ["invoices.view"],
};
const NOBODY: AiAuthCtx = {
userId: 999999995,
roleName: "viewer",
permissions: [],
};
afterAll(async () => {
await prisma.ai_usage.deleteMany({
where: { user_id: { in: [ADMIN.userId] }, kind: "agent" },
});
});
describe("ai-tools — permission delegation", () => {
it("ctxCan: admin bypasses, others need the exact permission", () => {
expect(ctxCan(ADMIN, "invoices.view")).toBe(true);
expect(ctxCan(VIEWER_INVOICES, "invoices.view")).toBe(true);
expect(ctxCan(VIEWER_INVOICES, "orders.view")).toBe(false);
expect(ctxCan(NOBODY, "invoices.view")).toBe(false);
});
it("toolDefinitionsFor filters the tool list by the user's permissions", () => {
const adminTools = toolDefinitionsFor(ADMIN).map((t) => t.name);
// All registered tools (the labels map is the authoritative name list).
expect(adminTools.sort()).toEqual(Object.keys(TOOL_LABELS).sort());
const viewerTools = toolDefinitionsFor(VIEWER_INVOICES).map((t) => t.name);
expect(viewerTools).toContain("list_invoices");
expect(viewerTools).toContain("get_invoice_stats");
expect(viewerTools).not.toContain("list_orders");
expect(viewerTools).not.toContain("get_stock_overview");
expect(toolDefinitionsFor(NOBODY)).toHaveLength(0);
});
it("executeTool DENIES a tool the user lacks the permission for", async () => {
const res = await executeTool("list_orders", {}, VIEWER_INVOICES);
expect(res.ok).toBe(false);
expect(JSON.stringify(res.result)).toContain("oprávnění");
});
it("executeTool rejects an unknown (hallucinated) tool name", async () => {
const res = await executeTool("drop_all_tables", {}, ADMIN);
expect(res.ok).toBe(false);
expect(JSON.stringify(res.result)).toContain("Neznámý nástroj");
});
it("get_attendance_summary: another user's data needs attendance.manage", async () => {
const self: AiAuthCtx = {
userId: 999999996,
roleName: "viewer",
permissions: ["attendance.record"],
};
// Own data — allowed (empty month is fine, shape matters).
const own = await executeTool("get_attendance_summary", {}, self);
expect(own.ok).toBe(true);
expect(own.result).toMatchObject({ user_id: self.userId });
// Someone else's data without attendance.manage — denied in-result.
const other = await executeTool(
"get_attendance_summary",
{ user_id: 1 },
self,
);
expect(other.ok).toBe(true); // handler-level denial, not a thrown error
expect(JSON.stringify(other.result)).toContain("oprávnění");
});
});
describe("ai-tools — real reads", () => {
it("list_invoices returns the compact shape from the real DB", async () => {
const res = await executeTool("list_invoices", {}, ADMIN);
expect(res.ok).toBe(true);
const r = res.result as {
total_matching: number;
shown: number;
invoices: unknown[];
};
expect(typeof r.total_matching).toBe("number");
expect(Array.isArray(r.invoices)).toBe(true);
expect(r.shown).toBe(r.invoices.length);
expect(r.shown).toBeLessThanOrEqual(20);
});
it("list_projects + get_stock_overview return their shapes", async () => {
const projects = await executeTool("list_projects", {}, ADMIN);
expect(projects.ok).toBe(true);
expect(
Array.isArray((projects.result as { projects: unknown[] }).projects),
).toBe(true);
const stock = await executeTool("get_stock_overview", {}, ADMIN);
expect(stock.ok).toBe(true);
expect(
typeof (stock.result as { below_minimum_count: number })
.below_minimum_count,
).toBe("number");
});
});
describe("agenticChat loop (SDK mocked)", () => {
it("executes a requested tool, feeds the result back, returns final text + trace", async () => {
createMock.mockReset();
createMock
.mockResolvedValueOnce({
stop_reason: "tool_use",
content: [
{ type: "text", text: "Podívám se." },
{
type: "tool_use",
id: "toolu_1",
name: "list_projects",
input: {},
},
],
usage: { input_tokens: 100, output_tokens: 20 },
})
.mockResolvedValueOnce({
stop_reason: "end_turn",
content: [{ type: "text", text: "Máte 2 projekty." }],
usage: { input_tokens: 200, output_tokens: 30 },
});
const res = await agenticChat(
[{ role: "user", content: "Projekty?" }],
ADMIN,
);
expect(res.reply).toBe("Máte 2 projekty.");
expect(res.toolTrace).toEqual([
{ name: "list_projects", label: "Projekty", ok: true },
]);
expect(createMock).toHaveBeenCalledTimes(2);
// Second call must carry the tool_result back to the model.
const secondCall = createMock.mock.calls[1][0];
const lastMsg = secondCall.messages[secondCall.messages.length - 1];
expect(lastMsg.role).toBe("user");
expect(lastMsg.content[0]).toMatchObject({
type: "tool_result",
tool_use_id: "toolu_1",
});
// Usage was recorded per round-trip.
const usageRows = await prisma.ai_usage.findMany({
where: { user_id: ADMIN.userId, kind: "agent" },
});
expect(usageRows.length).toBe(2);
});
it("a user without permissions gets NO tools passed to the model", async () => {
createMock.mockReset();
createMock.mockResolvedValueOnce({
stop_reason: "end_turn",
content: [{ type: "text", text: "Ahoj." }],
usage: { input_tokens: 10, output_tokens: 5 },
});
await agenticChat([{ role: "user", content: "Ahoj" }], NOBODY);
expect(createMock.mock.calls[0][0].tools).toBeUndefined();
});
it("stops at the iteration cap with an explanatory note", async () => {
createMock.mockReset();
createMock.mockResolvedValue({
stop_reason: "tool_use",
content: [
{ type: "tool_use", id: "toolu_x", name: "list_projects", input: {} },
],
usage: { input_tokens: 10, output_tokens: 5 },
});
const res = await agenticChat([{ role: "user", content: "smyčka" }], ADMIN);
expect(createMock.mock.calls.length).toBe(6); // MAX_AGENT_ITERATIONS
expect(res.reply).toContain("příliš složitý");
});
});