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:
205
src/__tests__/ai-tools.test.ts
Normal file
205
src/__tests__/ai-tools.test.ts
Normal 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ý");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user