diff --git a/src/handlers/message.ts b/src/handlers/message.ts index 5531d8e..6315c4a 100644 --- a/src/handlers/message.ts +++ b/src/handlers/message.ts @@ -3,9 +3,20 @@ import type { AssistantMessage, EventMessageUpdated, EventMessagePartUpdated, To import { errorSummary, setBoundedMap, accumulateSessionTotals, isMetricEnabled } from "../util.ts" import type { HandlerContext } from "../types.ts" +type SubtaskPart = { + type: "subtask" + sessionID: string + messageID: string + prompt: string + description: string + agent: string +} + /** * Handles a completed assistant message: increments token and cost counters and emits * either an `api_request` or `api_error` log event depending on whether the message errored. + * The `agent` attribute is sourced from the session totals, which are populated by the + * `chat.message` hook when the user prompt is received. */ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext) { const msg = e.properties.info @@ -15,38 +26,39 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext const { sessionID, modelID, providerID } = assistant const duration = assistant.time.completed - assistant.time.created + const agent = ctx.sessionTotals.get(sessionID)?.agent ?? "unknown" const totalTokens = assistant.tokens.input + assistant.tokens.output + assistant.tokens.reasoning + assistant.tokens.cache.read + assistant.tokens.cache.write if (isMetricEnabled("token.usage", ctx)) { const { tokenCounter } = ctx.instruments - tokenCounter.add(assistant.tokens.input, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "input" }) - tokenCounter.add(assistant.tokens.output, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "output" }) - tokenCounter.add(assistant.tokens.reasoning, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "reasoning" }) - tokenCounter.add(assistant.tokens.cache.read, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheRead" }) - tokenCounter.add(assistant.tokens.cache.write, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheCreation" }) + tokenCounter.add(assistant.tokens.input, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "input" }) + tokenCounter.add(assistant.tokens.output, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "output" }) + tokenCounter.add(assistant.tokens.reasoning, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "reasoning" }) + tokenCounter.add(assistant.tokens.cache.read, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheRead" }) + tokenCounter.add(assistant.tokens.cache.write, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheCreation" }) } if (isMetricEnabled("cost.usage", ctx)) { - ctx.instruments.costCounter.add(assistant.cost, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID }) + ctx.instruments.costCounter.add(assistant.cost, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent }) } if (isMetricEnabled("cache.count", ctx)) { if (assistant.tokens.cache.read > 0) { - ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheRead" }) + ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheRead" }) } if (assistant.tokens.cache.write > 0) { - ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheCreation" }) + ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheCreation" }) } } if (isMetricEnabled("message.count", ctx)) { - ctx.instruments.messageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID }) + ctx.instruments.messageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent }) } if (isMetricEnabled("model.usage", ctx)) { - ctx.instruments.modelUsageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, provider: providerID }) + ctx.instruments.modelUsageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, provider: providerID, agent }) } accumulateSessionTotals(sessionID, totalTokens, assistant.cost, ctx) @@ -54,6 +66,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext ctx.log("debug", "otel: token+cost counters incremented", { sessionID, model: modelID, + agent, input: assistant.tokens.input, output: assistant.tokens.output, reasoning: assistant.tokens.reasoning, @@ -74,6 +87,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext "session.id": sessionID, model: modelID, provider: providerID, + agent, error: errorSummary(assistant.error), duration_ms: duration, ...ctx.commonAttrs, @@ -82,6 +96,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext return ctx.log("error", "otel: api_error", { sessionID, model: modelID, + agent, error: errorSummary(assistant.error), duration_ms: duration, }) @@ -98,6 +113,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext "session.id": sessionID, model: modelID, provider: providerID, + agent, cost_usd: assistant.cost, duration_ms: duration, input_tokens: assistant.tokens.input, @@ -111,6 +127,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext return ctx.log("info", "otel: api_request", { sessionID, model: modelID, + agent, cost_usd: assistant.cost, duration_ms: duration, input_tokens: assistant.tokens.input, @@ -121,9 +138,43 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext /** * Tracks tool execution time between `running` and `completed`/`error` part updates, * records a `tool.duration` histogram measurement, and emits a `tool_result` log event. + * Also handles `subtask` parts, incrementing the sub-agent invocation counter and emitting + * a `subtask_invoked` log event. */ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: HandlerContext) { const part = e.properties.part + + if (part.type === "subtask") { + const subtask = part as unknown as SubtaskPart + if (isMetricEnabled("subtask.count", ctx)) { + ctx.instruments.subtaskCounter.add(1, { + ...ctx.commonAttrs, + "session.id": subtask.sessionID, + agent: subtask.agent, + }) + } + ctx.logger.emit({ + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + timestamp: Date.now(), + observedTimestamp: Date.now(), + body: "subtask_invoked", + attributes: { + "event.name": "subtask_invoked", + "session.id": subtask.sessionID, + agent: subtask.agent, + description: subtask.description, + prompt_length: subtask.prompt.length, + ...ctx.commonAttrs, + }, + }) + return ctx.log("info", "otel: subtask_invoked", { + sessionID: subtask.sessionID, + agent: subtask.agent, + description: subtask.description, + }) + } + if (part.type !== "tool") return const toolPart = part as ToolPart diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 0e8b473..7fc69c9 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -5,21 +5,22 @@ import type { HandlerContext } from "../types.ts" /** Increments the session counter, records start time, and emits a `session.created` log event. */ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext) { - const sessionID = e.properties.info.id - const createdAt = e.properties.info.time.created + const { id: sessionID, time, parentID } = e.properties.info + const createdAt = time.created + const isSubagent = !!parentID if (isMetricEnabled("session.count", ctx)) { - ctx.instruments.sessionCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID }) + ctx.instruments.sessionCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, is_subagent: isSubagent }) } - setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0 }) + setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0, agent: "unknown" }) ctx.logger.emit({ severityNumber: SeverityNumber.INFO, severityText: "INFO", timestamp: createdAt, observedTimestamp: Date.now(), body: "session.created", - attributes: { "event.name": "session.created", "session.id": sessionID, ...ctx.commonAttrs }, + attributes: { "event.name": "session.created", "session.id": sessionID, is_subagent: isSubagent, ...ctx.commonAttrs }, }) - return ctx.log("info", "otel: session.created", { sessionID, createdAt }) + return ctx.log("info", "otel: session.created", { sessionID, createdAt, isSubagent }) } function sweepSession(sessionID: string, ctx: HandlerContext) { diff --git a/src/index.ts b/src/index.ts index b1efd70..775d04a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -135,6 +135,9 @@ export const OtelPlugin: Plugin = async ({ project, client }) => { }, "chat.message": safe("chat.message", async (input, output) => { + const agent = input.agent ?? "unknown" + const totals = sessionTotals.get(input.sessionID) + if (totals) totals.agent = agent const promptLength = output.parts.reduce( (acc, p) => (p.type === "text" ? acc + p.text.length : acc), 0, @@ -148,7 +151,7 @@ export const OtelPlugin: Plugin = async ({ project, client }) => { attributes: { "event.name": "user_prompt", "session.id": input.sessionID, - agent: input.agent ?? "unknown", + agent, prompt_length: promptLength, model: input.model ? `${input.model.providerID}/${input.model.modelID}` diff --git a/src/otel.ts b/src/otel.ts index d59f628..ed3f7c7 100644 --- a/src/otel.ts +++ b/src/otel.ts @@ -133,5 +133,9 @@ export function createInstruments(prefix: string): Instruments { unit: "{retry}", description: "Number of API retries observed via session.status events", }), + subtaskCounter: meter.createCounter(`${prefix}subtask.count`, { + unit: "{subtask}", + description: "Number of sub-agent invocations observed via subtask message parts", + }), } } diff --git a/src/types.ts b/src/types.ts index b394fb6..92d8270 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,6 +49,7 @@ export type Instruments = { sessionCostGauge: Histogram modelUsageCounter: Counter retryCounter: Counter + subtaskCounter: Counter } /** Accumulated per-session totals used for gauge snapshots on session.idle. */ @@ -57,6 +58,7 @@ export type SessionTotals = { tokens: number cost: number messages: number + agent: string } /** Shared context threaded through every event handler. */ diff --git a/src/util.ts b/src/util.ts index 4a74b6d..65190f7 100644 --- a/src/util.ts +++ b/src/util.ts @@ -48,5 +48,6 @@ export function accumulateSessionTotals( tokens: existing.tokens + tokens, cost: existing.cost + cost, messages: existing.messages + 1, + agent: existing.agent, }) } diff --git a/tests/handlers/disabled-metrics.test.ts b/tests/handlers/disabled-metrics.test.ts index 9b0cf54..cece7d8 100644 --- a/tests/handlers/disabled-metrics.test.ts +++ b/tests/handlers/disabled-metrics.test.ts @@ -200,15 +200,48 @@ describe("OPENCODE_DISABLE_METRICS", () => { }) }) + describe("subtask.count disabled", () => { + test("does not increment subtask counter", async () => { + const { ctx, counters } = makeCtx("proj_test", ["subtask.count"]) + const e = { + type: "message.part.updated", + properties: { + part: { type: "subtask", sessionID: "ses_1", messageID: "msg_1", agent: "build", description: "desc", prompt: "prompt" }, + }, + } as unknown as EventMessagePartUpdated + await handleMessagePartUpdated(e, ctx) + expect(counters.subtask.calls).toHaveLength(0) + }) + + test("still emits subtask_invoked log record", async () => { + const { ctx, logger } = makeCtx("proj_test", ["subtask.count"]) + const e = { + type: "message.part.updated", + properties: { + part: { type: "subtask", sessionID: "ses_1", messageID: "msg_1", agent: "build", description: "desc", prompt: "prompt" }, + }, + } as unknown as EventMessagePartUpdated + await handleMessagePartUpdated(e, ctx) + expect(logger.records.at(0)!.body).toBe("subtask_invoked") + }) + }) + describe("multiple disabled at once", () => { test("disabling all metrics stops all counter/histogram calls", async () => { const all = [ "session.count", "token.usage", "cost.usage", "lines_of_code.count", "commit.count", "tool.duration", "cache.count", "session.duration", "message.count", "session.token.total", "session.cost.total", - "model.usage", "retry.count", + "model.usage", "retry.count", "subtask.count", ] const { ctx, counters, histograms, gauges } = makeCtx("proj_test", all) + const subtaskEvent = { + type: "message.part.updated", + properties: { + part: { type: "subtask", sessionID: "ses_1", messageID: "msg_1", agent: "build", description: "desc", prompt: "prompt" }, + }, + } as unknown as EventMessagePartUpdated + await handleSessionCreated(makeSessionCreated("ses_1"), ctx) await handleMessageUpdated(makeAssistantMessage(), ctx) handleSessionIdle(makeSessionIdle("ses_1"), ctx) @@ -217,6 +250,7 @@ describe("OPENCODE_DISABLE_METRICS", () => { handleCommandExecuted(makeCommandExecuted("git commit -m 'test'"), ctx) await handleMessagePartUpdated(makeToolPart("running"), ctx) await handleMessagePartUpdated(makeToolPart("completed"), ctx) + await handleMessagePartUpdated(subtaskEvent, ctx) expect(counters.session.calls).toHaveLength(0) expect(counters.token.calls).toHaveLength(0) @@ -227,6 +261,7 @@ describe("OPENCODE_DISABLE_METRICS", () => { expect(counters.retry.calls).toHaveLength(0) expect(counters.lines.calls).toHaveLength(0) expect(counters.commit.calls).toHaveLength(0) + expect(counters.subtask.calls).toHaveLength(0) expect(histograms.tool.calls).toHaveLength(0) expect(histograms.sessionDuration.calls).toHaveLength(0) expect(gauges.sessionToken.calls).toHaveLength(0) diff --git a/tests/handlers/message.test.ts b/tests/handlers/message.test.ts index c71e577..b454b51 100644 --- a/tests/handlers/message.test.ts +++ b/tests/handlers/message.test.ts @@ -3,6 +3,27 @@ import { handleMessageUpdated, handleMessagePartUpdated } from "../../src/handle import { makeCtx } from "../helpers.ts" import type { EventMessageUpdated, EventMessagePartUpdated } from "@opencode-ai/sdk" +function makeSubtaskPartUpdated(overrides: { + sessionID?: string + agent?: string + description?: string + prompt?: string +} = {}): EventMessagePartUpdated { + return { + type: "message.part.updated", + properties: { + part: { + type: "subtask", + sessionID: overrides.sessionID ?? "ses_1", + messageID: "msg_1", + agent: overrides.agent ?? "build", + description: overrides.description ?? "Build the project", + prompt: overrides.prompt ?? "Run the build and fix errors", + }, + }, + } as unknown as EventMessagePartUpdated +} + function makeAssistantMessageUpdated(overrides: { sessionID?: string modelID?: string @@ -173,7 +194,7 @@ describe("handleMessageUpdated", () => { test("accumulates session totals including cache tokens", async () => { const { ctx } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0 }) + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build" }) await handleMessageUpdated( makeAssistantMessageUpdated({ sessionID: "ses_1", @@ -298,3 +319,123 @@ describe("handleMessagePartUpdated", () => { expect(histograms.tool.calls).toHaveLength(0) }) }) + +describe("handleMessageUpdated — agent attribute", () => { + test("includes agent attr on token counters from session totals", async () => { + const { ctx, counters } = makeCtx() + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "plan" }) + await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) + const inputCall = counters.token.calls.find((c) => c.attrs["type"] === "input")! + expect(inputCall.attrs["agent"]).toBe("plan") + }) + + test("includes agent attr on cost counter", async () => { + const { ctx, counters } = makeCtx() + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build" }) + await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) + expect(counters.cost.calls.at(0)!.attrs["agent"]).toBe("build") + }) + + test("includes agent attr on message counter", async () => { + const { ctx, counters } = makeCtx() + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "general" }) + await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) + expect(counters.message.calls.at(0)!.attrs["agent"]).toBe("general") + }) + + test("includes agent attr on model usage counter", async () => { + const { ctx, counters } = makeCtx() + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "review" }) + await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) + expect(counters.modelUsage.calls.at(0)!.attrs["agent"]).toBe("review") + }) + + test("includes agent attr on cache counters", async () => { + const { ctx, counters } = makeCtx() + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "tdd" }) + await handleMessageUpdated( + makeAssistantMessageUpdated({ sessionID: "ses_1", tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 10, write: 5 } } }), + ctx, + ) + expect(counters.cache.calls.at(0)!.attrs["agent"]).toBe("tdd") + }) + + test("defaults agent to 'unknown' when session totals are absent", async () => { + const { ctx, counters } = makeCtx() + await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_no_totals" }), ctx) + const inputCall = counters.token.calls.find((c) => c.attrs["type"] === "input")! + expect(inputCall.attrs["agent"]).toBe("unknown") + }) + + test("includes agent on api_request log record", async () => { + const { ctx, logger } = makeCtx() + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "plan" }) + await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) + expect(logger.records.at(0)!.attributes?.["agent"]).toBe("plan") + }) + + test("includes agent on api_error log record", async () => { + const { ctx, logger } = makeCtx() + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build" }) + await handleMessageUpdated( + makeAssistantMessageUpdated({ sessionID: "ses_1", error: { name: "APIError" } }), + ctx, + ) + expect(logger.records.at(0)!.attributes?.["agent"]).toBe("build") + }) +}) + +describe("handleMessagePartUpdated — subtask parts", () => { + test("increments subtask counter with agent and session.id attrs", async () => { + const { ctx, counters } = makeCtx() + await handleMessagePartUpdated(makeSubtaskPartUpdated({ sessionID: "ses_1", agent: "build" }), ctx) + expect(counters.subtask.calls).toHaveLength(1) + const call = counters.subtask.calls.at(0)! + expect(call.value).toBe(1) + expect(call.attrs["agent"]).toBe("build") + expect(call.attrs["session.id"]).toBe("ses_1") + }) + + test("emits subtask_invoked log record", async () => { + const { ctx, logger } = makeCtx() + await handleMessagePartUpdated( + makeSubtaskPartUpdated({ agent: "plan", description: "Plan the feature", prompt: "Create a plan" }), + ctx, + ) + expect(logger.records).toHaveLength(1) + const record = logger.records.at(0)! + expect(record.body).toBe("subtask_invoked") + expect(record.attributes?.["agent"]).toBe("plan") + expect(record.attributes?.["description"]).toBe("Plan the feature") + expect(record.attributes?.["prompt_length"]).toBe("Create a plan".length) + }) + + test("includes project.id in subtask counter attrs", async () => { + const { ctx, counters } = makeCtx("proj_xyz") + await handleMessagePartUpdated(makeSubtaskPartUpdated(), ctx) + expect(counters.subtask.calls.at(0)!.attrs["project.id"]).toBe("proj_xyz") + }) + + test("does not record subtask counter when subtask.count is disabled", async () => { + const { ctx, counters } = makeCtx("proj_test", ["subtask.count"]) + await handleMessagePartUpdated(makeSubtaskPartUpdated(), ctx) + expect(counters.subtask.calls).toHaveLength(0) + }) + + test("still emits subtask_invoked log when subtask.count is disabled", async () => { + const { ctx, logger } = makeCtx("proj_test", ["subtask.count"]) + await handleMessagePartUpdated(makeSubtaskPartUpdated(), ctx) + expect(logger.records.at(0)!.body).toBe("subtask_invoked") + }) + + test("does not affect tool handling for non-subtask non-tool parts", async () => { + const { ctx, counters, histograms } = makeCtx() + const e = { + type: "message.part.updated", + properties: { part: { type: "text", text: "hello", sessionID: "ses_1" } }, + } as unknown as EventMessagePartUpdated + await handleMessagePartUpdated(e, ctx) + expect(counters.subtask.calls).toHaveLength(0) + expect(histograms.tool.calls).toHaveLength(0) + }) +}) diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index ae2060e..97ecadb 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -3,7 +3,7 @@ import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSess import { makeCtx } from "../helpers.ts" import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk" -function makeSessionCreated(sessionID: string, createdAt = 1000): EventSessionCreated { +function makeSessionCreated(sessionID: string, createdAt = 1000, parentID?: string): EventSessionCreated { return { type: "session.created", properties: { @@ -11,6 +11,7 @@ function makeSessionCreated(sessionID: string, createdAt = 1000): EventSessionCr id: sessionID, projectID: "proj_test", directory: "/tmp", + parentID, time: { created: createdAt }, }, }, @@ -119,7 +120,7 @@ describe("handleSessionIdle", () => { test("records session token and cost histograms when totals exist", async () => { const { ctx, gauges } = makeCtx() await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 500, tokens: 150, cost: 0.03, messages: 2 }) + ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 500, tokens: 150, cost: 0.03, messages: 2, agent: "build" }) handleSessionIdle(makeSessionIdle("ses_1"), ctx) expect(gauges.sessionToken.calls).toHaveLength(1) expect(gauges.sessionToken.calls.at(0)!.value).toBe(150) @@ -130,7 +131,7 @@ describe("handleSessionIdle", () => { test("emits total_tokens and total_messages in log record attributes", async () => { const { ctx, logger } = makeCtx() await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 100, tokens: 200, cost: 0.05, messages: 3 }) + ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 100, tokens: 200, cost: 0.05, messages: 3, agent: "general" }) handleSessionIdle(makeSessionIdle("ses_1"), ctx) const record = logger.records.find(r => r.body === "session.idle")! expect(record.attributes?.["total_tokens"]).toBe(200) @@ -196,6 +197,38 @@ describe("handleSessionError", () => { }) }) +describe("handleSessionCreated — is_subagent", () => { + test("tags session counter with is_subagent=false when no parentID", async () => { + const { ctx, counters } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_1"), ctx) + expect(counters.session.calls.at(0)!.attrs["is_subagent"]).toBe(false) + }) + + test("tags session counter with is_subagent=true when parentID is present", async () => { + const { ctx, counters } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_parent"), ctx) + expect(counters.session.calls.at(0)!.attrs["is_subagent"]).toBe(true) + }) + + test("includes is_subagent=false on session.created log record", async () => { + const { ctx, logger } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_1"), ctx) + expect(logger.records.at(0)!.attributes?.["is_subagent"]).toBe(false) + }) + + test("includes is_subagent=true on session.created log record for child session", async () => { + const { ctx, logger } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_parent"), ctx) + expect(logger.records.at(0)!.attributes?.["is_subagent"]).toBe(true) + }) + + test("seeds sessionTotals agent as 'unknown' on creation", async () => { + const { ctx } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_1"), ctx) + expect(ctx.sessionTotals.get("ses_1")!.agent).toBe("unknown") + }) +}) + describe("handleSessionStatus", () => { test("increments retry counter on retry status", () => { const { ctx, counters } = makeCtx() diff --git a/tests/helpers.ts b/tests/helpers.ts index f219edf..807f266 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -57,6 +57,7 @@ export type MockContext = { message: SpyCounter modelUsage: SpyCounter retry: SpyCounter + subtask: SpyCounter } histograms: { tool: SpyHistogram @@ -80,6 +81,7 @@ export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = []) const message = makeCounter() const modelUsage = makeCounter() const retry = makeCounter() + const subtask = makeCounter() const toolHistogram = makeHistogram() const sessionDurationHistogram = makeHistogram() const sessionTokenGauge = makeHistogram() @@ -99,9 +101,9 @@ export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = []) messageCounter: message as unknown as Counter, sessionTokenGauge: sessionTokenGauge as unknown as Histogram, sessionCostGauge: sessionCostGauge as unknown as Histogram, - modelUsageCounter: modelUsage as unknown as Counter, retryCounter: retry as unknown as Counter, + subtaskCounter: subtask as unknown as Counter, } const ctx: HandlerContext = { @@ -117,7 +119,7 @@ export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = []) return { ctx, - counters: { session, token, cost, lines, commit, cache, message, modelUsage, retry }, + counters: { session, token, cost, lines, commit, cache, message, modelUsage, retry, subtask }, histograms: { tool: toolHistogram, sessionDuration: sessionDurationHistogram }, gauges: { sessionToken: sessionTokenGauge, sessionCost: sessionCostGauge }, logger,