feat(x): llm_usage analytics for turn model calls + voice-output parity

Analytics: the new runtime emitted usage into durable events but never
reported it to PostHog (only the permission classifier self-reported).
New IUsageReporter seam on the turn loop — invoked once per completed
model call, after the durable append, failure-isolated. The real bridge
emits the same llm_usage event as the old loop; useCase/subUseCase come
from the AsyncLocalStorage context the stage-6 headless runners already
establish via withUseCase, defaulting to copilot_chat for UI-driven
session turns (matching the old createRun default). Capturing at the
loop covers ALL turns — the session bus never sees headless ones.

Voice: the new renderer path rendered <voice> tags literally and never
fired TTS. The live overlay now extracts completed <voice> blocks
(robust to tags split across deltas) into chatState.voiceSegments;
App speaks new segments via the existing useVoiceTTS when voice output
is on, skipping segments streamed before a session became active. Tags
are stripped from both the streaming message and persisted assistant
messages, mirroring the legacy display-time strip.

Tests: usage report assertion in the runtime suite; four voice tests
(split-delta extraction, per-call scan reset, state stripping+segments,
persisted-message stripping).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-02 14:07:49 +05:30
parent 49e1e99d16
commit fa6943e905
9 changed files with 212 additions and 9 deletions

View file

