diff --git a/src/app/api/agent/history/[threadId]/schema.ts b/src/app/api/agent/history/[threadId]/schema.ts index 4c318cb..78d5c70 100644 --- a/src/app/api/agent/history/[threadId]/schema.ts +++ b/src/app/api/agent/history/[threadId]/schema.ts @@ -1,13 +1,28 @@ import { z } from "@/lib/api/openapi/zod"; import { registry } from "@/lib/api/openapi/registry"; -// LangGraph checkpoint messages are loosely typed; documented as an open object -// array so the docs convey the shape without over-constraining it. -const HistoryMessage = z - .looseObject({ +// Projected to the fields the UI renders (see `projectHistory`); `data` varies by message type. +const HistoryMessageData = z + .object({ id: z.string().optional(), - type: z.string().optional(), content: z.unknown().optional(), + tool_calls: z.array(z.unknown()).optional().openapi({ description: "AI messages only" }), + pendingToolCallIds: z + .array(z.string()) + .optional() + .openapi({ description: "Tool calls the approval gate paused" }), + tool_call_id: z.string().optional().openapi({ description: "Tool messages only" }), + name: z.string().optional().openapi({ description: "Tool messages only" }), + status: z.string().optional().openapi({ description: "Tool messages only" }), + artifact: z.unknown().optional().openapi({ description: "Client-only tool output (charts)" }), + attachments: z.array(z.unknown()).optional().openapi({ description: "Human messages only" }), + }) + .openapi("HistoryMessageData"); + +const HistoryMessage = z + .object({ + type: z.enum(["human", "ai", "tool", "error"]), + data: HistoryMessageData, }) .openapi("HistoryMessage"); diff --git a/src/services/agentService.ts b/src/services/agentService.ts index 83b72a5..1effe72 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -3,11 +3,12 @@ import { ensureThread } from "@/lib/thread"; import type { MessageOptions, MessageResponse } from "@/types/message"; import * as threadRepo from "@/lib/repositories/threadRepository"; import { getHistory } from "@/lib/agent/memory"; -import { BaseMessage, HumanMessage } from "@langchain/core/messages"; +import { HumanMessage } from "@langchain/core/messages"; import { Command } from "@langchain/langgraph"; import type { HITLRequest, HITLResponse, Decision } from "langchain"; import { processAttachmentsForAI } from "@/lib/storage/content"; import { streamMessages, type AgentRun } from "./messageStream"; +import { projectHistory } from "./historyProjection"; import { CallbackHandler } from "@langfuse/langchain"; // Only instantiate when tracing is enabled; avoids errors when Langfuse credentials are absent. @@ -98,7 +99,7 @@ export async function fetchThreadHistory(threadId: string): Promise msg.toDict() as MessageResponse); + return projectHistory(history); } catch (e) { console.error("fetchThreadHistory error", e); return []; diff --git a/src/services/historyProjection.test.ts b/src/services/historyProjection.test.ts new file mode 100644 index 0000000..813514e --- /dev/null +++ b/src/services/historyProjection.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import { __projectMessage } from "./historyProjection"; +import type { AIMessageData, BasicMessageData, ToolMessageData } from "@/types/message"; + +// `toDict()` output as the checkpoint really returns it — provider fields included. +const aiWithToolCall = { + type: "ai", + data: { + id: "ai-1", + content: [{ type: "tool_call", id: "call-1", name: "log_expense", args: { amount: 12 } }], + content_blocks: [ + { type: "tool_call", id: "call-1", name: "log_expense", args: { amount: 12 } }, + ], + tool_calls: [{ type: "tool_call", id: "call-1", name: "log_expense", args: { amount: 12 } }], + usage_metadata: { input_tokens: 6807, output_tokens: 107, total_tokens: 6914 }, + response_metadata: { finish_reason: "tool_use", output_version: "v1" }, + invalid_tool_calls: [], + additional_kwargs: {}, + name: "model", + }, +}; + +describe("projectMessage", () => { + it("drops LangChain fields no component reads", () => { + const out = __projectMessage(aiWithToolCall); + for (const key of [ + "content_blocks", + "usage_metadata", + "response_metadata", + "invalid_tool_calls", + "additional_kwargs", + "name", + ]) { + expect(out.data).not.toHaveProperty(key); + } + }); + + it("drops a content array that only restates tool_calls", () => { + const out = __projectMessage(aiWithToolCall); + const data = out.data as AIMessageData; + expect(data.content).toBeUndefined(); + // The call itself must survive — it is what the UI renders. + expect(data.tool_calls).toEqual(aiWithToolCall.data.tool_calls); + }); + + it("keeps content when it mixes prose with a tool call", () => { + const out = __projectMessage({ + type: "ai", + data: { + id: "ai-2", + content: [ + { type: "text", text: "Logging that now." }, + { type: "tool_call", id: "call-2", name: "log_expense", args: {} }, + ], + tool_calls: [{ type: "tool_call", id: "call-2", name: "log_expense", args: {} }], + }, + }); + const data = out.data as AIMessageData; + expect(data.content).toHaveLength(2); + }); + + it("keeps plain assistant prose", () => { + const out = __projectMessage({ + type: "ai", + data: { id: "ai-3", content: "You spent 42 EUR on dining.", usage_metadata: { a: 1 } }, + }); + expect((out.data as AIMessageData).content).toBe("You spent 42 EUR on dining."); + }); + + it("preserves pendingToolCallIds — the only approval-gate signal", () => { + const out = __projectMessage({ + type: "ai", + data: { id: "ai-4", content: "", pendingToolCallIds: ["call-9"] }, + }); + expect((out.data as AIMessageData).pendingToolCallIds).toEqual(["call-9"]); + }); + + it("keeps the fields a tool result is paired and rendered by", () => { + const out = __projectMessage({ + type: "tool", + data: { + id: "tool-1", + content: '{"ok":true}', + tool_call_id: "call-1", + name: "log_expense", + status: "success", + metadata: { versions: { "@langchain/core": "1.2.1" } }, + additional_kwargs: {}, + }, + }); + const data = out.data as ToolMessageData; + expect(data).toEqual({ + id: "tool-1", + content: '{"ok":true}', + tool_call_id: "call-1", + name: "log_expense", + status: "success", + }); + }); + + it("keeps a chart artifact — the chart renders from it, not from content", () => { + const artifact = { chartType: "line", rows: [{ month: "2026-01", total: 12 }] }; + const out = __projectMessage({ + type: "tool", + data: { id: "t", content: "{}", tool_call_id: "c", name: "render_chart", artifact }, + }); + expect((out.data as ToolMessageData).artifact).toEqual(artifact); + }); + + it("passes human content through so checkpoint attachments survive", () => { + const content = [ + { type: "text", text: "load these" }, + { + type: "text", + text: "...", + file_metadata: { name: "releve.csv", key: "k", url: "u", type: "text/csv", size: 10 }, + }, + ]; + const out = __projectMessage({ type: "human", data: { id: "h-1", content } }); + expect(out.type).toBe("human"); + expect((out.data as BasicMessageData).content).toEqual(content); + }); +}); diff --git a/src/services/historyProjection.ts b/src/services/historyProjection.ts new file mode 100644 index 0000000..6d049b6 --- /dev/null +++ b/src/services/historyProjection.ts @@ -0,0 +1,79 @@ +import type { BaseMessage } from "@langchain/core/messages"; +import type { + AIMessageData, + BasicMessageData, + MessageResponse, + ToolCall, + ToolMessageData, +} from "@/types/message"; + +// `toDict()` is LangChain's serialization, not our wire contract — casting it shipped every +// field it carries. Projecting explicitly keeps `MessageResponse` the actual shape. + +/** Keep a key only when it carries information. */ +function put(target: T, key: keyof T, value: unknown): void { + if (value === undefined || value === null) return; + if (Array.isArray(value) && value.length === 0) return; + if (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0) return; + (target as Record)[key as string] = value; +} + +type ContentBlock = { type?: string; id?: string }; + +/** True when `content` only restates `tool_calls`. Mixed content keeps its prose. */ +function isRedundantToolCallContent(content: unknown, toolCalls: ToolCall[] | undefined): boolean { + if (!Array.isArray(content) || content.length === 0) return false; + if (!toolCalls || toolCalls.length === 0) return false; + const ids = new Set(toolCalls.map((call) => call.id)); + return (content as ContentBlock[]).every( + (block) => block?.type === "tool_call" && !!block.id && ids.has(block.id), + ); +} + +/** `toDict()` output — `data` carries provider fields beyond LangChain's declared type. */ +type CheckpointMessage = { + type?: string; + data?: Record; +}; + +function projectMessage(raw: CheckpointMessage): MessageResponse { + const type = raw.type; + const d = raw.data ?? {}; + + if (type === "ai") { + const data = {} as AIMessageData; + const toolCalls = d.tool_calls as ToolCall[] | undefined; + put(data, "id", d.id); + if (!isRedundantToolCallContent(d.content, toolCalls)) put(data, "content", d.content); + put(data, "tool_calls", toolCalls); + put(data, "pendingToolCallIds", d.pendingToolCallIds); + return { type: "ai", data }; + } + + if (type === "tool") { + const data = {} as ToolMessageData; + put(data, "id", d.id); + put(data, "content", d.content); + put(data, "tool_call_id", d.tool_call_id); + put(data, "name", d.name); + put(data, "status", d.status); + // A chart renders from its artifact, not from content — dropping it blanks the chart. + put(data, "artifact", d.artifact); + return { type: "tool", data }; + } + + // Human: attachments ride inside the content array, so it passes through untouched. + const data = {} as BasicMessageData; + put(data, "id", d.id); + put(data, "content", d.content); + put(data, "attachments", d.attachments); + return { type: type === "error" ? "error" : "human", data }; +} + +/** Project a checkpoint's messages onto the fields the UI reads. */ +export function projectHistory(messages: BaseMessage[]): MessageResponse[] { + return messages.map((msg) => projectMessage(msg.toDict() as unknown as CheckpointMessage)); +} + +/** Exported for tests, which feed plain `toDict()` output. */ +export const __projectMessage = projectMessage;