From df6f0f4d60b7285df1ffd29c5f9415afef2e09c5 Mon Sep 17 00:00:00 2001 From: Ben Bachem <10088265+bezbac@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:13:13 +0200 Subject: [PATCH] Improve message history shape on generations --- src/langfuse.ts | 5 +-- src/v2.ts | 45 +++++++++++------------- test/integration/v1.test.ts | 25 +++++++++++++ test/integration/v2-exports.test.ts | 54 ++++++++++++++++++++++------- test/integration/v2.test.ts | 12 ++++--- 5 files changed, 98 insertions(+), 43 deletions(-) diff --git a/src/langfuse.ts b/src/langfuse.ts index 80b0c55..5feb616 100644 --- a/src/langfuse.ts +++ b/src/langfuse.ts @@ -1233,9 +1233,10 @@ export class LangfuseClient { } private getAssistantMessage(messageID: string) { - return buildAssistantMessage( + const messages = splitAssistantSteps( Array.from(this.traceState.assistantParts.get(messageID)?.values() ?? []), - ); + ).flatMap((step) => buildAssistantMessage(step) ?? []); + return messages.length > 0 ? messages : undefined; } setSessionHistory(sessionID: string, history: SessionHistory) { diff --git a/src/v2.ts b/src/v2.ts index 4a088fa..99d4d92 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -1,4 +1,5 @@ import type { Plugin } from "@opencode/plugin"; +import type { SessionHooks } from "@opencode/plugin/promise/session"; import { Effect } from "effect"; import type { ToolDefinition } from "./langfuse.js"; @@ -31,9 +32,7 @@ const LangfusePlugin = { agent: string; model: { id: string; providerID: string; variant?: string }; started: number; - text: string[]; - reasoning: string[]; - toolCalls: Map; + content: SessionHooks["context"]["messages"][number]["content"][number][]; } >(); @@ -78,11 +77,20 @@ const LangfusePlugin = { registrations.push( await ctx.tool.hook("execute.before", (input) => { - generationDetails.get(input.messageID)?.toolCalls.set(input.id, { - id: input.id, - name: input.tool, - arguments: JSON.stringify(input.input), - }); + const content = generationDetails.get(input.messageID)?.content; + if ( + content && + !content.some( + (part) => part.type === "tool-call" && part.id === input.id, + ) + ) { + content.push({ + type: "tool-call", + id: input.id, + name: input.tool, + input: input.input, + }); + } langfuse.rememberToolCall({ callID: input.id, messageID: input.messageID, @@ -156,9 +164,7 @@ const LangfusePlugin = { agent: event.data.agent, model: event.data.model, started: event.created, - text: [], - reasoning: [], - toolCalls: new Map(), + content: [], }); langfuse.startActiveGenerationStep({ sessionID: event.data.sessionID, @@ -173,13 +179,13 @@ const LangfusePlugin = { if (event.type === "session.text.ended") { generationDetails .get(event.data.assistantMessageID) - ?.text.push(event.data.text); + ?.content.push({ type: "text", text: event.data.text }); } if (event.type === "session.reasoning.ended") { generationDetails .get(event.data.assistantMessageID) - ?.reasoning.push(event.data.text); + ?.content.push({ type: "reasoning", text: event.data.text }); } if (event.type === "session.step.ended") { @@ -206,18 +212,7 @@ const LangfusePlugin = { output: [ { role: "assistant", - content: details.text.join(""), - ...(details.reasoning.length > 0 - ? { - thinking: details.reasoning.map((content) => ({ - type: "thinking", - content, - })), - } - : {}), - ...(details.toolCalls.size > 0 - ? { tool_calls: Array.from(details.toolCalls.values()) } - : {}), + content: details.content, }, ], }); diff --git a/test/integration/v1.test.ts b/test/integration/v1.test.ts index 5749c50..1e3ba33 100644 --- a/test/integration/v1.test.ts +++ b/test/integration/v1.test.ts @@ -1956,6 +1956,7 @@ describe("built plugin", { concurrent: false }, () => { ); test.each([ + "complete", "stale-content", "missing-later-call", "missing-later-step", @@ -2112,6 +2113,7 @@ describe("built plugin", { concurrent: false }, () => { return [step.marker, step.text, step.reasoning, step.tool]; } if ( + snapshot === "complete" || snapshot === "snapshot-only-content" || snapshot === "snapshot-ahead-of-live" || snapshot === "missing-earlier-call" @@ -2165,6 +2167,29 @@ describe("built plugin", { concurrent: false }, () => { if (!followup) { throw new Error("Expected followup generation"); } + if (snapshot === "complete") { + const generation = spans.find( + (span) => span.name === "opencode.generation" && span !== followup, + ); + if (!generation) { + throw new Error("Expected multi-step generation"); + } + const input = getJsonAttribute(followup, "langfuse.observation.input"); + if (!Array.isArray(input)) { + throw new Error("Expected message history"); + } + expect( + getJsonAttribute(generation, "langfuse.observation.output"), + ).toEqual( + input.filter( + (message: unknown) => + typeof message === "object" && + message !== null && + "role" in message && + message.role === "assistant", + ), + ); + } expect(getJsonAttribute(followup, "langfuse.observation.input")).toEqual([ { role: "user", content: [{ type: "text", text: "Read two files" }] }, ...steps.flatMap((step) => [ diff --git a/test/integration/v2-exports.test.ts b/test/integration/v2-exports.test.ts index 6789e85..3578903 100644 --- a/test/integration/v2-exports.test.ts +++ b/test/integration/v2-exports.test.ts @@ -190,11 +190,6 @@ test("exports V2 parallel tool responses with explicit IDs and no duplicate cont status: "error" as const, }, ]; - const toolCalls = calls.map((call) => ({ - id: call.id, - name: "read", - arguments: JSON.stringify({ path: call.path }), - })); const results = calls.map((call) => ({ role: "tool", name: "read", @@ -208,12 +203,17 @@ test("exports V2 parallel tool responses with explicit IDs and no duplicate cont }, { role: "assistant" as const, - content: calls.map((call) => ({ - type: "tool-call" as const, - id: call.id, - name: "read", - input: { path: call.path }, - })), + content: [ + { type: "reasoning" as const, text: "Need the files" }, + { type: "text" as const, text: "Reading first. " }, + { type: "text" as const, text: "Reading the rest." }, + ...calls.map((call) => ({ + type: "tool-call" as const, + id: call.id, + name: "read", + input: { path: call.path }, + })), + ], }, ...calls.map((call) => ({ role: "tool" as const, @@ -281,6 +281,36 @@ test("exports V2 parallel tool responses with explicit IDs and no duplicate cont created: Date.now(), data: { sessionID, assistantMessageID, agent, model }, }; + yield { + id: "v2-reasoning-ended", + durable: { aggregateID: sessionID, seq: 2, version: 1 as const }, + type: "session.reasoning.ended", + created: Date.now(), + data: { + sessionID, + assistantMessageID, + ordinal: 0, + text: "Need the files", + }, + }; + for (const text of ["Reading first. ", "Reading the rest."]) { + yield { + id: `v2-text-ended-${text}`, + durable: { + aggregateID: sessionID, + seq: 3, + version: 1 as const, + }, + type: "session.text.ended", + created: Date.now(), + data: { + sessionID, + assistantMessageID, + ordinal: text === "Reading first. " ? 1 : 2, + text, + }, + }; + } for (const call of calls) { await toolHooks.run("execute.before", { sessionID, @@ -475,7 +505,7 @@ test("exports V2 parallel tool responses with explicit IDs and no duplicate cont throw new Error("Expected both generations"); } expect(getJsonAttribute(generation, "langfuse.observation.output")).toEqual([ - { role: "assistant", content: "", tool_calls: toolCalls }, + messages[1], ]); for (const [index, call] of calls.entries()) { const tool = tools.find((span) => { diff --git a/test/integration/v2.test.ts b/test/integration/v2.test.ts index a67fa05..549919e 100644 --- a/test/integration/v2.test.ts +++ b/test/integration/v2.test.ts @@ -190,6 +190,7 @@ describe("OpenCode 2 package entrypoint", () => { const toolCalled = new Promise((resolve) => { releaseStep = resolve; }); + const reasoningReceived = Promise.withResolvers(); const registration = { dispose: vi.fn(() => Promise.resolve()) }; const contextInput: unknown = { app: { version: "2.0.4" }, @@ -241,6 +242,7 @@ describe("OpenCode 2 package entrypoint", () => { text: "Thinking", }, }; + reasoningReceived.resolve(undefined); await toolCalled; yield { type: "session.step.ended", @@ -284,6 +286,7 @@ describe("OpenCode 2 package entrypoint", () => { await vi.waitFor(() => { expect(runtime.startActiveGenerationStep).toHaveBeenCalled(); }); + await reasoningReceived.promise; executeBefore?.({ id: "call-1", messageID: "assistant-1", @@ -306,13 +309,14 @@ describe("OpenCode 2 package entrypoint", () => { parentID: "user-1", output: [ expect.objectContaining({ - content: "Hello", - thinking: [{ type: "thinking", content: "Thinking" }], - tool_calls: [ + content: [ + { type: "text", text: "Hello" }, + { type: "reasoning", text: "Thinking" }, { + type: "tool-call", id: "call-1", name: "read", - arguments: JSON.stringify({ path: "README.md" }), + input: { path: "README.md" }, }, ], }),