Skip to content

Commit dfd5fab

Browse files
committed
feat: 重构性能监视器,更新相关文档和测试用例,优化会话切换时的性能恢复
1 parent 5443ecf commit dfd5fab

9 files changed

Lines changed: 294 additions & 77 deletions

File tree

‎ROADMAP.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ TTFT 2.7s
5959
目标:提供可查询的价格目录,便于用户了解不同模型的计费档位。
6060

6161
* [ ] 构建公开价格目录,包含模型、价格档位及阈值
62+
* [ ] 只收录半年内发布的模型
6263
<!-- TODO -->
6364

6465
## v0.4.0 — 用量命令

‎docs/verification.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,28 @@
22

33
## 未发布(SDK 2.0.11)
44

5-
验证日期:2026-09-20。环境:macOS、Node.js v22.23.2、npm 10.9.8、OpenCode v2.0.11;`@opencode/plugin`、`@opencode/client`、`@opencode/schema` 和 `@opencode/theme` 均精确锁定为 2.0.11。
5+
验证日期:2026-09-22。环境:macOS、Node.js v22.23.2、npm 10.9.8、OpenCode v2.0.11;`@opencode/plugin`、`@opencode/client`、`@opencode/schema` 和 `@opencode/theme` 均精确锁定为 2.0.11。
66

77
### 自动化检查
88

99
| 检查 | 结果 |
1010
| --- | --- |
1111
| `npm run typecheck` | 通过 |
12-
| `npm test` | 29 项通过 |
12+
| `npm test` | 31 项通过 |
1313
| `npm run build` | 通过,输出 ESM 与类型声明 |
1414
| `npm run test:smoke` | 通过,使用实际 `.tgz` 安装产物和真实 OpenCode 2.0.11 |
1515

1616
SDK 从 2.0.9 升级到 2.0.11 后,四个顶层 OpenCode 包及锁文件中的传递依赖均已统一到 2.0.11。`@opencode/plugin` 两版的导出结构一致;包内容差异除依赖版本外,仅为 `ToastOptions` 新增可选的 `sessionID` 类型字段。
1717

18+
新增共享性能监视器测试覆盖:查看会话 A 时捕获独立会话 B 的流,切换到 B 后立即恢复实时 TPS/TTFT;生成中切走、继续接收 delta 再切回;控制器销毁重建后恢复共享流;不同会话树隔离、新子代理即时纳入,以及删除会话清理运行期状态。既有重复事件去重与完成后收敛为精确 TPS 的断言继续通过。
19+
1820
### 真实 OpenCode 集成
1921

20-
隔离 smoke 测试确认打包插件可加载;空会话隐藏无数据行;活动 TPS 估算会收敛为精确值;TTFT、token、上下文与费用正常刷新;真实子代理用量可从父子视图累计且上下文保持会话局部;切换活动模型会同步更新价格选择与上下文上限。测试产物保存在 `/var/folders/rw/bmx6c8hd737brl55m0_ff_b80000gn/T/token-usage-smoke-JXe1KG`。
22+
隔离 smoke 测试确认打包插件可加载;空会话隐藏无数据行;在独立会话已经开始慢速流后,同一 TUI 切换过去会立即显示 `TPS ~…` 与 TTFT,完成后变为无 `~` 的精确 TPS;既有 token、上下文与费用刷新、真实子代理全树累计、会话局部上下文及模型切换验证均继续通过。测试产物保存在 `/var/folders/rw/bmx6c8hd737brl55m0_ff_b80000gn/T/token-usage-smoke-cgsCK2`。
2123

2224
### 验证边界
2325

