From 17496b8f0c7443643a76ab502ef3e13d6bc0dde0 Mon Sep 17 00:00:00 2001 From: suolongcn <78711197+suolongcn@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:12:43 +0800 Subject: [PATCH 01/11] feat: expose skill and subagent names in tool observations --- README.md | 12 ++ src/langfuse.ts | 20 ++- test/integration/plugin.test.ts | 211 ++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e238db1..1124197 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,18 @@ export LANGFUSE_USER_ID="your-user-id" If both `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` are set, the plugin uses environment variables instead of reading the config file. Optional values can be supplied either way. +## Tool Observation Names + +Skill and subagent tool observations include the skill name or subagent type so +Langfuse dashboards can group and filter their metrics by observation name: + +- `skill` with input `{ "name": "resolve-dependencies" }` is named `skill:resolve-dependencies`. +- `task` with input `{ "subagent_type": "developer" }` is named `task:developer`. + +Leading and trailing whitespace is trimmed from the name. Missing, blank, or +non-string values fall back to `skill` or `task`. Other tool names are unchanged. +The original tool input and the metadata `tool` field remain unchanged. + ## Contributing See the [contributing guide](./CONTRIBUTING.md). diff --git a/src/langfuse.ts b/src/langfuse.ts index 24cc937..1ff0327 100644 --- a/src/langfuse.ts +++ b/src/langfuse.ts @@ -939,10 +939,28 @@ export class LangfuseClient { this.ensureGenerationParent(input.sessionID); + let observationName = input.tool; + if ( + typeof input.args === "object" && + input.args !== null && + !Array.isArray(input.args) + ) { + const semanticName = + input.tool === "skill" && "name" in input.args + ? input.args.name + : input.tool === "task" && "subagent_type" in input.args + ? input.args.subagent_type + : undefined; + + if (typeof semanticName === "string" && semanticName.trim() !== "") { + observationName = `${input.tool}:${semanticName.trim()}`; + } + } + this.withObservationParent( input.sessionID, () => { - const span = this.traceState.tracer.startSpan(input.tool, { + const span = this.traceState.tracer.startSpan(observationName, { attributes: { "langfuse.observation.type": "tool", "session.id": input.sessionID, diff --git a/test/integration/plugin.test.ts b/test/integration/plugin.test.ts index e33a890..60a6ac0 100644 --- a/test/integration/plugin.test.ts +++ b/test/integration/plugin.test.ts @@ -1093,6 +1093,217 @@ describe("built plugin", { concurrent: false }, () => { ).toHaveLength(0); }); + test.each([ + { + tool: "skill", + args: { name: "resolve-dependencies" }, + observationName: "skill:resolve-dependencies", + }, + { + tool: "task", + args: { subagent_type: "developer", prompt: "Fix the build" }, + observationName: "task:developer", + }, + ])( + "exports $observationName without changing tool data", + async ({ tool, args, observationName }) => { + const sessionID = "semantic-tool-session"; + const callID = "semantic-tool-call"; + const messageID = "semantic-tool-assistant"; + await sendUserMessage({ + sessionID, + messageID: "semantic-tool-user", + text: "Fix the build", + started: startedAt, + }); + await startGeneration({ + id: "semantic-tool-step", + sessionID, + assistantMessageID: messageID, + started: startedAt + 100, + }); + await hooks["tool.execute.before"]?.( + { sessionID, callID, tool }, + { args }, + ); + const part = { + id: "semantic-tool-part", + sessionID, + messageID, + type: "tool" as const, + callID, + tool, + }; + await emitEvent({ + type: "message.part.updated", + properties: { + part: { + ...part, + state: { + status: "running", + input: args, + time: { start: startedAt + 200 }, + }, + }, + }, + }); + await hooks["tool.execute.after"]?.( + { sessionID, callID, tool, args }, + { title: "Done", output: "ok", metadata: {} }, + ); + await emitEvent({ + type: "message.part.updated", + properties: { + part: { + ...part, + state: { + status: "completed", + input: args, + title: "Done", + output: "ok", + metadata: {}, + time: { start: startedAt + 200, end: startedAt + 300 }, + }, + }, + }, + }); + + const { spans } = await flushSession(sessionID); + const tools = spans.filter( + (span) => getAttributes(span)["langfuse.observation.type"] === "tool", + ); + expect(tools).toHaveLength(1); + const observation = tools[0]; + expect(observation.name).toBe(observationName); + expect(observation.parentSpanId).toBe( + getSpan(spans, "opencode.generation").spanId, + ); + expect( + getJsonAttribute(observation, "langfuse.observation.input"), + ).toEqual(args); + expect( + getJsonAttribute(observation, "langfuse.observation.metadata"), + ).toEqual({ callID, tool }); + expect( + getJsonAttribute(observation, "langfuse.observation.output"), + ).toEqual({ title: "Done", output: "ok" }); + }, + ); + + test.each(["completed", "error"] as const)( + "names semantic tools from %s parts without execution hooks", + async (status) => { + const sessionID = "semantic-tool-parts-session"; + for (const [tool, args, observationName] of [ + [ + "skill", + { name: "resolve-dependencies" }, + "skill:resolve-dependencies", + ], + ["task", { subagent_type: "developer" }, "task:developer"], + ] as const) { + await emitEvent({ + type: "message.part.updated", + properties: { + part: { + id: `${tool}-part`, + sessionID, + messageID: "semantic-tool-parts-assistant", + type: "tool", + callID: `${tool}-call`, + tool, + state: { + input: args, + time: { start: startedAt, end: startedAt + 100 }, + ...(status === "completed" + ? { status, title: "Done", output: "ok", metadata: {} } + : { status, error: "Tool failed" }), + }, + }, + }, + }); + const { spans } = await flushSession(sessionID); + expect(spans).toHaveLength(1); + const observation = getSpan(spans, observationName); + expect( + getJsonAttribute(observation, "langfuse.observation.input"), + ).toEqual(args); + expect( + getJsonAttribute(observation, "langfuse.observation.metadata"), + ).toEqual({ callID: `${tool}-call`, tool }); + if (status === "error") { + expect(observation.status?.code).toBe(2); + expect( + getJsonAttribute(observation, "langfuse.observation.output"), + ).toEqual({ error: "Tool failed" }); + } + } + }, + ); + + test("keeps fallback tool names and preserves inputs when normalizing names", async () => { + const sessionID = "tool-name-fallback-session"; + const cases = [ + { tool: "skill", args: {}, name: "skill" }, + { tool: "skill", args: { name: "" }, name: "skill" }, + { tool: "skill", args: { name: " \t " }, name: "skill" }, + { tool: "skill", args: { name: 42 }, name: "skill" }, + { tool: "skill", args: { name: ["review"] }, name: "skill" }, + { tool: "skill", args: null, name: "skill" }, + { tool: "skill", args: "review", name: "skill" }, + { tool: "task", args: { name: "developer" }, name: "task" }, + { tool: "task", args: { subagent_type: "" }, name: "task" }, + { tool: "task", args: { subagent_type: " \n " }, name: "task" }, + { tool: "task", args: { subagent_type: null }, name: "task" }, + { tool: "task", args: [], name: "task" }, + { + tool: "read", + args: { name: "README.md", subagent_type: "developer" }, + name: "read", + }, + { tool: "skill", args: { name: " review " }, name: "skill:review" }, + { + tool: "task", + args: { subagent_type: " developer " }, + name: "task:developer", + }, + ]; + for (const [index, { tool, args }] of cases.entries()) { + await hooks["tool.execute.after"]?.( + { sessionID, callID: `fallback-${index.toString()}`, tool, args }, + { title: "Done", output: "ok", metadata: {} }, + ); + } + const { spans } = await flushSession(sessionID); + expect(spans).toHaveLength(cases.length); + for (const [index, { tool, args, name }] of cases.entries()) { + const callID = `fallback-${index.toString()}`; + const observation = spans.find((span) => { + const metadata = getJsonAttribute( + span, + "langfuse.observation.metadata", + ); + return ( + typeof metadata === "object" && + metadata !== null && + "callID" in metadata && + metadata.callID === callID + ); + }); + expect(observation).toBeDefined(); + if (!observation) { + throw new Error(`Expected tool observation ${callID}`); + } + expect(observation.name).toBe(name); + expect( + getJsonAttribute(observation, "langfuse.observation.input"), + ).toEqual(args); + expect( + getJsonAttribute(observation, "langfuse.observation.metadata"), + ).toEqual({ callID, tool }); + } + }); + // https://github.com/anomalyco/opencode/blob/v1.15.13/packages/core/src/session-event.ts#L353-L362 test("supports OpenCode >=1.15.13 <1.16 compaction events", async () => { const sessionID = "legacy-compaction-session"; From e0835ab01392c2548cce29b9e2fd1132affcbe6c Mon Sep 17 00:00:00 2001 From: suolongcn <78711197+suolongcn@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:49:54 +0800 Subject: [PATCH 02/11] refactor: extract tool observation naming method --- src/langfuse.ts | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/langfuse.ts b/src/langfuse.ts index 93c65fd..2bb9439 100644 --- a/src/langfuse.ts +++ b/src/langfuse.ts @@ -975,23 +975,7 @@ export class LangfuseClient { this.ensureGenerationParent(input.sessionID); - let observationName = input.tool; - if ( - typeof input.args === "object" && - input.args !== null && - !Array.isArray(input.args) - ) { - const semanticName = - input.tool === "skill" && "name" in input.args - ? input.args.name - : input.tool === "task" && "subagent_type" in input.args - ? input.args.subagent_type - : undefined; - - if (typeof semanticName === "string" && semanticName.trim() !== "") { - observationName = `${input.tool}:${semanticName.trim()}`; - } - } + const observationName = this.getToolObservationName(input.tool, input.args); this.withObservationParent( input.sessionID, @@ -1141,6 +1125,28 @@ export class LangfuseClient { this.traceState.toolMessageIdsByCallId.delete(input.callID); } + // Include skill or subagent names for grouping, falling back to the tool name + // when arguments are missing or invalid. Preserve the original input data. + private getToolObservationName(tool: string, args: unknown) { + if (typeof args !== "object" || args === null || Array.isArray(args)) { + return tool; + } + + const semanticName = + tool === "skill" && "name" in args + ? args.name + : tool === "task" && "subagent_type" in args + ? args.subagent_type + : undefined; + + if (typeof semanticName !== "string") { + return tool; + } + + const name = semanticName.trim(); + return name === "" ? tool : `${tool}:${name}`; + } + private ensureGenerationParent(sessionID: string) { if ( this.traceState.activeGenerationSteps.has(sessionID) || From bb1baf572edbba71e4fc115dbbabf7e8d6c100c1 Mon Sep 17 00:00:00 2001 From: suolongcn <78711197+suolongcn@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:21:20 +0800 Subject: [PATCH 03/11] test: verify semantic tool names across OpenCode versions --- test/integration/runtime.test.ts | 7 +++ test/integration/v2.test.ts | 84 ++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/test/integration/runtime.test.ts b/test/integration/runtime.test.ts index d27cd58..c074b82 100644 --- a/test/integration/runtime.test.ts +++ b/test/integration/runtime.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; const originalEnvironment = { home: process.env.HOME, + userProfile: process.env.USERPROFILE, publicKey: process.env.LANGFUSE_PUBLIC_KEY, secretKey: process.env.LANGFUSE_SECRET_KEY, }; @@ -19,6 +20,11 @@ afterEach(async () => { } else { process.env.HOME = originalEnvironment.home; } + if (originalEnvironment.userProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalEnvironment.userProfile; + } if (originalEnvironment.publicKey === undefined) { delete process.env.LANGFUSE_PUBLIC_KEY; } else { @@ -42,6 +48,7 @@ describe("Langfuse runtime", () => { const { createLangfuseRuntime } = await import("../../src/runtime.js"); temporaryHome = await mkdtemp(join(process.cwd(), ".test-runtime-")); process.env.HOME = temporaryHome; + process.env.USERPROFILE = temporaryHome; process.env.LANGFUSE_PUBLIC_KEY = ""; process.env.LANGFUSE_SECRET_KEY = ""; diff --git a/test/integration/v2.test.ts b/test/integration/v2.test.ts index 89e9b72..99ae2d5 100644 --- a/test/integration/v2.test.ts +++ b/test/integration/v2.test.ts @@ -293,6 +293,90 @@ describe("OpenCode 2 package entrypoint", () => { await cleanup?.(); }); + test.each([ + { + tool: "skill", + input: { name: "resolve-dependencies" }, + }, + { + tool: "task", + input: { subagent_type: "developer", prompt: "Fix the build" }, + }, + ])( + "forwards semantic $tool input for observation naming", + async (toolCall) => { + let executeBefore: ((input: unknown) => void) | undefined; + let executeAfter: ((input: unknown) => void) | undefined; + const registration = { dispose: vi.fn(() => Promise.resolve()) }; + const contextInput: unknown = { + app: { version: "2.0.4" }, + session: { hook: vi.fn(() => Promise.resolve(registration)) }, + tool: { + hook: vi.fn((name: string, handler: (input: unknown) => void) => { + if (name === "execute.before") { + executeBefore = handler; + } + if (name === "execute.after") { + executeAfter = handler; + } + return Promise.resolve(registration); + }), + }, + event: { + subscribe: () => ({ + async *[Symbol.asyncIterator]() { + await Promise.resolve(); + yield* []; + }, + }), + }, + }; + const context = Schema.decodeUnknownSync( + Schema.declare( + (input): input is Parameters[0] => + typeof input === "object" && input !== null, + ), + )(contextInput); + + const cleanup = await SourcePlugin.setup(context); + expect(executeBefore).toBeTypeOf("function"); + expect(executeAfter).toBeTypeOf("function"); + + const input = { + id: `${toolCall.tool}-call`, + messageID: "assistant-1", + sessionID: "session-1", + tool: toolCall.tool, + input: toolCall.input, + }; + executeBefore?.(input); + executeAfter?.({ + ...input, + status: "success", + result: { content: "ok" }, + }); + + expect(runtime.traceToolStart).toHaveBeenCalledWith({ + sessionID: "session-1", + messageID: "assistant-1", + callID: `${toolCall.tool}-call`, + tool: toolCall.tool, + args: toolCall.input, + }); + expect(runtime.traceToolEnd).toHaveBeenCalledWith({ + sessionID: "session-1", + messageID: "assistant-1", + callID: `${toolCall.tool}-call`, + tool: toolCall.tool, + args: toolCall.input, + title: toolCall.tool, + output: "ok", + }); + + await cleanup?.(); + }, + ); + test("captures the complete model input from each context hook", async () => { let context: | ((input: { From 00806f00212165d0cfd4dfa19dd12811ef279a53 Mon Sep 17 00:00:00 2001 From: suolongcn <78711197+suolongcn@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:13:16 +0800 Subject: [PATCH 04/11] docs: correct OpenCode 2 local plugin path --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9e46b8..e8619bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ For OpenCode 2: ```jsonc { "$schema": "https://opencode.ai/config.json", - "plugins": ["/path/to/langfuse-opencode/dist/index.js"], + "plugins": ["/path/to/langfuse-opencode/dist/v2"], } ``` @@ -55,7 +55,7 @@ Check formatting: pnpm run format:check ``` -tsdown bundles the combined plugin and its runtime dependencies into `dist/index.js`. OpenCode 1 calls its `server()` implementation and OpenCode 2 calls its `setup()` implementation. Version-specific entrypoints remain available at `dist/v1/index.js` and `dist/v2/index.js`. Do not edit generated files in `dist/` by hand. +tsdown bundles the combined plugin and its runtime dependencies into `dist/index.js`. OpenCode 1 calls its `server()` implementation, while the OpenCode 2 local plugin loader uses the `dist/v2` directory and its `index.js` entrypoint. Version-specific entrypoints remain available at `dist/v1/index.js` and `dist/v2/index.js`. Do not edit generated files in `dist/` by hand. ## Releasing From 42d3f187f65bcaa8c76b247915a28358048ebc33 Mon Sep 17 00:00:00 2001 From: suolongcn <78711197+suolongcn@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:06:44 +0800 Subject: [PATCH 05/11] test: drop local Windows runtime adaptation --- test/integration/runtime.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/test/integration/runtime.test.ts b/test/integration/runtime.test.ts index c074b82..d27cd58 100644 --- a/test/integration/runtime.test.ts +++ b/test/integration/runtime.test.ts @@ -6,7 +6,6 @@ import { afterEach, describe, expect, test, vi } from "vitest"; const originalEnvironment = { home: process.env.HOME, - userProfile: process.env.USERPROFILE, publicKey: process.env.LANGFUSE_PUBLIC_KEY, secretKey: process.env.LANGFUSE_SECRET_KEY, }; @@ -20,11 +19,6 @@ afterEach(async () => { } else { process.env.HOME = originalEnvironment.home; } - if (originalEnvironment.userProfile === undefined) { - delete process.env.USERPROFILE; - } else { - process.env.USERPROFILE = originalEnvironment.userProfile; - } if (originalEnvironment.publicKey === undefined) { delete process.env.LANGFUSE_PUBLIC_KEY; } else { @@ -48,7 +42,6 @@ describe("Langfuse runtime", () => { const { createLangfuseRuntime } = await import("../../src/runtime.js"); temporaryHome = await mkdtemp(join(process.cwd(), ".test-runtime-")); process.env.HOME = temporaryHome; - process.env.USERPROFILE = temporaryHome; process.env.LANGFUSE_PUBLIC_KEY = ""; process.env.LANGFUSE_SECRET_KEY = ""; From eec83cbf44c2b292e1eb349532b2d7521a6b20b1 Mon Sep 17 00:00:00 2001 From: suolongcn <78711197+suolongcn@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:14:04 +0800 Subject: [PATCH 06/11] docs: keep contributing guide aligned with upstream --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8619bb..b9e46b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ For OpenCode 2: ```jsonc { "$schema": "https://opencode.ai/config.json", - "plugins": ["/path/to/langfuse-opencode/dist/v2"], + "plugins": ["/path/to/langfuse-opencode/dist/index.js"], } ``` @@ -55,7 +55,7 @@ Check formatting: pnpm run format:check ``` -tsdown bundles the combined plugin and its runtime dependencies into `dist/index.js`. OpenCode 1 calls its `server()` implementation, while the OpenCode 2 local plugin loader uses the `dist/v2` directory and its `index.js` entrypoint. Version-specific entrypoints remain available at `dist/v1/index.js` and `dist/v2/index.js`. Do not edit generated files in `dist/` by hand. +tsdown bundles the combined plugin and its runtime dependencies into `dist/index.js`. OpenCode 1 calls its `server()` implementation and OpenCode 2 calls its `setup()` implementation. Version-specific entrypoints remain available at `dist/v1/index.js` and `dist/v2/index.js`. Do not edit generated files in `dist/` by hand. ## Releasing From baec874975488ae03a0f05d7e30ee74dc5aca5f3 Mon Sep 17 00:00:00 2001 From: suolongcn <78711197+suolongcn@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:17:16 +0800 Subject: [PATCH 07/11] docs: keep readme aligned with upstream --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index cb32640..58c1da8 100644 --- a/README.md +++ b/README.md @@ -62,18 +62,6 @@ export LANGFUSE_USER_ID="your-user-id" If both `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` are set, the plugin uses environment variables instead of reading the config file. Optional values can be supplied either way. -## Tool Observation Names - -Skill and subagent tool observations include the skill name or subagent type so -Langfuse dashboards can group and filter their metrics by observation name: - -- `skill` with input `{ "name": "resolve-dependencies" }` is named `skill:resolve-dependencies`. -- `task` with input `{ "subagent_type": "developer" }` is named `task:developer`. - -Leading and trailing whitespace is trimmed from the name. Missing, blank, or -non-string values fall back to `skill` or `task`. Other tool names are unchanged. -The original tool input and the metadata `tool` field remain unchanged. - ## Contributing See the [contributing guide](./CONTRIBUTING.md). From c411febde007514d4416db5c7c05c0ace07cca07 Mon Sep 17 00:00:00 2001 From: suolongcn <78711197+suolongcn@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:24:33 +0800 Subject: [PATCH 08/11] refactor: simplify semantic tool name selection --- src/langfuse.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/langfuse.ts b/src/langfuse.ts index bafcdd1..c9b6332 100644 --- a/src/langfuse.ts +++ b/src/langfuse.ts @@ -1131,19 +1131,20 @@ export class LangfuseClient { this.traceState.toolMessageIdsByCallId.delete(input.callID); } - // Include skill or subagent names for grouping, falling back to the tool name - // when arguments are missing or invalid. Preserve the original input data. + // Use skill or subagent names when available; otherwise fall back to the tool name. private getToolObservationName(tool: string, args: unknown) { if (typeof args !== "object" || args === null || Array.isArray(args)) { return tool; } - const semanticName = - tool === "skill" && "name" in args - ? args.name - : tool === "task" && "subagent_type" in args - ? args.subagent_type - : undefined; + let semanticName: unknown; + if (tool === "skill" && "name" in args) { + semanticName = args.name; + } else if (tool === "task" && "subagent_type" in args) { + semanticName = args.subagent_type; + } else { + return tool; + } if (typeof semanticName !== "string") { return tool; From 59814d2099c94c0af471bb09239e4690e1d91665 Mon Sep 17 00:00:00 2001 From: suolongcn <78711197+suolongcn@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:18:08 +0800 Subject: [PATCH 09/11] fix: support OpenCode 2 skill identifiers --- src/langfuse.ts | 10 ++++++++-- test/integration/v1.test.ts | 7 +++++++ test/integration/v2.test.ts | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/langfuse.ts b/src/langfuse.ts index c9b6332..0d05376 100644 --- a/src/langfuse.ts +++ b/src/langfuse.ts @@ -1138,8 +1138,14 @@ export class LangfuseClient { } let semanticName: unknown; - if (tool === "skill" && "name" in args) { - semanticName = args.name; + if (tool === "skill") { + if ("name" in args && typeof args.name === "string") { + semanticName = args.name; + } else if ("id" in args) { + semanticName = args.id; + } else { + return tool; + } } else if (tool === "task" && "subagent_type" in args) { semanticName = args.subagent_type; } else { diff --git a/test/integration/v1.test.ts b/test/integration/v1.test.ts index e54c4ed..b6d58c0 100644 --- a/test/integration/v1.test.ts +++ b/test/integration/v1.test.ts @@ -1264,6 +1264,8 @@ describe("built plugin", { concurrent: false }, () => { { tool: "skill", args: { name: " \t " }, name: "skill" }, { tool: "skill", args: { name: 42 }, name: "skill" }, { tool: "skill", args: { name: ["review"] }, name: "skill" }, + { tool: "skill", args: { id: "" }, name: "skill" }, + { tool: "skill", args: { id: 42 }, name: "skill" }, { tool: "skill", args: null, name: "skill" }, { tool: "skill", args: "review", name: "skill" }, { tool: "task", args: { name: "developer" }, name: "task" }, @@ -1277,6 +1279,11 @@ describe("built plugin", { concurrent: false }, () => { name: "read", }, { tool: "skill", args: { name: " review " }, name: "skill:review" }, + { + tool: "skill", + args: { id: " build-project " }, + name: "skill:build-project", + }, { tool: "task", args: { subagent_type: " developer " }, diff --git a/test/integration/v2.test.ts b/test/integration/v2.test.ts index 99ae2d5..f526463 100644 --- a/test/integration/v2.test.ts +++ b/test/integration/v2.test.ts @@ -296,7 +296,7 @@ describe("OpenCode 2 package entrypoint", () => { test.each([ { tool: "skill", - input: { name: "resolve-dependencies" }, + input: { id: "build-project" }, }, { tool: "task", From ab787fc90ba084407ad5383f0a9234a5f07b73e1 Mon Sep 17 00:00:00 2001 From: suolongcn <78711197+suolongcn@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:36:56 +0800 Subject: [PATCH 10/11] fix: support OpenCode 2 subagent identifiers --- src/langfuse.ts | 2 ++ test/integration/v1.test.ts | 8 ++++++++ test/integration/v2.test.ts | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/src/langfuse.ts b/src/langfuse.ts index 0d05376..441dde0 100644 --- a/src/langfuse.ts +++ b/src/langfuse.ts @@ -1148,6 +1148,8 @@ export class LangfuseClient { } } else if (tool === "task" && "subagent_type" in args) { semanticName = args.subagent_type; + } else if (tool === "subagent" && "agent" in args) { + semanticName = args.agent; } else { return tool; } diff --git a/test/integration/v1.test.ts b/test/integration/v1.test.ts index b6d58c0..b69eec3 100644 --- a/test/integration/v1.test.ts +++ b/test/integration/v1.test.ts @@ -1273,6 +1273,9 @@ describe("built plugin", { concurrent: false }, () => { { tool: "task", args: { subagent_type: " \n " }, name: "task" }, { tool: "task", args: { subagent_type: null }, name: "task" }, { tool: "task", args: [], name: "task" }, + { tool: "subagent", args: {}, name: "subagent" }, + { tool: "subagent", args: { agent: "" }, name: "subagent" }, + { tool: "subagent", args: { agent: 42 }, name: "subagent" }, { tool: "read", args: { name: "README.md", subagent_type: "developer" }, @@ -1289,6 +1292,11 @@ describe("built plugin", { concurrent: false }, () => { args: { subagent_type: " developer " }, name: "task:developer", }, + { + tool: "subagent", + args: { agent: " ts-reviewer " }, + name: "subagent:ts-reviewer", + }, ]; for (const [index, { tool, args }] of cases.entries()) { await hooks["tool.execute.after"]?.( diff --git a/test/integration/v2.test.ts b/test/integration/v2.test.ts index f526463..823dab2 100644 --- a/test/integration/v2.test.ts +++ b/test/integration/v2.test.ts @@ -302,6 +302,10 @@ describe("OpenCode 2 package entrypoint", () => { tool: "task", input: { subagent_type: "developer", prompt: "Fix the build" }, }, + { + tool: "subagent", + input: { agent: "ts-reviewer", prompt: "Review the build" }, + }, ])( "forwards semantic $tool input for observation naming", async (toolCall) => { From 17d951e3113e753043079d10407c3ce640283f72 Mon Sep 17 00:00:00 2001 From: suolongcn <78711197+suolongcn@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:53:37 +0800 Subject: [PATCH 11/11] docs: clarify OpenCode version-specific tool fields --- src/langfuse.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/langfuse.ts b/src/langfuse.ts index 441dde0..4bc34f4 100644 --- a/src/langfuse.ts +++ b/src/langfuse.ts @@ -1139,6 +1139,7 @@ export class LangfuseClient { let semanticName: unknown; if (tool === "skill") { + // OpenCode v1 sends the skill name; OpenCode v2 sends the skill id. if ("name" in args && typeof args.name === "string") { semanticName = args.name; } else if ("id" in args) { @@ -1146,8 +1147,10 @@ export class LangfuseClient { } else { return tool; } + // OpenCode v1 represents subagent calls as task tools. } else if (tool === "task" && "subagent_type" in args) { semanticName = args.subagent_type; + // OpenCode v2 represents subagent calls as dedicated subagent tools. } else if (tool === "subagent" && "agent" in args) { semanticName = args.agent; } else {