From 4f13e8591373de583351e0c8558714f16d7fa779 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 13 May 2026 11:38:57 +0300 Subject: [PATCH] fix(cli): track /review completions in telemetry --- packages/kilo-telemetry/src/telemetry.ts | 2 +- .../src/kilocode/session/processor.ts | 30 +++++-- packages/opencode/src/session/prompt.ts | 10 +-- packages/opencode/src/tool/task.ts | 2 + ...session-processor-review-telemetry.test.ts | 83 +++++++++++++++++++ packages/opencode/test/session/prompt.test.ts | 44 +++++++++- 6 files changed, 154 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/test/kilocode/session-processor-review-telemetry.test.ts diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 16dbcc92201..4f8837a2579 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -156,7 +156,7 @@ export namespace Telemetry { taskId?: string mode?: "review" feature?: "code_reviews" - command?: "local-review" | "local-review-uncommitted" + command?: "review" | "local-review" | "local-review-uncommitted" apiProvider: string modelId: string inputTokens?: number diff --git a/packages/opencode/src/kilocode/session/processor.ts b/packages/opencode/src/kilocode/session/processor.ts index bdba0151880..098c1acbfa3 100644 --- a/packages/opencode/src/kilocode/session/processor.ts +++ b/packages/opencode/src/kilocode/session/processor.ts @@ -11,7 +11,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" export type ReviewTelemetry = { mode: "review" feature: "code_reviews" - command: "local-review" | "local-review-uncommitted" + command: "review" | "local-review" | "local-review-uncommitted" } export namespace KiloSessionProcessor { @@ -22,12 +22,31 @@ export namespace KiloSessionProcessor { export const PROVIDER_FINISH_ERROR_MESSAGE = "The provider ended the response with an error before returning details. Start a new message to retry; Kilo will compact the oversized conversation first if needed." - export function reviewTelemetry(command: string): ReviewTelemetry | undefined { - if (command === "local-review" || command === "local-review-uncommitted") { + export function reviewTelemetry(command: string | undefined): ReviewTelemetry | undefined { + if (command === "review" || command === "local-review" || command === "local-review-uncommitted") { return { mode: "review", feature: "code_reviews", command } } } + /** + * Tag the text parts of a prompt with review telemetry metadata so that + * downstream LLM completions in the same turn (including child sessions + * spawned by subtask commands) are attributed to the originating review + * command. No-op when the command is not a recognized review command. + */ + export function markReviewTelemetry( + parts: Array<{ type: string; metadata?: Record }>, + command: string | undefined, + ): ReviewTelemetry | undefined { + const tel = reviewTelemetry(command) + if (!tel) return + for (const part of parts) { + if (part.type !== "text") continue + part.metadata = { ...part.metadata, ...tel } + } + return tel + } + export function extractReviewTelemetry(parts: MessageV2.Part[]): ReviewTelemetry | undefined { for (const part of parts) { if (part.type !== "text") continue @@ -35,9 +54,8 @@ export namespace KiloSessionProcessor { if (!meta) continue if (meta.mode !== "review") continue if (meta.feature !== "code_reviews") continue - const command = meta.command - if (command !== "local-review" && command !== "local-review-uncommitted") continue - return { mode: "review", feature: "code_reviews", command } + const tel = reviewTelemetry(typeof meta.command === "string" ? meta.command : undefined) + if (tel) return tel } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1f679142639..8564c2273f2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1824,15 +1824,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const templateParts = yield* resolvePromptParts(template) - // kilocode_change start - mark local review commands for completion telemetry - const telemetry = KiloSessionProcessor.reviewTelemetry(input.command) - if (telemetry) { - for (const part of templateParts) { - if (part.type !== "text") continue - part.metadata = { ...part.metadata, ...telemetry } - } - } - // kilocode_change end + KiloSessionProcessor.markReviewTelemetry(templateParts, input.command) // kilocode_change - mark review commands for completion telemetry const isSubtask = (agent.mode === "subagent" && cmd.subtask !== false) || cmd.subtask === true const parts = isSubtask ? [ diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 33ab459a3c9..f39d0bee9ff 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -8,6 +8,7 @@ import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" import { KiloTask } from "../kilocode/tool/task" // kilocode_change import { KiloCostPropagation } from "../kilocode/session/cost-propagation" // kilocode_change +import { KiloSessionProcessor } from "../kilocode/session/processor" // kilocode_change import { Effect, Schema } from "effect" export interface TaskPromptOps { @@ -154,6 +155,7 @@ export const TaskTool = Tool.define( () => Effect.gen(function* () { const parts = yield* ops.resolvePromptParts(params.prompt) + KiloSessionProcessor.markReviewTelemetry(parts, params.command) // kilocode_change - carry review command into child session telemetry const result = yield* ops.prompt({ messageID, sessionID: nextSession.id, diff --git a/packages/opencode/test/kilocode/session-processor-review-telemetry.test.ts b/packages/opencode/test/kilocode/session-processor-review-telemetry.test.ts new file mode 100644 index 00000000000..1a235c7b134 --- /dev/null +++ b/packages/opencode/test/kilocode/session-processor-review-telemetry.test.ts @@ -0,0 +1,83 @@ +// kilocode_change - new file +import { describe, expect, test } from "bun:test" +import { KiloSessionProcessor } from "../../src/kilocode/session/processor" +import type { MessageV2 } from "../../src/session/message-v2" + +const REVIEW_COMMANDS = ["review", "local-review", "local-review-uncommitted"] as const + +const expected = (command: (typeof REVIEW_COMMANDS)[number]) => ({ + mode: "review" as const, + feature: "code_reviews" as const, + command, +}) + +describe("KiloSessionProcessor.reviewTelemetry", () => { + for (const command of REVIEW_COMMANDS) { + test(`returns telemetry for ${command}`, () => { + expect(KiloSessionProcessor.reviewTelemetry(command)).toEqual(expected(command)) + }) + } + + test("returns undefined for an unrelated command", () => { + expect(KiloSessionProcessor.reviewTelemetry("init")).toBeUndefined() + }) + + test("returns undefined for an undefined command", () => { + expect(KiloSessionProcessor.reviewTelemetry(undefined)).toBeUndefined() + }) +}) + +describe("KiloSessionProcessor.markReviewTelemetry", () => { + for (const command of REVIEW_COMMANDS) { + test(`stamps text parts with telemetry for ${command}`, () => { + const parts: Array<{ type: string; metadata?: Record }> = [ + { type: "text", metadata: { existing: "keep" } }, + { type: "file" }, + { type: "text" }, + ] + const tel = KiloSessionProcessor.markReviewTelemetry(parts, command) + expect(tel).toEqual(expected(command)) + expect(parts[0].metadata).toEqual({ existing: "keep", ...expected(command) }) + expect(parts[1].metadata).toBeUndefined() + expect(parts[2].metadata).toEqual({ ...expected(command) }) + }) + } + + test("does nothing for an unrelated command", () => { + const parts: Array<{ type: string; metadata?: Record }> = [{ type: "text" }] + expect(KiloSessionProcessor.markReviewTelemetry(parts, "init")).toBeUndefined() + expect(parts[0].metadata).toBeUndefined() + }) + + test("does nothing for an undefined command", () => { + const parts: Array<{ type: string; metadata?: Record }> = [{ type: "text" }] + expect(KiloSessionProcessor.markReviewTelemetry(parts, undefined)).toBeUndefined() + expect(parts[0].metadata).toBeUndefined() + }) +}) + +describe("KiloSessionProcessor.extractReviewTelemetry", () => { + for (const command of REVIEW_COMMANDS) { + test(`recovers ${command} telemetry from marked text parts`, () => { + const parts: Array<{ type: string; metadata?: Record }> = [{ type: "text" }] + KiloSessionProcessor.markReviewTelemetry(parts, command) + const round = KiloSessionProcessor.extractReviewTelemetry(parts as unknown as MessageV2.Part[]) + expect(round).toEqual(expected(command)) + }) + } + + test("returns undefined when parts have no review metadata", () => { + const parts: Array<{ type: string; metadata?: Record }> = [ + { type: "text" }, + { type: "text", metadata: { foo: "bar" } }, + ] + expect(KiloSessionProcessor.extractReviewTelemetry(parts as unknown as MessageV2.Part[])).toBeUndefined() + }) + + test("returns undefined when command in metadata is unknown", () => { + const parts: Array<{ type: string; metadata?: Record }> = [ + { type: "text", metadata: { mode: "review", feature: "code_reviews", command: "unknown" } }, + ] + expect(KiloSessionProcessor.extractReviewTelemetry(parts as unknown as MessageV2.Part[])).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index c913af1d09a..63d353f7b2a 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,6 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { FetchHttpClient } from "effect/unstable/http" -import { expect } from "bun:test" +import { afterEach, expect, mock, spyOn } from "bun:test" // kilocode_change - spy on review telemetry +import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change - assert review command telemetry import { Cause, Effect, Exit, Fiber, Layer } from "effect" import path from "path" import { fileURLToPath } from "url" @@ -216,6 +217,12 @@ function makeHttp() { const it = testEffect(makeHttp()) const unix = process.platform !== "win32" ? it.live : it.live.skip +// kilocode_change start - restore any spies between tests so review telemetry spy never leaks +afterEach(() => { + mock.restore() +}) +// kilocode_change end + // Config that registers a custom "test" provider with a "test-model" model // so provider model lookup succeeds inside the loop. const cfg = { @@ -1985,6 +1992,41 @@ it.live("applies agent variant only when using agent model", () => ), ) +// kilocode_change start - /review subtask path tags child completions for telemetry +it.live( + "review command marks child completions with review telemetry", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const trackSpy = spyOn(Telemetry, "trackLlmCompletion") + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Review telemetry", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + // child subagent's first LLM step needs non-zero usage so trackStep fires + yield* llm.text("review done", { usage: { input: 100, output: 50 } }) + + yield* prompt.command({ + sessionID: chat.id, + command: "review", + arguments: "", + agent: "general", + }) + + const tagged = trackSpy.mock.calls + .map((args) => args[0] as Parameters[0]) + .find((p) => p.mode === "review" && p.feature === "code_reviews" && p.command === "review") + expect(tagged).toBeDefined() + }), + { git: true, config: providerCfg }, + ), + 30_000, +) +// kilocode_change end + // Agent / command resolution errors it.live(