diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 5d0c502cf4..de4b63a1e1 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -165,6 +165,7 @@ import { restoreClaudePassthroughToolNames, mergeResponseToolNameMap, } from "./chatCore/passthroughToolNames.ts"; +import { recordContextEditingTelemetryHook } from "./chatCore/contextEditingTelemetry.ts"; import { appendNonStreamingSseTerminalSignal, type NonStreamingSseTerminalState, @@ -3540,25 +3541,13 @@ export async function handleChatCore({ // Context Editing telemetry: when the delegated server-side clear actually ran, // record the provider's cleared-token receipt under engine "context-editing" so // it surfaces in compression analytics. Best-effort, Claude-only, non-streaming. - if (contextEditingEnabled && provider === "claude") { - void (async () => { - try { - const { extractContextEditingTelemetry } = await import("../config/contextEditing.ts"); - const tele = extractContextEditingTelemetry(responseBody); - if (tele) { - const { recordContextEditingTelemetry } = - await import("../../src/lib/db/compressionAnalytics.ts"); - recordContextEditingTelemetry(skillRequestId, tele, provider); - log?.debug?.( - "CONTEXT_EDITING", - `cleared ${tele.clearedInputTokens} input tokens / ${tele.clearedToolUses} tool uses (${tele.editCount} edits)` - ); - } - } catch { - // Telemetry is best-effort and must never affect the response. - } - })(); - } + recordContextEditingTelemetryHook({ + contextEditingEnabled, + provider, + responseBody, + skillRequestId, + log, + }); appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch( () => {} ); diff --git a/open-sse/handlers/chatCore/contextEditingTelemetry.ts b/open-sse/handlers/chatCore/contextEditingTelemetry.ts new file mode 100644 index 0000000000..abe1c7bde2 --- /dev/null +++ b/open-sse/handlers/chatCore/contextEditingTelemetry.ts @@ -0,0 +1,40 @@ +/** + * chatCore context-editing telemetry hook (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's non-streaming success path: when the delegated server-side + * context-clear actually ran (Claude-only), record the provider's cleared-token receipt under the + * "context-editing" engine so it surfaces in compression analytics. Best-effort, fire-and-forget, + * and must never affect the response — the inner work is an un-awaited IIFE that swallows its own + * errors. Behaviour is byte-identical to the previous inline block. + */ + +type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; + +export function recordContextEditingTelemetryHook(args: { + contextEditingEnabled: boolean; + provider: string | null | undefined; + responseBody: unknown; + skillRequestId: string; + log?: LoggerLike; +}): void { + const { contextEditingEnabled, provider, responseBody, skillRequestId, log } = args; + if (!contextEditingEnabled || provider !== "claude") return; + + void (async () => { + try { + const { extractContextEditingTelemetry } = await import("../../config/contextEditing.ts"); + const tele = extractContextEditingTelemetry(responseBody); + if (tele) { + const { recordContextEditingTelemetry } = await import("@/lib/db/compressionAnalytics"); + recordContextEditingTelemetry(skillRequestId, tele, provider); + log?.debug?.( + "CONTEXT_EDITING", + `cleared ${tele.clearedInputTokens} input tokens / ${tele.clearedToolUses} tool uses (${tele.editCount} edits)` + ); + } + } catch { + // Telemetry is best-effort and must never affect the response. + } + })(); +} diff --git a/tests/unit/chatcore-context-editing-telemetry.test.ts b/tests/unit/chatcore-context-editing-telemetry.test.ts new file mode 100644 index 0000000000..bfb6cad524 --- /dev/null +++ b/tests/unit/chatcore-context-editing-telemetry.test.ts @@ -0,0 +1,106 @@ +// Characterization of recordContextEditingTelemetryHook — the Claude-only context-editing +// telemetry hook extracted from handleChatCore's non-streaming success path (chatCore god-file +// decomposition, #3501). The work is a fire-and-forget IIFE; uses a real temp DB and polls the +// captured log. Locks: the enabled+claude guard, the no-telemetry no-op, and that a valid +// applied_edits payload records and logs the cleared-token receipt. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-ctxedit-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { recordContextEditingTelemetryHook } = await import( + "../../open-sse/handlers/chatCore/contextEditingTelemetry.ts" +); + +function makeLog() { + const debug: string[] = []; + return { + log: { debug: (tag: string, msg: string) => debug.push(`${tag} ${msg}`) }, + debug, + }; +} + +const telemetryBody = { + context_management: { + applied_edits: [{ cleared_input_tokens: 120, cleared_tool_uses: 3 }], + }, +}; + +async function waitFor(pred: () => boolean, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && !pred()) { + await new Promise((r) => setTimeout(r, 25)); + } +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("disabled context-editing is a no-op", async () => { + const { log, debug } = makeLog(); + recordContextEditingTelemetryHook({ + contextEditingEnabled: false, + provider: "claude", + responseBody: telemetryBody, + skillRequestId: "req-1", + log, + }); + await new Promise((r) => setTimeout(r, 100)); + assert.equal(debug.length, 0); +}); + +test("non-claude provider is a no-op", async () => { + const { log, debug } = makeLog(); + recordContextEditingTelemetryHook({ + contextEditingEnabled: true, + provider: "openai", + responseBody: telemetryBody, + skillRequestId: "req-2", + log, + }); + await new Promise((r) => setTimeout(r, 100)); + assert.equal(debug.length, 0); +}); + +test("valid applied_edits records and logs the cleared-token receipt", async () => { + const { log, debug } = makeLog(); + recordContextEditingTelemetryHook({ + contextEditingEnabled: true, + provider: "claude", + responseBody: telemetryBody, + skillRequestId: "req-3", + log, + }); + await waitFor(() => debug.length > 0); + assert.ok(debug.length >= 1, "expected a CONTEXT_EDITING debug line"); + assert.match(debug[0], /CONTEXT_EDITING/); + assert.match(debug[0], /cleared 120 input tokens \/ 3 tool uses \(1 edits\)/); +}); + +test("response without applied_edits is a silent no-op (no throw)", async () => { + const { log, debug } = makeLog(); + recordContextEditingTelemetryHook({ + contextEditingEnabled: true, + provider: "claude", + responseBody: { choices: [] }, + skillRequestId: "req-4", + log, + }); + await new Promise((r) => setTimeout(r, 100)); + assert.equal(debug.length, 0); +});