Merge branch 'main' of https://github.com/andrewyng/openworker into feature/memory

This commit is contained in:
Devika Verma
2026-07-29 12:04:52 +05:30
20 changed files with 1155 additions and 58 deletions
+17 -1
View File
@@ -46,6 +46,11 @@ const SETTINGS = {
"anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic",
"zai:glm-5.2": "GLM-5.2 · Z AI",
},
// Context windows (subset — mirrors /v1/settings.model_context_windows); drives the
// composer usage chip's context-fill meter.
model_context_windows: {
"anthropic:claude-opus-4-8": 200_000,
},
};
const PERSONAS = {
@@ -704,7 +709,18 @@ export async function mockApi(page: import("@playwright/test").Page) {
send("assistant_delta", { text: msg.text });
// Echo the model the message carried — pins the model-per-message contract (the
// composer's visible model must ride on every user_message; 2026-07-04 fix).
send("assistant_message", { text: `Echo: ${msg.text} [model=${msg.model || "none"}]` });
// `usage` mirrors the real engine's assistant_message sidecar (OPE-42): fixed
// counts per turn so the usage-chip specs can assert exact accumulation.
send("assistant_message", {
text: `Echo: ${msg.text} [model=${msg.model || "none"}]`,
usage: {
model: msg.model || "anthropic:claude-opus-4-8",
input: 1_000,
output: 200,
cache_read: 8_000,
cache_write: 800,
},
});
send("turn_done");
} else if (msg.type === "approval") {
if (pendingTool === "run_shell") {
+66
View File
@@ -0,0 +1,66 @@
// Token-usage chip (OPE-42): after a turn reports usage, a quiet meter+count chip appears
// in the composer's bottom row; clicking it opens the per-model breakdown popover with the
// context-window fill. The fake agent attaches fixed usage to every echo turn
// (input 1k / output 200 / cache_read 8k / cache_write 800 — 10k per turn), and the
// settings fixture maps the default model to a 200k context window.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("usage chip appears after a turn and opens the breakdown popover", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
// Fresh session: no usage yet — the chip is hidden entirely.
await expect(page.getByTestId("usage-chip")).toHaveCount(0);
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello");
await box.press("Enter");
await expect(page.getByText("Echo: hello", { exact: false }).first()).toBeVisible({
timeout: 10_000,
});
// Chip shows the session total (1k + 200 + 8k + 800 = 10k).
const chip = page.getByTestId("usage-chip");
await expect(chip).toContainText("10k");
// Popover: context fill (9.8k prompt-side of 200k = 5%) + per-model breakdown.
await chip.click();
const pop = page.getByTestId("usage-popover");
await expect(pop).toBeVisible();
await expect(pop).toContainText("Context window");
await expect(pop).toContainText("9.8k of 200k · 5%");
await expect(pop).toContainText("Session totals");
await expect(pop).toContainText("Claude Opus 4.8 · Anthropic");
// Cache split present → the input rows read as components of Total input.
await expect(pop).toContainText("Uncached input");
await expect(pop).toContainText("Cache reads");
await expect(pop).toContainText("Cache writes");
// Total input = fresh 1k + cache_read 8k + cache_write 800 (cumulative billed input).
await expect(pop).toContainText("Total input");
await expect(pop).toContainText("9.8k");
await expect(pop).toContainText("10k tokens");
// Second turn accumulates (totals double), and the scrim click closes the popover.
await page.mouse.click(10, 10);
await expect(pop).toHaveCount(0);
await box.fill("again");
await box.press("Enter");
await expect(page.getByText("Echo: again", { exact: false }).first()).toBeVisible({
timeout: 10_000,
});
await expect(chip).toContainText("20k");
});
test("usage resets on a new session", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello");
await box.press("Enter");
await expect(page.getByTestId("usage-chip")).toContainText("10k", { timeout: 10_000 });
// " New session" wipes the transcript — and the usage accumulation with it.
await page.getByRole("button", { name: /New session/ }).first().click();
await expect(page.getByTestId("usage-chip")).toHaveCount(0);
});
+36 -3
View File
@@ -31,10 +31,19 @@ import {
type SurfaceVisibility,
type WorkspaceCommandTrust,
} from "./api";
import type { ApprovalDecision, Attachment, Item, SessionInfo, TodoItem, WsEvent } from "./types";
import type {
ApprovalDecision,
Attachment,
Item,
SessionInfo,
SessionUsage,
TodoItem,
WsEvent,
} from "./types";
import { isProjectScoped } from "./personaScope";
import { baseName } from "./paths";
import { itemsFromMessages } from "./itemsFromMessages";
import { addTurnUsage, emptyUsage, usageFromMessages } from "./usage";
import { streamMode } from "./streamGate";
import { InboxItemCard } from "./components/InboxItemCard";
import { isTauri, platformOS, startWindowDrag } from "./tauri";
@@ -156,6 +165,12 @@ export function App() {
const [model, setModel] = useState("gpt-5.6-sol");
const [models, setModels] = useState<string[]>([]);
const [modelLabels, setModelLabels] = useState<Record<string, string>>({});
// {full model id → context window in tokens} from the curated matrix (verified only);
// drives the composer usage chip's context-fill meter.
const [modelContextWindows, setModelContextWindows] = useState<Record<string, number>>({});
// Per-session token usage (OPE-42): rebuilt from the transcript on session load,
// accumulated live from assistant_message events, reset with the transcript.
const [usage, setUsage] = useState<SessionUsage>(emptyUsage());
const [surfaces, setSurfaces] = useState<SurfaceVisibility>({ cowork: true, chat: false, code: false });
const [mode, setMode] = useState("interactive");
const [connected, setConnected] = useState(false);
@@ -395,9 +410,12 @@ export function App() {
setBranch(null);
}
try {
setItems(itemsFromMessages(await getSessionMessages(last.session_id)));
const messages = await getSessionMessages(last.session_id);
setItems(itemsFromMessages(messages));
setUsage(usageFromMessages(messages));
} catch {
setItems([]);
setUsage(emptyUsage());
}
setSessionId(last.session_id);
setShowGate(false);
@@ -486,6 +504,7 @@ export function App() {
.then((s) => {
setModels(s.models || []);
setModelLabels(s.model_labels || {});
setModelContextWindows(s.model_context_windows || {});
setModelReady(s.model_ready);
if (s.surfaces) setSurfaces(s.surfaces);
})
@@ -601,6 +620,7 @@ export function App() {
setReasoningStream(reasoningRef.current + (d.text || ""));
break;
case "assistant_message": {
if (d.usage) setUsage((u) => addTurnUsage(u, d.usage));
// The event's reasoning is authoritative (covers background-delivered turns);
// the local buffer is the fallback for older servers.
const reasoning = d.reasoning || reasoningRef.current;
@@ -899,6 +919,7 @@ export function App() {
const target = forAgent || agent;
setSurface("session"); // return to the conversation view if we were on a sub-view
setItems([]);
setUsage(emptyUsage());
setStreaming("");
setTodo([]);
setRunning(false);
@@ -975,8 +996,10 @@ export function App() {
try {
const messages = await getSessionMessages(id);
setItems(itemsFromMessages(messages));
setUsage(usageFromMessages(messages));
} catch {
setItems([]);
setUsage(emptyUsage());
}
};
const switchAgent = async (name: string) => {
@@ -989,6 +1012,7 @@ export function App() {
setAgent(name);
setItems([]);
setUsage(emptyUsage());
setStreaming("");
setTodo([]);
setRunning(false);
@@ -1017,9 +1041,12 @@ export function App() {
else setShowGate(true);
setSessionId(target.sessionId);
try {
setItems(itemsFromMessages(await getSessionMessages(target.sessionId)));
const messages = await getSessionMessages(target.sessionId);
setItems(itemsFromMessages(messages));
setUsage(usageFromMessages(messages));
} catch {
setItems([]);
setUsage(emptyUsage());
}
return;
}
@@ -1043,6 +1070,7 @@ export function App() {
setShowGate(false);
setGateCreate(false);
setItems([]);
setUsage(emptyUsage());
setStreaming("");
setTodo([]);
setSessionId(newId());
@@ -1055,6 +1083,7 @@ export function App() {
const target = forAgent || agent;
setSurface("session");
setItems([]);
setUsage(emptyUsage());
setStreaming("");
setTodo([]);
setRunning(false);
@@ -1079,6 +1108,7 @@ export function App() {
// Archiving the open chat: leave it and start fresh (it moves to the Archived section).
if (archived && id === sessionId) {
setItems([]);
setUsage(emptyUsage());
setStreaming("");
setTodo([]);
setRunning(false);
@@ -1091,6 +1121,7 @@ export function App() {
refreshSessions();
if (id === sessionId) {
setItems([]);
setUsage(emptyUsage());
setStreaming("");
setTodo([]);
setRunning(false);
@@ -1569,6 +1600,8 @@ export function App() {
onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined}
prefill={composerPrefill}
resetKey={sessionId}
usage={usage}
contextWindow={modelContextWindows[model]}
placeholder={
agent === "code"
? "Ask the coder to build, fix, or explain… (drop or paste files)"
+6
View File
@@ -141,6 +141,9 @@ export interface ConversationMessage {
tool_calls?: any[];
tool_call_id?: string;
source?: MessageSource;
// Token counts for the round-trip that produced an assistant message
// ({model, input, output, cache_read, cache_write}); absent on older servers.
usage?: import("./types").TurnUsage;
[key: string]: any;
}
@@ -691,6 +694,9 @@ export interface ModelSettings {
sessions_peek?: number;
// Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
model_labels?: Record<string, string>;
// {full id → context window in tokens}, verified matrix entries only — drives the
// composer's context-fill meter (absent id → the meter hides). Optional for older backends.
model_context_windows?: Record<string, number>;
// Token savings (PDF attachments): fallback for models without native PDF support,
// and attach-time thresholds. Optional so the GUI is robust to an older backend.
pdf_fallback?: "text" | "images";
+149 -1
View File
@@ -1,7 +1,8 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import type { Attachment } from "../types";
import type { Attachment, SessionUsage } from "../types";
import { isPdfFile, readFile } from "../attach";
import { getSettings, inspectPdf } from "../api";
import { formatTokens, totalTokens } from "../usage";
import { Dropdown, type Option } from "./Dropdown";
import { Icon } from "./Icon";
import { Toggle } from "./Toggle";
@@ -77,6 +78,12 @@ interface Props {
resetKey?: string;
// Surface-specific hint shown in the empty textarea.
placeholder?: string;
// Per-session token usage (OPE-42) — absent/empty hides the usage chip entirely
// (older servers, backends that don't report usage, fresh sessions).
usage?: SessionUsage;
// Context-window size (tokens) of the ACTIVE model, from the curated matrix;
// undefined hides the fill meter (unverified/custom models) but keeps the counts.
contextWindow?: number;
}
export function Composer(props: Props) {
@@ -462,6 +469,18 @@ export function Composer(props: Props) {
<span className="ml-auto" />
{/* token usage (OPE-42) a quiet meter+count chip; hidden until the server
reports usage. Fill = context-window occupancy (bounded), count = session
consumption (unbounded, so never a fill). */}
{!dictation?.recording && props.usage && totalTokens(props.usage) > 0 && (
<UsageChip
usage={props.usage}
contextWindow={props.contextWindow}
model={props.model}
modelLabels={props.modelLabels}
/>
)}
{/* model a quiet chip, now for the session's whole life (§17 rev 2026-07-22:
mid-session switching shipped, so the picker stays actionable; the topbar
subtitle still states the current model). */}
@@ -546,6 +565,135 @@ export function Composer(props: Props) {
);
}
// Token-usage chip + popover (OPE-42). Trigger: a tiny context-fill meter (only when the
// active model's window is known) + the session's total token count. Click → per-model
// breakdown. Tokens only, never dollars (true cost is unknowable client-side — discounted
// pricing, per-provider cache billing).
function UsageChip({
usage,
contextWindow,
model,
modelLabels,
}: {
usage: SessionUsage;
contextWindow?: number;
model: string;
modelLabels?: Record<string, string>;
}) {
const [open, setOpen] = useState(false);
const total = totalTokens(usage);
const pct = contextWindow
? Math.min(100, Math.round((usage.context / contextWindow) * 100))
: null;
const labelFor = (id: string) =>
id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id);
// One field per line, session-summed (owner ask 2026-07-28). Values are cumulative
// across the whole session, never just the last turn; "Input" is the fresh
// (uncached) share — the cached share sits in the cache rows at its own price.
const stat = (label: string, value: number) => (
<div className="flex items-baseline justify-between text-[11.5px] leading-snug">
<span className="text-faint">{label}</span>
<span className="text-ink tabular-nums">{formatTokens(value)}</span>
</div>
);
return (
<div className="relative">
<button
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11.5px] text-muted hover:text-ink hover:bg-paper shrink-0"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label="Token usage"
title={
pct !== null
? `Token usage — ${pct}% of the context window used`
: "Token usage this session"
}
data-testid="usage-chip"
>
{pct !== null && (
<span className="w-7 h-1 rounded-full bg-line overflow-hidden" aria-hidden="true">
<span
className="block h-full bg-accent transition-all"
style={{ width: `${Math.max(pct, 4)}%` }}
/>
</span>
)}
<span className="tabular-nums">{formatTokens(total)}</span>
</button>
{open && (
<>
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
<div
className="absolute z-40 bottom-full mb-1 right-0 w-[280px] rounded-xl border border-line bg-panel shadow-2xl p-3"
role="menu"
data-testid="usage-popover"
>
{contextWindow ? (
<div className="mb-2.5">
<div className="text-[10.5px] uppercase tracking-[0.06em] text-faint font-semibold mb-1">
Context window
</div>
<div className="h-1.5 rounded-full bg-line overflow-hidden">
<div
className="h-full bg-accent transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-1 text-[11.5px] text-muted tabular-nums">
{formatTokens(usage.context)} of {formatTokens(contextWindow)} · {pct}%
</div>
</div>
) : usage.context > 0 ? (
<div className="mb-2.5 text-[11.5px] text-muted tabular-nums">
In context now: {formatTokens(usage.context)} tokens
</div>
) : null}
<div className="text-[10.5px] uppercase tracking-[0.06em] text-faint font-semibold mb-1">
Session totals
</div>
<div className="flex flex-col gap-1.5">
{Object.entries(usage.byModel).map(([id, t]) => (
<div key={id}>
<div className="text-[12px] text-ink font-medium truncate" title={id}>
{labelFor(id)}
</div>
{/* Every row is a session sum. With a cache split, the input rows are
the three BILLING CLASSES of input (each priced differently) and
read as components: uncached + cache reads + cache writes = total.
Without one (Ollama, compat vendors), plain "Input" says it all. */}
<div className="mt-0.5 flex flex-col gap-0.5">
{t.cache_read + t.cache_write > 0 ? (
<>
{stat("Uncached input", t.input)}
{stat("Cache reads", t.cache_read)}
{stat("Cache writes", t.cache_write)}
{stat("Total input", t.input + t.cache_read + t.cache_write)}
</>
) : (
stat("Input", t.input)
)}
{stat("Output", t.output)}
</div>
</div>
))}
</div>
<div className="mt-2 pt-2 border-t border-line flex items-baseline justify-between text-[11.5px]">
<span className="text-faint">Total</span>
<span className="text-ink tabular-nums">{formatTokens(total)} tokens</span>
</div>
{model && !modelLabels?.[model] && contextWindow === undefined && (
<div className="mt-1 text-[10.5px] text-faint leading-snug">
Context meter unavailable for custom models.
</div>
)}
</div>
</>
)}
</div>
);
}
// The composer's Mode menu (§22): a quiet "Mode ⌄" chip opening the five permission options with
// the current one marked, plus — when the session supports it — the "Send approvals to Inbox"
// toggle at the bottom (the old standalone InboxControl, folded in).
+19
View File
@@ -39,6 +39,25 @@ export interface TodoItem {
status: "pending" | "in_progress" | "done";
}
// Per-round-trip token counts, as attached by the server to assistant messages and
// the assistant_message event (`{model, input, output, cache_read, cache_write}`).
// Absent on older servers and on backends that don't report usage.
export interface TurnUsage {
model?: string | null;
input: number;
output: number;
cache_read: number;
cache_write: number;
}
// Per-session accumulation, keyed by model id (multiple models when the user
// switched mid-session). `context` = the latest round-trip's prompt-side total —
// what currently occupies the active model's context window.
export interface SessionUsage {
byModel: Record<string, TurnUsage>;
context: number;
}
export interface SessionInfo {
session_id: string;
title?: string;
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { addTurnUsage, emptyUsage, formatTokens, totalTokens, usageFromMessages } from "./usage";
const turn = (over: Record<string, unknown> = {}) => ({
model: "anthropic:claude-fable-5",
input: 100,
output: 20,
cache_read: 50,
cache_write: 10,
...over,
});
describe("addTurnUsage", () => {
it("accumulates per model and tracks the latest prompt-side total as context", () => {
let u = addTurnUsage(emptyUsage(), turn());
u = addTurnUsage(u, turn({ input: 40, output: 5, cache_read: 200, cache_write: 0 }));
const m = u.byModel["anthropic:claude-fable-5"];
expect(m).toEqual({
model: "anthropic:claude-fable-5",
input: 140,
output: 25,
cache_read: 250,
cache_write: 10,
});
// context = LAST turn's input + cache_read + cache_write, not a sum.
expect(u.context).toBe(240);
});
it("keys separate models separately (mid-session switch)", () => {
let u = addTurnUsage(emptyUsage(), turn());
u = addTurnUsage(u, turn({ model: "gpt-5.5", input: 7 }));
expect(Object.keys(u.byModel).sort()).toEqual(["anthropic:claude-fable-5", "gpt-5.5"]);
});
it("ignores malformed payloads and clamps negatives", () => {
expect(addTurnUsage(emptyUsage(), null)).toEqual(emptyUsage());
expect(addTurnUsage(emptyUsage(), "x")).toEqual(emptyUsage());
const u = addTurnUsage(emptyUsage(), turn({ input: -5, output: "9" }));
expect(u.byModel["anthropic:claude-fable-5"].input).toBe(0);
expect(u.byModel["anthropic:claude-fable-5"].output).toBe(9);
});
it("buckets a missing model id under 'unknown'", () => {
const u = addTurnUsage(emptyUsage(), turn({ model: undefined }));
expect(u.byModel["unknown"].input).toBe(100);
});
});
describe("usageFromMessages", () => {
it("folds assistant usage sidecars and ignores everything else", () => {
const u = usageFromMessages([
{ role: "user", content: "hi" },
{ role: "assistant", content: "a", usage: turn() as any },
{ role: "tool", content: "r", usage: turn() as any }, // wrong role — ignored
{ role: "assistant", content: "b" }, // no sidecar (older server) — ignored
{ role: "assistant", content: "c", usage: turn({ input: 300 }) as any },
]);
expect(u.byModel["anthropic:claude-fable-5"].input).toBe(400);
expect(u.context).toBe(360);
});
});
describe("totalTokens / formatTokens", () => {
it("totals across models and directions", () => {
let u = addTurnUsage(emptyUsage(), turn());
u = addTurnUsage(u, turn({ model: "gpt-5.5" }));
expect(totalTokens(u)).toBe(360);
});
it("formats humane numbers", () => {
expect(formatTokens(0)).toBe("0");
expect(formatTokens(980)).toBe("980");
expect(formatTokens(12_400)).toBe("12.4k");
expect(formatTokens(982_000)).toBe("982k");
expect(formatTokens(1_240_000)).toBe("1.24M");
expect(formatTokens(NaN)).toBe("0");
});
});
+75
View File
@@ -0,0 +1,75 @@
// Per-session token-usage accumulation (OPE-42). Pure functions so the reducer is
// unit-testable without the app. The server attaches a `usage` sidecar
// ({model, input, output, cache_read, cache_write}) to assistant messages and to the
// assistant_message event; older servers and non-reporting backends send none, and
// everything here no-ops gracefully in that case.
import type { ConversationMessage } from "./api";
import type { SessionUsage, TurnUsage } from "./types";
export function emptyUsage(): SessionUsage {
return { byModel: {}, context: 0 };
}
const num = (v: any): number => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : 0;
};
/** Fold one turn's usage sidecar into the session accumulation. */
export function addTurnUsage(prev: SessionUsage, raw: any): SessionUsage {
if (!raw || typeof raw !== "object") return prev;
const turn: TurnUsage = {
model: typeof raw.model === "string" && raw.model ? raw.model : null,
input: num(raw.input),
output: num(raw.output),
cache_read: num(raw.cache_read),
cache_write: num(raw.cache_write),
};
const key = turn.model || "unknown";
const cur = prev.byModel[key];
return {
byModel: {
...prev.byModel,
[key]: {
model: turn.model,
input: (cur?.input || 0) + turn.input,
output: (cur?.output || 0) + turn.output,
cache_read: (cur?.cache_read || 0) + turn.cache_read,
cache_write: (cur?.cache_write || 0) + turn.cache_write,
},
},
// Prompt-side total of the LATEST round-trip = what currently sits in the
// context window (not a sum — each request resends the whole history).
context: turn.input + turn.cache_read + turn.cache_write,
};
}
/** Rebuild the accumulation from a replayed transcript (session load/switch). */
export function usageFromMessages(messages: ConversationMessage[]): SessionUsage {
let acc = emptyUsage();
for (const m of messages || []) {
if (m.role === "assistant" && m.usage) acc = addTurnUsage(acc, m.usage);
}
return acc;
}
/** All tokens consumed this session, across models and directions (chip headline). */
export function totalTokens(u: SessionUsage): number {
return Object.values(u.byModel).reduce(
(sum, t) => sum + t.input + t.output + t.cache_read + t.cache_write,
0,
);
}
/** 980 → "980", 12_400 → "12.4k", 982_000 → "982k", 1_240_000 → "1.24M". */
export function formatTokens(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0";
if (n < 1000) return String(Math.round(n));
if (n < 1_000_000) {
const k = n / 1000;
return (k < 100 ? k.toFixed(1).replace(/\.0$/, "") : String(Math.round(k))) + "k";
}
const m = n / 1_000_000;
return (m < 100 ? m.toFixed(2).replace(/\.?0+$/, "") : String(Math.round(m))) + "M";
}