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
5 changes: 3 additions & 2 deletions src/langfuse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
45 changes: 20 additions & 25 deletions src/v2.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -31,9 +32,7 @@ const LangfusePlugin = {
agent: string;
model: { id: string; providerID: string; variant?: string };
started: number;
text: string[];
reasoning: string[];
toolCalls: Map<string, { id: string; name: string; arguments: string }>;
content: SessionHooks["context"]["messages"][number]["content"][number][];
}
>();

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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") {
Expand All @@ -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,
},
],
});
Expand Down
25 changes: 25 additions & 0 deletions test/integration/v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1956,6 +1956,7 @@ describe("built plugin", { concurrent: false }, () => {
);

test.each([
"complete",
"stale-content",
"missing-later-call",
"missing-later-step",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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) => [
Expand Down
54 changes: 42 additions & 12 deletions test/integration/v2-exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
12 changes: 8 additions & 4 deletions test/integration/v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ describe("OpenCode 2 package entrypoint", () => {
const toolCalled = new Promise<void>((resolve) => {
releaseStep = resolve;
});
const reasoningReceived = Promise.withResolvers<undefined>();
const registration = { dispose: vi.fn(() => Promise.resolve()) };
const contextInput: unknown = {
app: { version: "2.0.4" },
Expand Down Expand Up @@ -241,6 +242,7 @@ describe("OpenCode 2 package entrypoint", () => {
text: "Thinking",
},
};
reasoningReceived.resolve(undefined);
await toolCalled;
yield {
type: "session.step.ended",
Expand Down Expand Up @@ -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",
Expand All @@ -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" },
},
],
}),
Expand Down
Loading