From 9357f0674478af0d6e52a404546a8b726ce4210e Mon Sep 17 00:00:00 2001 From: Tiago Date: Thu, 8 Oct 2026 01:26:51 -0300 Subject: [PATCH 1/2] =?UTF-8?q?perf(tui):=20=E5=90=88=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E5=88=B7=E6=96=B0=E5=B9=B6=E9=9A=94=E7=A6=BB=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 3 + README.md | 1 + README_EN.md | 1 + package.json | 1 + src/panel/TokenCachePanel.tsx | 225 ++++++++++++---------------------- src/panel/distribution.ts | 104 ++++++++++++++++ src/panel/refresh.ts | 31 +++++ src/panel/snapshot.ts | 56 +++++++++ src/v2/v2-panel-api.ts | 18 ++- tests/distribution.test.ts | 122 ++++++++++++++++++ tests/panel-api.test.ts | 42 +++++++ tests/refresh.test.ts | 36 ++++++ tests/snapshot.test.ts | 117 ++++++++++++++++++ 13 files changed, 606 insertions(+), 151 deletions(-) create mode 100644 src/panel/distribution.ts create mode 100644 src/panel/refresh.ts create mode 100644 src/panel/snapshot.ts create mode 100644 tests/distribution.test.ts create mode 100644 tests/panel-api.test.ts create mode 100644 tests/refresh.test.ts create mode 100644 tests/snapshot.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26ebaa7..f28bb21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,6 @@ jobs: - name: Build run: npm run build + + - name: Test + run: npm test diff --git a/README.md b/README.md index e485be3..293b8c7 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ - **折叠面板**:主标题默认折叠,点击展开;明细、模型、分布各自独立折叠 - **颜色自适应**:命中率 ≥85% 绿 · ≥70% 橙 · <70% 红,颜色从主题色自动去饱和 - **Token 分布**:按角色(系统提示 / 用户 / 子代理指令 / Tool 调用 / Tool 结果)展示估算 Token 占比 +- **合并刷新**:流式更新在 100ms 窗口内合并,复用未变化历史文本的估算;其他会话的 V2 全局事件不刷新当前面板。分布快照记录会话身份、合并写入且只保存变化值;余额凭据及轮询保持不变。 - **折叠记忆**:折叠状态持久化,重启后保持 - **语言适配**:支持 中文 / English / 日本語 / 한국어,自动检测系统语言,`/cache-lang` 运行时切换,偏好持久化优先 - **多币种**:通过 `/cache-currency` 切换货币,费用和节省同步换算 diff --git a/README_EN.md b/README_EN.md index cdcca0e..fa7416c 100644 --- a/README_EN.md +++ b/README_EN.md @@ -47,6 +47,7 @@ Interested in sub-agent monitoring? Check out [opencode-subagent-magazine](https - **Collapsible**: Main title collapsed by default; click to expand. Detail, model, and distribution sections fold independently - **Adaptive Colors**: ≥85% green · ≥70% orange · <70% red, auto-desaturated from current theme - **Token Distribution**: Per-role (system / user / sub-agent instr / tool call / tool result) estimated token breakdown +- **Bounded Refresh Work**: Streaming updates coalesce within 100ms; unchanged historical text estimates are reused. Global V2 events from other sessions do not refresh the panel. Distribution snapshots are session-owned, coalesced and only saved when changed; balance credentials and polling are unchanged. - **Persistent State**: Fold preferences and config remembered across restarts via api.kv - **Language**: Chinese / English / 日本語 / 한국어, auto-detects system locale, with `/cache-lang` for runtime switching — user preference takes priority over auto-detection - **Multi-currency**: Switch via `/cache-currency` — costs, savings, and per-million rates convert in real time diff --git a/package.json b/package.json index 957b5d2..b807127 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "build": "tsc --emitDeclarationOnly && node build.tui.mjs", "build:tui": "node build.tui.mjs", "typecheck": "tsc --noEmit", + "test": "tsx --test tests/*.test.ts", "test:balance": "tsx tests/codex-balance.test.ts", "test:credentials": "tsx tests/credentials.test.ts", "version": "node -e \"require('fs').writeFileSync('src/_version.ts','// auto-generated\\nexport const PLUGIN_VERSION='+JSON.stringify(require('./package.json').version)+';\\n')\"", diff --git a/src/panel/TokenCachePanel.tsx b/src/panel/TokenCachePanel.tsx index 2ddfed9..eda0016 100644 --- a/src/panel/TokenCachePanel.tsx +++ b/src/panel/TokenCachePanel.tsx @@ -2,17 +2,20 @@ import type { JSX } from "@opentui/solid" import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" -import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk" -import type { Part, TextPart, ToolPart, FilePart, ReasoningPart } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Message } from "@opencode-ai/sdk" import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, For, untrack } from "solid-js" import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceDetail, type BalanceDetailKey, type BalanceEntry, type BalanceProvider } from "../balance-providers" import { createT, type LangCode, type Translation } from "../i18n" -import { MAX_SAT, FALLBACK, desaturateTo, dimColor, fmt, fmtCost, num, estimateTokens, progressBar, visualWidth, visualPadEnd, truncateVisual, formatBalanceText, type TokenDist } from "../core" +import { MAX_SAT, FALLBACK, desaturateTo, dimColor, fmt, fmtCost, num, progressBar, visualWidth, visualPadEnd, truncateVisual, formatBalanceText } from "../core" import { PLUGIN_VERSION } from "../_version" import type { PanelApi, PanelSignals } from "./panel-api" +import { createDistributionCalculator, emptyDistribution } from "./distribution" +import { createCoalescedTask, isViewedSessionEvent } from "./refresh" +import { createSnapshotWriter, readDistributionSnapshot } from "./snapshot" const MIN_PANEL_WIDTH = 20 const DEFAULT_PANEL_WIDTH = 26 +const KV_PREFIX = "cache_panel" /** ── layout measurement constants (visual columns) ── */ const LABEL_GAP = 1 // label(如 "Hit")后面的空格 @@ -106,19 +109,21 @@ export function TokenCachePanel(props: { } // ── scan session messages reactively ── - // SolidJS createMemo re-evaluates whenever the underlying - // api.state.session state changes — no event listener needed. + // Track the cheap summary synchronously; coalesce expensive part work. + // Explicit events also cover hosts that mutate parts without replacing rows. // ── distribution cache ──────────────────────────────────────── // When data() re-computes before api.state.part() is warm (e.g. after // a view switch), hasDistData flips to false and the distribution // block disappears. Keep the last valid snapshot so the UI stays // stable until the next successful computation arrives. - const [lastDist, setLastDist] = createSignal({ - system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, - output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0, - }) - const [lastHasDist, setLastHasDist] = createSignal(false) + const distribution = createDistributionCalculator() + const refreshData = createCoalescedTask() + const snapshots = createSnapshotWriter(props.api.kv) + let viewedSid: string | undefined + let lastDist = emptyDistribution() + let lastHasDist = false + onCleanup(() => { refreshData.cancel(); snapshots.flush(); distribution.clear() }) const [dataSignal, setDataSignal] = createSignal({ hitRate: 0, read: 0, write: 0, freshInput: 0, output: 0, @@ -131,6 +136,9 @@ export function TokenCachePanel(props: { hasSkills: false, }) const [refreshTick, setRefreshTick] = createSignal(0) + // Pricing is independent of message streaming; build the provider index once + // per model-list update, not twice per message update. + const providers = createMemo(() => props.api.state.provider) // 当前 provider 显示名(余额查询状态为共享信号,见 PanelSignals.balanceState) const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name) @@ -191,7 +199,20 @@ export function TokenCachePanel(props: { createEffect(() => { const sid = props.signals.overrideSessionId() ?? props.sessionId void refreshTick() - void partVersion() + if (sid !== viewedSid) { + refreshData.cancel() + untrack(snapshots.flush) + distribution.clear() + viewedSid = sid + const restored = untrack(() => { + try { return readDistributionSnapshot(props.api.kv, sid) } catch { return undefined } + }) + lastDist = restored ?? emptyDistribution() + lastHasDist = Boolean(restored) + // Never keep another session's cost, skills or distribution on screen. + setDataSignal((previous: any) => ({ ...previous, hasData: false, hasDistData: lastHasDist, + dist: lastDist, skills: [], hasSkills: false })) + } // 自然追踪 messages 和 provider(SDK 数据就绪时自动重新执行) const msgs = props.api.state.session.messages(sid) as Message[] @@ -217,6 +238,10 @@ export function TokenCachePanel(props: { for (const msg of msgs) { if (msg.role !== "assistant") continue const tok = (msg as AssistantMessage).tokens; if (!tok) continue + // Distribution publication is coalesced, but retain deep host-store + // dependencies even when session aggregates avoid the fallback loop. + void tok.output + void tok.reasoning const mit = num(tok.input) + num(tok.cache?.read) + num(tok.cache?.write), mrt = num(tok.cache?.read) if (mit > 0) { prevMsgHitRate = lastMsgHitRate; lastMsgHitRate = (mrt / mit) * 100 } if (fallbackTokens) { @@ -230,7 +255,7 @@ export function TokenCachePanel(props: { } } let saved = 0, inputRate = 0, cacheReadRate = 0, cacheWriteRate = 0 - if (read > 0 && pid && mid && Array.isArray(props.api.state.provider)) for (const provider of props.api.state.provider) { + if (read > 0 && pid && mid && Array.isArray(providers())) for (const provider of providers()) { if (provider.id !== pid) continue const model = provider.models[mid]; if (!model?.cost) continue inputRate = num(model.cost.input); cacheReadRate = num(model.cost.cache?.read); cacheWriteRate = num(model.cost.cache?.write) @@ -244,135 +269,36 @@ export function TokenCachePanel(props: { const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0 const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || "" - // untrack 只包裹已知触发死锁的 API - const distData = untrack(() => { - let dist: TokenDist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0 } - let hasDistData = false - const loadedSkills = new Map() - try { - const cfg = props.api.state.config as Record - const agentName = String(session?.agent ?? (cfg as any)?.default_agent ?? "build") - const agents = cfg?.agent as Record | undefined - const agentCfg = agents?.[agentName] as Record | undefined - const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : "" - if (sysPrompt) dist.system = estimateTokens(sysPrompt) - let lastAssMsg: AssistantMessage | undefined - for (const msg of msgs) { - if (msg.role === "user") { - const um = msg as UserMessage; if (um.system) dist.system += estimateTokens(um.system) - let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {} - for (const p of parts) { - if (p.type === "text" && !(p as any).synthetic && !(p as any).ignored) dist.user += estimateTokens((p as any).text) - else if (p.type === "file") { const fp = p as any; if (fp.source?.text?.value) dist.user += estimateTokens(fp.source.text.value) } - } - } else if (msg.role === "assistant") { - const am = msg as AssistantMessage - dist.output += num(am.tokens?.output) - dist.reasoning += num(am.tokens?.reasoning) - let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {} - for (const p of parts) { - if (p.type === "tool") { - const tp = p as any; let rawInput = "" - try { rawInput = tp.state.raw ?? (tp.state.input != null ? JSON.stringify(tp.state.input) : "") } catch {} - if (rawInput) dist.toolCall += estimateTokens(rawInput) - // 子代理委托(task 工具):任务描述计入子代理指令(1.15.x 无 subtask part) - if (tp.tool === "task" && tp.state?.input) { - const ti = tp.state.input - const prompt = typeof ti.prompt === "string" ? ti.prompt : "" - const desc = typeof ti.description === "string" ? ti.description : "" - dist.agent += estimateTokens(prompt || desc) - } - if (tp.state.status === "completed") { const c = tp.state; if (c.output) dist.toolResult += estimateTokens(c.output) } - else if (tp.state.status === "error") { const e = tp.state; if (e.error) dist.toolResult += estimateTokens(e.error) } - if (tp.tool === "skill" && tp.state.status === "completed") { - // TUI SDK strips tool metadata — extract skill name from well-known output format. - // Cross-validated against api.client.app.skills() when available. - let name: string | undefined = tp.state.metadata?.name - if (typeof name !== "string") { - const m = typeof tp.state.output === "string" - ? tp.state.output.match(/^#{1,2}\s*Skill:\s*(.+)/m) - : null - if (m) name = m[1].trim() - } - if (typeof name === "string") { - const tokens = typeof tp.state.output === "string" ? estimateTokens(tp.state.output) : 0 - const existing = loadedSkills.get(name) - if (!existing || existing.tokens < tokens) { - loadedSkills.set(name, { name, tokens }) - } - } - } - } else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") } - } - } - } - // 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息) - for (let i = msgs.length - 1; i >= 0; i--) { - if (msgs[i].role !== "assistant") continue - const tok = (msgs[i] as AssistantMessage).tokens - if (tok && ((tok.input ?? 0) > 0 || (tok.cache?.read ?? 0) > 0 || (tok.cache?.write ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break } - } - // 取最后一条有数据消息的总输入(含缓存读/写)作为当前 context 大小 - dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + num(lastAssMsg?.tokens?.cache?.write) - dist.apiOutput = num(lastAssMsg?.tokens?.output) - // 本回合(最后一条有数据消息所在的 parentID 链)的 API 调用次数与末次成本。 - // opencode 将回合内每次工具调用循环拆为独立 assistant 消息(各含 1 个 step-finish), - // 故按 parentID 链聚合统计,而非单条消息。 - if (lastAssMsg) { - const roundParent = (lastAssMsg as AssistantMessage).parentID - let lastCost: number | undefined - for (let i = msgs.length - 1; i >= 0; i--) { - const m = msgs[i] - if (m.role !== "assistant") continue - if ((m as AssistantMessage).parentID !== roundParent) break - let parts: readonly Part[] = []; try { parts = props.api.state.part(m.id) } catch {} - for (const p of parts) { - if (p.type !== "step-finish") continue - dist.stepCount++ - const sc = (p as { cost?: unknown }).cost - if (lastCost === undefined && typeof sc === "number" && Number.isFinite(sc)) lastCost = sc - } - } - if (lastCost !== undefined) dist.stepCost = lastCost - } - hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0 || dist.reasoning > 0 - } catch {} - const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist() - const skills = [...loadedSkills.values()] - return { finalDist, finalHasDist, skills } - }) - - setDataSignal({ - hitRate, read, write, freshInput: input, output, cost, saved, model, - inputRate, cacheReadRate, cacheWriteRate, hasPricing, - hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0, - trend, hasTrendData, providerName, sessionHitRate, - dist: distData.finalDist, hasDistData: distData.finalHasDist, - skills: distData.skills, hasSkills: distData.skills.length > 0, - }) + // Coalesce expensive part/output work independently of the cheap tracked + // summary reads. A continuous stream cannot postpone this timer forever. + refreshData.schedule(() => untrack(() => { + if (sid !== viewedSid) return + let cfg: Record = {} + try { cfg = props.api.state.config ?? {} } catch { /* Config may not be warm yet. */ } + const agentName = String(session?.agent ?? cfg?.default_agent ?? "build") + const prompt = cfg?.agent?.[agentName]?.prompt + const distData = distribution.calculate(msgs, (id) => props.api.state.part(id), typeof prompt === "string" ? prompt : "") + if (distData.hasData) { + lastDist = distData.dist + lastHasDist = true + snapshots.schedule(sid, lastDist) + } + setDataSignal({ + hitRate, read, write, freshInput: input, output, cost, saved, model, + inputRate, cacheReadRate, cacheWriteRate, hasPricing, + hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0, + trend, hasTrendData, providerName, sessionHitRate, + dist: lastDist, hasDistData: lastHasDist, + skills: distData.skills, hasSkills: distData.skills.length > 0, + }) + })) }) const data = createMemo(() => { return dataSignal() }) - // Persist the last valid distribution so that data() can fall back - // to it while api.state.part() is re-hydrating after a view switch. - createEffect(() => { - const d = data() - if (d.hasDistData) { - setLastDist({ ...d.dist }) - setLastHasDist(true) - // Also persist across component remounts (view switches) - try { props.api.kv.set(`${KV_PREFIX}.dist_snapshot`, { ...d.dist }) } catch {} - } - }) - - // ── token distribution (in-process via api.state.part) ── - const [partVersion, setPartVersion] = createSignal(0) - // Persist fold state to api.kv - const KV_PREFIX = "cache_panel" const persistFold = (key: string, val: boolean) => { try { props.api.kv.set(`${KV_PREFIX}.${key}`, val) } catch {} } @@ -430,10 +356,11 @@ export function TokenCachePanel(props: { setBorderVisible(bv !== false) // Restore distribution snapshot so the token distribution block // doesn't blank out while api.state.part() re-hydrates. - const cachedDist = props.api.kv.get(`${KV_PREFIX}.dist_snapshot`) - if (cachedDist) { - setLastDist(cachedDist) - setLastHasDist(true) + const cachedDist = readDistributionSnapshot(props.api.kv, props.signals.overrideSessionId() ?? props.sessionId) + if (cachedDist && !lastHasDist) { + lastDist = cachedDist + lastHasDist = true + setDataSignal((previous: any) => ({ ...previous, dist: lastDist, hasDistData: true })) } } catch { // kv read failed — signals stay at defaults @@ -444,6 +371,8 @@ export function TokenCachePanel(props: { } } + const restoreTask = createCoalescedTask(10) + onCleanup(restoreTask.cancel) if (props.api.kv.ready) { doRestore() } else { @@ -455,7 +384,7 @@ export function TokenCachePanel(props: { const pollRestore = () => { if (!props.api.kv.ready) { if (++tries > MAX_POLL) { doRestore(); return } - setTimeout(pollRestore, 10) + restoreTask.schedule(pollRestore) return } doRestore() @@ -463,19 +392,15 @@ export function TokenCachePanel(props: { pollRestore() } - // Debounce partVersion updates so that event bursts during session - // switching / streaming don't cause data() to re-compute on every - // single event (up to hundreds per second on Linux single-thread). - let partTimer: ReturnType | undefined - const bumpPartVersion = () => { - clearTimeout(partTimer) - partTimer = setTimeout(() => setPartVersion((v) => v + 1), 100) + const requestRefresh = (event: unknown) => { + const sid = props.signals.overrideSessionId() ?? props.sessionId + if (isViewedSessionEvent(event, sid)) setRefreshTick((v) => v + 1) } - const unsubPart = props.api.event.on("message.part.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) }) - const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) }) - const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1) }) + const unsubPart = props.api.event.on("message.part.updated", requestRefresh) + const unsubMsg = props.api.event.on("message.updated", requestRefresh) + const unsubSession = props.api.event.on("session.updated", requestRefresh) setRefreshTick(v => v + 1) - onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession() }) + onCleanup(() => { unsubPart(); unsubMsg(); unsubSession() }) }) // ── colours ── diff --git a/src/panel/distribution.ts b/src/panel/distribution.ts new file mode 100644 index 0000000..4fa7a34 --- /dev/null +++ b/src/panel/distribution.ts @@ -0,0 +1,104 @@ +import { estimateTokens, num, type TokenDist } from "../core" + +export const emptyDistribution = (): TokenDist => ({ + system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, + output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0, +}) +export type SkillUsage = { name: string; tokens: number } +type Contribution = Pick & { skill?: SkillUsage } +type CachedPart = { signature: readonly unknown[]; value: Contribution } +const same = (a: readonly unknown[], b: readonly unknown[]) => a.length === b.length && a.every((v, i) => v === b[i]) + +/** Cache semantic inputs, never just object identity: host stores mutate in place. + * Retains only the currently loaded messages and their current parts. */ +export function createDistributionCalculator(estimate = estimateTokens) { + const cache = new Map() + const text = (value: unknown) => typeof value === "string" ? value : "" + const contribution = (part: Record, role: string, previous?: CachedPart): CachedPart => { + let user = "", agent = "", input = "", result = "", skillName: string | undefined + if (role === "user") { + if (part.type === "text" && !part.synthetic && !part.ignored) user = part.text ?? "" + else if (part.type === "file") user = part.source?.text?.value ?? "" + } else if (role === "assistant") { + if (part.type === "tool") { + const state = part.state ?? {} + try { input = state.raw ?? (state.input != null ? JSON.stringify(state.input) : "") } catch {} + if (part.tool === "task") agent = text(state.input?.prompt) || text(state.input?.description) + if (state.status === "completed") result = state.output ?? "" + else if (state.status === "error") result = state.error ?? "" + if (part.tool === "skill" && state.status === "completed") { + skillName = typeof state.metadata?.name === "string" ? state.metadata.name : undefined + if (skillName === undefined && typeof result === "string") { + skillName = result.match(/^#{1,2}\s*Skill:\s*(.+)/m)?.[1]?.trim() + } + } + } else if (part.type === "subtask") agent = part.prompt || part.description || "" + } + user = text(user); agent = text(agent); input = text(input); result = text(result) + const signature = [user, agent, input, result, skillName] + if (previous && same(previous.signature, signature)) return previous + const resultTokens = result ? estimate(result) : 0 + return { signature, value: { + user: user ? estimate(user) : 0, agent: agent ? estimate(agent) : 0, + toolCall: input ? estimate(input) : 0, toolResult: resultTokens, + ...(skillName !== undefined ? { skill: { name: skillName, tokens: resultTokens } } : {}), + } } + } + return { + clear: () => cache.clear(), + calculate(messages: readonly Record[], readParts: (id: string) => readonly any[], systemPrompt = "") { + const dist = emptyDistribution() + const skills = new Map() + const loaded = new Set() + const partsByMessage = new Map() + if (systemPrompt) dist.system = estimate(systemPrompt) + for (const message of messages) { + if (message.role !== "user" && message.role !== "assistant") continue + const id = String(message.id) + loaded.add(id) + if (message.role === "user" && typeof message.system === "string") dist.system += estimate(message.system) + if (message.role === "assistant") { + dist.output += num(message.tokens?.output) + dist.reasoning += num(message.tokens?.reasoning) + } + let parts: readonly any[] = [] + try { parts = readParts(id) ?? [] } catch {} + if (!Array.isArray(parts)) parts = [] + partsByMessage.set(id, parts) + const previous = cache.get(id) + const next = parts.map((part, index) => contribution(part ?? {}, message.role, previous?.[index])) + cache.set(id, next) + for (const { value } of next) { + dist.user += value.user; dist.agent += value.agent + dist.toolCall += value.toolCall; dist.toolResult += value.toolResult + if (value.skill) { + const old = skills.get(value.skill.name) + if (!old || old.tokens < value.skill.tokens) skills.set(value.skill.name, value.skill) + } + } + } + for (const id of cache.keys()) if (!loaded.has(id)) cache.delete(id) + const last = [...messages].reverse().find((m) => m.role === "assistant" && + num(m.tokens?.input) + num(m.tokens?.cache?.read) + num(m.tokens?.cache?.write) > 0) + dist.apiInput = num(last?.tokens?.input) + num(last?.tokens?.cache?.read) + num(last?.tokens?.cache?.write) + dist.apiOutput = num(last?.tokens?.output) + if (last) { + let lastCost: number | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== "assistant") continue + if (message.parentID !== last.parentID) break + for (const part of partsByMessage.get(String(message.id)) ?? []) { + if (part?.type !== "step-finish") continue + dist.stepCount++ + if (lastCost === undefined && typeof part.cost === "number" && Number.isFinite(part.cost)) lastCost = part.cost + } + } + dist.stepCost = lastCost ?? 0 + } + return { dist, skills: [...skills.values()], hasData: + dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || + dist.apiOutput > 0 || dist.apiInput > 0 || dist.reasoning > 0 } + }, + } +} diff --git a/src/panel/refresh.ts b/src/panel/refresh.ts new file mode 100644 index 0000000..36b9fba --- /dev/null +++ b/src/panel/refresh.ts @@ -0,0 +1,31 @@ +/** Bounded coalescing, not trailing debounce: sustained streams still refresh. */ +export function createCoalescedTask(delay = 100) { + let timer: ReturnType | undefined + let pending: (() => void) | undefined + const cancel = () => { + clearTimeout(timer) + timer = undefined + pending = undefined + } + const flush = () => { + const task = pending + cancel() + task?.() + } + return { + schedule(task: () => void) { + pending = task + if (timer === undefined) timer = setTimeout(flush, delay) + }, + cancel, + flush, + } +} + +/** V1 can be host-scoped; global V2 events must have a matching owner. */ +export function isViewedSessionEvent(event: unknown, sid: string): boolean { + const e = event as Record | undefined + const data = e?.data ?? e?.properties ?? e?.payload + const owner = e?.sessionID ?? data?.sessionID ?? data?.info?.sessionID ?? data?.part?.sessionID + return owner == null ? e?.scope !== "global" : String(owner) === sid +} diff --git a/src/panel/snapshot.ts b/src/panel/snapshot.ts new file mode 100644 index 0000000..7b450fe --- /dev/null +++ b/src/panel/snapshot.ts @@ -0,0 +1,56 @@ +import type { TokenDist } from "../core" +import type { PanelApi } from "./panel-api" +import { createCoalescedTask } from "./refresh" +import { emptyDistribution } from "./distribution" + +const KEY = "cache_panel.dist_snapshot" +type Snapshot = { version: 1; sessionID: string; dist: TokenDist } +const fields = Object.keys(emptyDistribution()) as Array +export const sameDistribution = (a: TokenDist, b: TokenDist) => fields.every((key) => a[key] === b[key]) + +/** Legacy snapshots have no owner: do not display them in an arbitrary session. */ +export function readDistributionSnapshot(kv: PanelApi["kv"], sid: string): TokenDist | undefined { + const raw = kv.get(KEY) + if (raw?.version !== 1 || raw.sessionID !== sid || !raw.dist) return undefined + if (!fields.every((key) => typeof raw.dist[key] === "number" && Number.isFinite(raw.dist[key]) && raw.dist[key] >= 0)) return undefined + return { ...raw.dist } +} + +/** Derived cache only. Coalesce and serialize writes, retaining at most the + * newest waiting value so a slow store cannot build up an unbounded queue. */ +export function createSnapshotWriter(kv: PanelApi["kv"], delay = 250) { + const task = createCoalescedTask(delay) + let sessionID: string | undefined + let writing = false + let queued: Snapshot | undefined + const finish = () => { writing = false; drain() } + const drain = () => { + while (!writing && queued) { + const snapshot = queued + queued = undefined + try { + const previous = readDistributionSnapshot(kv, snapshot.sessionID) + if (previous && sameDistribution(previous, snapshot.dist)) continue + writing = true + const result = kv.set(KEY, snapshot) + if (result) { + void Promise.resolve(result).then(finish, finish) + return + } + } catch { /* Derived cache failures must not break the panel. */ } + writing = false + } + } + return { + schedule(sid: string, dist: TokenDist) { + if (sessionID !== sid) task.flush() + sessionID = sid + const snapshot: Snapshot = { version: 1, sessionID: sid, dist: { ...dist } } + task.schedule(() => { + queued = snapshot + drain() + }) + }, + flush: task.flush, + } +} diff --git a/src/v2/v2-panel-api.ts b/src/v2/v2-panel-api.ts index caf8b9f..651a107 100644 --- a/src/v2/v2-panel-api.ts +++ b/src/v2/v2-panel-api.ts @@ -77,11 +77,14 @@ export function createPanelApi(context: Context): PanelApi { const s = context.data.session.get(id) if (!s) return undefined const model = s.model as { providerID?: string; id?: string } | undefined + const aggregate = s as unknown as PanelSession return { id: s.id, title: s.title, agent: s.agent, model: model ? { providerID: model.providerID, id: model.id } : undefined, + tokens: aggregate.tokens, + cost: typeof aggregate.cost === "number" && Number.isFinite(aggregate.cost) ? aggregate.cost : undefined, } }, messages(id: string): readonly any[] { @@ -164,7 +167,20 @@ export function createPanelApi(context: Context): PanelApi { path: { directory: String((context.location as { directory?: string } | undefined)?.directory ?? "") }, }, event: { - on: (type: string, handler: (event: unknown) => void) => context.data.on(type, handler), + on(type: string, handler: (event: unknown) => void) { + // Retain legacy names for compatible hosts while translating the real + // V2 events. All V2 notifications are global, never implicitly local. + const aliases: Record = { + "message.part.updated": ["session.tool.input.started", "session.tool.called", "session.tool.progress", "session.tool.success", "session.tool.failed"], + "message.updated": ["session.step.started", "session.step.ended", "session.step.failed"], + "session.updated": ["session.execution.succeeded", "session.execution.interrupted", "session.execution.failed"], + } + const unsubs = [type, ...(aliases[type] ?? [])].map((name) => context.data.on(name, (event) => { + const e = event as Record + handler({ ...e, scope: "global" }) + })) + return () => { for (const unsubscribe of unsubs) unsubscribe() } + }, }, renderer: { terminalWidth: context.renderer.terminalWidth }, keys: { formatBindings: () => undefined }, diff --git a/tests/distribution.test.ts b/tests/distribution.test.ts new file mode 100644 index 0000000..ccdc4a5 --- /dev/null +++ b/tests/distribution.test.ts @@ -0,0 +1,122 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { estimateTokens } from "../src/core" +import { createDistributionCalculator, emptyDistribution } from "../src/panel/distribution" + +test("distribution preserves text flags, files, task/subtask instructions, skill maxima and exact API usage", () => { + const messages = [ + { id: "user", role: "user", system: "user system" }, + { id: "old", role: "assistant", parentID: "old-turn", tokens: { input: 5, output: 2, reasoning: 3 } }, + { id: "a", role: "assistant", parentID: "turn", tokens: { input: 10, output: 4, cache: { read: 20, write: 5 } } }, + { id: "b", role: "assistant", parentID: "turn", tokens: { input: 12, output: 6, reasoning: 2, cache: { read: 30, write: 8 } } }, + { id: "streaming", role: "assistant", parentID: "turn" }, + ] + const parts: Record = { + user: [{ type: "text", text: "hello" }, { type: "text", text: "skip", synthetic: true }, + { type: "text", text: "skip", ignored: true }, { type: "file", source: { text: { value: "file text" } } }], + old: [{ type: "step-finish", cost: 99 }], + a: [{ type: "tool", tool: "task", state: { raw: "parameters", input: { prompt: "delegate" }, status: "completed", output: "result" } }, + { type: "tool", tool: "skill", state: { input: {}, status: "completed", output: "# Skill: example\nlong content" } }, + { type: "step-finish", cost: 0.1 }], + b: [{ type: "subtask", prompt: "subtask instructions" }, + { type: "tool", tool: "skill", state: { status: "completed", metadata: { name: "example" }, output: "short" } }, + { type: "tool", tool: "shell", state: { status: "error", error: "failure" } }, { type: "step-finish", cost: 0.2 }], + streaming: [], + } + const calculator = createDistributionCalculator() + const result = calculator.calculate(messages, (id) => parts[id], "configured system") + const e = estimateTokens + assert.deepEqual(result.dist, { ...emptyDistribution(), + system: e("configured system") + e("user system"), user: e("hello") + e("file text"), + agent: e("delegate") + e("subtask instructions"), toolCall: e("parameters") + e("{}"), + toolResult: e("result") + e("# Skill: example\nlong content") + e("short") + e("failure"), + output: 12, reasoning: 5, apiInput: 50, apiOutput: 6, stepCount: 2, stepCost: 0.2, + }) + assert.deepEqual(result.skills, [{ name: "example", tokens: e("# Skill: example\nlong content") }]) + assert.equal(result.hasData, true) +}) + +test("token-only changes reuse historical text estimation but update exact counters", () => { + let estimates = 0 + const calculator = createDistributionCalculator((text) => { estimates++; return estimateTokens(text) }) + const message = { id: "a", role: "assistant", tokens: { input: 10, output: 2, reasoning: 0 } } + const parts = [{ type: "tool", tool: "shell", state: { input: { command: "example" }, status: "completed", output: "large ".repeat(1000) } }] + const first = calculator.calculate([message], () => parts) + const previous = estimates + message.tokens.output = 20 + message.tokens.reasoning = 5 + const next = calculator.calculate([message], () => parts) + assert.equal(estimates, previous) + assert.equal(next.dist.toolResult, first.dist.toolResult) + assert.equal(next.dist.output, 20) + assert.equal(next.dist.reasoning, 5) +}) + +test("in-place tool input, output and status changes invalidate semantic contributions", () => { + const calculator = createDistributionCalculator() + const message = { id: "a", role: "assistant" } + const state = { input: { prompt: "before" }, status: "running", output: "", error: "failure" } + const parts = [{ type: "tool", tool: "task", state }] + assert.equal(calculator.calculate([message], () => parts).dist.toolResult, 0) + state.input.prompt = "after with more instructions" + state.status = "completed"; state.output = "success result" + const done = calculator.calculate([message], () => parts) + assert.equal(done.dist.agent, estimateTokens(state.input.prompt)) + assert.equal(done.dist.toolCall, estimateTokens(JSON.stringify(state.input))) + assert.equal(done.dist.toolResult, estimateTokens(state.output)) + state.status = "error" + assert.equal(calculator.calculate([message], () => parts).dist.toolResult, estimateTokens(state.error)) +}) + +test("late hydration, removed messages and changed identities do not leave stale contributions", () => { + const calculator = createDistributionCalculator() + const message = { id: "a", role: "user" } + assert.equal(calculator.calculate([message], () => []).hasData, false) + assert.equal(calculator.calculate([message], () => [{ type: "text", text: "hydrated" }]).dist.user, estimateTokens("hydrated")) + assert.deepEqual(calculator.calculate([], () => []).dist, emptyDistribution()) + assert.equal(calculator.calculate([message], () => [{ type: "text", text: "different" }]).dist.user, estimateTokens("different")) + calculator.clear() + assert.equal(calculator.calculate([message], () => { throw new Error("unavailable") }).hasData, false) +}) + +test("circular inputs and malformed optional parts do not crash statistics", () => { + const input: any = {}; input.self = input + const calculator = createDistributionCalculator() + const result = calculator.calculate([{ id: "a", role: "assistant", tokens: { input: 1 } }], () => + [null, { type: "tool", tool: "shell", state: { input, status: "completed", output: "ok" } }]) + assert.equal(result.dist.toolCall, 0) + assert.equal(result.dist.toolResult, estimateTokens("ok")) +}) + +test("non-text task prompts preserve the valid description fallback", () => { + const calculator = createDistributionCalculator() + const result = calculator.calculate([{ id: "a", role: "assistant" }], () => [ + { type: "tool", tool: "task", state: { input: { prompt: 42, description: "valid instructions" } } }, + ]) + assert.equal(result.dist.agent, estimateTokens("valid instructions")) +}) + +test("cached and uncached results agree across in-place edits, reorder, hydration and turn changes", () => { + const cached = createDistributionCalculator() + const messages: any[] = [ + { id: "u", role: "user", system: "system" }, + { id: "a", role: "assistant", parentID: "turn", tokens: { input: 3, output: 4, reasoning: 1 } }, + { id: "b", role: "assistant", parentID: "turn", tokens: { input: 5, output: 6 } }, + ] + const parts: Record = { + u: [{ type: "text", text: "user" }], + a: [{ type: "tool", tool: "skill", state: { input: {}, status: "completed", output: "# Skill: original\npayload" } }, { type: "step-finish", cost: 1 }], + b: [], + } + for (let i = 0; i < 40; i++) { + messages[1].tokens.output++ + parts.a[0].state.output = `# Skill: skill-${i % 3}\n${"payload ".repeat(i + 1)}` + parts.a[0].state.input.update = i + messages[2].parentID = i % 2 ? "other-turn" : "turn" + parts.b = i % 3 ? [{ type: "step-finish", cost: i / 10 }, { type: "subtask", prompt: `instructions-${i}` }] : [] + parts.u[0].ignored = i % 2 === 0 + const order = i % 5 === 0 ? [messages[0], messages[2], messages[1]] : messages + const read = (id: string) => parts[id] + assert.deepEqual(cached.calculate(order, read, "configured"), createDistributionCalculator().calculate(order, read, "configured")) + } +}) diff --git a/tests/panel-api.test.ts b/tests/panel-api.test.ts new file mode 100644 index 0000000..0d7a883 --- /dev/null +++ b/tests/panel-api.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createPanelApi } from "../src/v2/v2-panel-api" +import type { Context } from "../src/v2/types" + +test("V2 adapter retains real aggregates and mutable parts, model and turn semantics", () => { + const state = { status: "running", input: { command: "fixture" }, content: [{ type: "text", text: "before" }] } + const messages = [ + { id: "a", type: "assistant", finish: "tool-calls", cost: 1, model: { providerID: "provider", id: "model" }, content: [{ type: "tool", name: "shell", state }] }, + { id: "b", type: "assistant", finish: "stop", cost: 2 }, + { id: "c", type: "assistant", finish: "stop" }, + ] + const aggregate = { id: "sid", tokens: { input: 1000, cache: { read: 2000 } }, cost: 3 } + const api = createPanelApi({ data: { session: { get: () => aggregate, message: { list: () => messages } } }, + renderer: {} } as unknown as Context) + assert.deepEqual(api.state.session.get("sid")?.tokens, aggregate.tokens) + assert.equal(api.state.session.get("sid")?.cost, 3) + const rows = api.state.session.messages("sid") + assert.equal(rows[0].parentID, rows[1].parentID) + assert.notEqual(rows[1].parentID, rows[2].parentID) + assert.equal(rows[0].providerID, "provider") + assert.equal(api.state.part("a")[0].state.output, "before") + state.content[0].text = "after" + state.status = "completed" + assert.equal(api.state.part("a")[0].state.output, "after") + assert.equal(api.state.part("a")[0].state.status, "completed") + assert.deepEqual(api.state.part("a")[1], { type: "step-finish", cost: 1 }) +}) + +test("real V2 events preserve ownership and unsubscribe every bridge listener", () => { + const listeners = new Map void>() + const api = createPanelApi({ data: { on: (type: string, cb: (e: unknown) => void) => { + listeners.set(type, cb); return () => listeners.delete(type) + } }, renderer: {} } as unknown as Context) + const events: any[] = [] + const unsub = api.event.on("message.updated", (e) => events.push(e)) + listeners.get("session.step.ended")!({ data: { sessionID: "foreign" } }) + assert.equal(events[0].scope, "global") + assert.equal(events[0].data.sessionID, "foreign") + unsub() + assert.equal(listeners.size, 0) +}) diff --git a/tests/refresh.test.ts b/tests/refresh.test.ts new file mode 100644 index 0000000..9311380 --- /dev/null +++ b/tests/refresh.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createCoalescedTask, isViewedSessionEvent } from "../src/panel/refresh" + +test("event filtering respects viewed/override session and preserves host-scoped V1 events", () => { + assert.equal(isViewedSessionEvent({ scope: "global", data: { sessionID: "foreign" } }, "viewed"), false) + assert.equal(isViewedSessionEvent({ scope: "global" }, "viewed"), false) + assert.equal(isViewedSessionEvent({ scope: "global", data: { sessionID: "child" } }, "child"), true) + assert.equal(isViewedSessionEvent({ properties: { part: { sessionID: "foreign" } } }, "viewed"), false) + assert.equal(isViewedSessionEvent({ scope: "session" }, "viewed"), true) + assert.equal(isViewedSessionEvent({}, "viewed"), true) +}) + +test("bursts coalesce to their latest value without starving a continuous stream", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }) + const task = createCoalescedTask(100) + const published: number[] = [] + task.schedule(() => published.push(1)) + t.mock.timers.tick(80) + task.schedule(() => published.push(2)) + t.mock.timers.tick(20) + assert.deepEqual(published, [2]) + task.schedule(() => published.push(3)) + t.mock.timers.tick(100) + assert.deepEqual(published, [2, 3]) +}) + +test("cancel drops stale work, flush publishes once and timers cannot survive disposal", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }) + const task = createCoalescedTask() + let runs = 0 + task.schedule(() => { runs++ }); task.cancel() + t.mock.timers.tick(200); assert.equal(runs, 0) + task.schedule(() => { runs++ }); task.flush() + t.mock.timers.tick(200); assert.equal(runs, 1) +}) diff --git a/tests/snapshot.test.ts b/tests/snapshot.test.ts new file mode 100644 index 0000000..e8a59b9 --- /dev/null +++ b/tests/snapshot.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { emptyDistribution } from "../src/panel/distribution" +import type { PanelApi } from "../src/panel/panel-api" +import { createSnapshotWriter, readDistributionSnapshot } from "../src/panel/snapshot" + +function fixture(initial?: unknown) { + let value = initial + const writes: any[] = [] + const kv: PanelApi["kv"] = { ready: true, get: () => value as T, set: (_key, next) => { value = next; writes.push(next) } } + return { kv, writes } +} + +test("snapshots are session-owned; legacy, foreign and invalid values cannot contaminate a view", () => { + const dist = { ...emptyDistribution(), user: 10 } + for (const initial of [dist, { version: 1, sessionID: "other", dist }, + { version: 1, sessionID: "viewed", dist: { ...dist, apiInput: NaN } }]) { + assert.equal(readDistributionSnapshot(fixture(initial).kv, "viewed"), undefined) + } + assert.deepEqual(readDistributionSnapshot(fixture({ version: 1, sessionID: "viewed", dist }).kv, "viewed"), dist) +}) + +test("unchanged snapshots do not write; bursts save only their newest value", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }) + const dist = emptyDistribution() + const f = fixture({ version: 1, sessionID: "a", dist }) + const writer = createSnapshotWriter(f.kv) + writer.schedule("a", dist); t.mock.timers.tick(250) + assert.equal(f.writes.length, 0) + writer.schedule("a", { ...dist, output: 1 }) + writer.schedule("a", { ...dist, output: 2 }) + t.mock.timers.tick(250) + assert.equal(f.writes.length, 1) + assert.equal(f.writes[0].dist.output, 2) +}) + +test("switching sessions flushes the previous snapshot; teardown flushes the latest once", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }) + const f = fixture() + const writer = createSnapshotWriter(f.kv) + const dist = emptyDistribution() + writer.schedule("a", { ...dist, user: 1 }) + writer.schedule("b", { ...dist, user: 2 }) + assert.equal(f.writes[0].sessionID, "a") + writer.flush(); t.mock.timers.tick(500) + assert.equal(f.writes.length, 2) + assert.equal(f.writes[1].sessionID, "b") +}) + +test("storage failures stay non-critical and a subsequent refresh can retry", async () => { + const f = fixture() + const set = f.kv.set + const writer = createSnapshotWriter(f.kv) + f.kv.set = () => { throw new Error("sync failure") } + writer.schedule("a", emptyDistribution()); writer.flush() + f.kv.set = () => Promise.reject(new Error("failed")) + writer.schedule("a", emptyDistribution()); writer.flush() + await Promise.resolve(); await Promise.resolve() + f.kv.set = set + writer.schedule("a", emptyDistribution()); writer.flush() + assert.equal(f.writes.length, 1) +}) + +test("slow async writes are serialized and only the newest waiting snapshot is retained", async () => { + const f = fixture() + const completed: any[] = [], releases: Array<() => void> = [] + const set = f.kv.set + f.kv.set = (_key, snapshot) => new Promise((resolve) => { + releases.push(() => { set(_key, snapshot); completed.push(snapshot); resolve() }) + }) + const writer = createSnapshotWriter(f.kv) + writer.schedule("a", { ...emptyDistribution(), output: 1 }); writer.flush() + writer.schedule("b", { ...emptyDistribution(), output: 2 }); writer.flush() + writer.schedule("c", { ...emptyDistribution(), output: 3 }); writer.flush() + assert.equal(releases.length, 1, "a second storage write cannot race the first") + releases.shift()!() + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(releases.length, 1) + releases.shift()!() + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(completed.map((snapshot) => snapshot.sessionID), ["a", "c"]) + assert.equal(readDistributionSnapshot(f.kv, "c")?.output, 3) +}) + +test("an identical refresh while an async save is in flight does not duplicate the write", async () => { + const f = fixture() + const set = f.kv.set + let calls = 0, release!: () => void + f.kv.set = (key, value) => { + calls++ + return new Promise((resolve) => { release = () => { set(key, value); resolve() } }) + } + const writer = createSnapshotWriter(f.kv) + const dist = { ...emptyDistribution(), output: 1 } + writer.schedule("a", dist); writer.flush() + writer.schedule("a", dist); writer.flush() + assert.equal(calls, 1) + release() + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(calls, 1) +}) + +test("a rejected in-flight write does not strand the newest queued snapshot", async () => { + const f = fixture() + const set = f.kv.set + let reject!: (error: Error) => void + f.kv.set = () => new Promise((_resolve, fail) => { reject = fail }) + const writer = createSnapshotWriter(f.kv) + writer.schedule("a", emptyDistribution()); writer.flush() + f.kv.set = set + writer.schedule("b", { ...emptyDistribution(), output: 2 }); writer.flush() + assert.equal(f.writes.length, 0) + reject(new Error("failed")) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(f.writes.length, 1) + assert.equal(readDistributionSnapshot(f.kv, "b")?.output, 2) +}) From 4e4137671485b7916799c9eef76ce2ece7b5f475 Mon Sep 17 00:00:00 2001 From: Tiago Date: Thu, 8 Oct 2026 01:27:23 -0300 Subject: [PATCH 2/2] =?UTF-8?q?test(perf):=20=E6=B7=BB=E5=8A=A0=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=20TUI=20=E5=AF=B9=E6=AF=94=E8=84=9A=E6=9C=AC=E4=B8=8E?= =?UTF-8?q?=E8=84=B1=E6=95=8F=E6=B5=8B=E9=87=8F=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/performance/visual-cache-2026-10-08.json | 72 ++++++ docs/performance/visual-cache-2026-10-08.md | 189 +++++++++++++++ scripts/benchmark-visual-cache.py | 146 ++++++++++++ scripts/fixtures/visual-cache.mjs | 216 ++++++++++++++++++ 4 files changed, 623 insertions(+) create mode 100644 docs/performance/visual-cache-2026-10-08.json create mode 100644 docs/performance/visual-cache-2026-10-08.md create mode 100644 scripts/benchmark-visual-cache.py create mode 100644 scripts/fixtures/visual-cache.mjs diff --git a/docs/performance/visual-cache-2026-10-08.json b/docs/performance/visual-cache-2026-10-08.json new file mode 100644 index 0000000..d826a9f --- /dev/null +++ b/docs/performance/visual-cache-2026-10-08.json @@ -0,0 +1,72 @@ +{ + "date": "2026-10-08", + "baseline": { + "version": "1.7.5", + "upstreamCommit": "6d93e072b76be983394c4e2f82803803697e1bb4", + "bundleSHA256": "97af24aa84cc4016614236420e7c251063ab2fe3e75398b14117544cede80183" + }, + "proposedBundleSHA256": "cc0eb85591d8f6fc88216e98eeb74efc36210a0923a331cc821b0e9b9b3065d6", + "fixtureSourceSHA256": "45c3ea20472c9fcc1a71e3f370b757d57db6060eacc66adb5b74ee6836b7b0a1", + "rawResultSHA256": "7cc8c28b5db2fd3a94328d0d0cc8648a55778161851bfecce081fb05aaef2a92", + "method": { + "opencodeVersion": "2.0.22", + "terminalColumns": 180, + "terminalRows": 52, + "roundsPerVariant": 3, + "orders": [["baseline", "optimized"], ["optimized", "baseline"], ["baseline", "optimized"]], + "syntheticMessages": 128, + "syntheticOutputCharsPerMessage": 17408, + "tokenUpdatesPerTrial": 64, + "updateSpacingMilliseconds": 20, + "settleMilliseconds": 450, + "promptsSent": 0, + "durableFixtureStoresCreated": 0, + "balancePollingMounted": false, + "globalCLIConfigUnchanged": true, + "allOtherConfiguredPluginsRetained": false, + "cpuDefinition": "Whole temporary client user + system CPU milliseconds during the matched 64-update phase", + "wallDefinition": "Matched phase elapsed time, including scheduled waits and event-loop delay", + "snapshotDefinition": "Host memory-store update calls, not physical disk writes", + "outputReadDefinition": "Synthetic output text getter accesses, not physical disk reads", + "limitations": [ + "Synthetic output-heavy workload, not a general real-session end-to-end speedup claim", + "Shared service and other TUIs remained live", + "The broader ten-second profiles include different extra correctness phases; do not compare their aggregate CPU/RSS", + "No idle CPU or RAM savings claim" + ] + }, + "matchedPhase": "current-reactive-token-updates-64", + "runs": [ + {"round": 1, "mode": "baseline", "cpuMilliseconds": 1967, "wallMilliseconds": 3325, "messageLists": 128, "modelLists": 128, "outputTextReads": 8256, "snapshotUpdates": 64, "identicalSnapshotUpdates": 0, "bundleProfileVerified": true, "correctnessChecksPassed": 9}, + {"round": 1, "mode": "optimized", "cpuMilliseconds": 229, "wallMilliseconds": 1792, "messageLists": 128, "modelLists": 0, "outputTextReads": 1664, "snapshotUpdates": 5, "identicalSnapshotUpdates": 0, "bundleProfileVerified": true, "correctnessChecksPassed": 19}, + {"round": 2, "mode": "optimized", "cpuMilliseconds": 192, "wallMilliseconds": 1806, "messageLists": 128, "modelLists": 0, "outputTextReads": 1664, "snapshotUpdates": 5, "identicalSnapshotUpdates": 0, "bundleProfileVerified": true, "correctnessChecksPassed": 19}, + {"round": 2, "mode": "baseline", "cpuMilliseconds": 2037, "wallMilliseconds": 3414, "messageLists": 128, "modelLists": 128, "outputTextReads": 8256, "snapshotUpdates": 64, "identicalSnapshotUpdates": 0, "bundleProfileVerified": true, "correctnessChecksPassed": 9}, + {"round": 3, "mode": "baseline", "cpuMilliseconds": 2065, "wallMilliseconds": 3401, "messageLists": 128, "modelLists": 128, "outputTextReads": 8256, "snapshotUpdates": 64, "identicalSnapshotUpdates": 0, "bundleProfileVerified": true, "correctnessChecksPassed": 9}, + {"round": 3, "mode": "optimized", "cpuMilliseconds": 243, "wallMilliseconds": 1822, "messageLists": 128, "modelLists": 0, "outputTextReads": 1664, "snapshotUpdates": 5, "identicalSnapshotUpdates": 0, "bundleProfileVerified": true, "correctnessChecksPassed": 19} + ], + "activationSmoke": { + "success": true, + "actualLocalPluginProducedOwnedSnapshot": true, + "configuredCLIPluginDirectivesRetained": 7, + "temporaryTUIAliveForSeconds": 12, + "promptsSent": 0, + "historyClearCommands": 0, + "historicalSubagentEntriesBefore": 281, + "historicalSubagentEntriesAfter": 281, + "historicalSubagentEntriesIdentical": true, + "historicalShellManifestByteIdentical": true, + "configAndBundleUnchangedBySmoke": true, + "performanceComparison": false + }, + "summary": { + "baselineCpuMsMean": 2023, + "proposedCpuMsMean": 221.33333333333334, + "matchedPhaseCpuReductionPercent": 89.1, + "outputGetterReductionPercent": 79.8, + "snapshotUpdateReductionPercent": 92.2, + "fixtureChecksPassed": 84, + "baselineAndProposedDistributionsEqual": true, + "proposedForeignEventScans": 0, + "proposedUnchangedCurrentEventSnapshotUpdates": 0 + } +} diff --git a/docs/performance/visual-cache-2026-10-08.md b/docs/performance/visual-cache-2026-10-08.md new file mode 100644 index 0000000..39999cb --- /dev/null +++ b/docs/performance/visual-cache-2026-10-08.md @@ -0,0 +1,189 @@ +# Visual-cache: bounded refresh and session-owned snapshots + +Date: 2026-10-08. Local implementation based on upstream 1.7.5, commit +`6d93e072b76be983394c4e2f82803803697e1bb4`. Package version remains 1.7.5; +this PR does not include a release or npm publication. + +## Changes + +- Heavy distribution work is coalesced in bounded 100ms windows. Unlike a + trailing debounce, ongoing streams cannot keep postponing publication. + Cheap tracked summary reads retain host reactivity, including nested output + and reasoning counters. Model pricing has its own memo rather than rebuilding + the provider index twice on every message update. +- Estimated text contributions are reused when their **semantic inputs** have + not changed. This is not an object-identity cache: in-place input/output, + status, file/text flags and skill changes are detected. Exact API counters and + last-turn step summaries remain live; only estimates are cached. Records are + pruned to the current loaded messages and cleared on viewed-session changes. +- Part traversal is shared with the last-turn calculation, avoiding a second + read of the same message's parts. Large host tool responses are still joined + when a distribution refresh happens; this is not yet incremental output I/O. +- V2 tool/step/execution events are translated to the panel's event interface + with global ownership preserved. Events from another session do not cause + refreshes; scoped V1 events still work. +- Snapshot updates are coalesced over 250ms and compared field-by-field against + stored values. No update is requested for an unchanged snapshot. Pending + snapshots flush on viewed-session changes and disposal; storage errors remain + non-critical. Async saves are serialized, retaining only the newest waiting + value so slow persistence cannot produce an unbounded backlog or out-of-order + writes. Identical refreshes during an in-flight save do not duplicate writes. +- The derived `cache_panel.dist_snapshot` value is now + `{ version: 1, sessionID, dist }`. A foreign or ownerless legacy value cannot + be used to display another session's distribution. Legacy derived caches are + recomputed rather than guessed; OpenCode messages/history are not changed. +- The V2 adapter preserves available session token/cost aggregates instead of + forcing message-window fallback. When unavailable, the previous fallback + remains. Pricing, token-estimation ratios, currency conversion, skill maxima + and turn grouping are unchanged. +- Balance-provider logic, credentials and polling are unchanged. +- Storage-readiness restoration is canceled on panel disposal. A delayed restore + cannot replace an already valid live distribution with an older stored value. + +## Tests + +`npm test`: 21 passing test entries, including the two pre-existing balance and +credential test files. `npm run typecheck`, `npm run build`, package dry-run and +`git diff --check`: passed. + +Coverage includes exact token/distribution/skill/step semantics; token-only +updates without text re-estimation; in-place edits; late hydration; removed and +reordered messages; circular inputs; bounded timers and cleanup; event owners; +snapshot session isolation, unchanged-write suppression, coalescing, flushing, +slow asynchronous saves, queued latest-value retention, synchronous and async +storage failures. CI now runs the tests in addition to typecheck/build. + +## Actual-TUI comparison + +The isolated fixture mounts the actual panel and V2 adapter, using the installed +1.7.5 bundle as the baseline and the proposed compiled bundle as the alternative. +Both copied bundles receive only test-only exports. It uses host **memory** +storage, 128 synthetic assistant messages, each with a 17,408-character synthetic +tool result, and no model calls or real prompts. The balance root and commands +are not mounted, so no credential lookup or provider-balance request is made. + +Common checks compare the exact initial and final distributions and rendered +hit rate, cost, model and changed skill. Proposed-only checks cover real session +aggregates beyond the loaded window, empty child selection, late hydration, +return to the parent, stale pending parent work during a child switch, nested +Solid-store token changes without events even when session aggregates exist, +and disposal with restoration/refresh work pending. + +Six final trials (three per implementation, alternating order) passed all +84 fixture assertions (27 baseline, 57 proposed). Each final trial verified the +expected bundle in its CPU profile. Initial and final distributions were equal +between implementations. The global CLI config was unchanged by these trials. + +Results for the same 64-update phase: + +| Metric | Installed 1.7.5 | Proposed | +| --- | ---: | ---: | +| Whole-client CPU-ms, trials | 1,967 / 2,037 / 2,065 | 229 / 192 / 243 | +| Whole-client CPU-ms, mean | 2,023.0 | 221.3 | +| Wall-ms including waits, mean | 3,380.0 | 1,806.7 | +| Message-list accesses, each trial | 128 | 128 | +| Provider/model-list accesses, each trial | 128 | 0 | +| Historical output-text getter reads, each trial | 8,256 | 1,664 | +| Snapshot update calls, each trial | 64 | 5 | + +That is **89.1% less whole-client CPU work in this synthetic output-heavy phase**, +79.8% fewer output getter accesses, and 92.2% fewer snapshot update calls. The +message-list count is unchanged: both the panel and its balance-provider +auto-selection still read the reactive list, but heavy part work is coalesced. + +Eight foreign legacy callbacks previously caused 9 scans, 1,161 output reads and +9 identical snapshot updates in every trial; proposed caused zero of each. +Foreign real V2 step events caused no work in either version (baseline never +bridged those names). Eight unchanged current callbacks caused 9 snapshot +updates before and zero after, with output reads falling from 1,161 to 256. + +Development trials were excluded from these final measurements. Fixture-only +issues were corrected (Solid proxies require JSON copying rather than +`structuredClone`, rendered token counts use `fmt`, and panel mounting must be +awaited instead of assuming readiness after a fixed sleep). The earlier 90.0% +result was superseded after final-review fixes and new correctness scenarios. + +## Measurement definition and limits + +- A fixed batch of **64 current-message token updates**, 20ms between updates, + is followed by 450ms for coalesced UI/snapshot work to settle. +- Fixture phase CPU is the whole temporary client's `process.cpuUsage()` user + plus system microseconds, expressed as CPU-ms consumed for the same work. + Wall time includes scheduled waits and any event-loop delay; it is not pure + computation time or a model throughput measurement. +- Counters track output-text getter accesses and snapshot mutation invocations, + **not** physical disk reads/writes, fsync count or persisted-history changes. +- A separate ten-second CPU profile verifies the loaded bundle and covers part + of the broader fixture run. Baseline and proposed have different extra + correctness phases, so aggregate profile CPU/RSS are not a matched performance + comparison. Only the same named 64-update phase is used for CPU comparisons. +- Fresh 180×52 PTYs attach to one existing historical session, verified before + startup. Inline configuration explicitly loads only the fixture (and disables + the normal visual-cache), not the entire global plugin list. The shared service + and other TUIs remain live. The fixture's messages are synthetic and do not + replace or modify the attached session's messages. +- No idle performance improvement is claimed: earlier real-session ablations + were inconclusive, and neither implementation does distribution work in the + fixture's idle phase. There is no general RAM-gain claim. +- No end-to-end gain percentage is inferred for all real streaming sessions. + The synthetic output-heavy workload deliberately exercises redundant history + estimates; contribution-cache storage also has a bounded memory cost. + +## Evidence and reproduction + +The portable Linux driver is [`scripts/benchmark-visual-cache.py`](../../scripts/benchmark-visual-cache.py); +the complete synthetic fixture is [`scripts/fixtures/visual-cache.mjs`](../../scripts/fixtures/visual-cache.mjs). +It needs OpenCode 2.0.22 with SIGPROF support, a built checkout, an unmodified +1.7.5 bundle, and an existing idle session. It verifies the session before +starting a TUI, never submits a prompt, and refuses to overwrite an evidence +directory. It checks global config identity and original bundle fingerprints. + +```sh +npm run version +npm run build +python3 scripts/benchmark-visual-cache.py \ + --session '' \ + --directory '' \ + --baseline '/dist/v2.js' \ + --output '/tmp/opencode/visual-cache-comparison-new' \ + --rounds 3 +``` + +The final raw local result is `/tmp/opencode/visual-cache-pr-final/result.json`. +Original profiles and machine-local artifacts remain private; the committed +fixture contains only synthetic messages and no real IDs, prompts or credentials. + +Sanitized final measurements: [`visual-cache-2026-10-08.json`](visual-cache-2026-10-08.json). + +SHA-256 fingerprints: + +- Installed 1.7.5 V2 bundle: + `97af24aa84cc4016614236420e7c251063ab2fe3e75398b14117544cede80183` +- Proposed V2 build: + `cc0eb85591d8f6fc88216e98eeb74efc36210a0923a331cc821b0e9b9b3065d6` +- Raw six-trial local result: + `7cc8c28b5db2fd3a94328d0d0cc8648a55778161851bfecce081fb05aaef2a92` + +## Activation + +Activated the validated local checkout in `~/.config/opencode/cli.json`, replacing +only `opencode-visual-cache@latest`. All seven configured plugin directives and +every unrelated setting were retained. The CLI config and previous derived +snapshot were backed up before activation under +`~/.local/state/opencode/visual-cache-backups/20261008T035502Z/` (private directory; +backup files mode 0600). The subsequent smoke also backed up the attached +session's shell manifest and subagent store. + +A fresh normal TUI with all seven configured plugin directives stayed alive for +12 seconds and the actual local plugin produced a version-1 snapshot owned by +the viewed session. All 281 historical subagent records for that session were +identical before/after; the shell manifest was byte-identical. No prompt or +history-clear command was sent, and the smoke did not modify the CLI config or +the measured bundle. This is startup/correctness validation, not a full-plugin +performance comparison. Normal plugin startup may still refresh balances. + +Local final-build smoke evidence: +`/tmp/opencode/visual-cache-activation-smoke-pr-final.json`; driver: +`/tmp/opencode/smoke-visual-cache-local.py`. No background-service restart or npm +publication is part of the change. Already-open TUIs should be reopened to +ensure that they load the validated bundle. diff --git a/scripts/benchmark-visual-cache.py b/scripts/benchmark-visual-cache.py new file mode 100644 index 0000000..d8fb5c8 --- /dev/null +++ b/scripts/benchmark-visual-cache.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Linux real-TUI synthetic A/B fixture; no prompts or global config changes. + +Requires a built checkout, an unmodified 1.7.5 V2 bundle, and an existing idle +session. Only the fixture is loaded through process-local CLI configuration. +""" +import argparse +import fcntl +import hashlib +import json +import os +from pathlib import Path +import pty +import select +import signal +import struct +import subprocess +import termios +import time + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def drain(master): + # Do not retain or print real rendered session history. Answer probes only. + while select.select([master], [], [], 0)[0]: + try: + data = os.read(master, 1048576) + except (BlockingIOError, OSError): + return + if not data: + return + for probe, response in [ + (b'\x1b[6n', b'\x1b[1;1R'), + (b'\x1b[c', b'\x1b[?1;2c'), (b'\x1b[0c', b'\x1b[?1;2c'), + (b'\x1b[>c', b'\x1b[>0;276;0c'), (b'\x1b[>0c', b'\x1b[>0;276;0c'), + ]: + if probe in data: + os.write(master, response) + for code, color in [(10, b'rgb:eeee/eeee/eeee'), (11, b'rgb:1111/1111/1111')]: + if f'\x1b]{code};?'.encode() in data: + os.write(master, f'\x1b]{code};'.encode() + color + b'\x1b\\') + + +def run(args, fixture, bundle, report, mode): + env = {**os.environ, 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor', + 'OPENCODE_CLI_CONFIG_CONTENT': json.dumps({'plugins': ['-opencode-visual-cache', str(fixture)], 'tabs': {'mode': 'off'}}), + 'VISUAL_CACHE_FIXTURE_BUNDLE': str(bundle), 'VISUAL_CACHE_FIXTURE_REPORT': str(report), + 'VISUAL_CACHE_FIXTURE_MODE': mode} + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 52, 180, 0, 0)) + os.set_blocking(master, False) + process = subprocess.Popen(['opencode', '--session', args.session], cwd=args.directory, env=env, + stdin=slave, stdout=slave, stderr=slave, start_new_session=True) + os.close(slave) + started = time.monotonic() + profile_started = None + logs = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local/share')) / 'opencode/log' + try: + while time.monotonic() - started < 30: + if process.poll() is not None: + raise RuntimeError(f'{mode} TUI exited: {process.returncode}') + drain(master) + if profile_started is None and time.monotonic() - started >= 1: + profile_started = time.time() + os.kill(process.pid, signal.SIGPROF) + profiles = [p for p in logs.glob(f'cpu-{process.pid}-*.cpuprofile') + if profile_started is not None and p.stat().st_mtime >= profile_started - 1] + if report.exists() and profiles: + try: + result = json.loads(report.read_text()) + profile = json.loads(max(profiles, key=lambda p: p.stat().st_mtime).read_text()) + except (OSError, ValueError): + time.sleep(.02) + continue + if result.get('complete'): + if not result.get('success'): + raise RuntimeError(result) + urls = [node['callFrame'].get('url', '') for node in profile['nodes']] + assert any(bundle.as_uri() in url for url in urls), 'Expected test bundle absent from profile' + result['bundleProfileVerified'] = True + return result + time.sleep(.02) + raise RuntimeError(f'{mode} fixture or profile timed out') + finally: + # Terminate only the temporary client, never the service or other TUIs. + if process.poll() is None: + os.write(master, b'\x03') + deadline = time.monotonic() + 5 + while process.poll() is None and time.monotonic() < deadline: + drain(master); time.sleep(.02) + if process.poll() is None: + process.terminate(); process.wait(timeout=5) + os.close(master) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--session', required=True, help='Existing idle session; verified before startup') + parser.add_argument('--directory', required=True, type=Path, help='Directory associated with that session') + parser.add_argument('--baseline', required=True, type=Path, help='Unmodified 1.7.5 dist/v2.js') + parser.add_argument('--output', required=True, type=Path, help='New evidence directory, preferably under /tmp/opencode') + parser.add_argument('--rounds', type=int, default=3) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + proposed = root / 'dist/v2.js' + if args.rounds < 1 or args.output.exists() or not args.baseline.is_file() or not proposed.is_file(): + parser.error('Build first, provide an existing baseline, positive rounds and a new output directory') + checked = subprocess.run(['opencode', 'api', 'get', f'/api/session/{args.session}'], cwd=args.directory, + capture_output=True, text=True, timeout=30) + if checked.returncode or json.loads(checked.stdout).get('data', {}).get('id') != args.session: + raise SystemExit('Existing session verification failed; no TUI started') + config = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'opencode/cli.json' + config_before = config.read_bytes() if config.exists() else None + baseline_hash, proposed_hash = digest(args.baseline), digest(proposed) + args.output = args.output.resolve() + args.output.mkdir(parents=True) + fixture = args.output / 'fixture' + fixture.mkdir() + (fixture / 'package.json').write_text(json.dumps({'name': 'fixture-visual-cache-comparison', 'type': 'module', 'exports': {'./tui': './tui.js'}})) + (fixture / 'tui.js').write_bytes((root / 'scripts/fixtures/visual-cache.mjs').read_bytes()) + bundles = {} + for mode, original in [('baseline', args.baseline), ('optimized', proposed)]: + bundle = args.output / f'{mode}.mjs' + bundle.write_bytes(original.read_bytes() + b'\n// Test-only exports; original bundle above is unchanged.\nexport { TokenCachePanel, createPanelApi, createPanelSignals, mapTheme };\n') + bundles[mode] = bundle + evidence = {'method': {'opencodeVersion': subprocess.check_output(['opencode', '--version'], text=True).strip(), + 'baselineBundleSHA256': baseline_hash, 'proposedBundleSHA256': proposed_hash, + 'roundsPerVariant': args.rounds, 'promptsSent': 0, 'terminalColumns': 180, 'terminalRows': 52, + 'onlyFixtureLoaded': True, 'storage': 'host memory', 'balancePollingMounted': False}, 'runs': []} + for index in range(args.rounds): + order = ['baseline', 'optimized'] if index % 2 == 0 else ['optimized', 'baseline'] + for mode in order: + result = run(args, fixture, bundles[mode], args.output / f'{index + 1}-{mode}.json', mode) + evidence['runs'].append({'round': index + 1, 'mode': mode, 'fixture': result}) + (args.output / 'result.json').write_text(json.dumps(evidence, indent=2) + '\n') + print(json.dumps({'round': index + 1, 'mode': mode, 'success': True}), flush=True) + assert (config.read_bytes() if config.exists() else None) == config_before, 'Global CLI config changed' + assert digest(args.baseline) == baseline_hash and digest(proposed) == proposed_hash, 'A bundle changed' + print(f'RESULT_FILE {args.output / "result.json"}') + + +if __name__ == '__main__': + main() diff --git a/scripts/fixtures/visual-cache.mjs b/scripts/fixtures/visual-cache.mjs new file mode 100644 index 0000000..d0fa0d8 --- /dev/null +++ b/scripts/fixtures/visual-cache.mjs @@ -0,0 +1,216 @@ +const { TokenCachePanel, createPanelApi, createPanelSignals, mapTheme } = await import(process.env.VISUAL_CACHE_FIXTURE_BUNDLE) +import { createComponent } from "@opentui/solid" +import { createSignal, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { writeFileSync } from "node:fs" + +const REPORT = process.env.VISUAL_CACHE_FIXTURE_REPORT +const OPTIMIZED = process.env.VISUAL_CACHE_FIXTURE_MODE === "optimized" +const SID = "fixture-cache-session", CHILD = "fixture-cache-child" +const COUNT = 128, TEXT = "synthetic output\n".repeat(1024) +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const cpu = () => { const c = process.cpuUsage(); return c.user + c.system } + +export default { + id: "fixture.visual-cache.comparison", + setup(context) { + const stores = new Map(), handlers = new Map(), results = [], checks = [] + const counts = { messageLists: 0, modelLists: 0, outputTextReads: 0, snapshotWrites: 0, identicalSnapshotWrites: 0 } + let lastSnapshot, panel, tail = TEXT + const messages = Array.from({ length: COUNT }, (_, index) => ({ + id: `fixture-message-${index}`, type: "assistant", finish: "stop", time: Date.now(), + model: { providerID: "fixture-no-balance", id: "fixture-model" }, cost: 0.001, + tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 5, write: 0 } }, + content: [{ id: `fixture-tool-${index}`, type: "tool", name: "shell", state: { + status: "completed", input: { command: "synthetic command" }, + content: [{ type: "text", get text() { counts.outputTextReads++; return index === COUNT - 1 ? tail : TEXT } }], + } }], + })) + const [list, setList] = createSignal(messages) + const [childStore, setChildStore] = createStore({ messages: [] }) + const setChildList = (messages) => setChildStore("messages", messages) + const [viewed] = createSignal(SID) + const [mounted, setMounted] = createSignal(true) + const [aggregate, setAggregate] = createSignal(undefined) + const [childAggregate, setChildAggregate] = createSignal(undefined) + const fake = { + ...context, + storage: { ...context.storage, + store(key, options) { + if (!stores.has(key)) { + let value = options.initial.value + if (key.endsWith(".open") || key.endsWith(".dist") || key.endsWith(".skills")) value = true + if (key.endsWith(".lang")) value = "en" + const [state, mutate] = context.storage.memory(`fixture.visual.compare.${key}`, { initial: { value } }) + stores.set(key, [state, async (fn) => { + mutate((draft) => { + fn(draft) + if (key.endsWith(".dist_snapshot")) { + counts.snapshotWrites++ + const snapshot = JSON.stringify(draft.value) + if (snapshot === lastSnapshot) counts.identicalSnapshotWrites++ + lastSnapshot = snapshot + } + }) + }]) + } + return stores.get(key) + }, + }, + data: { ...context.data, + session: { ...context.data.session, + get: (id) => ({ id, parentID: id === CHILD ? SID : undefined, agent: "fixture", + model: { providerID: "fixture-no-balance", id: "fixture-model" }, ...(id === SID ? aggregate() : childAggregate()) }), + message: { list: (id) => { counts.messageLists++; return id === SID ? list() : id === CHILD ? childStore.messages : [] } }, + }, + location: { ...context.data.location, model: { list: () => { + counts.modelLists++ + return [{ providerID: "fixture-no-balance", modelID: "fixture-model", id: "fixture-model", + cost: [{ input: 1, output: 1, cache: { read: 0.1, write: 1 } }] }] + } } }, + on(type, callback) { + if (!handlers.has(type)) handlers.set(type, new Set()) + handlers.get(type).add(callback) + return () => handlers.get(type).delete(callback) + }, + }, + } + const api = createPanelApi(fake), signals = createPanelSignals() + signals.setSectionBalance(false) + writeFileSync(REPORT, JSON.stringify({ complete: false, phase: "setup" })) + context.ui.slot({ append: "sidebar.content", render: () => createComponent(Show, { + get when() { return mounted() }, + children: (_when) => { + panel = createComponent(TokenCachePanel, { + theme: mapTheme(context.theme), api, get sessionId() { return viewed() }, signals, + }) + return panel + }, + }) }) + const emit = (type, sid) => { + for (const fn of handlers.get(type) ?? []) fn({ type, data: { sessionID: sid, id: "fixture-event" } }) + } + const dist = () => { + const value = stores.get("opencode-visual-cache.cache_panel.dist_snapshot")?.[0].value + const raw = value?.dist ?? value + return raw === undefined ? undefined : JSON.parse(JSON.stringify(raw)) + } + const rendered = () => { + const texts = [] + const visit = (node) => { + if (typeof node?.plainText === "string") texts.push(node.plainText) + for (const child of node?.getChildren?.() ?? []) visit(child) + } + visit(panel) + return texts.join("\n") + } + const check = (name, ok) => { checks.push({ name, ok }); if (!ok) throw new Error(`Check failed: ${name}`) } + const phase = async (name, action) => { + const before = { ...counts }, start = performance.now(), beforeCpu = cpu() + await action() + await delay(450) + results.push({ phase: name, wallMilliseconds: Math.round(performance.now() - start), + cpuMilliseconds: Math.round((cpu() - beforeCpu) / 1000), + ...Object.fromEntries(Object.entries(counts).map(([key, value]) => [key, value - before[key]])) }) + } + const inspect = async () => { + try { + // Sidebar mounting can lag behind plugin setup while the real attached + // transcript loads. Do not start the fixture before its panel is ready. + await delay(1200) + for (let tries = 0; tries < 200 && (!panel || dist()?.output !== COUNT * 5); tries++) await delay(50) + const initial = dist() + check("initial exact output", initial?.output === COUNT * 5) + check("initial exact API input", initial?.apiInput === 15) + check("rendered initial hit rate", rendered().includes("33.3%")) + check("rendered initial cost", rendered().includes("$0.128")) + check("rendered selected model", rendered().includes("fixture-model")) + await phase("idle", async () => { await delay(300) }) + await phase("foreign-legacy-events", async () => { + for (let i = 0; i < 8; i++) { emit("message.updated", "other-session"); await delay(20) } + }) + await phase("foreign-v2-events", async () => { + for (let i = 0; i < 8; i++) { emit("session.step.started", "other-session"); await delay(20) } + }) + await phase("current-reactive-token-updates-64", async () => { + for (let i = 0; i < 64; i++) { + setList((previous) => { + const last = previous.at(-1) + return [...previous.slice(0, -1), { ...last, tokens: { ...last.tokens, output: last.tokens.output + 1 } }] + }) + await delay(20) + } + }) + const streamed = dist() + check("latest exact output after 64 changes", streamed.output === COUNT * 5 + 64 && streamed.apiOutput === 69) + check("unchanged historical estimates", streamed.toolResult === initial.toolResult && streamed.toolCall === initial.toolCall) + await phase("current-unchanged-events", async () => { + for (let i = 0; i < 8; i++) { emit("message.updated", SID); await delay(20) } + }) + await phase("in-place-tool-and-skill-update", async () => { + const tool = list().at(-1).content[0] + tool.name = "skill" + tool.state.input.command = "changed input" + tail = "# Skill: fixture-skill\n" + "skill payload ".repeat(20) + emit("session.tool.success", SID); emit("message.part.updated", SID) + }) + check("in-place changed output detected", dist().toolResult !== initial.toolResult) + check("in-place skill rendered", rendered().includes("fixture-skill")) + if (OPTIMIZED) { + await phase("session-aggregates", async () => { + setAggregate({ tokens: { input: 5000, output: 10000, cache: { read: 8000, write: 200 } }, cost: 12.5 }) + }) + check("real aggregates used beyond loaded window", rendered().includes("$12.50") && rendered().includes("10.0K")) + setAggregate(undefined) + await delay(150) + await phase("empty-child-switch", async () => { signals.setOverrideSessionId(CHILD) }) + check("child does not display parent distribution", !rendered().includes("fixture-skill") && !rendered().includes("33.3%")) + await phase("child-late-hydration", async () => { + setChildList([{ id: "child-a", type: "assistant", finish: "stop", cost: 0.2, + model: { providerID: "fixture-no-balance", id: "fixture-model" }, + tokens: { input: 30, output: 3, cache: { read: 30, write: 0 } }, + content: [{ type: "tool", name: "task", state: { status: "completed", input: { prompt: "child instructions" }, + content: [{ type: "text", text: "child output" }] } }] }]) + }) + check("child token values isolated", dist().output === 3 && dist().apiInput === 60 && rendered().includes("50.0%")) + await phase("deep-reactive-output-with-aggregates", async () => { + setChildAggregate({ tokens: { input: 30, output: 3000, cache: { read: 30, write: 0 } }, cost: 0.2 }) + await delay(150) + setChildStore("messages", 0, "tokens", "output", 6) + setChildStore("messages", 0, "tokens", "reasoning", 4) + }) + check("in-place token changes update distribution even with aggregates", dist().output === 6 && dist().reasoning === 4 && rendered().includes("3,000")) + await phase("return-to-parent", async () => { signals.setOverrideSessionId(undefined) }) + check("parent exact state restored", dist().output === COUNT * 5 + 64 && rendered().includes("fixture-skill")) + emit("message.updated", SID) + signals.setOverrideSessionId(CHILD) + await delay(500) + check("pending parent work cannot overwrite child", dist().output === 6 && dist().apiInput === 60) + const foreign = results.filter((r) => r.phase.startsWith("foreign")) + check("foreign events cause no work", foreign.every((r) => r.messageLists === 0 && r.snapshotWrites === 0)) + check("unchanged current events do not write", results.find((r) => r.phase === "current-unchanged-events").snapshotWrites === 0) + setMounted(false) + api.kv.ready = false + setMounted(true) + await delay(30) + setMounted(false) + await delay(150) + const beforeRestore = signals.balanceRefresh() + const beforeUnmount = { ...counts } + api.kv.ready = true + await delay(350) + check("disposed panel cannot restore configuration later", signals.balanceRefresh() === beforeRestore) + check("disposed refresh timers and event handlers do no work", counts.messageLists === beforeUnmount.messageLists && counts.snapshotWrites === beforeUnmount.snapshotWrites) + } + writeFileSync(REPORT, JSON.stringify({ complete: true, success: true, mode: OPTIMIZED ? "optimized" : "baseline", + messageCount: COUNT, syntheticOutputCharsPerMessage: TEXT.length, results, checks, + initialDistribution: initial, finalStreamedDistribution: streamed, + durableStoresCreated: 0, balancePollingMounted: false }, null, 2)) + } catch (error) { + writeFileSync(REPORT, JSON.stringify({ complete: true, success: false, error: String(error), results, checks, + diagnostics: { counts, panelMounted: Boolean(panel), distribution: dist(), syntheticPanelText: rendered() } }, null, 2)) + } + } + void inspect() + }, +}