@ -31,6 +31,8 @@ import { FSTurnRepo } from "../turns/fs-repo.js";
import type { ITurnRepo } from "../turns/repo.js";
import { TurnRepoContextResolver, type IContextResolver } from "../turns/context-resolver.js";
import { EmitterTurnLifecycleBus, type ITurnLifecycleBus } from "../turns/bus.js";
import { RealUsageReporter } from "../turns/bridges/real-usage-reporter.js";
import type { IUsageReporter } from "../turns/usage-reporter.js";
import { TurnRuntime } from "../turns/runtime.js";
import type { ITurnRuntime } from "../turns/api.js";
import type { IAgentResolver } from "../turns/agent-resolver.js";
@ -103,6 +105,7 @@ container.register({
turnRepo: asClass<ITurnRepo>(FSTurnRepo).singleton(),
contextResolver: asClass<IContextResolver>(TurnRepoContextResolver).singleton(),
lifecycleBus: asClass<ITurnLifecycleBus>(EmitterTurnLifecycleBus).singleton(),
usageReporter: asClass<IUsageReporter>(RealUsageReporter).singleton(),
agentResolver: asFunction<IAgentResolver>(() => new RealAgentResolver()).singleton(),
modelRegistry: asFunction<IModelRegistry>(() => new RealModelRegistry()).singleton(),
toolRegistry: asFunction<IToolRegistry>(() => new RealToolRegistry()).singleton(),

View file

@ -0,0 +1,22 @@
import { captureLlmUsage } from "../../analytics/usage.js";
import { getCurrentUseCase } from "../../analytics/use_case.js";
import type { IUsageReporter, ModelUsageReport } from "../usage-reporter.js";
// Reports each completed model call as the same `llm_usage` PostHog event
// the old run loop emitted. The use case comes from the AsyncLocalStorage
// context the caller established (headless runners wrap startHeadlessAgent
// in withUseCase); UI-driven session turns have no context and default to
// copilot_chat — matching the old createRun default.
export class RealUsageReporter implements IUsageReporter {
reportModelUsage(report: ModelUsageReport): void {
const context = getCurrentUseCase();
captureLlmUsage({
useCase: context?.useCase ?? "copilot_chat",
...(context?.subUseCase ? { subUseCase: context.subUseCase } : {}),
agentName: report.agentId,
model: report.model.model,
provider: report.model.provider,
usage: report.usage,
});
}
}

View file

@ -13,3 +13,4 @@ export * from "./repo.js";
export * from "./runtime.js";
export * from "./stream.js";
export * from "./tool-registry.js";
export * from "./usage-reporter.js";

View file

@ -308,6 +308,7 @@ function makeRuntime(opts: {
const checker = opts.checker ?? new FakePermissionChecker();
const classifier = opts.classifier ?? new FakePermissionClassifier();
const bus = new FakeBus();
const usage = new FakeUsageReporter();
const runtime = new TurnRuntime({
turnRepo: repo,
idGenerator: new FakeIdGen(opts.idStart ?? 0),
@ -319,8 +320,9 @@ function makeRuntime(opts: {
permissionChecker: checker,
permissionClassifier: classifier,
lifecycleBus: bus,
usageReporter: usage,
});
return { runtime, repo, models, checker, classifier, bus };
return { runtime, repo, models, checker, classifier, bus, usage };
}
async function newTurn(
@ -371,6 +373,21 @@ async function advanceAndSettle(
return settle(runtime.advanceTurn(turnId, input, options));
}
class FakeUsageReporter {
reports: Array<{
agentId: string;
model: { provider: string; model: string };
usage: Record<string, number | undefined>;
}> = [];
reportModelUsage(report: {
agentId: string;
model: { provider: string; model: string };
usage: Record<string, number | undefined>;
}): void {
this.reports.push(report);
}
}
function typesOf(events: Array<{ type: string }>): string[] {
return events.map((e) => e.type);
}
@ -397,7 +414,7 @@ async function persisted(
describe("plain model response (26.1)", () => {
it("runs one model step to completion with exact persisted request", async () => {
const { runtime, repo, models, bus } = makeRuntime({
const { runtime, repo, models, bus, usage } = makeRuntime({
models: [
respond(
{ type: "text_delta", delta: "do" },
@ -433,6 +450,14 @@ describe("plain model response (26.1)", () => {
// snapshot tools, encoded messages.
expect(models.requests[0].systemPrompt).toBe("SYS");
expect(sentMessages(models.requests[0])).toEqual([user("hello")]);
// One usage report per completed model call, after the durable append.
expect(usage.reports).toEqual([
{
agentId: "copilot",
model: defaultAgent.model,
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
},
]);
// Deltas are streamed but never persisted.
expect(events.filter((e) => e.type === "text_delta")).toHaveLength(2);
expect(typesOf(log)).not.toContain("text_delta");

View file

@ -41,6 +41,7 @@ import {
import type { ITurnLifecycleBus } from "./bus.js";
import type { IClock } from "./clock.js";
import { composeModelRequest } from "./compose-model-request.js";
import type { IUsageReporter } from "./usage-reporter.js";
import type { IContextResolver } from "./context-resolver.js";
import type {
IModelRegistry,
@ -68,6 +69,7 @@ export interface TurnRuntimeDependencies {
permissionChecker: IPermissionChecker;
permissionClassifier: IPermissionClassifier;
lifecycleBus: ITurnLifecycleBus;
usageReporter: IUsageReporter;
}
// Immutable dependency container: holds no mutable per-turn state. All active
@ -83,6 +85,7 @@ export class TurnRuntime implements ITurnRuntime {
private readonly permissionChecker: IPermissionChecker;
private readonly permissionClassifier: IPermissionClassifier;
private readonly lifecycleBus: ITurnLifecycleBus;
private readonly usageReporter: IUsageReporter;
constructor({
turnRepo,
@ -95,6 +98,7 @@ export class TurnRuntime implements ITurnRuntime {
permissionChecker,
permissionClassifier,
lifecycleBus,
usageReporter,
}: TurnRuntimeDependencies) {
this.turnRepo = turnRepo;
this.idGenerator = idGenerator;
@ -106,6 +110,7 @@ export class TurnRuntime implements ITurnRuntime {
this.permissionChecker = permissionChecker;
this.permissionClassifier = permissionClassifier;
this.lifecycleBus = lifecycleBus;
this.usageReporter = usageReporter;
}
async createTurn(input: CreateTurnInput): Promise<string> {
@ -257,6 +262,7 @@ export class TurnRuntime implements ITurnRuntime {
resolvedContext,
resolvedAgent,
model,
usageReporter: this.usageReporter,
toolsByName,
signal: controller.signal,
turnRepo: this.turnRepo,
@ -285,6 +291,7 @@ class TurnAdvance {
private readonly resolvedContext: Array<z.infer<typeof ConversationMessage>>;
private readonly resolvedAgent: z.infer<typeof ResolvedAgent>;
private readonly model: ResolvedModel;
private readonly usageReporter: IUsageReporter;
private readonly toolsByName: Map<string, RuntimeTool>;
private readonly signal: AbortSignal;
private readonly turnRepo: ITurnRepo;
@ -306,6 +313,7 @@ class TurnAdvance {
resolvedContext: Array<z.infer<typeof ConversationMessage>>;
resolvedAgent: z.infer<typeof ResolvedAgent>;
model: ResolvedModel;
usageReporter: IUsageReporter;
toolsByName: Map<string, RuntimeTool>;
signal: AbortSignal;
turnRepo: ITurnRepo;
@ -320,6 +328,7 @@ class TurnAdvance {
this.resolvedContext = init.resolvedContext;
this.resolvedAgent = init.resolvedAgent;
this.model = init.model;
this.usageReporter = init.usageReporter;
this.toolsByName = init.toolsByName;
this.signal = init.signal;
this.turnRepo = init.turnRepo;
@ -1015,6 +1024,17 @@ class TurnAdvance {
? {}
: { providerMetadata: completion.providerMetadata }),
});
// Analytics after the durable barrier; a reporter failure must never
// affect the turn.
try {
this.usageReporter.reportModelUsage({
agentId: this.resolvedAgent.agentId,
model: this.resolvedAgent.model,
usage: completion.usage,
});
} catch {
// best effort
}
return undefined;
}

View file

@ -0,0 +1,19 @@
import type { z } from "zod";
import type { ModelDescriptor, TurnUsage } from "@x/shared/dist/turns.js";
// Analytics seam for the turn loop: invoked once per completed model call,
// after the durable model_call_completed append. Implementations must never
// throw into the loop and must not block it (fire-and-forget).
export interface ModelUsageReport {
agentId: string;
model: z.infer<typeof ModelDescriptor>;
usage: z.infer<typeof TurnUsage>;
}
export interface IUsageReporter {
reportModelUsage(report: ModelUsageReport): void;
}
export class NoopUsageReporter implements IUsageReporter {
reportModelUsage(): void {}
}