diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index fa2ba2438..90df7c8c8 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -219,6 +219,7 @@ export async function runAgentTurnWithFallback(params: { createIfMissing: true, provider, model, + usage: result.meta.agentMeta?.usage, }); if (!assistantPersistResult.ok) { logVerbose( diff --git a/src/gateway/cli-session-manager.test.ts b/src/gateway/cli-session-manager.test.ts new file mode 100644 index 000000000..d20d58433 --- /dev/null +++ b/src/gateway/cli-session-manager.test.ts @@ -0,0 +1,347 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { CliSessionManager } from "./cli-session-manager.js"; + +describe("CliSessionManager", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-session-manager-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("static factories", () => { + test("create() creates a new session manager with transcript file", () => { + const sessionId = "test-session-create"; + const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); + + const mgr = CliSessionManager.create({ + sessionId, + sessionFile, + }); + + expect(mgr.getSessionId()).toBe(sessionId); + expect(mgr.getSessionFile()).toBe(sessionFile); + expect(fs.existsSync(sessionFile)).toBe(true); + + // Verify header was written + const content = fs.readFileSync(sessionFile, "utf-8"); + const header = JSON.parse(content.trim()); + expect(header.type).toBe("session"); + expect(header.id).toBe(sessionId); + }); + + test("open() opens existing session and loads entries", () => { + const sessionId = "test-session-open"; + const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); + + // Write some existing entries + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ + type: "message", + id: "msg1", + timestamp: new Date().toISOString(), + message: { + role: "user", + content: [{ type: "text", text: "Hello" }], + timestamp: Date.now(), + }, + }), + JSON.stringify({ + type: "message", + id: "msg2", + timestamp: new Date().toISOString(), + message: { + role: "assistant", + content: [{ type: "text", text: "Hi" }], + timestamp: Date.now(), + }, + }), + ]; + fs.writeFileSync(sessionFile, lines.join("\n") + "\n", "utf-8"); + + const mgr = CliSessionManager.open(sessionFile, { sessionId }); + + expect(mgr.getSessionId()).toBe(sessionId); + expect(mgr.getEntries()).toHaveLength(2); + expect(mgr.getLeafId()).toBe("msg2"); + }); + + test("open() creates file if it does not exist", () => { + const sessionId = "test-session-open-new"; + const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); + + expect(fs.existsSync(sessionFile)).toBe(false); + + const mgr = CliSessionManager.open(sessionFile, { sessionId }); + + expect(fs.existsSync(sessionFile)).toBe(true); + expect(mgr.getEntries()).toHaveLength(0); + }); + }); + + describe("accessors", () => { + test("getSessionId() returns session ID", () => { + const sessionId = "accessor-test-session"; + const mgr = CliSessionManager.create({ + sessionId, + sessionFile: path.join(tmpDir, `${sessionId}.jsonl`), + }); + + expect(mgr.getSessionId()).toBe(sessionId); + }); + + test("getLeafId() returns null initially, then entry ID after append", () => { + const sessionId = "leaf-test-session"; + const mgr = CliSessionManager.create({ + sessionId, + sessionFile: path.join(tmpDir, `${sessionId}.jsonl`), + }); + + expect(mgr.getLeafId()).toBeNull(); + + const entryId = mgr.appendMessage({ role: "user", content: "Hello" }); + + expect(mgr.getLeafId()).toBe(entryId); + }); + + test("getLeafEntry() returns the last appended entry", () => { + const sessionId = "leaf-entry-test"; + const mgr = CliSessionManager.create({ + sessionId, + sessionFile: path.join(tmpDir, `${sessionId}.jsonl`), + }); + + expect(mgr.getLeafEntry()).toBeUndefined(); + + mgr.appendMessage({ role: "user", content: "Hello" }); + const leafEntry = mgr.getLeafEntry(); + + expect(leafEntry).toBeDefined(); + expect(leafEntry?.type).toBe("message"); + expect(leafEntry?.message?.role).toBe("user"); + }); + + test("isPersisted() returns true when sessionFile is set", () => { + const mgr = CliSessionManager.create({ + sessionId: "persisted-test", + sessionFile: path.join(tmpDir, "persisted.jsonl"), + }); + + expect(mgr.isPersisted()).toBe(true); + }); + }); + + describe("appendMessage", () => { + test("appends user message and returns entry ID", () => { + const sessionId = "append-user-test"; + const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); + const mgr = CliSessionManager.create({ sessionId, sessionFile }); + + const entryId = mgr.appendMessage({ role: "user", content: "Hello world" }); + + expect(entryId).toBeDefined(); + expect(typeof entryId).toBe("string"); + expect(entryId.length).toBe(8); + + const entries = mgr.getEntries(); + expect(entries).toHaveLength(1); + expect(entries[0]?.message?.role).toBe("user"); + }); + + test("appends assistant message with usage data", () => { + const sessionId = "append-assistant-usage-test"; + const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); + const mgr = CliSessionManager.create({ + sessionId, + sessionFile, + provider: "claude-cli", + model: "opus", + }); + + const entryId = mgr.appendMessage({ + role: "assistant", + content: "Hello!", + usage: { + input: 100, + output: 50, + cacheRead: 10, + cacheWrite: 5, + total: 165, + }, + }); + + expect(entryId).toBeDefined(); + + // Verify written to file + const content = fs.readFileSync(sessionFile, "utf-8"); + const lines = content.trim().split("\n"); + // Skip header, get the message entry + const messageEntry = JSON.parse(lines[lines.length - 1]); + + expect(messageEntry.message.role).toBe("assistant"); + expect(messageEntry.message.usage).toEqual({ + input: 100, + output: 50, + cacheRead: 10, + cacheWrite: 5, + totalTokens: 165, + }); + expect(messageEntry.message.provider).toBe("claude-cli"); + expect(messageEntry.message.model).toBe("opus"); + expect(messageEntry.message.stopReason).toBe("cli_backend"); + }); + + test("usage defaults to zeros when not provided", () => { + const sessionId = "append-no-usage-test"; + const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); + const mgr = CliSessionManager.create({ sessionId, sessionFile }); + + mgr.appendMessage({ role: "assistant", content: "Response" }); + + const content = fs.readFileSync(sessionFile, "utf-8"); + const lines = content.trim().split("\n"); + const messageEntry = JSON.parse(lines[lines.length - 1]); + + expect(messageEntry.message.usage).toEqual({ + input: 0, + output: 0, + cacheRead: undefined, + cacheWrite: undefined, + totalTokens: 0, + }); + }); + + test("handles string content", () => { + const sessionId = "string-content-test"; + const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); + const mgr = CliSessionManager.create({ sessionId, sessionFile }); + + mgr.appendMessage({ role: "user", content: "Plain string message" }); + + const entry = mgr.getLeafEntry(); + expect(entry?.message?.content).toEqual([{ type: "text", text: "Plain string message" }]); + }); + + test("handles array content", () => { + const sessionId = "array-content-test"; + const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); + const mgr = CliSessionManager.create({ sessionId, sessionFile }); + + mgr.appendMessage({ + role: "user", + content: [ + { type: "text", text: "Part 1" }, + { type: "text", text: "Part 2" }, + ], + }); + + const entry = mgr.getLeafEntry(); + expect(entry?.message?.content).toEqual([ + { type: "text", text: "Part 1" }, + { type: "text", text: "Part 2" }, + ]); + }); + + test("sets parentId to previous leaf", () => { + const sessionId = "parent-id-test"; + const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); + const mgr = CliSessionManager.create({ sessionId, sessionFile }); + + const id1 = mgr.appendMessage({ role: "user", content: "First" }); + const id2 = mgr.appendMessage({ role: "assistant", content: "Second" }); + + const entries = mgr.getEntries(); + expect(entries[0]?.parentId).toBeUndefined(); + expect(entries[1]?.parentId).toBe(id1); + expect(mgr.getLeafId()).toBe(id2); + }); + }); + + describe("appendCustomEntry", () => { + test("appends custom entry and returns entry ID", () => { + const sessionId = "custom-entry-test"; + const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); + const mgr = CliSessionManager.create({ sessionId, sessionFile }); + + const entryId = mgr.appendCustomEntry("tool_result", { result: "success" }); + + expect(entryId).toBeDefined(); + expect(mgr.getLeafId()).toBe(entryId); + + const content = fs.readFileSync(sessionFile, "utf-8"); + const lines = content.trim().split("\n"); + const customEntry = JSON.parse(lines[lines.length - 1]); + + expect(customEntry.type).toBe("custom"); + expect(customEntry.customType).toBe("tool_result"); + expect(customEntry.data).toEqual({ result: "success" }); + }); + }); + + describe("CLI backend session ID", () => { + test("setCliBackendSessionId and getCliBackendSessionId", () => { + const mgr = CliSessionManager.create({ + sessionId: "cli-backend-id-test", + sessionFile: path.join(tmpDir, "cli-backend.jsonl"), + }); + + expect(mgr.getCliBackendSessionId()).toBeUndefined(); + + mgr.setCliBackendSessionId("claude-cli-session-123"); + + expect(mgr.getCliBackendSessionId()).toBe("claude-cli-session-123"); + }); + }); + + describe("archive", () => { + test("archives transcript file with reason suffix", () => { + const sessionId = "archive-test"; + const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); + const mgr = CliSessionManager.create({ sessionId, sessionFile }); + + mgr.appendMessage({ role: "user", content: "Message to archive" }); + + const archivedPath = mgr.archive("context-overflow"); + + expect(archivedPath).toContain("context-overflow"); + expect(fs.existsSync(archivedPath)).toBe(true); + expect(fs.existsSync(sessionFile)).toBe(false); + }); + }); + + describe("concurrent write safety", () => { + test("appendMessageAsync serializes writes", async () => { + const sessionId = "concurrent-test"; + const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); + const mgr = CliSessionManager.create({ sessionId, sessionFile }); + + // Fire multiple async appends concurrently + const promises = [ + mgr.appendMessageAsync({ role: "user", content: "Message 1" }), + mgr.appendMessageAsync({ role: "assistant", content: "Response 1" }), + mgr.appendMessageAsync({ role: "user", content: "Message 2" }), + mgr.appendMessageAsync({ role: "assistant", content: "Response 2" }), + ]; + + const ids = await Promise.all(promises); + + expect(ids).toHaveLength(4); + expect(new Set(ids).size).toBe(4); // All unique IDs + + const entries = mgr.getEntries(); + expect(entries).toHaveLength(4); + + // Verify parent chain is correct + for (let i = 1; i < entries.length; i++) { + expect(entries[i]?.parentId).toBe(entries[i - 1]?.id); + } + }); + }); +}); diff --git a/src/gateway/cli-session-manager.ts b/src/gateway/cli-session-manager.ts new file mode 100644 index 000000000..2b89dd903 --- /dev/null +++ b/src/gateway/cli-session-manager.ts @@ -0,0 +1,359 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import type { NormalizedUsage } from "../agents/usage.js"; +import { ensureTranscriptFile } from "./session-utils.fs.js"; + +/** + * Content block type for CLI messages (matches SDK format). + */ +export type CliContentBlock = + | { type: "text"; text: string } + | { type: "input_text"; text: string } + | { type: "output_text"; text: string }; + +/** + * Message type for CLI session manager with usage support. + */ +export type CliMessage = { + role: "user" | "assistant"; + content: string | CliContentBlock[]; + provider?: string; + model?: string; + /** Token usage for assistant messages. */ + usage?: NormalizedUsage; + stopReason?: string; +}; + +/** + * Entry in a CLI session transcript (matches SDK SessionEntry format). + */ +export type CliSessionEntry = { + type: "message" | "custom" | "session"; + id: string; + parentId?: string; + timestamp: string; + message?: { + role: string; + content: CliContentBlock[]; + timestamp: number; + stopReason?: string; + usage?: { + input: number; + output: number; + cacheRead?: number; + cacheWrite?: number; + totalTokens: number; + }; + provider?: string; + model?: string; + }; + customType?: string; + data?: unknown; +}; + +/** + * Parameters for creating a new CLI session manager. + */ +export type CliSessionManagerParams = { + sessionId: string; + sessionFile?: string; + storePath?: string; + provider?: string; + model?: string; +}; + +/** + * In-process lock for session transcript writes. + * Ensures atomic user+assistant message pairs within the same Node process. + */ +const sessionWriteLocks = new Map>(); + +async function withSessionLock(sessionId: string, fn: () => T | Promise): Promise { + const pending = sessionWriteLocks.get(sessionId); + if (pending) { + await pending.catch(() => {}); + } + + let resolve: () => void; + const lock = new Promise((r) => { + resolve = r; + }); + sessionWriteLocks.set(sessionId, lock); + + try { + return await fn(); + } finally { + resolve!(); + if (sessionWriteLocks.get(sessionId) === lock) { + sessionWriteLocks.delete(sessionId); + } + } +} + +/** + * CLI Session Manager - manages session transcripts for CLI backends. + * + * API aligned with SDK's SessionManager where appropriate: + * - Static factories (open/create) instead of public constructor + * - Accessor methods (getSessionId, getSessionFile, etc.) + * - appendMessage returns entry ID (string) + * - Write lock for concurrency safety + * + * Key difference from SDK: CLI doesn't need branching/navigation (linear transcript only). + */ +export class CliSessionManager { + private readonly sessionId: string; + private readonly sessionFile: string | undefined; + private readonly storePath: string | undefined; + private readonly provider: string | undefined; + private readonly model: string | undefined; + private readonly entries: CliSessionEntry[] = []; + private leafId: string | null = null; + private cliBackendSessionId: string | undefined; + + private constructor(params: CliSessionManagerParams) { + this.sessionId = params.sessionId; + this.sessionFile = params.sessionFile; + this.storePath = params.storePath; + this.provider = params.provider; + this.model = params.model; + } + + /** + * Creates a new CLI session manager (SDK-style factory). + * Creates the transcript file if it doesn't exist. + */ + static create(params: CliSessionManagerParams): CliSessionManager { + const manager = new CliSessionManager(params); + const transcriptPath = manager.resolveTranscriptPath(); + if (transcriptPath) { + ensureTranscriptFile({ transcriptPath, sessionId: params.sessionId }); + } + return manager; + } + + /** + * Opens an existing session from a transcript file (SDK-style factory). + * Creates the transcript file if it doesn't exist. + */ + static open(sessionFile: string, params?: Partial): CliSessionManager { + const sessionId = params?.sessionId ?? crypto.randomUUID(); + const manager = new CliSessionManager({ + sessionId, + sessionFile, + storePath: params?.storePath, + provider: params?.provider, + model: params?.model, + }); + + // Ensure transcript file exists + ensureTranscriptFile({ transcriptPath: sessionFile, sessionId }); + + // Load existing entries from transcript + manager.loadEntries(sessionFile); + + return manager; + } + + // --- Accessors (matching SDK surface) --- + + getSessionId(): string { + return this.sessionId; + } + + getSessionFile(): string | undefined { + return this.sessionFile ?? this.resolveTranscriptPath() ?? undefined; + } + + getLeafId(): string | null { + return this.leafId; + } + + getLeafEntry(): CliSessionEntry | undefined { + if (!this.leafId) return undefined; + return this.entries.find((e) => e.id === this.leafId); + } + + getEntries(): CliSessionEntry[] { + return [...this.entries]; + } + + isPersisted(): boolean { + return Boolean(this.sessionFile || this.storePath); + } + + // --- CLI-specific extensions --- + + setCliBackendSessionId(cliSessionId: string): void { + this.cliBackendSessionId = cliSessionId; + } + + getCliBackendSessionId(): string | undefined { + return this.cliBackendSessionId; + } + + // --- Append Methods (matching SDK pattern, returns entry ID) --- + + /** + * Appends a message to the session transcript. + * Returns the entry ID (string) like SDK SessionManager. + */ + appendMessage(message: CliMessage): string { + const transcriptPath = this.resolveTranscriptPath(); + if (!transcriptPath) { + throw new Error("Cannot append message: no transcript path available"); + } + + const now = Date.now(); + const entryId = crypto.randomUUID().slice(0, 8); + const content = this.normalizeContent(message.content); + + const messageBody: CliSessionEntry["message"] = { + role: message.role, + content, + timestamp: now, + }; + + if (message.role === "assistant") { + messageBody.stopReason = message.stopReason ?? "cli_backend"; + const u = message.usage; + messageBody.usage = { + input: u?.input ?? 0, + output: u?.output ?? 0, + cacheRead: u?.cacheRead, + cacheWrite: u?.cacheWrite, + totalTokens: u?.total ?? (u?.input ?? 0) + (u?.output ?? 0), + }; + if (message.provider ?? this.provider) { + messageBody.provider = message.provider ?? this.provider; + } + if (message.model ?? this.model) { + messageBody.model = message.model ?? this.model; + } + } + + const entry: CliSessionEntry = { + type: "message", + id: entryId, + parentId: this.leafId ?? undefined, + timestamp: new Date(now).toISOString(), + message: messageBody, + }; + + // Append to transcript file + try { + fs.appendFileSync(transcriptPath, `${JSON.stringify(entry)}\n`, "utf-8"); + } catch (err) { + throw new Error( + `Failed to write to transcript: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Update in-memory state + this.entries.push(entry); + this.leafId = entryId; + + return entryId; + } + + /** + * Appends a message asynchronously with session-level locking. + * Returns the entry ID (string) like SDK SessionManager. + */ + async appendMessageAsync(message: CliMessage): Promise { + return withSessionLock(this.sessionId, () => this.appendMessage(message)); + } + + /** + * Appends a custom entry to the session transcript. + * Returns the entry ID (string) like SDK SessionManager. + */ + appendCustomEntry(customType: string, data?: unknown): string { + const transcriptPath = this.resolveTranscriptPath(); + if (!transcriptPath) { + throw new Error("Cannot append custom entry: no transcript path available"); + } + + const entryId = crypto.randomUUID().slice(0, 8); + const entry: CliSessionEntry = { + type: "custom", + id: entryId, + parentId: this.leafId ?? undefined, + timestamp: new Date().toISOString(), + customType, + data, + }; + + try { + fs.appendFileSync(transcriptPath, `${JSON.stringify(entry)}\n`, "utf-8"); + } catch (err) { + throw new Error( + `Failed to write custom entry: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + this.entries.push(entry); + this.leafId = entryId; + + return entryId; + } + + /** + * Archives the session transcript with a reason suffix. + * Returns the path to the archived file. + */ + archive(reason: string): string { + const transcriptPath = this.resolveTranscriptPath(); + if (!transcriptPath || !fs.existsSync(transcriptPath)) { + throw new Error("Cannot archive: transcript file not found"); + } + + const ts = new Date().toISOString().replaceAll(":", "-"); + const archived = `${transcriptPath}.${reason}.${ts}`; + fs.renameSync(transcriptPath, archived); + return archived; + } + + // --- Private helpers --- + + private resolveTranscriptPath(): string | null { + if (this.sessionFile) return this.sessionFile; + if (this.storePath) { + return path.join(path.dirname(this.storePath), `${this.sessionId}.jsonl`); + } + return null; + } + + private normalizeContent(content: string | CliContentBlock[]): CliContentBlock[] { + if (typeof content === "string") { + return [{ type: "text", text: content }]; + } + return content; + } + + private loadEntries(filePath: string): void { + if (!fs.existsSync(filePath)) return; + + try { + const data = fs.readFileSync(filePath, "utf-8"); + const lines = data.split(/\r?\n/); + + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as CliSessionEntry; + if (parsed.type === "message" || parsed.type === "custom") { + this.entries.push(parsed); + this.leafId = parsed.id; + } + } catch { + // Skip malformed lines + } + } + } catch { + // File read error - start fresh + } + } +} diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index c81f9619a..f72d599fc 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -573,6 +573,63 @@ describe("appendMessageToTranscript", () => { expect(msg.message.provider).toBeUndefined(); expect(msg.message.model).toBeUndefined(); }); + + test("includes usage data when provided for assistant message", () => { + const sessionId = "test-append-usage"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const header = JSON.stringify({ type: "session", version: 1, id: sessionId }); + fs.writeFileSync(transcriptPath, header + "\n", "utf-8"); + + const result = appendMessageToTranscript({ + message: "Response with usage", + role: "assistant", + sessionId, + storePath, + usage: { + input: 100, + output: 50, + cacheRead: 10, + cacheWrite: 5, + total: 165, + }, + }); + + expect(result.ok).toBe(true); + + const content = fs.readFileSync(transcriptPath, "utf-8"); + const lines = content.trim().split("\n"); + const msg = JSON.parse(lines[1]); + expect(msg.message.usage).toEqual({ + input: 100, + output: 50, + cacheRead: 10, + cacheWrite: 5, + totalTokens: 165, + }); + }); + + test("defaults usage to zeros when not provided for assistant message", () => { + const sessionId = "test-append-no-usage"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const header = JSON.stringify({ type: "session", version: 1, id: sessionId }); + fs.writeFileSync(transcriptPath, header + "\n", "utf-8"); + + const result = appendMessageToTranscript({ + message: "Response without usage", + role: "assistant", + sessionId, + storePath, + }); + + expect(result.ok).toBe(true); + + const content = fs.readFileSync(transcriptPath, "utf-8"); + const lines = content.trim().split("\n"); + const msg = JSON.parse(lines[1]); + expect(msg.message.usage.input).toBe(0); + expect(msg.message.usage.output).toBe(0); + expect(msg.message.usage.totalTokens).toBe(0); + }); }); describe("ensureTranscriptFile", () => { diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 32af4e0a4..6eb1ac155 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; +import type { NormalizedUsage } from "../agents/usage.js"; import { resolveSessionTranscriptPath } from "../config/sessions.js"; import { stripEnvelope } from "./chat-sanitize.js"; import type { SessionPreviewItem } from "./session-utils.types.js"; @@ -112,6 +113,8 @@ function appendMessageToTranscriptImpl( stopReason?: string; provider?: string; model?: string; + /** Token usage for assistant messages (CLI backends). */ + usage?: NormalizedUsage; }, transcriptPath: string, ): TranscriptAppendResult { @@ -137,7 +140,14 @@ function appendMessageToTranscriptImpl( }; if (params.role === "assistant") { messageBody.stopReason = params.stopReason ?? "cli_backend"; - messageBody.usage = { input: 0, output: 0, totalTokens: 0 }; + const u = params.usage; + messageBody.usage = { + input: u?.input ?? 0, + output: u?.output ?? 0, + cacheRead: u?.cacheRead, + cacheWrite: u?.cacheWrite, + totalTokens: u?.total ?? (u?.input ?? 0) + (u?.output ?? 0), + }; if (params.provider) messageBody.provider = params.provider; if (params.model) messageBody.model = params.model; } @@ -176,6 +186,8 @@ export function appendMessageToTranscript(params: { provider?: string; /** Model that generated the response (for assistant messages). */ model?: string; + /** Token usage for assistant messages (CLI backends). */ + usage?: NormalizedUsage; }): TranscriptAppendResult { const transcriptPath = resolveTranscriptPathFromParams(params); if (!transcriptPath) { @@ -199,6 +211,8 @@ export async function appendMessageToTranscriptAsync(params: { stopReason?: string; provider?: string; model?: string; + /** Token usage for assistant messages (CLI backends). */ + usage?: NormalizedUsage; }): Promise { const transcriptPath = resolveTranscriptPathFromParams(params); if (!transcriptPath) { @@ -223,6 +237,8 @@ export function appendAssistantMessageToTranscript(params: { provider?: string; /** Model that generated the response. */ model?: string; + /** Token usage for assistant messages (CLI backends). */ + usage?: NormalizedUsage; }): TranscriptAppendResult { return appendMessageToTranscript({ ...params, @@ -241,6 +257,8 @@ export async function appendAssistantMessageToTranscriptAsync(params: { createIfMissing?: boolean; provider?: string; model?: string; + /** Token usage for assistant messages (CLI backends). */ + usage?: NormalizedUsage; }): Promise { return appendMessageToTranscriptAsync({ ...params,