diff --git a/CHANGELOG.md b/CHANGELOG.md index 269feba..5c3b73b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Restored usage cards and cache telemetry with Reasonix 1.19+ by consuming its advertised namespaced ACP status snapshot and update methods, while retaining compatibility with legacy usage updates. + ## 0.3.0 - Added a composer `+` menu with four actions: attach local files or images, reference workspace files or folders, reference past sessions, and insert slash commands. Attachments show as removable chips and are read at send time with size caps; images are sent as ACP `image` blocks when the backend supports them. diff --git a/package-lock.json b/package-lock.json index 209b6c5..e4aef1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1435,16 +1435,16 @@ "license": "BSD-2-Clause" }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -2134,9 +2134,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -2714,9 +2714,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -2869,9 +2869,9 @@ } }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "dev": true, "funding": [ { diff --git a/scripts/acp-smoke.mjs b/scripts/acp-smoke.mjs index b2f2475..61f580a 100644 --- a/scripts/acp-smoke.mjs +++ b/scripts/acp-smoke.mjs @@ -5,6 +5,7 @@ const required = process.env.REASONIX_ACP_SMOKE_REQUIRED === "1"; const independentAxesRequired = process.env.REASONIX_ACP_INDEPENDENT_AXES_REQUIRED === "1"; const binary = process.env.REASONIX_BINARY || "reasonix"; const timeoutMs = Number(process.env.REASONIX_ACP_SMOKE_TIMEOUT_MS || 15_000); +const reasonixStatusMethod = "_reasonix.io/session/status"; const child = spawn(binary, ["acp"], { cwd: process.cwd(), @@ -92,6 +93,17 @@ try { throw new Error("session/new did not return a sessionId"); } + let reasonixStatus = "not advertised"; + const statusCapability = init.result.agentCapabilities._meta?.[reasonixStatusMethod]; + if (statusCapability?.schemaVersion === 1) { + const status = await request(reasonixStatusMethod, { sessionId }); + failOnError(status, reasonixStatusMethod); + if (status.result?.schemaVersion !== 1 || status.result?.sessionId !== sessionId || !status.result?.usage?.turn || !status.result?.usage?.cumulative) { + throw new Error(`${reasonixStatusMethod} did not return a matching schema v1 usage snapshot`); + } + reasonixStatus = "schema v1"; + } + if (!Array.isArray(newSession.result?.configOptions) || !newSession.result?.modes) { throw new Error("session/new did not return configOptions and modes"); } @@ -135,6 +147,7 @@ try { console.log(`- config options: ${newSession.result.configOptions.length}`); console.log(`- modes: ${newSession.result.modes.availableModes?.length ?? 0}`); console.log(`- independent axes: ${independentAxes ? "work_mode + tool_approval" : "legacy"}`); + console.log(`- Reasonix status: ${reasonixStatus}`); console.log(`- session/list: ${sessions.result.sessions.length} session(s)`); child.kill(); clearTimeout(timer); diff --git a/src/acpClient.ts b/src/acpClient.ts index 096454f..60ae7f8 100644 --- a/src/acpClient.ts +++ b/src/acpClient.ts @@ -5,9 +5,14 @@ import { parseFSReadTextFileParams, parseFSWriteTextFileParams, parsePermissionRequestParams, + parseReasonixSessionStatus, + parseReasonixStatusUpdateParams, parseSessionUpdateParams, parseTerminalCreateParams, parseTerminalIDParams, + REASONIX_STATUS_METHOD, + REASONIX_STATUS_UPDATE_METHOD, + supportsReasonixStatusMethod, type ProtocolParseResult, } from "./acpProtocol"; import { redactLocalPaths } from "./sanitize"; @@ -23,6 +28,7 @@ import type { ModelListResult, PermissionRequestParams, PermissionRequestResult, + ReasonixSessionStatus, SessionConfigOption, SessionInfo, SessionListResult, @@ -74,6 +80,7 @@ export type AcpClientOptions = { onDisconnect: (reason: string) => void; onSessionId: (sessionId: string) => void; onSessionState?: (state: SessionStateResult) => void; + onReasonixStatus?: (status: ReasonixSessionStatus, event?: string) => void; }; export class AcpClient { @@ -83,6 +90,7 @@ export class AcpClient { private runningPrompt = false; private initialized?: InitializeResult; private state: SessionStateResult = {}; + private readonly statusSequences = new Map(); constructor(private readonly options: AcpClientOptions) {} @@ -168,6 +176,7 @@ export class AcpClient { try { const resumed = await this.openExistingSession(previous); this.applySessionState(resumed); + await this.syncReasonixStatus(); return { sessionId: previous, isNewSession: false }; } catch (err) { this.appendLine(`Could not restore Reasonix session ${previous}: ${errorMessage(err)}`); @@ -185,6 +194,7 @@ export class AcpClient { this.sessionId = created.sessionId; this.applySessionState(created); this.options.onSessionId(created.sessionId); + await this.syncReasonixStatus(); return { sessionId: created.sessionId, isNewSession: true }; } @@ -290,6 +300,19 @@ export class AcpClient { } private handleNotification(method: string, params: unknown): void { + if (method === REASONIX_STATUS_UPDATE_METHOD) { + if (!supportsReasonixStatusMethod(this.capabilities, method)) { + this.appendLine(`Ignoring unadvertised ACP notification: ${method}`); + return; + } + const parsedStatus = parseReasonixStatusUpdateParams(params); + if (!parsedStatus.ok) { + this.appendLine(`Ignoring invalid Reasonix status update: ${parsedStatus.error}`); + return; + } + this.acceptReasonixStatus(parsedStatus.value.status, parsedStatus.value.event); + return; + } if (method !== "session/update") { this.appendLine(`Ignoring unsupported ACP notification: ${method}`); return; @@ -308,6 +331,36 @@ export class AcpClient { this.options.onUpdate(parsed.value); } + private async syncReasonixStatus(): Promise { + if (!supportsReasonixStatusMethod(this.capabilities, REASONIX_STATUS_METHOD)) { + return; + } + try { + const raw = await this.requirePeer().sendRequest(REASONIX_STATUS_METHOD, { sessionId: this.requireSession() }); + const parsed = parseReasonixSessionStatus(raw); + if (!parsed.ok) { + this.appendLine(`Ignoring invalid Reasonix session status: ${parsed.error}`); + return; + } + this.acceptReasonixStatus(parsed.value); + } catch (err) { + this.appendLine(`Could not read Reasonix session status: ${errorMessage(err)}`); + } + } + + private acceptReasonixStatus(status: ReasonixSessionStatus, event?: string): void { + if (status.sessionId !== this.sessionId) { + this.appendLine(`Ignoring Reasonix status for inactive session ${status.sessionId}`); + return; + } + const previous = this.statusSequences.get(status.sessionId); + if (previous !== undefined && status.sequence <= previous) { + return; + } + this.statusSequences.set(status.sessionId, status.sequence); + this.options.onReasonixStatus?.(status, event); + } + private async handleRequest(method: string, params: unknown): Promise { switch (method) { case "session/request_permission": { diff --git a/src/acpProtocol.ts b/src/acpProtocol.ts index 1d3338d..d52fcf0 100644 --- a/src/acpProtocol.ts +++ b/src/acpProtocol.ts @@ -3,13 +3,20 @@ import type { FSReadTextFileParams, FSWriteTextFileParams, PermissionRequestParams, + ReasonixSessionStatus, + ReasonixStatusUpdateParams, SessionConfigOption, SessionUpdate, SessionUpdateParams, TerminalCreateParams, TerminalIDParams, + UsageData, } from "./acpTypes"; +export const REASONIX_STATUS_METHOD = "_reasonix.io/session/status"; +export const REASONIX_STATUS_UPDATE_METHOD = "_reasonix.io/session/status_update"; +const REASONIX_STATUS_SCHEMA_VERSION = 1; + export type ProtocolParseResult = | { ok: true; value: T } | { ok: false; error: string }; @@ -25,6 +32,55 @@ export function parseSessionUpdateParams(value: unknown): ProtocolParseResult { + if (!isRecord(value) || value.schemaVersion !== REASONIX_STATUS_SCHEMA_VERSION + || !nonNegativeInteger(value.sequence) || !nonEmptyString(value.sessionId) || !isRecord(value.usage) + || !isReasonixStatusUsage(value.usage.turn) || !isReasonixStatusUsage(value.usage.cumulative)) { + return invalid("Reasonix session status is malformed"); + } + return valid(value as unknown as ReasonixSessionStatus); +} + +export function parseReasonixStatusUpdateParams(value: unknown): ProtocolParseResult { + if (!isRecord(value) || value.schemaVersion !== REASONIX_STATUS_SCHEMA_VERSION + || !nonNegativeInteger(value.sequence) || !nonEmptyString(value.sessionId) || !nonEmptyString(value.event)) { + return invalid("Reasonix status update is malformed"); + } + const status = parseReasonixSessionStatus(value.status); + if (!status.ok) { + return status; + } + if (status.value.sequence !== value.sequence || status.value.sessionId !== value.sessionId) { + return invalid("Reasonix status update does not match its status snapshot"); + } + return valid(value as unknown as ReasonixStatusUpdateParams); +} + +export function usageDataFromReasonixStatus(status: ReasonixSessionStatus): UsageData { + const turn = status.usage.turn; + const cumulative = status.usage.cumulative; + return { + promptTokens: turn.promptTokens, + completionTokens: turn.completionTokens, + totalTokens: turn.promptTokens + turn.completionTokens, + cacheHitTokens: turn.cacheHitTokens, + cacheMissTokens: turn.cacheMissTokens, + reasoningTokens: turn.reasoningTokens, + sessionCacheHitTokens: cumulative.cacheHitTokens, + sessionCacheMissTokens: cumulative.cacheMissTokens, + ...(turn.estimatedCost === undefined || turn.estimatedCost === null ? {} : { cost: turn.estimatedCost }), + ...(turn.currency === undefined || turn.currency === null ? {} : { currency: turn.currency }), + }; +} + export function parsePermissionRequestParams(value: unknown): ProtocolParseResult { if (!isRecord(value) || !nonEmptyString(value.sessionId) || !isRecord(value.toolCall) || !Array.isArray(value.options)) { return invalid("permission request requires sessionId, toolCall, and options"); @@ -182,6 +238,23 @@ function isUsage(value: unknown): boolean { .every((key) => typeof value[key] === "number" && Number.isFinite(value[key])); } +function isReasonixStatusUsage(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const counts = ["promptTokens", "completionTokens", "reasoningTokens", "cacheHitTokens", "cacheMissTokens"]; + return counts.every((key) => nonNegativeInteger(value[key])) + && (value.estimated === undefined || typeof value.estimated === "boolean") + && optionalFiniteNumber(value.cacheHitRatio) + && optionalFiniteNumber(value.estimatedCost) + && (value.currency === undefined || value.currency === null || typeof value.currency === "string") + && typeof value.usageSource === "string"; +} + +function optionalFiniteNumber(value: unknown): boolean { + return value === undefined || value === null || (typeof value === "number" && Number.isFinite(value)); +} + function isPermissionOption(value: unknown): boolean { return isRecord(value) && nonEmptyString(value.optionId) && nonEmptyString(value.name) && nonEmptyString(value.kind); } @@ -190,6 +263,10 @@ function positiveInteger(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value > 0; } +function nonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + function nonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim() !== ""; } diff --git a/src/acpTypes.ts b/src/acpTypes.ts index 50255c0..6214093 100644 --- a/src/acpTypes.ts +++ b/src/acpTypes.ts @@ -55,6 +55,7 @@ export interface AgentCapabilities { embeddedContext?: boolean; }; mcpCapabilities?: { http?: boolean; sse?: boolean }; + _meta?: Record; } export interface AuthMethod { @@ -259,6 +260,37 @@ export interface UsageData { }; } +export interface ReasonixStatusUsageValue { + promptTokens: number; + completionTokens: number; + reasoningTokens: number; + cacheHitTokens: number; + cacheMissTokens: number; + estimated?: boolean; + cacheHitRatio?: number | null; + estimatedCost?: number | null; + currency?: string | null; + usageSource: string; +} + +export interface ReasonixSessionStatus { + schemaVersion: number; + sequence: number; + sessionId: string; + usage: { + turn: ReasonixStatusUsageValue; + cumulative: ReasonixStatusUsageValue; + }; +} + +export interface ReasonixStatusUpdateParams { + schemaVersion: number; + sequence: number; + sessionId: string; + event: string; + status: ReasonixSessionStatus; +} + export interface ChangePreview { path: string; kind: string; diff --git a/src/extension.ts b/src/extension.ts index 02a8aa6..21d9b4c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import { promisify } from "node:util"; import * as vscode from "vscode"; import { AcpClient } from "./acpClient"; +import { usageDataFromReasonixStatus } from "./acpProtocol"; import type { AgentCapabilities, AuthMethod, @@ -14,6 +15,7 @@ import type { ModelInfo, PermissionRequestParams, PermissionRequestResult, + ReasonixSessionStatus, SessionConfigOption, SessionModeState, SessionModelState, @@ -1352,6 +1354,7 @@ class ReasonixChatProvider implements vscode.WebviewViewProvider, vscode.Disposa this.syncSessionState(state, sessionState); this.postSnapshot(); }, + onReasonixStatus: (status, event) => this.handleReasonixStatus(folder, status, event), }); this.clients.set(key, client); try { @@ -1439,6 +1442,32 @@ class ReasonixChatProvider implements vscode.WebviewViewProvider, vscode.Disposa this.postSnapshot(); } + private handleReasonixStatus(folder: vscode.WorkspaceFolder, status: ReasonixSessionStatus, event?: string): void { + if (event !== undefined && event !== "usage") { + return; + } + const state = this.stateFor(folder); + if (state.sessionId && status.sessionId !== state.sessionId) { + this.appendOutput(`Ignored status for inactive session ${status.sessionId}`, folder); + return; + } + const usage = usageDataFromReasonixStatus(status); + const turnHasUsage = usage.totalTokens > 0 || usage.cacheHitTokens > 0 || usage.cacheMissTokens > 0 || usage.cost !== undefined; + const cumulative = status.usage.cumulative; + const sessionHasUsage = cumulative.promptTokens > 0 || cumulative.completionTokens > 0 + || cumulative.cacheHitTokens > 0 || cumulative.cacheMissTokens > 0 + || (cumulative.estimatedCost !== undefined && cumulative.estimatedCost !== null); + if (!turnHasUsage && !sessionHasUsage) { + return; + } + if (turnHasUsage) { + applySessionUpdate(state.items, { sessionUpdate: "usage", usage }); + } + state.usage = usage; + this.updateStatusBar(folder); + this.postSnapshot(); + } + private async handlePermissionRequest(folder: vscode.WorkspaceFolder, params: PermissionRequestParams): Promise { const state = this.stateFor(folder); const approvalMode = this.toolApprovalMode(state); diff --git a/test/acpProtocol.test.ts b/test/acpProtocol.test.ts index 63b0e6e..11f07c3 100644 --- a/test/acpProtocol.test.ts +++ b/test/acpProtocol.test.ts @@ -3,8 +3,14 @@ import assert from "node:assert/strict"; import { parseFSReadTextFileParams, parsePermissionRequestParams, + parseReasonixSessionStatus, + parseReasonixStatusUpdateParams, parseSessionUpdateParams, parseTerminalCreateParams, + REASONIX_STATUS_METHOD, + REASONIX_STATUS_UPDATE_METHOD, + supportsReasonixStatusMethod, + usageDataFromReasonixStatus, } from "../src/acpProtocol"; test("parseSessionUpdateParams accepts main-v2 command, plan, and location updates", () => { @@ -37,6 +43,24 @@ test("parseSessionUpdateParams rejects unknown or malformed frames without throw assert.equal(malformed.ok, false); }); +test("parseSessionUpdateParams keeps legacy Reasonix usage updates compatible", () => { + assert.equal(parseSessionUpdateParams({ + sessionId: "s1", + update: { + sessionUpdate: "usage", + usage: { + promptTokens: 100, + completionTokens: 25, + totalTokens: 125, + cacheHitTokens: 80, + cacheMissTokens: 20, + sessionCacheHitTokens: 180, + sessionCacheMissTokens: 20, + }, + }, + }).ok, true); +}); + test("ACP client request parsers enforce required fields", () => { assert.equal(parseFSReadTextFileParams({ sessionId: "s1", path: "README.md", line: 1, limit: 20 }).ok, true); assert.equal(parseFSReadTextFileParams({ sessionId: "s1", path: "README.md", line: 0 }).ok, false); @@ -48,3 +72,83 @@ test("ACP client request parsers enforce required fields", () => { options: [{ optionId: "q:1", name: "One", kind: "allow_once" }], }).ok, true); }); + +test("Reasonix status capability and current schema expose usage telemetry", () => { + const capabilities = { + _meta: { + [REASONIX_STATUS_METHOD]: { schemaVersion: 1 }, + [REASONIX_STATUS_UPDATE_METHOD]: { schemaVersion: 1 }, + }, + }; + assert.equal(supportsReasonixStatusMethod(capabilities, REASONIX_STATUS_METHOD), true); + assert.equal(supportsReasonixStatusMethod(capabilities, REASONIX_STATUS_UPDATE_METHOD), true); + assert.equal(supportsReasonixStatusMethod({ _meta: { [REASONIX_STATUS_METHOD]: { schemaVersion: 2 } } }, REASONIX_STATUS_METHOD), false); + + const status = reasonixStatus(7, 120, 30, 80, 40, 180, 60); + const parsed = parseReasonixSessionStatus(status); + assert.equal(parsed.ok, true); + assert.deepEqual(parsed.ok ? usageDataFromReasonixStatus(parsed.value) : undefined, { + promptTokens: 120, + completionTokens: 30, + totalTokens: 150, + cacheHitTokens: 80, + cacheMissTokens: 40, + reasoningTokens: 12, + sessionCacheHitTokens: 180, + sessionCacheMissTokens: 60, + cost: 0.0042, + currency: "USD", + }); + + assert.equal(parseReasonixStatusUpdateParams({ + schemaVersion: 1, + sequence: 7, + sessionId: "s1", + event: "usage", + status, + }).ok, true); +}); + +test("Reasonix status parser rejects malformed and mismatched snapshots", () => { + const status = reasonixStatus(7, 120, 30, 80, 40, 180, 60); + assert.equal(parseReasonixSessionStatus({ ...status, usage: { ...status.usage, turn: { ...status.usage.turn, promptTokens: -1 } } }).ok, false); + assert.equal(parseReasonixStatusUpdateParams({ + schemaVersion: 1, + sequence: 8, + sessionId: "s1", + event: "usage", + status, + }).ok, false); +}); + +function reasonixStatus( + sequence: number, + promptTokens: number, + completionTokens: number, + cacheHitTokens: number, + cacheMissTokens: number, + sessionCacheHitTokens: number, + sessionCacheMissTokens: number, +) { + const usage = (prompt: number, completion: number, hit: number, miss: number, cost: number | null) => ({ + promptTokens: prompt, + completionTokens: completion, + reasoningTokens: 12, + cacheHitTokens: hit, + cacheMissTokens: miss, + estimated: false, + cacheHitRatio: hit + miss > 0 ? hit / (hit + miss) : null, + estimatedCost: cost, + currency: cost === null ? null : "USD", + usageSource: "executor", + }); + return { + schemaVersion: 1, + sequence, + sessionId: "s1", + usage: { + turn: usage(promptTokens, completionTokens, cacheHitTokens, cacheMissTokens, 0.0042), + cumulative: usage(promptTokens, completionTokens, sessionCacheHitTokens, sessionCacheMissTokens, 0.0042), + }, + }; +} diff --git a/test/vscode/fake-acp.cjs b/test/vscode/fake-acp.cjs index 7cfdcf9..ac22749 100755 --- a/test/vscode/fake-acp.cjs +++ b/test/vscode/fake-acp.cjs @@ -11,6 +11,9 @@ let currentModelId = "fake/default"; let currentEffort = "medium"; let currentWorkMode = "balanced"; let currentApprovalMode = "ask"; +let statusSequence = 0; +let currentTurnUsage = statusUsage(0, 0, 0, 0, 0, null, null); +let cumulativeUsage = statusUsage(0, 0, 0, 0, 0, null, null); const pendingAgentRequests = new Map(); const sessions = new Map(); @@ -128,6 +131,41 @@ function availableCommands() { }; } +function statusUsage(promptTokens, completionTokens, reasoningTokens, cacheHitTokens, cacheMissTokens, estimatedCost, currency) { + return { + promptTokens, + completionTokens, + reasoningTokens, + cacheHitTokens, + cacheMissTokens, + estimated: false, + cacheHitRatio: cacheHitTokens + cacheMissTokens > 0 ? cacheHitTokens / (cacheHitTokens + cacheMissTokens) : null, + estimatedCost, + currency, + usageSource: "executor", + }; +} + +function reasonixStatus(sequence = statusSequence, turn = currentTurnUsage, cumulative = cumulativeUsage) { + return { + schemaVersion: 1, + sequence, + sessionId, + state: "idle", + model: currentModelId, + effort: currentEffort, + mode: currentModeId, + workMode: currentWorkMode, + plannerMode: "on", + goal: { status: "stopped" }, + phase: "completed", + turnOutcome: { kind: "completed" }, + finalReadiness: { readyForReview: true, summary: "", risks: [] }, + sandbox: { mode: "off", engine: "none", available: false, workspaceRoot: process.cwd(), writeRoots: [], networkEnabled: true }, + usage: { turn, cumulative }, + }; +} + function handle(message) { if (!message.method && pendingAgentRequests.has(message.id)) { handleAgentResponse(message, pendingAgentRequests.get(message.id)); @@ -145,6 +183,10 @@ function handle(message) { sessionCapabilities: { list: {}, resume: {}, close: {}, delete: {} }, promptCapabilities: { image: false, audio: false, embeddedContext: true }, mcpCapabilities: { http: true, sse: false }, + _meta: { + "_reasonix.io/session/status": { schemaVersion: 1 }, + "_reasonix.io/session/status_update": { schemaVersion: 1 }, + }, }, authMethods: [], }); @@ -170,6 +212,9 @@ function handle(message) { case "session/list": result(message.id, { sessions: [...sessions.values()] }); return; + case "_reasonix.io/session/status": + result(message.id, reasonixStatus()); + return; case "session/close": result(message.id, {}); return; @@ -219,6 +264,28 @@ function handle(message) { function handlePrompt(message) { const text = promptText(message.params); + if (text.includes("usage_probe")) { + statusSequence = 2; + currentTurnUsage = statusUsage(120, 30, 12, 80, 40, 0.0042, "USD"); + cumulativeUsage = statusUsage(240, 60, 20, 180, 60, 0.0084, "USD"); + notify("_reasonix.io/session/status_update", { + schemaVersion: 1, + sequence: statusSequence, + sessionId, + event: "usage", + status: reasonixStatus(), + }); + const staleTurn = statusUsage(999, 999, 999, 1, 1, 99, "USD"); + notify("_reasonix.io/session/status_update", { + schemaVersion: 1, + sequence: 1, + sessionId, + event: "usage", + status: reasonixStatus(1, staleTurn, staleTurn), + }); + result(message.id, { stopReason: "end_turn" }); + return; + } if (text.includes("slow_prompt")) { pendingSlowPromptId = message.id; log({ method: "slow/prompt" }); diff --git a/test/vscode/suite/index.ts b/test/vscode/suite/index.ts index 59c93d1..8e66352 100644 --- a/test/vscode/suite/index.ts +++ b/test/vscode/suite/index.ts @@ -43,6 +43,19 @@ export async function run(): Promise { assert.equal(initialSnapshot.modes?.currentModeId, "normal"); assert.equal(initialSnapshot.configOptions?.length, 4); assert.equal(initialSnapshot.sessions?.length, 1); + await waitForLog(fakeLog, "_reasonix.io/session/status"); + + await vscode.commands.executeCommand("reasonix.test.webviewMessage", { command: "sendPrompt", text: "usage_probe" }); + const usageSnapshot = await waitForSnapshot((state) => Array.isArray(state.items) + && state.items.some((item: TestRecord) => item.type === "usage")); + const usageItem = usageSnapshot.items.find((item: TestRecord) => item.type === "usage"); + assert.equal(usageItem.usage.totalTokens, 150); + assert.equal(usageItem.usage.promptTokens, 120); + assert.equal(usageItem.usage.completionTokens, 30); + assert.equal(usageItem.usage.sessionCacheHitTokens, 180); + assert.equal(usageSnapshot.usage.sessionCacheMissTokens, 60); + assert.equal(Math.round((usageSnapshot.usage.sessionCacheHitTokens + / (usageSnapshot.usage.sessionCacheHitTokens + usageSnapshot.usage.sessionCacheMissTokens)) * 100), 75); await vscode.commands.executeCommand("reasonix.test.webviewMessage", { command: "setModel", value: "fake/fast" }); const modelSwitch = await waitForLogMatch(fakeLog, (event) => event.method === "session/set_config_option" && event.params?.configId === "model" && event.params?.value === "fake/fast", "webview model switch");