feat(observability): add v2 genai tracing

This commit is contained in:
starptech 2026-07-08 18:02:31 +02:00
commit 0271872c09
42 changed files with 3150 additions and 373 deletions

View file

@ -1,11 +1,23 @@
import { afterEach, describe, expect, test } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Logger } from "effect"
import {
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
ATTR_OPENCODE_CLIENT,
ATTR_OPENCODE_RUN,
ATTR_SERVICE_INSTANCE_ID,
ATTR_SERVICE_NAMESPACE,
} from "@opencode-ai/core/observability/semconv"
import { Deferred, Effect, Fiber, Layer, Logger, Option, Tracer } from "effect"
import { ParentSpan, type Span } from "effect/Tracer"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { fileLogger } from "../../src/observability/logging"
import { resource } from "../../src/observability/otlp"
import { SessionTelemetry } from "../../src/observability/session"
import { HttpTelemetry } from "../../src/observability/http"
import { it } from "../lib/effect"
const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
const opencodeClient = process.env.OPENCODE_CLIENT
@ -20,8 +32,7 @@ afterEach(() => {
describe("resource", () => {
test("parses and decodes OTEL resource attributes", () => {
process.env.OTEL_RESOURCE_ATTRIBUTES =
"service.namespace=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here"
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_SERVICE_NAMESPACE}=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here`
expect(resource().attributes).toMatchObject({
"service.namespace": "anomalyco",
@ -32,26 +43,87 @@ describe("resource", () => {
})
test("drops OTEL resource attributes when any entry is invalid", () => {
process.env.OTEL_RESOURCE_ATTRIBUTES = "service.namespace=anomalyco,broken"
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_SERVICE_NAMESPACE}=anomalyco,broken`
expect(resource().attributes["service.namespace"]).toBeUndefined()
expect(resource().attributes["opencode.client"]).toBeDefined()
expect(resource().attributes[ATTR_SERVICE_NAMESPACE]).toBeUndefined()
expect(resource().attributes[ATTR_OPENCODE_CLIENT]).toBeDefined()
})
test("keeps built-in attributes when env values conflict", () => {
process.env.OPENCODE_CLIENT = "cli"
process.env.OTEL_RESOURCE_ATTRIBUTES =
"opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_OPENCODE_CLIENT}=web,${ATTR_SERVICE_INSTANCE_ID}=override,${ATTR_SERVICE_NAMESPACE}=anomalyco`
expect(resource().attributes).toMatchObject({
"opencode.client": "cli",
"service.namespace": "anomalyco",
[ATTR_OPENCODE_CLIENT]: "cli",
[ATTR_SERVICE_NAMESPACE]: "anomalyco",
})
expect(resource().attributes["service.instance.id"]).not.toBe("override")
expect(resource().attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
expect(resource().attributes[ATTR_SERVICE_INSTANCE_ID]).not.toBe("override")
expect(resource().attributes[ATTR_OPENCODE_RUN]).toMatch(/^[0-9a-f]{8}$/)
})
test("uses deployment environment from OTEL resource attributes", () => {
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_DEPLOYMENT_ENVIRONMENT_NAME}=development`
expect(resource().attributes[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]).toBe("development")
})
})
it.effect("retains an execution trace parent until the execution settles", () =>
Effect.gen(function* () {
const telemetry = SessionTelemetry.makeExecution<string>()
const started = yield* Deferred.make<void>()
let parent: Span | undefined
yield* Effect.useSpan("parent", (span) =>
Effect.gen(function* () {
parent = span
const joiner = yield* telemetry
.resume("session", Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)))
.pipe(Effect.provideService(SessionTelemetry.TraceParent, span), Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(joiner)
}),
)
const retained = Option.getOrUndefined(yield* telemetry.drain("session", Effect.serviceOption(ParentSpan)))
expect(retained).toBe(parent)
yield* telemetry.settled("session")
const released = Option.getOrUndefined(yield* telemetry.drain("session", Effect.serviceOption(ParentSpan)))
expect(released).toBeUndefined()
}),
)
it.effect("retains an external ambient trace parent", () =>
Effect.gen(function* () {
const telemetry = SessionTelemetry.makeExecution<string>()
const parent = Tracer.externalSpan({ traceId: "1".repeat(32), spanId: "2".repeat(16) })
yield* telemetry.resume("session", Effect.void).pipe(Effect.withParentSpan(parent))
const retained = Option.getOrUndefined(yield* telemetry.drain("session", Effect.serviceOption(ParentSpan)))
expect(retained).toBe(parent)
}),
)
it.effect("applies HTTP response validation without a parent span", () =>
Effect.gen(function* () {
const request = HttpClientRequest.get("https://example.test/missing")
const http = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, new Response("missing", { status: 404 }))),
)
const exit = yield* HttpTelemetry.use(
http,
request,
Effect.succeed,
HttpClientResponse.filterStatusOk,
).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
test("falls back to local logging when OTLP initialization fails", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-observability-test-"))
await using _ = {

View file

@ -15,7 +15,8 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { ATTR_ERROR_TYPE } from "@opencode-ai/core/observability/semconv"
import { Deferred, Effect, Fiber, Layer, Stream, Tracer } from "effect"
import { testEffect } from "./lib/effect"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
@ -293,6 +294,14 @@ it.effect("waits for permission before calling an MCP tool", () =>
it.effect("does not call MCP when permission is blocked", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
calls = 0
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
@ -308,12 +317,17 @@ it.effect("does not call MCP when permission is blocked", () =>
name: "execute",
input: { code: "return await tools.demo.search({})" },
},
})
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" })
expect(settlement.output?.structured).toEqual({
toolCalls: [{ tool: "demo.search", status: "error" }],
error: true,
})
expect(calls).toBe(0)
const outer = spans.find((span) => span.name === "execute_tool execute")
const nested = spans.find((span) => span.name === "execute_tool demo_search")
expect(outer?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.execution")
expect(nested?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.execution")
expect(nested?.parent._tag === "Some" && nested.parent.value === outer).toBeTrue()
}),
)

View file

@ -1,5 +1,11 @@
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
import {
ATTR_GEN_AI_CONVERSATION_ID,
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
ATTR_HTTP_REQUEST_METHOD,
} from "@opencode-ai/core/observability/semconv"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { Database } from "@opencode-ai/core/database/database"
@ -39,7 +45,7 @@ import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { Effect, Layer, Tracer } from "effect"
import path from "node:path"
import { testEffect } from "./lib/effect"
@ -93,6 +99,14 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
@ -108,7 +122,10 @@ const execution = Layer.effect(
awaitIdle: coordinator.awaitIdle,
})
}),
).pipe(Layer.provide(runnerLayer))
).pipe(
Layer.provide(runnerLayer),
Layer.updateService(Tracer.Tracer, () => tracer),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
@ -149,6 +166,7 @@ const sessionID = SessionV2.ID.make("ses_runner_recorded")
describe("SessionRunnerLLM recorded", () => {
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
Effect.gen(function* () {
spans.length = 0
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
@ -185,6 +203,14 @@ describe("SessionRunnerLLM recorded", () => {
expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([
{ type: "text", text: "Hello!" },
])
const agent = spans.find((span) => span.name === "invoke_agent")
const model = spans.find((span) => span.name === "chat gpt-4o-mini")
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
expect(agent?.attributes.get(ATTR_GEN_AI_CONVERSATION_ID)).toBe(sessionID)
expect(model?.parent._tag === "Some" ? model.parent.value.spanId : undefined).toBe(agent?.spanId)
expect(http?.parent._tag === "Some" ? http.parent.value.spanId : undefined).toBe(model?.spanId)
expect(model?.attributes.get(ATTR_GEN_AI_USAGE_INPUT_TOKENS)).toBeNumber()
expect(model?.attributes.get(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS)).toBeNumber()
expect(
(yield* db
.select({ type: EventTable.type })

View file

@ -7,8 +7,22 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ATTR_ERROR_TYPE, ATTR_GEN_AI_TOOL_CALL_ID } from "@opencode-ai/core/observability/semconv"
import { executeTool, settleTool, testModel, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import {
Cause,
Deferred,
Effect,
Exit,
Fiber,
Layer,
Option,
Schema,
SchemaGetter,
SchemaIssue,
Scope,
Tracer,
} from "effect"
import { testEffect } from "./lib/effect"
const bounds: ToolOutputStore.BoundInput[] = []
@ -212,6 +226,33 @@ describe("ToolRegistry", () => {
}),
)
it.effect("traces unknown and stale tool attempts", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize({ model: testModel })
yield* materialized.settle(call("missing", "call-unknown")).pipe(Effect.provideService(Tracer.Tracer, tracer))
yield* service.register({ echo: make() })
yield* materialized.settle(call("echo", "call-stale")).pipe(Effect.provideService(Tracer.Tracer, tracer))
expect(spans.map((span) => span.name)).toEqual(["execute_tool missing", "execute_tool echo"])
expect(spans[0]?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.unknown")
expect(spans[0]?.attributes.get(ATTR_GEN_AI_TOOL_CALL_ID)).toBe("call-unknown")
expect(spans[1]?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.stale")
expect(spans[1]?.attributes.get(ATTR_GEN_AI_TOOL_CALL_ID)).toBe("call-stale")
expect(spans.every((span) => span.status._tag === "Ended" && span.status.exit._tag === "Failure")).toBeTrue()
}),
)
it.effect("propagates retention failures through settlement", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service

View file

@ -8,9 +8,42 @@ import {
TransportReason,
InvalidRequestReason,
RateLimitReason,
Usage,
type LLMClientShape,
type LLMRequest,
} from "@opencode-ai/llm"
import {
ATTR_ERROR_TYPE,
ATTR_GEN_AI_AGENT_NAME,
ATTR_GEN_AI_CONVERSATION_ID,
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
ATTR_GEN_AI_TOOL_CALL_ID,
ATTR_GEN_AI_TOOL_NAME,
GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL,
GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT,
ATTR_OPENCODE_AGENT_STEP_INDEX,
ATTR_OPENCODE_AGENT_STEP_TRIGGER,
ATTR_OPENCODE_COMPACTION_REASON,
ATTR_OPENCODE_ERROR_STAGE,
ATTR_OPENCODE_ERROR_SOURCE,
ATTR_OPENCODE_RETRY_DECISION,
ATTR_OPENCODE_RETRY_DELAY_SOURCE,
ATTR_OPENCODE_RETRY_MAX_ATTEMPTS,
ATTR_OPENCODE_RETRY_ATTEMPT,
ATTR_OPENCODE_RETRY_DELAY_MS,
ATTR_OPENCODE_SESSION_INPUT_COUNT,
ATTR_OPENCODE_SESSION_INPUT_DELIVERY,
ATTR_OPENCODE_SESSION_PARENT_ID,
EVENT_OPENCODE_COMPACTION_COMPLETED,
EVENT_OPENCODE_RETRY_SCHEDULED,
EVENT_OPENCODE_RETRY_STOPPED,
EVENT_OPENCODE_SESSION_INPUT_PROMOTED,
} from "@opencode-ai/core/observability/semconv"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { Database } from "@opencode-ai/core/database/database"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
@ -61,7 +94,7 @@ import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream, Tracer } from "effect"
import { TestClock } from "effect/testing"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@ -259,6 +292,14 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[McpGuidance.node, mcpGuidance],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
@ -274,7 +315,10 @@ const execution = Layer.effect(
awaitIdle: coordinator.awaitIdle,
})
}),
).pipe(Layer.provide(runnerLayer))
).pipe(
Layer.provide(runnerLayer),
Layer.updateService(Tracer.Tracer, () => tracer),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
@ -338,6 +382,7 @@ const insertSession = (id: SessionV2.ID) =>
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
requests.length = 0
response = []
systemBaseline = "Initial context"
systemRemoved = false
@ -356,6 +401,7 @@ const setup = Effect.gen(function* () {
toolExecutionsReady = 5
activeToolExecutions = 0
maxActiveToolExecutions = 0
spans.length = 0
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
@ -643,6 +689,180 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
})
describe("SessionRunnerLLM", () => {
it.effect("parents forked tool spans under the V2 agent turn", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use echo" }), resume: false })
const usage = new Usage({
inputTokens: 8,
outputTokens: 3,
cacheReadInputTokens: 2,
cacheWriteInputTokens: 1,
reasoningTokens: 1,
})
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-telemetry", name: "echo", input: { text: "hello" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls", usage }),
LLMEvent.finish({ reason: "tool-calls", usage }),
],
[],
]
yield* session.resume(sessionID)
const agent = spans.find((span) => span.name === "invoke_agent")
const tool = spans.find((span) => span.name === "execute_tool echo")
expect(agent?.attributes).toMatchObject(
new Map([
[ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT],
[ATTR_GEN_AI_AGENT_NAME, "build"],
[ATTR_GEN_AI_CONVERSATION_ID, sessionID],
]),
)
expect(agent?.events.find(([name]) => name === EVENT_OPENCODE_SESSION_INPUT_PROMOTED)?.[2]).toMatchObject({
[ATTR_OPENCODE_SESSION_INPUT_DELIVERY]: "steer",
[ATTR_OPENCODE_SESSION_INPUT_COUNT]: 1,
})
expect(ancestorNames(agent)).toContain("SessionRunner.drain")
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_INPUT_TOKENS)).toBe(8)
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS)).toBe(3)
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS)).toBe(2)
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS)).toBe(1)
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS)).toBe(1)
expect(tool?.attributes).toMatchObject(
new Map([
[ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL],
[ATTR_GEN_AI_TOOL_NAME, "echo"],
[ATTR_GEN_AI_TOOL_CALL_ID, "call-telemetry"],
[ATTR_GEN_AI_CONVERSATION_ID, sessionID],
]),
)
expect(ancestorNames(tool)).toContain("invoke_agent")
expect(ancestorNames(tool)).not.toContain("SessionRunner.attemptStep")
expect(ancestorNames(tool)).not.toContain("chat fake-model")
expect(tool?.status._tag === "Ended" && tool.status.exit._tag).toBe("Success")
requests.length = 0
spans.length = 0
}),
)
it.effect("tracks V2 subagent sessions with their parent session", () =>
Effect.gen(function* () {
yield* setup
const agents = yield* AgentV2.Service
yield* agents.transform((editor) =>
editor.update(AgentV2.ID.make("explore"), (agent) => {
agent.mode = "subagent"
}),
)
const session = yield* SessionV2.Service
const child = yield* session.create({
id: otherSessionID,
parentID: sessionID,
agent: AgentV2.ID.make("explore"),
})
yield* session.prompt({
sessionID: child.id,
prompt: PromptInput.Prompt.make({ text: "Explore" }),
resume: false,
})
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-subagent-telemetry", name: "echo", input: { text: "found" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
yield* session.resume(child.id)
const agent = spans.find((span) => span.name === "invoke_agent")
const tool = spans.find((span) => span.name === "execute_tool echo")
expect(agent?.attributes).toMatchObject(
new Map([
[ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT],
[ATTR_GEN_AI_AGENT_NAME, "explore"],
[ATTR_GEN_AI_CONVERSATION_ID, child.id],
[ATTR_OPENCODE_SESSION_PARENT_ID, sessionID],
]),
)
expect(tool?.attributes).toMatchObject(
new Map([
[ATTR_GEN_AI_AGENT_NAME, "explore"],
[ATTR_GEN_AI_CONVERSATION_ID, child.id],
[ATTR_OPENCODE_SESSION_PARENT_ID, sessionID],
]),
)
expect(ancestorNames(tool)).toContain("invoke_agent")
}),
)
it.effect("distinguishes input and tool-result model calls", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Use echo twice" }),
resume: false,
})
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-context-1", name: "echo", input: { text: "first" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-context-2", name: "echo", input: { text: "second" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
yield* session.resume(sessionID)
const tools = spans.filter((span) => span.name === "execute_tool echo")
expect(tools).toHaveLength(2)
expect(tools[0]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_INDEX)).toBe(1)
expect(tools[0]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_TRIGGER)).toBe("input")
expect(tools[1]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_INDEX)).toBe(2)
expect(tools[1]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_TRIGGER)).toBe("tool_result")
}),
)
it.effect("marks failed tool spans before returning the error to the model", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail tool" }), resume: false })
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-telemetry-failure", name: "echo", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
yield* session.resume(sessionID)
const tool = spans.find((span) => span.name === "execute_tool echo")
expect(tool?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.execution")
expect(tool?.status._tag === "Ended" && tool.status.exit._tag).toBe("Failure")
requests.length = 0
spans.length = 0
}),
)
it.effect("advertises and executes a location registered tool", () =>
Effect.gen(function* () {
yield* setup
@ -1445,6 +1665,12 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(
spans
.filter((span) => span.name === "invoke_agent")
.at(-1)
?.events.find(([name]) => name === EVENT_OPENCODE_COMPACTION_COMPLETED)?.[2],
).toMatchObject({ [ATTR_OPENCODE_COMPACTION_REASON]: "automatic" })
expect(userTexts(requests[0])[0]).toContain("## Objective")
expect(userTexts(requests[1])).toHaveLength(1)
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
@ -3429,6 +3655,9 @@ describe("SessionRunnerLLM", () => {
{ type: "assistant", finish: "error", error: { type: "aborted", message: "Step interrupted" } },
])
expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1")
const agent = spans.find((span) => span.name === "invoke_agent")
expect(agent?.attributes.get(ATTR_ERROR_TYPE)).toBe("canceled")
expect(agent?.status._tag === "Ended" && agent.status.exit._tag).toBe("Failure")
yield* session.interrupt(sessionID)
}),
)
@ -3766,7 +3995,20 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes).toContain("session.retry.scheduled.1")
expect(
spans
.find((span) => span.name === "invoke_agent")
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_SCHEDULED)?.[2],
).toMatchObject({
[ATTR_OPENCODE_RETRY_ATTEMPT]: 2,
[ATTR_OPENCODE_RETRY_MAX_ATTEMPTS]: 5,
[ATTR_OPENCODE_RETRY_DELAY_MS]: 2_000,
[ATTR_OPENCODE_RETRY_DELAY_SOURCE]: "backoff",
[ATTR_OPENCODE_RETRY_DECISION]: "scheduled",
[ATTR_ERROR_TYPE]: "provider.transport",
})
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2)
expect(spans.find((span) => span.name === "invoke_agent")?.attributes.has(ATTR_OPENCODE_ERROR_STAGE)).toBeFalse()
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
@ -3826,8 +4068,26 @@ describe("SessionRunnerLLM", () => {
{ attempt: 4, at: 14_000 },
{ attempt: 5, at: 30_000 },
])
expect(
spans
.find((span) => span.name === "invoke_agent")
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_STOPPED)?.[2],
).toMatchObject({
[ATTR_OPENCODE_RETRY_DECISION]: "exhausted",
[ATTR_OPENCODE_RETRY_ATTEMPT]: 5,
[ATTR_OPENCODE_RETRY_MAX_ATTEMPTS]: 5,
})
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
const agent = spans.find((span) => span.name === "invoke_agent")
expect(agent?.attributes.get(ATTR_ERROR_TYPE)).toBe("provider.transport")
expect(agent?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("provider")
expect(agent?.attributes.get(ATTR_OPENCODE_ERROR_STAGE)).toBe("model")
if (agent?.status._tag === "Ended" && agent.status.exit._tag === "Failure") {
const failure = Cause.squash(agent.status.exit.cause)
expect(failure).toMatchObject({ message: "provider.transport" })
expect(failure).not.toMatchObject({ message: "Provider unavailable" })
}
}),
)
@ -4310,3 +4570,13 @@ describe("SessionRunnerLLM", () => {
}),
)
})
function ancestorNames(span: Tracer.NativeSpan | undefined) {
const names: string[] = []
let current = span?.parent._tag === "Some" ? span.parent.value : undefined
while (current?._tag === "Span") {
names.push(current.name)
current = current.parent._tag === "Some" ? current.parent.value : undefined
}
return names
}

View file

@ -1,5 +1,9 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Schema } from "effect"
import { DateTime, Effect, Layer, Schema, Tracer } from "effect"
import {
ATTR_OPENCODE_SUBAGENT_AGENT_NAME,
ATTR_OPENCODE_SUBAGENT_SESSION_ID,
} from "@opencode-ai/core/observability/semconv"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@ -22,6 +26,7 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { SessionTelemetry } from "@opencode-ai/core/observability/session"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, settleTool, testModel, toolIdentity, waitForTool } from "./lib/tool"
@ -30,6 +35,7 @@ const childText = "child final response"
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") })
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let resumedWith: Tracer.AnySpan | undefined
const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID
@ -76,7 +82,11 @@ const executionNode = makeGlobalNode({
})
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: complete,
resume: (sessionID) =>
Effect.gen(function* () {
resumedWith = (yield* SessionTelemetry.TraceParent) ?? undefined
return yield* complete(sessionID)
}),
wake: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
@ -171,6 +181,14 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const settled = yield* settleTool(registry, {
sessionID: parent.id,
@ -181,7 +199,7 @@ describe("SubagentTool", () => {
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review this" },
},
})
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
@ -191,6 +209,10 @@ describe("SubagentTool", () => {
agent: "reviewer",
model: childModel,
})
const span = spans.find((span) => span.name === "execute_tool subagent")
expect(span?.attributes.get(ATTR_OPENCODE_SUBAGENT_AGENT_NAME)).toBe("reviewer")
expect(span?.attributes.get(ATTR_OPENCODE_SUBAGENT_SESSION_ID)).toBe(child.id)
expect(resumedWith?.spanId).toBe(span?.spanId)
const fallback = yield* settleTool(registry, {
sessionID: parent.id,
@ -255,6 +277,15 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
resumedWith = undefined
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const settled = yield* settleTool(registry, {
sessionID: parent.id,
@ -265,7 +296,7 @@ describe("SubagentTool", () => {
name: SubagentTool.name,
input: { agent: "reviewer", description: "background review", prompt: "review", background: true },
},
})
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({ status: "running" })
@ -274,6 +305,7 @@ describe("SubagentTool", () => {
expect(synthetic).toHaveLength(1)
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
expect(synthetic[0]?.text).toContain(childText)
expect(resumedWith).toBeUndefined()
}),
),
),

View file

@ -1,5 +1,12 @@
import { describe, expect, test } from "bun:test"
import { Duration, Effect, Fiber, Layer, Schema } from "effect"
import { Duration, Effect, Fiber, Layer, Schema, Tracer } from "effect"
import {
ATTR_ERROR_TYPE,
ATTR_HTTP_REQUEST_METHOD,
ATTR_HTTP_RESPONSE_STATUS_CODE,
ATTR_SERVER_PORT,
ATTR_URL_FULL,
} from "@opencode-ai/core/observability/semconv"
import * as TestClock from "effect/testing/TestClock"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -88,9 +95,21 @@ describe("WebFetchTool registration", () => {
reset()
const registry = yield* ToolRegistry.Service
const url = "http://example.com/public"
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
expect(
yield* settleTool(registry, call({ url, format: "text", timeout: 4 })).pipe(
Effect.provideService(Tracer.Tracer, tracer),
),
).toEqual({
result: { type: "text", value: "hello" },
output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
@ -101,6 +120,38 @@ describe("WebFetchTool registration", () => {
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
])
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
const tool = spans.find((span) => span.name === "execute_tool webfetch")
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "GET")
expect(http?.name).toBe("GET")
expect(http?.attributes.get(ATTR_SERVER_PORT)).toBe(80)
expect(http?.attributes.get(ATTR_URL_FULL)).toBe(url)
expect(http?.parent._tag === "Some" ? http.parent.value.spanId : undefined).toBe(tool?.spanId)
expect(requests[0]?.headers.traceparent).toBeUndefined()
}),
)
it.effect("marks rejected HTTP statuses on the HTTP span", () =>
Effect.gen(function* () {
reset()
respond = () => Effect.succeed(new Response("missing", { status: 404 }))
const registry = yield* ToolRegistry.Service
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
yield* settleTool(registry, call({ url: "https://example.com/missing", format: "text" })).pipe(
Effect.provideService(Tracer.Tracer, tracer),
)
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "GET")
expect(http?.attributes.get(ATTR_HTTP_RESPONSE_STATUS_CODE)).toBe(404)
expect(http?.attributes.get(ATTR_ERROR_TYPE)).toBe("StatusCodeError")
expect(http?.status._tag === "Ended" && http.status.exit._tag).toBe("Failure")
}),
)