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
153 changes: 141 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { homedir } from "node:os";
import { join } from "node:path";

import type { Hooks, Plugin } from "@opencode-ai/plugin";
import { Data, Effect, Layer, Schema } from "effect";
import { Data, Effect, Layer, Option, Schema } from "effect";

import {
LangfuseClientService,
Expand Down Expand Up @@ -348,6 +348,108 @@ const formatHookError = (error: unknown) => {
}
};

// https://github.com/anomalyco/opencode/blob/v1.15.13/packages/opencode/src/tool/tool.ts#L46-L52
const NativeToolResultSchema = Schema.Struct({
title: Schema.String,
metadata: Schema.Record({ key: Schema.String, value: Schema.Unknown }),
output: Schema.String,
attachments: Schema.optional(Schema.Array(Schema.Unknown)),
});

// OpenCode returns CallToolResultSchema from its MCP adapter and passes that raw
// value to the hook before normalization:
// https://github.com/anomalyco/opencode/blob/v1.15.13/packages/opencode/src/mcp/index.ts#L158-L187
const McpToolResultSchema = Schema.Struct({
content: Schema.Array(Schema.Unknown),
structuredContent: Schema.optional(
Schema.Record({ key: Schema.String, value: Schema.Unknown }),
),
isError: Schema.optional(Schema.Boolean),
_meta: Schema.optional(
Schema.Record({ key: Schema.String, value: Schema.Unknown }),
),
});

const McpResourceContentsSchema = Schema.Union(
Schema.Struct({
uri: Schema.String,
mimeType: Schema.optional(Schema.String),
text: Schema.String,
}),
Schema.Struct({
uri: Schema.String,
mimeType: Schema.optional(Schema.String),
blob: Schema.String,
}),
);

// These are the MCP content variants OpenCode handles when creating its native
// output: https://github.com/anomalyco/opencode/blob/v1.15.13/packages/opencode/src/session/tools.ts#L153-L176
const McpContentSchema = Schema.Union(
Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
}),
Schema.Struct({
type: Schema.Literal("image"),
data: Schema.String,
mimeType: Schema.String,
}),
Schema.Struct({
type: Schema.Literal("resource"),
resource: McpResourceContentsSchema,
}),
);

const flattenMcpContent = (content: readonly unknown[]) => {
const textParts: string[] = [];

for (const part of content) {
const decoded = Schema.decodeUnknownOption(McpContentSchema)(part);
if (Option.isNone(decoded)) continue;

if (decoded.value.type === "text") {
textParts.push(decoded.value.text);
continue;
}

if (decoded.value.type === "resource" && "text" in decoded.value.resource) {
textParts.push(decoded.value.resource.text);
}
}

return textParts.join("\n\n");
};

const normalizeToolResult = (tool: string, output: unknown) => {
const nativeResult = Schema.decodeUnknownOption(NativeToolResultSchema)(
output,
);
if (Option.isSome(nativeResult)) {
return {
title: nativeResult.value.title,
output: nativeResult.value.output,
isError: false,
unexpected: false,
};
}

const mcpResult = Schema.decodeUnknownOption(McpToolResultSchema)(output);
if (Option.isSome(mcpResult)) {
return {
title: tool,
output: flattenMcpContent(mcpResult.value.content),
isError: mcpResult.value.isError === true,
unexpected: false,
};
}
if (output === undefined || output === null) {
return { title: tool, output: "", isError: false, unexpected: false };
}

return { title: tool, output: "", isError: false, unexpected: true };
};

const createShutdownOnce = (langfuse: LangfuseClient) => {
let shutdownPromise: Promise<void> | undefined;

Expand Down Expand Up @@ -549,17 +651,44 @@ const main = Effect.gen(function* () {
"tool.execute.after": (input, output) =>
runHook(
"tool.execute.after",
Effect.try({
try: () =>
langfuse.traceToolEnd({
sessionID: input.sessionID,
callID: input.callID,
tool: input.tool,
args: input.args,
title: output.title,
output: output.output,
}),
catch: (error) => error,
Effect.gen(function* () {
const normalized = normalizeToolResult(input.tool, output);

if (normalized.unexpected) {
yield* log(
"warn",
`Tool "${input.tool}" returned an unrecognized result shape; recording empty output`,
);
}

if (normalized.isError) {
yield* Effect.sync(() =>
langfuse.traceToolError({
sessionID: input.sessionID,
callID: input.callID,
tool: input.tool,
args: input.args,
error:
normalized.output ||
`MCP tool "${input.tool}" returned an error`,
completed: Date.now(),
}),
);
return;
}

yield* Effect.try({
try: () =>
langfuse.traceToolEnd({
sessionID: input.sessionID,
callID: input.callID,
tool: input.tool,
args: input.args,
title: normalized.title,
output: normalized.output,
}),
catch: (error) => error,
});
}),
),
};
Expand Down
62 changes: 62 additions & 0 deletions test/integration/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,53 @@ describe.sequential("built plugin", () => {
},
{ title: "README.md", output: "# Project", metadata: {} },
);
const mcpCallID = "nested-observations-mcp-call";
await hooks["tool.execute.before"]?.(
{ sessionID, callID: mcpCallID, tool: "mcp_test_tool" },
{ args: { query: "test" } },
);
await hooks["tool.execute.after"]?.(
{
sessionID,
callID: mcpCallID,
tool: "mcp_test_tool",
args: { query: "test" },
},
{
content: [
{ type: "text", text: "MCP tool result" },
{
type: "resource",
resource: {
uri: "foo://bar",
text: "MCP resource contents",
},
},
{ type: "image", mimeType: "image/png", data: "aW1hZ2U=" },
],
} as unknown as Parameters<
NonNullable<PluginHooks["tool.execute.after"]>
>[1],
);
const failedMcpCallID = "nested-observations-failed-mcp-call";
await hooks["tool.execute.before"]?.(
{ sessionID, callID: failedMcpCallID, tool: "mcp_failing_tool" },
{ args: { query: "fail" } },
);
await hooks["tool.execute.after"]?.(
{
sessionID,
callID: failedMcpCallID,
tool: "mcp_failing_tool",
args: { query: "fail" },
},
{
content: [{ type: "text", text: "MCP tool failed" }],
isError: true,
} as unknown as Parameters<
NonNullable<PluginHooks["tool.execute.after"]>
>[1],
);
await emitEvent({
type: "message.part.updated",
properties: {
Expand Down Expand Up @@ -816,6 +863,8 @@ describe.sequential("built plugin", () => {
"opencode.message.user",
"opencode.generation",
"read",
"mcp_test_tool",
"mcp_failing_tool",
"webfetch",
"opencode.generation.retry",
"opencode.generation.compaction",
Expand Down Expand Up @@ -865,6 +914,19 @@ describe.sequential("built plugin", () => {
title: "README.md",
output: "# Project",
});
const mcpTool = getSpan(spans, "mcp_test_tool");
expect(getJsonAttribute(mcpTool, "langfuse.observation.output")).toEqual({
title: "mcp_test_tool",
output: "MCP tool result\n\nMCP resource contents",
});
const failedMcpTool = getSpan(spans, "mcp_failing_tool");
expect(failedMcpTool.status).toEqual({
code: 2,
message: "MCP tool failed",
});
expect(
getJsonAttribute(failedMcpTool, "langfuse.observation.output"),
).toEqual({ error: "MCP tool failed" });
expect(tool.traceId).toBe(generation.traceId);
expect(tool.parentSpanId).toBe(generation.spanId);

Expand Down