diff --git a/src/__tests__/ai-tools.test.ts b/src/__tests__/ai-tools.test.ts index e89a29f..39b97a1 100644 --- a/src/__tests__/ai-tools.test.ts +++ b/src/__tests__/ai-tools.test.ts @@ -534,6 +534,55 @@ describe("agenticChat loop (SDK mocked)", () => { expect(usageRows.length).toBe(2); }); + it("dedupes the tool trace: failed attempt + successful retry = one ok chip", async () => { + const self: AiAuthCtx = { + userId: 999999996, + roleName: "viewer", + permissions: ["attendance.record"], + }; + createMock.mockReset(); + createMock + .mockResolvedValueOnce({ + stop_reason: "tool_use", + content: [ + { + type: "tool_use", + id: "toolu_a", + name: "get_attendance_summary", + input: { user_id: 1 }, // denied: someone else without manage + }, + ], + usage: { input_tokens: 10, output_tokens: 5 }, + }) + .mockResolvedValueOnce({ + stop_reason: "tool_use", + content: [ + { + type: "tool_use", + id: "toolu_b", + name: "get_attendance_summary", + input: {}, // retry: own data, succeeds + }, + ], + usage: { input_tokens: 10, output_tokens: 5 }, + }) + .mockResolvedValueOnce({ + stop_reason: "end_turn", + content: [{ type: "text", text: "Hotovo." }], + usage: { input_tokens: 10, output_tokens: 5 }, + }); + const res = await agenticChat( + [{ role: "user", content: "Moje docházka?" }], + self, + ); + expect(res.toolTrace).toEqual([ + { name: "get_attendance_summary", label: "Docházka", ok: true }, + ]); + await prisma.ai_usage.deleteMany({ + where: { user_id: self.userId, kind: "agent" }, + }); + }); + it("injects the caller's identity into the system prompt", async () => { createMock.mockReset(); createMock.mockResolvedValueOnce({ diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts index e1b40e3..f1dfb18 100644 --- a/src/services/ai.service.ts +++ b/src/services/ai.service.ts @@ -381,6 +381,17 @@ export async function agenticChat( } } + // One chip per tool: a failed attempt followed by a successful retry is + // loop mechanics, not information for the user. ok = the tool delivered + // data at least once; orange stays only when every attempt failed. Also + // keeps the persisted meta.tools comfortably under its 30-entry cap. + const dedupedTrace: ToolTraceEntry[] = []; + for (const t of trace) { + const seen = dedupedTrace.find((d) => d.name === t.name); + if (!seen) dedupedTrace.push({ ...t }); + else seen.ok = seen.ok || t.ok; + } + const text = (res?.content ?? []) .filter((b): b is Anthropic.TextBlock => b.type === "text") .map((b) => b.text) @@ -399,7 +410,7 @@ export async function agenticChat( } else if (!reply) { reply = "Nepodařilo se získat odpověď, zkuste to prosím znovu."; } - return { reply, toolTrace: trace }; + return { reply, toolTrace: dedupedTrace }; } export interface ExtractedInvoice {