Fix turn-usage keying: idempotent per request turn id, not messageId

Live smoke exposed two bugs in the turn usage log:
- onFinish messages carry no ids (withMessageIds fills copies inside
  persistTurn), so fresh turns keyed messageId "" — provenance lost.
- The SDK coalesces continuation turns into ONE response message (a whole
  probe conversation lands in a single assistant message), so per-messageId
  idempotency swallowed every continuation turn's usage.

TurnUsage gains id: string (unique per request, the append idempotency
key); messageId stays as transcript provenance, taken from the id-filled
list. appendTurnUsage dedupes by id; continuation turns with the same
messageId each keep their usage.
This commit is contained in:
amosblomqvist
2026-09-11 04:16:05 +03:00
parent f4abad3f15
commit 741ff416d6
6 changed files with 38 additions and 21 deletions
+7 -2
View File
@@ -93,8 +93,13 @@ export interface CallUsage {
* agent steps, and each step bills separately. Capture lands at the persist
* seam (server); cost is INFERRED at read time from core/prices.ts. */
export interface TurnUsage {
/** The assistant message the turn produced — keys this entry to the
* transcript (the block this turn "belongs" to). */
/** Unique per REQUEST (turn): one invocation of the handler — the
* idempotency key for the append-only log. Continuation turns share a
* response message id (the SDK coalesces them); keying on messageId
* would swallow their usage. */
id: string;
/** The assistant message the turn produced — provenance, keys this
* entry to the transcript (the block this turn "belongs" to). */
messageId: string;
at: string;
/** The OpenRouter model id that ran the turn. */
+3 -2
View File
@@ -39,8 +39,9 @@ export interface ChatStore {
/** Persist the session plan tree (plan tools call this in execute). */
saveTree(id: string, tree: PlanTree): void;
/** Append one usage entry to the record's turn log (the money channel,
* 2026-09): idempotent per messageId — a turn persisted twice (abort
* replays, wake-turn retries) appends exactly once. */
* 2026-09): idempotent per turn id — each request carries its own, so
* a turn persisted twice appends once while continuation turns (which
* share a response message id) each keep their usage. */
appendTurnUsage(id: string, entry: TurnUsage): void;
/** Append one extraction-run usage entry — idempotent per startedAt. */
appendExtractionUsage(id: string, entry: ExtractionRunUsage): void;
+14 -9
View File
@@ -3,12 +3,10 @@ import { createTeacherAgent, type TeacherAgent } from "../agent/teacher-agent.ts
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import { createTeacherTools } from "../agent/tools.ts";
import { modelFor } from "../agent/models.ts";
import { MODELS } from "../../config.ts";
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
import { MODELS } from "../../config.ts";import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
import { stores } from "./stores.ts";
import { beginTurn, endTurn } from "./turn-liveness.ts";
import { buildTeacherContext, persistTurn, triggerExtraction } from "./turn-service.ts";
import { buildTeacherContext, persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts";
import type { CallUsage } from "../core/contracts.ts";
/**
@@ -98,8 +96,10 @@ export async function handleChatRequest(req: Request, agent?: TeacherAgent): Pro
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
// step callback. Every model call's usage lands in the chat record's
// append-only turns log at onFinish — idempotent per response message id,
// so abort replays and wake retries append exactly once.
// append-only turns log at onFinish — idempotent per turn id, so a
// replayed persist appends once while continuation turns (which share a
// response message id — the SDK coalesces them) each keep their usage.
const turnUsageId = crypto.randomUUID();
const steps: CallUsage[] = [];
const collectUsage = (step: { usage: LanguageModelUsage }) => {
steps.push({
@@ -140,11 +140,16 @@ export async function handleChatRequest(req: Request, agent?: TeacherAgent): Pro
},
onFinish: ({ messages, isAborted }) => {
persistTurn(stores.chatStore, chatId, messages as UIMessage[], isAborted);
// usage log append (idempotent per response message id) — the
// stats bar refolds this on the client after the turn settles
const response = [...messages].reverse().find((m) => m.role === "assistant");
// usage log append (idempotent per turn id) — the stats bar
// refolds this on the client after the turn settles. messageId
// comes from the id-FILLED list: onFinish messages carry no ids
// (withMessageIds fills copies in persistTurn, not these); the
// re-fill here is idempotent, so the key is stable.
const filled = withMessageIds(messages as UIMessage[]);
const response = [...filled].reverse().find((m) => m.role === "assistant");
if (response && steps.length > 0) {
stores.chatStore.appendTurnUsage(chatId, {
id: turnUsageId,
messageId: response.id,
at: new Date().toISOString(),
model: MODELS.teacher.model,
+4 -3
View File
@@ -80,12 +80,13 @@ function saveMessages(id: string, messages: UIMessage[]): void {
}
/** Append one usage entry to the record's turn log — idempotent per
* messageId: abort replays and wake-turn retries persist the same turn
* twice; the second append is a no-op. */
* turn id: each REQUEST carries its own id, so a replay of the same turn's
* persist appends once, while continuation turns (which share a response
* message id) each keep their usage. */
function appendTurnUsage(id: string, entry: TurnUsage): void {
const record = getChat(id);
if (!record) throw new Error(`Chat not found: ${id}`);
record.turns = record.turns?.some((t) => t.messageId === entry.messageId)
record.turns = record.turns?.some((t) => t.id === entry.id)
? record.turns
: [...(record.turns ?? []), entry];
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");