24-
当前 SDK 2.0.11 与宿主 2.0.11 的组合已完整验证。2.0.9 和 2.0.10 宿主的历史验证记录保留在下方,但升级 SDK 后未重新执行这两个旧宿主的打包矩阵:本地仅有 2.0.11,官方 GitHub Releases 也没有可获取的对应旧版本资产。后续升级 SDK 或宿主时,仍需重新核对分叉副本、compaction 排序及 step/delta 事件语义。
26+
当前 SDK 2.0.11 与宿主 2.0.11 的组合已完整验证。验证时机器默认宿主已是 2.0.12,因此 smoke 使用校验完整性的官方 `@opencode/cli-darwin-arm64@2.0.11` 隔离二进制,没有放宽宿主版本断言。2.0.9 和 2.0.10 宿主的历史验证记录保留在下方,但升级 SDK 后未重新执行这两个旧宿主的打包矩阵。后续升级 SDK 或宿主时,仍需重新核对分叉副本、compaction 排序及 step/delta 事件语义。
2527

2628
## v0.2.1(TPS 与 TTFT)
2729

‎opencode.jsonc‎

Lines changed: 0 additions & 6 deletions
This file was deleted.

‎scripts/smoke.mjs‎

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ const mock = createServer(async (request, response) => {
5050
const data = JSON.parse(body);
5151
requests.push(data);
5252
const lastUser = data.messages.findLastIndex(message => message.role === "user");
53-
const spawnChild = JSON.stringify(data.messages[lastUser]).includes("SPAWN_SMOKE_CHILD")
53+
const userText = JSON.stringify(data.messages[lastUser]);
54+
const switchSmoke = userText.includes("SWITCH_SMOKE");
55+
const spawnChild = userText.includes("SPAWN_SMOKE_CHILD")
5456
&& !data.messages.slice(lastUser + 1).some(message => message.role === "tool");
5557
const tool = data.tools?.find(tool => tool.function.name === "subagent");
5658
const toolCalls = spawnChild && tool ? [{ index: 0, id: "usage-smoke-child", type: "function", function: {
@@ -71,7 +73,7 @@ const mock = createServer(async (request, response) => {
7173
chunk({ role: "assistant" });
7274
await new Promise(resolve => setTimeout(resolve, 300));
7375
chunk(toolCalls ? { tool_calls: toolCalls } : { content: content.slice(0, 4) });
74-
await new Promise(resolve => setTimeout(resolve, 700));
76+
await new Promise(resolve => setTimeout(resolve, switchSmoke ? 2_500 : 700));
7577
if (content) chunk({ content: content.slice(4) });
7678
await new Promise(resolve => setTimeout(resolve, 100));
7779
chunk({}, toolCalls ? "tool_calls" : "stop", usage);
@@ -131,7 +133,8 @@ const openTui = sessionID => {
131133
await writeFile(path.join(work, `${name}.txt`), screen());
132134
await writeFile(path.join(work, `${name}.ansi`), raw);
133135
};
134-
const entry = { process, screen, save, terminal };
136+
const send = data => process.stdin.write(data);
137+
const entry = { process, screen, save, send, terminal };
135138
terminals.push(entry);
136139
return entry;
137140
};
@@ -164,9 +167,43 @@ try {
164167
assert.doesNotMatch(tui.screen(), /\b(?:TPS|TTFT)\b/, "empty session hides unavailable performance rows");
165168
await tui.save("01-empty");
166169
console.log("PASS: packed plugin loads; empty sidebar hides zero-value and context rows");
170+
171+
const switchTarget = await client.session.create({ location: { directory: project }, title: "Live Switch Target", permissions: [{ action: "*", resource: "*", effect: "allow" }] });
172+
const switchPrompt = client.session.prompt({ sessionID: switchTarget.id, text: "SWITCH_SMOKE" });
173+
await new Promise(resolve => setTimeout(resolve, 650));
174+
tui.send("\x18");
175+
await new Promise(resolve => setTimeout(resolve, 50));
176+
tui.send("l");
177+
await wait(() => /Sessions for project/.test(tui.screen()) && /Live Switch Target/.test(tui.screen()), "session switch dialog");
178+
tui.send("Live Switch Target");
179+
await new Promise(resolve => setTimeout(resolve, 100));
180+
tui.send("\r");
181+
await wait(() => !/Sessions for project/.test(tui.screen()) && /TPS\s+~[\d.]+ tok\/s/.test(tui.screen())
182+
&& /TTFT\s+[\d.]+s/.test(tui.screen()), "live performance restored after session switch");
183+
await tui.save("02-switched-streaming");
184+
await switchPrompt;
185+
await wait(async () => (await client.message.list({ sessionID: switchTarget.id })).data.some(m => m.type === "assistant" && m.tokens), "switched session usage");
186+
const switchSnapshot = await loadSnapshot(source, switchTarget.id, new AbortController().signal);
187+
const switchPerformance = historicalPerformance(uniqueMessages(switchSnapshot));
188+
assert.ok(switchPerformance.tps && switchPerformance.tps > 0);
189+
await wait(() => new RegExp(`TPS\\s+${switchPerformance.tps.toFixed(1).replace(".", "\\.")} tok/s`).test(tui.screen()), "switched stream exact TPS");
190+
assert.doesNotMatch(tui.screen(), /TPS\s+~/, "switched stream converges to exact TPS");
191+
await tui.save("03-switched-complete");
192+
console.log("PASS: switching to an in-flight session restores live TPS/TTFT and converges to exact TPS");
193+
194+
tui.send("\x18");
195+
await new Promise(resolve => setTimeout(resolve, 50));
196+
tui.send("l");
197+
await wait(() => /Sessions for project/.test(tui.screen()) && /Token Usage Smoke/.test(tui.screen()), "return session dialog");
198+
tui.send("\x1b[B");
199+
await new Promise(resolve => setTimeout(resolve, 100));
200+
tui.send("\r");
201+
await wait(() => !/Sessions for project/.test(tui.screen()) && /Token Usage/.test(tui.screen())
202+
&& /Cache Rate\s+0\.0%/.test(tui.screen()) && !/\bTPS\b/.test(tui.screen()), "return to empty root session");
203+
167204
const firstPrompt = client.session.prompt({ sessionID: root.id, text: "Return SMOKE_OK." });
168205
await wait(() => /TPS\s+~[\d.]+ tok\/s/.test(tui.screen()) && /TTFT\s+[\d.]+s/.test(tui.screen()), "live TPS and TTFT");
169-
await tui.save("02-streaming");
206+
await tui.save("04-streaming");
170207
await firstPrompt;
171208
await wait(async () => (await client.message.list({ sessionID: root.id })).data.some(m => m.type === "assistant" && m.tokens), "first usage");
172209
let snapshot = await loadSnapshot(source, root.id, new AbortController().signal);
@@ -186,19 +223,19 @@ try {
186223
assert.equal(lineNumber(tui.screen(), /\bTPS\s+/), lineNumber(tui.screen(), /\bCost\s+/) + 2, "one blank line separates usage and performance");
187224
assert.ok(lineNumber(tui.screen(), /\bTPS\s+/) < lineNumber(tui.screen(), /\bTTFT\s+/));
188225
assert.ok(lineNumber(tui.screen(), /\bCost\s+/) < lineNumber(tui.screen(), /\bTPS\s+/));
189-
await tui.save("03-message");
226+
await tui.save("05-message");
190227
console.log("PASS: live estimates converge to exact TPS; TTFT and token/context rows update");
191228
await client.session.wait({ sessionID: root.id });
192229
await client.session.prompt({ sessionID: root.id, text: "SPAWN_SMOKE_CHILD" });
193230
await wait(async () => (await client.session.list({ parentID: root.id })).data.length > 0, "real subagent creation");
194231
const child = (await client.session.list({ parentID: root.id })).data[0];
195232
await client.session.wait({ sessionID: root.id });
196233
await wait(() => /Input\s+400/.test(tui.screen()) && /Cache Read\s+4,000/.test(tui.screen()), "child usage in root sidebar");
197-
await tui.save("04-subagent");
234+
await tui.save("06-subagent");
198235
const childTui = openTui(child.id);
199236
await wait(() => /Input\s+400/.test(childTui.screen()) && /Cache Read\s+4,000/.test(childTui.screen()), "same tree in child sidebar");
200237
assert.match(childTui.screen(), /Context\s+1,270 \/ 128,000 \(1\.0%\)/);
201-
await childTui.save("05-child-view");
238+
await childTui.save("07-child-view");
202239
snapshot = await loadSnapshot(source, child.id, new AbortController().signal);
203240
assert.equal(snapshot.rootID, root.id);
204241
assert.equal(snapshot.viewedID, child.id);
@@ -208,9 +245,9 @@ try {
208245
await client.session.switchModel({ sessionID: root.id, model: { providerID: "usage-test", id: "large" } });
209246
await wait(async () => (await loadSnapshot(source, root.id, new AbortController().signal)).model.label === "usage-test/large", "active model switch");
210247
await wait(() => /Context\s+1,270 \/ 32,000 \(4\.0%\)/.test(tui.screen()), "context window follows model switch");
211-
await tui.save("06-model-switch");
248+
await tui.save("08-model-switch");
212249
console.log("PASS: active model switch refreshes price selection and context window");
213-
await writeFile(path.join(work, "result.json"), JSON.stringify({ version: opencodeVersion.replace(/^opencode v/, ""), root: root.id, child: child.id, total: 5080, performance, context: { used: 1270, limitBefore: 128000, limitAfter: 32000, percentAfter: "4.0" }, package: packed[0].filename, files }, null, 2));
250+
await writeFile(path.join(work, "result.json"), JSON.stringify({ version: opencodeVersion.replace(/^opencode v/, ""), root: root.id, child: child.id, switchTarget: switchTarget.id, total: 5080, performance, switchPerformance, context: { used: 1270, limitBefore: 128000, limitAfter: 32000, percentAfter: "4.0" }, package: packed[0].filename, files }, null, 2));
214251
} finally {
215252
for (const [index, terminal] of terminals.entries()) {
216253
await terminal.save(`final-${index}`);

‎src/controller.ts‎

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { loadSnapshot, uniqueMessages, viewedMessages } from "./source.js";
22
import type { Snapshot, UsageSource } from "./source.js";
3-
import { PerformanceTracker } from "./performance.js";
3+
import { PerformanceMonitor } from "./performance.js";
44
import type { PerformanceSummary } from "./performance.js";
55
import { contextUsage, summarize } from "./usage.js";
66
import type { ContextUsage, Summary } from "./usage.js";
@@ -16,11 +16,6 @@ export interface UsageState {
1616
export interface UsageEvent { type: string; data: unknown; id?: string; created?: number }
1717
export type Subscribe = (listener: (event: UsageEvent) => void) => () => void;
1818

19-
const performanceEvents = new Set([
20-
"session.step.started", "session.step.streamed", "session.step.ended", "session.step.failed",
21-
"session.text.delta", "session.reasoning.delta", "session.tool.input.delta",
22-
]);
23-
2419
const sessionEvents = new Set([
2520
"session.created", "session.deleted", "session.forked", "session.moved",
2621
"session.model.selected", "session.agent.selected", "session.step.ended", "session.step.failed",
@@ -43,10 +38,13 @@ export class UsageController {
4338
private timer: ReturnType<typeof setTimeout> | undefined;
4439
private retry: ReturnType<typeof setTimeout> | undefined;
4540
private performanceTimer: ReturnType<typeof setTimeout> | undefined;
41+
private sessions = new Set<string>();
4642
private pending = false;
4743
private disposed = false;
4844
private unsubscribe: () => void;
49-
private readonly performance = new PerformanceTracker();
45+
private readonly unsubscribePerformance: () => void;
46+
private readonly performance: PerformanceMonitor;
47+
private readonly ownsPerformance: boolean;
5048

5149
constructor(
5250
private readonly source: UsageSource,
@@ -55,11 +53,19 @@ export class UsageController {
5553
private readonly delay = 80,
5654
private readonly retryDelay = 3_000,
5755
private readonly performanceDelay = 100,
56+
performance?: PerformanceMonitor,
5857
) {
58+
this.ownsPerformance = performance === undefined;
59+
this.performance = performance ?? new PerformanceMonitor(subscribe);
60+
this.unsubscribePerformance = this.performance.listen(sessionID => {
61+
if (this.sessions.has(sessionID)) this.refreshPerformance();
62+
});
5963
this.unsubscribe = subscribe(event => {
6064
const data = (event.data && typeof event.data === "object" ? event.data : {}) as { sessionID?: string; parentID?: string };
61-
if (event.type === "session.created") this.performance.addSession(data.sessionID, data.parentID);
62-
if (performanceEvents.has(event.type) && this.performance.handle(event)) this.refreshPerformance();
65+
if (event.type === "session.created" && data.sessionID && data.parentID && this.sessions.has(data.parentID)) {
66+
this.sessions.add(data.sessionID);
67+
}
68+
if (event.type === "session.deleted" && data.sessionID) this.sessions.delete(data.sessionID);
6369
if (globalEvents.has(event.type)) return this.refresh();
6470
if (!sessionEvents.has(event.type)) return;
6571
if (!this.snapshot || event.type === "session.created" || event.type === "session.forked"
@@ -79,6 +85,7 @@ export class UsageController {
7985
this.performanceTimer = undefined;
8086
this.pending = false;
8187
this.snapshot = undefined;
88+
this.sessions.clear();
8289
this.sessionID = sessionID;
8390
this.update({ status: "loading" });
8491
void this.run();
@@ -101,7 +108,9 @@ export class UsageController {
101108
clearTimeout(this.timer);
102109
clearTimeout(this.retry);
103110
clearTimeout(this.performanceTimer);
111+
this.unsubscribePerformance();
104112
this.unsubscribe();
113+
if (this.ownsPerformance) this.performance.dispose();
105114
}
106115

107116
private update(state: UsageState) { this.state = state; this.publish(state); }
@@ -111,7 +120,7 @@ export class UsageController {
111120
this.performanceTimer = setTimeout(() => {
112121
this.performanceTimer = undefined;
113122
if (this.disposed || !this.snapshot || !this.state.summary) return;
114-
this.update({ ...this.state, performance: this.performance.summary(uniqueMessages(this.snapshot)) });
123+
this.update({ ...this.state, performance: this.performance.summary(uniqueMessages(this.snapshot), this.sessions) });
115124
}, this.performanceDelay);
116125
}
117126

@@ -126,15 +135,15 @@ export class UsageController {
126135
if (this.disposed || generation !== this.generation) return;
127136
this.snapshot = snapshot;
128137
const messages = [...uniqueMessages(snapshot)];
129-
this.performance.setTree(snapshot.rootID, snapshot.sessions.keys());
130-
this.performance.reconcile(messages);
138+
this.sessions = new Set(snapshot.sessions.keys());
139+
this.performance.reconcile(messages, this.sessions);
131140
clearTimeout(this.performanceTimer);
132141
this.performanceTimer = undefined;
133142
this.update({
134143
status: "ready",
135144
summary: summarize(messages, snapshot.model.prices),
136145
context: contextUsage(viewedMessages(snapshot), snapshot.model.context),
137-
performance: this.performance.summary(messages),
146+
performance: this.performance.summary(messages, this.sessions),
138147
model: snapshot.model.label,
139148
});
140149
} catch {

0 commit comments

Comments
 (0)