diff --git a/src/index.ts b/src/index.ts index ab12aa5..cfe8203 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { Data, Effect, Layer, Option, Schema } from "effect"; import { LangfuseClientService, + buildSessionHistory, createLangfuseClient, type LangfuseClient, type ToolDefinition, @@ -78,6 +79,37 @@ const loadLangfuseCredentials = Effect.gen(function* () { return credentials; }); +const refreshSessionHistory = (sessionID: string) => + Effect.gen(function* () { + const opencode = yield* OpencodeClientService; + const langfuse = yield* LangfuseClientService; + const response = yield* Effect.tryPromise({ + try: () => opencode.session.messages({ path: { id: sessionID } }), + catch: (error) => error, + }).pipe( + Effect.catchAll((error) => + log( + "info", + `Reading the conversation of session ${sessionID} failed: ${formatHookError(error)}`, + ).pipe(Effect.as(undefined)), + ), + ); + + if (response === undefined) { + return; + } + + if (response.data === undefined) { + yield* log( + "info", + `OpenCode returned no messages for session ${sessionID}; keeping the previous conversation snapshot`, + ); + return; + } + + langfuse.setSessionHistory(sessionID, buildSessionHistory(response.data)); + }); + const eventHook = (event: OpencodeEvent, shutdown?: () => Promise) => Effect.gen(function* () { const langfuse = yield* LangfuseClientService; @@ -178,6 +210,7 @@ const eventHook = (event: OpencodeEvent, shutdown?: () => Promise) => } if (event.type === "session.next.step.started") { + yield* refreshSessionHistory(event.properties.sessionID); langfuse.startActiveGenerationStep({ sessionID: event.properties.sessionID, assistantMessageID: @@ -270,6 +303,10 @@ const eventHook = (event: OpencodeEvent, shutdown?: () => Promise) => return; } + if (!langfuse.hasSessionHistory(message.sessionID)) { + yield* refreshSessionHistory(message.sessionID); + } + langfuse.startActiveGenerationStep({ sessionID: message.sessionID, assistantMessageID: message.id, diff --git a/src/langfuse.ts b/src/langfuse.ts index 1a29667..24cc937 100644 --- a/src/langfuse.ts +++ b/src/langfuse.ts @@ -47,6 +47,8 @@ export class LangfuseClient { this.traceState.latestTurnObservationsBySession.clear(); this.traceState.finalizedToolCallIds.clear(); this.traceState.sessionParentIds.clear(); + this.traceState.sessionHistories.clear(); + this.traceState.pendingUserMessageIdsBySession.clear(); } clearSessionTraceState(sessionID: string) { @@ -101,6 +103,8 @@ export class LangfuseClient { this.traceState.generationInputsBySession.delete(sessionID); this.traceState.toolResultSourceMessageIdsBySession.delete(sessionID); this.traceState.latestTurnObservationsBySession.delete(sessionID); + this.traceState.sessionHistories.delete(sessionID); + this.traceState.pendingUserMessageIdsBySession.delete(sessionID); } endActiveToolObservations(sessionID?: string, error?: SessionErrorInfo) { @@ -427,7 +431,10 @@ export class LangfuseClient { return; } - const generationInput = this.consumeGenerationInput(input.sessionID); + const generationInput = this.consumeGenerationInput( + input.sessionID, + input.assistantMessageID, + ); this.withTurnParent(input.sessionID, undefined, () => { const span = this.traceState.tracer.startSpan("opencode.generation", { @@ -486,44 +493,7 @@ export class LangfuseClient { this.traceState.abortedSessions.delete(input.sessionID); - const formattedMessage = { - role: "user" as const, - content: input.parts.map((part) => { - if (part.type === "text") { - return { type: part.type, text: part.text }; - } - - if (part.type === "file") { - return { - type: part.type, - filename: part.filename, - url: part.url, - }; - } - - if (part.type === "agent") { - return { type: part.type, name: part.name }; - } - - if (part.type === "subtask") { - return { - type: part.type, - prompt: part.prompt, - agent: part.agent, - }; - } - - if (part.type === "tool") { - return { - type: part.type, - tool: part.tool, - title: "title" in part.state ? part.state.title : undefined, - }; - } - - return { type: part.type }; - }), - }; + const formattedMessage = formatUserMessage(input.parts); const generationInput = [ { ...formattedMessage, @@ -538,6 +508,10 @@ export class LangfuseClient { if (input.messageID != null) { this.traceState.tracedMessageIds.add(input.messageID); + this.traceState.pendingUserMessageIdsBySession.set( + input.sessionID, + input.messageID, + ); } const previousTurn = this.traceState.latestTurnObservationsBySession.get( @@ -769,7 +743,10 @@ export class LangfuseClient { return; } - const generationInput = this.consumeGenerationInput(input.sessionID); + const generationInput = this.consumeGenerationInput( + input.sessionID, + input.messageID, + ); this.withTurnParent(input.sessionID, input.parentID, () => { const span = this.traceState.tracer.startSpan("opencode.generation", { @@ -1197,65 +1174,111 @@ export class LangfuseClient { } private getAssistantMessage(messageID: string) { - const parts = Array.from( - this.traceState.assistantParts.get(messageID)?.values() ?? [], - ); - const content = parts - .filter( - (part): part is Extract => - part.type === "text" && part.text !== "", - ) - .map((part) => part.text) - .join(""); - const thinking = parts - .filter( - (part): part is Extract => - part.type === "reasoning" && part.text !== "", - ) - .map((part) => ({ type: "thinking" as const, content: part.text })); - const toolCallsById = new Map( - parts - .filter( - (part): part is Extract => - part.type === "tool", - ) - .map((part) => [part.callID, part] as const), + return buildAssistantMessage( + Array.from(this.traceState.assistantParts.get(messageID)?.values() ?? []), ); - const toolCalls = Array.from(toolCallsById.values()).map((part) => ({ - id: part.callID, - name: part.tool, - arguments: JSON.stringify(part.state.input), - })); + } - if (!content && thinking.length === 0 && toolCalls.length === 0) { - return undefined; + setSessionHistory(sessionID: string, history: SessionHistory) { + this.traceState.sessionHistories.set(sessionID, history); + } + + hasSessionHistory(sessionID: string) { + return this.traceState.sessionHistories.has(sessionID); + } + + private getHistoryPrefix(sessionID: string, assistantMessageID?: string) { + const history = this.traceState.sessionHistories.get(sessionID); + + if (!history) { + return []; } - return [ - { - role: "assistant" as const, - ...(content ? { content } : {}), - ...(thinking.length ? { thinking } : {}), - ...(toolCalls.length ? { tool_calls: toolCalls } : {}), - }, - ]; + if (assistantMessageID == null) { + return history.messages; + } + + const startIndex = + history.startIndexByAssistantMessageId.get(assistantMessageID); + + return startIndex == null + ? history.messages + : history.messages.slice(0, startIndex); + } + + // True only when the snapshot provably holds that message. A failed refresh + // leaves the previous snapshot in place, and that one predates the request. + private snapshotHolds(sessionID: string, messageID: string | undefined) { + if (messageID == null) { + return false; + } + + return ( + this.traceState.sessionHistories + .get(sessionID) + ?.messageIds.has(messageID) === true + ); } - private consumeGenerationInput(sessionID: string) { - const input = this.traceState.generationInputsBySession.get(sessionID); + private consumeGenerationInput( + sessionID: string, + assistantMessageID?: string, + ) { + const pending = this.traceState.generationInputsBySession.get(sessionID); const sourceMessageID = this.traceState.toolResultSourceMessageIdsBySession.get(sessionID); + const pendingUserMessageID = + this.traceState.pendingUserMessageIdsBySession.get(sessionID); this.traceState.generationInputsBySession.delete(sessionID); this.traceState.toolResultSourceMessageIdsBySession.delete(sessionID); - if (sourceMessageID == null) { - return input; + const assistantOfToolResults = + sourceMessageID == null + ? [] + : (this.getAssistantMessage(sourceMessageID) ?? []); + const prefix = this.getHistoryPrefix(sessionID, assistantMessageID); + + if (prefix.length === 0) { + // No snapshot from OpenCode yet, so the live delta is all there is. + return sourceMessageID == null + ? pending + : [...assistantOfToolResults, ...(pending ?? [])]; } - return [ - ...(this.getAssistantMessage(sourceMessageID) ?? []), - ...(input ?? []), + // Taking the user message from both sources would list it twice, so it + // comes from the live buffer only when the snapshot does not hold it - + // after a failed refresh, or when the store had not caught up yet. The + // buffer's copy already carries this request's tool definitions. + const pendingUserMessages = this.snapshotHolds( + sessionID, + pendingUserMessageID, + ) + ? [] + : (pending ?? []).filter((message) => message.role === "user"); + const toolResults = (pending ?? []).filter( + (message) => message.role === "tool", + ); + const combined = [ + ...prefix, + ...assistantOfToolResults, + ...toolResults, + ...pendingUserMessages, ]; + + if (pendingUserMessages.length > 0) { + return combined; + } + + // Tool definitions describe this request, not the stored conversation, so + // they ride on its newest user message. + const tools = pending?.find( + (message): message is Extract => + message.role === "user" && "tools" in message, + )?.tools; + + return tools === undefined + ? combined + : withToolDefinitions(combined, tools); } private rememberToolResult(input: { @@ -1325,6 +1348,8 @@ export type LangfuseTraceState = { generationInputsBySession: Map; toolResultSourceMessageIdsBySession: Map; sessionParentIds: Map; + sessionHistories: Map; + pendingUserMessageIdsBySession: Map; }; export type MessagePart = Extract< @@ -1332,6 +1357,190 @@ export type MessagePart = Extract< { type: "message.part.updated" } >["properties"]["part"]; +export type SessionHistory = { + messages: ChatMlMessage[]; + startIndexByAssistantMessageId: Map; + // Which messages the snapshot actually holds. A stale snapshot - the last + // refresh failed, or it ran before the message existed - does not contain + // the request that triggered the generation, and the live buffer has to + // supply it. + messageIds: Set; +}; + +function splitAssistantSteps(parts: MessagePart[]) { + const steps: MessagePart[][] = []; + let current: MessagePart[] = []; + + for (const part of parts) { + if (part.type === "step-start" && current.length > 0) { + steps.push(current); + current = []; + continue; + } + + if (part.type !== "step-start") { + current.push(part); + } + } + + if (current.length > 0) { + steps.push(current); + } + + return steps; +} + +function toolResultContent( + state: Extract["state"], +) { + if ("output" in state) { + return state.output; + } + + return "error" in state ? state.error : ""; +} + +function toolResultsOfStep(parts: MessagePart[]): ChatMlMessage[] { + return parts + .filter( + (part): part is Extract => + part.type === "tool" && + (part.state.status === "completed" || part.state.status === "error"), + ) + .map((part) => ({ + role: "tool" as const, + name: part.tool, + tool_call_id: part.callID, + content: toolResultContent(part.state), + })); +} + +export function buildSessionHistory( + messages: readonly { + info: { id: string; role: string }; + parts: MessagePart[]; + }[], +): SessionHistory { + const history: ChatMlMessage[] = []; + const startIndexByAssistantMessageId = new Map(); + const messageIds = new Set(); + + for (const message of messages) { + messageIds.add(message.info.id); + + if (message.info.role === "user") { + history.push(formatUserMessage(message.parts)); + continue; + } + + startIndexByAssistantMessageId.set(message.info.id, history.length); + + for (const step of splitAssistantSteps(message.parts)) { + const assistant = buildAssistantMessage(step); + + if (assistant) { + history.push(...assistant); + } + + history.push(...toolResultsOfStep(step)); + } + } + + return { messages: history, startIndexByAssistantMessageId, messageIds }; +} + +function withToolDefinitions( + messages: ChatMlMessage[], + tools: ToolDefinition[], +): ChatMlMessage[] { + const newestUserIndex = messages.reduce( + (found, message, index) => (message.role === "user" ? index : found), + -1, + ); + + if (newestUserIndex < 0) { + return messages; + } + + return messages.map((message, index) => + index === newestUserIndex ? { ...message, tools } : message, + ); +} + +function formatUserMessagePart(part: MessagePart): FormattedMessagePart { + if (part.type === "text") { + return { type: part.type, text: part.text }; + } + + if (part.type === "file") { + return { type: part.type, filename: part.filename, url: part.url }; + } + + if (part.type === "agent") { + return { type: part.type, name: part.name }; + } + + if (part.type === "subtask") { + return { type: part.type, prompt: part.prompt, agent: part.agent }; + } + + if (part.type === "tool") { + return { + type: part.type, + tool: part.tool, + title: "title" in part.state ? part.state.title : undefined, + }; + } + + return { type: part.type }; +} + +function formatUserMessage(parts: MessagePart[]) { + return { role: "user" as const, content: parts.map(formatUserMessagePart) }; +} + +function buildAssistantMessage(parts: MessagePart[]) { + const content = parts + .filter( + (part): part is Extract => + part.type === "text" && part.text !== "", + ) + .map((part) => part.text) + .join(""); + const thinking = parts + .filter( + (part): part is Extract => + part.type === "reasoning" && part.text !== "", + ) + .map((part) => ({ type: "thinking" as const, content: part.text })); + const toolCallsById = new Map( + parts + .filter( + (part): part is Extract => + part.type === "tool", + ) + .map((part) => [part.callID, part] as const), + ); + const toolCalls = Array.from(toolCallsById.values()).map((part) => ({ + id: part.callID, + name: part.tool, + arguments: JSON.stringify(part.state.input), + })); + + if (!content && thinking.length === 0 && toolCalls.length === 0) { + return undefined; + } + + return [ + { + role: "assistant" as const, + ...(content ? { content } : {}), + ...(thinking.length ? { thinking } : {}), + ...(toolCalls.length ? { tool_calls: toolCalls } : {}), + }, + ]; +} + function getCompletedReasoningTimestamp(part: MessagePart) { if (!("time" in part) || !part.time || typeof part.time !== "object") { return undefined; @@ -1483,6 +1692,8 @@ export const createLangfuseClient = (input: { generationInputsBySession: new Map(), toolResultSourceMessageIdsBySession: new Map(), sessionParentIds: new Map(), + sessionHistories: new Map(), + pendingUserMessageIdsBySession: new Map(), }; const processor = new LangfuseSpanProcessor({ diff --git a/src/opencode.ts b/src/opencode.ts index 3872522..565adeb 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -1,7 +1,10 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { Context as EffectContext } from "effect"; -export type OpencodeClient = Pick; +export type OpencodeClient = Pick< + PluginInput["client"], + "app" | "tool" | "session" +>; export class OpencodeClientService extends EffectContext.Tag( "OpencodeClientService", diff --git a/test/integration/plugin.test.ts b/test/integration/plugin.test.ts index 7d2693a..e33a890 100644 --- a/test/integration/plugin.test.ts +++ b/test/integration/plugin.test.ts @@ -151,6 +151,52 @@ let toolListShouldFail = false; const startedAt = 1_750_000_000_000; +// The tool definitions the fake OpenCode client serves. They describe the +// request being made, so only the newest user message carries them. +const expectedToolDefinitions = [ + { + name: "read", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + { + name: "webfetch", + description: "Fetch a URL", + parameters: { + type: "object", + properties: { url: { type: "string" } }, + required: ["url"], + }, + }, +]; + +// Stands in for OpenCode's own message store, which session.messages() serves. +// It outlives a plugin restart on purpose: reading the conversation from +// OpenCode instead of accumulating it in the plugin is the point. +type StoredMessage = { + info: { id: string; role: "user" | "assistant"; sessionID: string }; + parts: unknown[]; +}; +const sessionStore = new Map(); +let sessionMessagesShouldFail = false; + +const storeMessage = (sessionID: string, message: StoredMessage) => { + const messages = sessionStore.get(sessionID) ?? []; + const existing = messages.findIndex((m) => m.info.id === message.info.id); + + if (existing >= 0) { + messages[existing] = message; + } else { + messages.push(message); + } + + sessionStore.set(sessionID, messages); +}; + const getSpans = (request: CapturedRequest) => request.body.resourceSpans.flatMap((resourceSpan) => resourceSpan.scopeSpans.flatMap((scopeSpan) => scopeSpan.spans), @@ -266,6 +312,19 @@ const sendUserMessage = async (input: { ], }, ); + + storeMessage(input.sessionID, { + info: { id: input.messageID, role: "user", sessionID: input.sessionID }, + parts: [ + { + id: `${input.messageID}-part`, + sessionID: input.sessionID, + messageID: input.messageID, + type: "text", + text: input.text, + }, + ], + }); }; const startGeneration = async (input: { @@ -335,6 +394,26 @@ const completeGeneration = async (input: { completed: number; text?: string; }) => { + storeMessage(input.sessionID, { + info: { + id: input.assistantMessageID, + role: "assistant", + sessionID: input.sessionID, + }, + parts: + input.text !== undefined && input.text !== "" + ? [ + { + id: `${input.assistantMessageID}-part`, + sessionID: input.sessionID, + messageID: input.assistantMessageID, + type: "text", + text: input.text, + }, + ] + : [], + }); + if (input.text !== undefined && input.text !== "") { await emitEvent({ type: "message.part.updated", @@ -382,6 +461,12 @@ const createHooks = async (baseUrl: string) => { app: { log: () => Promise.resolve(), }, + session: { + messages: ({ path }: { path: { id: string } }) => + sessionMessagesShouldFail + ? Promise.reject(new Error("session.messages unavailable")) + : Promise.resolve({ data: sessionStore.get(path.id) ?? [] }), + }, tool: { list: () => { toolListCalls += 1; @@ -491,11 +576,13 @@ beforeAll(async () => { beforeEach(async () => { requests.length = 0; + sessionStore.clear(); collectorErrors.length = 0; collectorStatus = 200; hooksDisposed = false; toolListCalls = 0; toolListShouldFail = false; + sessionMessagesShouldFail = false; hooks = await createHooks(collectorBaseUrl); }); @@ -1385,6 +1472,10 @@ describe("built plugin", { concurrent: false }, () => { expect( getJsonAttribute(generationSpans[1], "langfuse.observation.input"), ).toEqual([ + { + role: "user", + content: [{ type: "text", text: "Run three tool batches" }], + }, { role: "assistant", tool_calls: [ @@ -1548,6 +1639,10 @@ describe("built plugin", { concurrent: false }, () => { expect( getJsonAttribute(secondGeneration, "langfuse.observation.input"), ).toEqual([ + { + role: "user", + content: [{ type: "text", text: "Show recent commits" }], + }, { role: "assistant", tool_calls: [ @@ -2328,6 +2423,312 @@ describe("built plugin", { concurrent: false }, () => { } }, 15_000); + test("carries prior turns into later generation inputs", async () => { + const sessionID = "history-session"; + const started = startedAt; + + await sendUserMessage({ + sessionID, + messageID: "history-user-1", + text: "Read the README", + started, + }); + await startGeneration({ + id: "history-step-1", + sessionID, + started: started + 100, + }); + await completeGeneration({ + sessionID, + userMessageID: "history-user-1", + assistantMessageID: "history-assistant-1", + started: started + 100, + completed: started + 500, + text: "The README describes the project", + }); + await flushSession(sessionID); + + await sendUserMessage({ + sessionID, + messageID: "history-user-2", + text: "Now summarize it", + started: started + 1_000, + }); + await startGeneration({ + id: "history-step-2", + sessionID, + started: started + 1_100, + }); + await completeGeneration({ + sessionID, + userMessageID: "history-user-2", + assistantMessageID: "history-assistant-2", + started: started + 1_100, + completed: started + 1_500, + text: "A summary", + }); + + const { spans } = await flushSession(sessionID); + const generation = getSpan(spans, "opencode.generation"); + + expect(getJsonAttribute(generation, "langfuse.observation.input")).toEqual([ + { role: "user", content: [{ type: "text", text: "Read the README" }] }, + { role: "assistant", content: "The README describes the project" }, + { + role: "user", + content: [{ type: "text", text: "Now summarize it" }], + tools: expectedToolDefinitions, + }, + ]); + }); + + test("keeps the history complete when the plugin restarts mid-session", async () => { + // The conversation lives in OpenCode, not in the plugin, so a restart + // must not truncate what a generation input shows. A plugin that + // accumulated the history in memory would lose everything before it. + const sessionID = "restart-session"; + const started = startedAt; + + await sendUserMessage({ + sessionID, + messageID: "restart-user-1", + text: "First question", + started, + }); + await startGeneration({ + id: "restart-step-1", + sessionID, + started: started + 100, + }); + await completeGeneration({ + sessionID, + userMessageID: "restart-user-1", + assistantMessageID: "restart-assistant-1", + started: started + 100, + completed: started + 800, + text: "First answer", + }); + await flushSession(sessionID); + + await disposeHooks(); + hooksDisposed = false; + hooks = await createHooks(collectorBaseUrl); + + await sendUserMessage({ + sessionID, + messageID: "restart-user-2", + text: "Second question", + started: started + 5_000, + }); + await startGeneration({ + id: "restart-step-2", + sessionID, + started: started + 5_100, + }); + await completeGeneration({ + sessionID, + userMessageID: "restart-user-2", + assistantMessageID: "restart-assistant-2", + started: started + 5_100, + completed: started + 5_800, + text: "Second answer", + }); + + const { spans } = await flushSession(sessionID); + const generation = getSpan(spans, "opencode.generation"); + + expect(getJsonAttribute(generation, "langfuse.observation.input")).toEqual([ + { role: "user", content: [{ type: "text", text: "First question" }] }, + { role: "assistant", content: "First answer" }, + { + role: "user", + content: [{ type: "text", text: "Second question" }], + tools: expectedToolDefinitions, + }, + ]); + }); + + test("keeps the new user message when the history refresh fails", async () => { + // A snapshot from earlier in the same busy period does not contain a + // request that arrives afterwards. If the refresh that would pick it up + // fails, dropping the pending user message loses exactly the prompt this + // feature exists to show. No session.idle here on purpose: that would + // clear the cache and take the stale-snapshot path out of reach. + const sessionID = "stale-snapshot-session"; + const started = startedAt; + + await sendUserMessage({ + sessionID, + messageID: "stale-user-1", + text: "Question 1", + started, + }); + await startGeneration({ + id: "stale-step-1", + sessionID, + started: started + 100, + }); + await completeGeneration({ + sessionID, + userMessageID: "stale-user-1", + assistantMessageID: "stale-assistant-1", + started: started + 100, + completed: started + 800, + text: "Answer 1", + }); + + // From here on OpenCode cannot be reached, so the snapshot stays behind. + sessionMessagesShouldFail = true; + + await sendUserMessage({ + sessionID, + messageID: "stale-user-2", + text: "Question 2", + started: started + 5_000, + }); + await startGeneration({ + id: "stale-step-2", + sessionID, + started: started + 5_100, + }); + await completeGeneration({ + sessionID, + userMessageID: "stale-user-2", + assistantMessageID: "stale-assistant-2", + started: started + 5_100, + completed: started + 5_800, + text: "Answer 2", + }); + + const { spans } = await flushSession(sessionID); + const generations = spans + .filter((span) => span.name === "opencode.generation") + .sort( + (a, b) => Number(a.startTimeUnixNano) - Number(b.startTimeUnixNano), + ); + const input = getJsonAttribute( + generations[generations.length - 1], + "langfuse.observation.input", + ); + + const messages = Schema.decodeUnknownSync(Schema.Array(Schema.Unknown))( + input, + ); + + // The request that triggered the generation must be there, and last. + expect(messages[messages.length - 1]).toEqual({ + role: "user", + content: [{ type: "text", text: "Question 2" }], + tools: expectedToolDefinitions, + }); + // And the stale snapshot is still used for what it does hold. + expect(messages[0]).toEqual({ + role: "user", + content: [{ type: "text", text: "Question 1" }], + }); + }); + + test("keeps a failed tool result in the rebuilt history", async () => { + const sessionID = "failed-tool-session"; + const started = startedAt; + const callID = "call-that-fails"; + + await sendUserMessage({ + sessionID, + messageID: "failed-user-1", + text: "Read a file that is not there", + started, + }); + await startGeneration({ + id: "failed-step-1", + sessionID, + started: started + 100, + }); + await completeGeneration({ + sessionID, + userMessageID: "failed-user-1", + assistantMessageID: "failed-assistant-1", + started: started + 100, + completed: started + 800, + text: "That file is missing.", + }); + await flushSession(sessionID); + + storeMessage(sessionID, { + info: { id: "failed-assistant-1", role: "assistant", sessionID }, + parts: [ + { + id: "failed-tool-part", + sessionID, + messageID: "failed-assistant-1", + type: "tool", + callID, + tool: "read", + state: { + status: "error", + input: { path: "missing.txt" }, + error: "ENOENT: no such file or directory", + time: { start: started + 200, end: started + 300 }, + }, + }, + { + id: "failed-text-part", + sessionID, + messageID: "failed-assistant-1", + type: "text", + text: "That file is missing.", + }, + ], + }); + + await sendUserMessage({ + sessionID, + messageID: "failed-user-2", + text: "What happened?", + started: started + 5_000, + }); + await startGeneration({ + id: "failed-step-2", + sessionID, + started: started + 5_100, + }); + await completeGeneration({ + sessionID, + userMessageID: "failed-user-2", + assistantMessageID: "failed-assistant-2", + started: started + 5_100, + completed: started + 5_800, + text: "The read failed.", + }); + + const { spans } = await flushSession(sessionID); + const generation = getSpan(spans, "opencode.generation"); + const input = getJsonAttribute(generation, "langfuse.observation.input"); + const messages = Schema.decodeUnknownSync(Schema.Array(Schema.Unknown))( + input, + ); + + // The assistant's tool call must be followed by its result. + expect(messages).toHaveLength(4); + expect(messages[1]).toEqual({ + role: "assistant", + content: "That file is missing.", + tool_calls: [ + { + id: callID, + name: "read", + arguments: JSON.stringify({ path: "missing.txt" }), + }, + ], + }); + expect(messages[2]).toEqual({ + role: "tool", + name: "read", + tool_call_id: callID, + content: "ENOENT: no such file or directory", + }); + }); + test("can be disposed repeatedly", async () => { expect(hooks.dispose).toBeDefined(); await hooks.dispose?.();