pending_changes.agent stamped at every queue site (native -> 'boocode', dispatched external -> task.agent, manual RightRail -> NULL) + flows through listPending. New GET /api/sessions/:id/agent-sessions -> [{agent,status,has_session,last_active_at}] per (chat,agent). opencode warm server consumes session.next.step.ended, accumulating input_tokens/output_tokens/cost onto agent_sessions (new idempotent columns) via a pure opencode-usage.ts mapper. Tests: agent-sessions.routes (3) + opencode-usage (6); tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
78 lines
2.7 KiB
TypeScript
78 lines
2.7 KiB
TypeScript
/**
|
|
* v2.6 Phase 1-UX (U.6) — pure mapper for opencode's per-step usage event.
|
|
*
|
|
* opencode's warm server emits `session.next.step.ended` once per completed LLM
|
|
* step (so a multi-tool turn fires it several times). Its `properties` carry the
|
|
* step's token + cost accounting:
|
|
*
|
|
* {
|
|
* timestamp: number;
|
|
* sessionID: string;
|
|
* finish: string;
|
|
* cost: number; // USD for this step
|
|
* tokens: {
|
|
* input: number; output: number; reasoning: number;
|
|
* cache: { read: number; write: number };
|
|
* };
|
|
* snapshot?: string;
|
|
* }
|
|
*
|
|
* (Verified against @opencode-ai/sdk@1.15.12 — `EventSessionNextStepEnded` in
|
|
* `dist/v2/gen/types.gen.d.ts`, a member of the `Event` union the SSE loop
|
|
* switches on.)
|
|
*
|
|
* We normalize to the review's target slice `{input, output, cost}` (the
|
|
* provider-agnostic `AgentUsage` shape lands later). cache read/write tokens are
|
|
* folded into `input` so the persisted input count reflects the real context the
|
|
* model billed for; reasoning tokens are folded into `output` since that's what
|
|
* the provider counts them as for generation. This keeps the persisted totals a
|
|
* faithful sum of what opencode reported, without inventing extra columns yet.
|
|
*/
|
|
|
|
/** The `properties` shape of a `session.next.step.ended` event (subset we read). */
|
|
export interface StepEndedProps {
|
|
cost: number;
|
|
tokens: {
|
|
input: number;
|
|
output: number;
|
|
reasoning: number;
|
|
cache: { read: number; write: number };
|
|
};
|
|
}
|
|
|
|
/** Normalized per-step usage delta persisted onto the agent_sessions row. */
|
|
export interface StepUsage {
|
|
input: number;
|
|
output: number;
|
|
cost: number;
|
|
}
|
|
|
|
/** Coerce a possibly-missing/NaN number to a non-negative finite integer (tokens). */
|
|
function n(v: unknown): number {
|
|
const x = typeof v === 'number' ? v : Number(v);
|
|
return Number.isFinite(x) && x > 0 ? Math.round(x) : 0;
|
|
}
|
|
|
|
/** Coerce a possibly-missing/NaN number to a non-negative finite float (cost USD). */
|
|
function f(v: unknown): number {
|
|
const x = typeof v === 'number' ? v : Number(v);
|
|
return Number.isFinite(x) && x > 0 ? x : 0;
|
|
}
|
|
|
|
/**
|
|
* Map a `session.next.step.ended` payload → the normalized `{input, output, cost}`
|
|
* delta. Defensive against missing/partial token blocks (the wire is trusted but
|
|
* we never want a NaN to poison the accumulated DB total). `input` folds in cache
|
|
* read+write; `output` folds in reasoning.
|
|
*/
|
|
export function stepEndedToUsage(props: Partial<StepEndedProps> | undefined): StepUsage {
|
|
const t = props?.tokens;
|
|
const cacheRead = n(t?.cache?.read);
|
|
const cacheWrite = n(t?.cache?.write);
|
|
return {
|
|
input: n(t?.input) + cacheRead + cacheWrite,
|
|
output: n(t?.output) + n(t?.reasoning),
|
|
cost: f(props?.cost),
|
|
};
|
|
}
|