Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
26 changes: 13 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions scripts/acp-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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);
Expand Down
53 changes: 53 additions & 0 deletions src/acpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,6 +28,7 @@ import type {
ModelListResult,
PermissionRequestParams,
PermissionRequestResult,
ReasonixSessionStatus,
SessionConfigOption,
SessionInfo,
SessionListResult,
Expand Down Expand Up @@ -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 {
Expand All @@ -83,6 +90,7 @@ export class AcpClient {
private runningPrompt = false;
private initialized?: InitializeResult;
private state: SessionStateResult = {};
private readonly statusSequences = new Map<string, number>();

constructor(private readonly options: AcpClientOptions) {}

Expand Down Expand Up @@ -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)}`);
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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;
Expand All @@ -308,6 +331,36 @@ export class AcpClient {
this.options.onUpdate(parsed.value);
}

private async syncReasonixStatus(): Promise<void> {
if (!supportsReasonixStatusMethod(this.capabilities, REASONIX_STATUS_METHOD)) {
return;
}
try {
const raw = await this.requirePeer().sendRequest<unknown>(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<unknown> {
switch (method) {
case "session/request_permission": {
Expand Down
77 changes: 77 additions & 0 deletions src/acpProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> =
| { ok: true; value: T }
| { ok: false; error: string };
Expand All @@ -25,6 +32,55 @@ export function parseSessionUpdateParams(value: unknown): ProtocolParseResult<Se
return valid({ sessionId: value.sessionId, update: update.value });
}

export function supportsReasonixStatusMethod(capabilities: unknown, method: string): boolean {
if (!isRecord(capabilities) || !isRecord(capabilities._meta)) {
return false;
}
const advertised = capabilities._meta[method];
return isRecord(advertised) && advertised.schemaVersion === REASONIX_STATUS_SCHEMA_VERSION;
}

export function parseReasonixSessionStatus(value: unknown): ProtocolParseResult<ReasonixSessionStatus> {
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<ReasonixStatusUpdateParams> {
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<PermissionRequestParams> {
if (!isRecord(value) || !nonEmptyString(value.sessionId) || !isRecord(value.toolCall) || !Array.isArray(value.options)) {
return invalid("permission request requires sessionId, toolCall, and options");
Expand Down Expand Up @@ -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);
}
Expand All @@ -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() !== "";
}
Expand Down
32 changes: 32 additions & 0 deletions src/acpTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface AgentCapabilities {
embeddedContext?: boolean;
};
mcpCapabilities?: { http?: boolean; sse?: boolean };
_meta?: Record<string, unknown>;
}

export interface AuthMethod {
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading