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
25 changes: 20 additions & 5 deletions src/app/api/agent/history/[threadId]/schema.ts
Original file line number Diff line number Diff line change
@@ -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");

Expand Down
5 changes: 3 additions & 2 deletions src/services/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -98,7 +99,7 @@ export async function fetchThreadHistory(threadId: string): Promise<MessageRespo
if (!thread) return [];
try {
const history = await getHistory(threadId);
return history.map((msg: BaseMessage) => msg.toDict() as MessageResponse);
return projectHistory(history);
} catch (e) {
console.error("fetchThreadHistory error", e);
return [];
Expand Down
123 changes: 123 additions & 0 deletions src/services/historyProjection.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
79 changes: 79 additions & 0 deletions src/services/historyProjection.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object>(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<string, unknown>)[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<string, unknown>;
};

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;