From 3752f1c34cdbd98b19b263d0d613c9ceb9b36876 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 2 Jul 2026 23:53:14 +0800 Subject: [PATCH] Track implicit skill loads --- src/event-processor.ts | 24 +++++++++++++++++++++++- src/tracing.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/tracing.ts | 24 +++++++++++++++++++++++- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/event-processor.ts b/src/event-processor.ts index ae66997..64b9ec6 100644 --- a/src/event-processor.ts +++ b/src/event-processor.ts @@ -16,6 +16,26 @@ function generateUUID(): string { return crypto.randomUUID() } +function skillLoadMetadata(tool: string, args: unknown): Record | undefined { + if (tool !== "skill") return undefined + const name = + args && typeof args === "object" && "name" in args && typeof args.name === "string" + ? args.name + : undefined + return { + skill_name: name, + } +} + +function skillToolSpanName(tool: string, title: string, args: unknown, fallback: string): string { + if (tool !== "skill") return fallback + const name = + args && typeof args === "object" && "name" in args && typeof args.name === "string" + ? args.name + : undefined + return name ? `skill: ${name}` : title || "skill" +} + // State management for tracing interface SessionState { rootSpanId: string @@ -311,6 +331,7 @@ export class EventProcessor { // MCP tools return { content: [{ type: "text", text: "..." }] } instead of a plain // output value, so extract the text from content[] as a last resort. const rawOutput = capturedOutput !== undefined ? capturedOutput : extractToolOutput(output) + const formattedName = this.formatToolName(tool, title) const toolSpan: SpanData = { id: generateUUID(), span_id: toolSpanId, @@ -323,13 +344,14 @@ export class EventProcessor { call_id: callID, title, reasoning: reasoning || undefined, + ...skillLoadMetadata(tool, toolArgs), }, metrics: { start: startTime, end: endTime, }, span_attributes: { - name: this.formatToolName(tool, title), + name: skillToolSpanName(tool, title, toolArgs, formattedName), type: "tool", }, } diff --git a/src/tracing.test.ts b/src/tracing.test.ts index 8948f4a..2d38004 100644 --- a/src/tracing.test.ts +++ b/src/tracing.test.ts @@ -241,6 +241,45 @@ describe("Event to Span Transformation", () => { ) }) + it("normalizes native skill tool calls as skill load spans", async () => { + const sessionId = "ses_skill_tool" + const messageId = "msg_1" + + await assertEventsProduceTree( + session( + sessionId, + sessionCreated(sessionId), + chatMessage("Use the review skill"), + toolCallPart(sessionId, messageId, "call_skill", "skill", { name: "review" }), + toolExecute("call_skill", "skill", "review", { name: "review" }, "Loaded skill review"), + textPart(sessionId, messageId, "Loaded the review skill."), + messageCompleted(sessionId, messageId, { tokens: { input: 20, output: 15 } }), + sessionIdle(sessionId), + ), + { + span_attributes: { name: "OpenCode: test-project", type: "task" }, + children: [ + { + span_attributes: { name: "Turn 1", type: "task" }, + children: [ + { + span_attributes: { name: "skill: review", type: "tool" }, + metadata: { + tool_name: "skill", + call_id: "call_skill", + skill_name: "review", + }, + }, + { + span_attributes: { name: "anthropic/claude-3-haiku", type: "llm" }, + }, + ], + }, + ], + }, + ) + }) + it("session -> turn -> multiple tool calls", async () => { const sessionId = "ses_multi_tool" const messageId = "msg_1" diff --git a/src/tracing.ts b/src/tracing.ts index ef50a29..48f5bf1 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -22,6 +22,26 @@ function generateUUID(): string { return crypto.randomUUID() } +function skillLoadMetadata(tool: string, args: unknown): Record | undefined { + if (tool !== "skill") return undefined + const name = + args && typeof args === "object" && "name" in args && typeof args.name === "string" + ? args.name + : undefined + return { + skill_name: name, + } +} + +function skillToolSpanName(tool: string, title: string, args: unknown, fallback: string): string { + if (tool !== "skill") return fallback + const name = + args && typeof args === "object" && "name" in args && typeof args.name === "string" + ? args.name + : undefined + return name ? `skill: ${name}` : title || "skill" +} + // State management for tracing interface SessionState { rootSpanId: string @@ -1018,6 +1038,7 @@ export function createTracingHooks( const rawOutput = capturedOutput !== undefined ? capturedOutput : extractToolOutput(result.output ?? result) const toolOutput = typeof rawOutput === "string" ? rawOutput.substring(0, 10000) : rawOutput + const formattedName = formatToolName(tool, result.title) const toolSpan: SpanData = { id: generateUUID(), span_id: toolSpanId, @@ -1030,13 +1051,14 @@ export function createTracingHooks( call_id: callID, title: result.title, reasoning: reasoning || undefined, + ...skillLoadMetadata(tool, toolArgs), }, metrics: { start: startTime, end: endTime, }, span_attributes: { - name: formatToolName(tool, result.title), + name: skillToolSpanName(tool, result.title, toolArgs, formattedName), type: "tool", }, }