@monotykamary/pi-tps 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ _Originally from [badlogic/pi-mono](https://github.com/badlogic/pi-mono/blob/mai
20
20
  ## Quick start
21
21
 
22
22
  ```bash
23
- pi install https://github.com/monotykamary/pi-tps
23
+ pi install npm:@monotykamary/pi-tps
24
24
  ```
25
25
 
26
26
  ## What's included
@@ -43,6 +43,12 @@ pi install https://github.com/monotykamary/pi-tps
43
43
 
44
44
  ## Install
45
45
 
46
+ ```bash
47
+ pi install npm:@monotykamary/pi-tps
48
+ ```
49
+
50
+ Or install from GitHub:
51
+
46
52
  ```bash
47
53
  pi install https://github.com/monotykamary/pi-tps
48
54
  ```
@@ -63,24 +69,35 @@ Then `/reload` in pi.
63
69
  ## Output format
64
70
 
65
71
  ```
66
- TPS 42.5 tok/s · TTFT 1.2s · 29.7s · in 567 · out 1.2K · stall 4.3s×1
72
+ TPS 42.5 tok/s · TTFT 1.2s · 29.7s · in 567 · out 1.2K · stall 4.3s×1 · $3.60/M
67
73
  ```
68
74
 
69
- | Field | Description |
70
- | ------- | ------------------------------------------------------------------- |
71
- | `TPS` | Tokens per second (generation speed, excludes TTFT & stalls) |
72
- | `TTFT` | Time to first token (seconds, 1 decimal) |
73
- | `s` | Total wall-clock time from request to completion |
74
- | `in` | Input tokens (human-readable: K/M/B) |
75
- | `out` | Output tokens (human-readable: K/M/B) |
76
- | `stall` | Accumulated stall time × stall count (shown only when stalls exist) |
75
+ | Field | Description |
76
+ | ------- | ---------------------------------------------------------------------------------------- |
77
+ | `TPS` | Tokens per second (generation speed, excludes TTFT & stalls) |
78
+ | `TTFT` | Time to first token (seconds, 1 decimal) |
79
+ | `s` | Total wall-clock time from request to completion |
80
+ | `in` | Input tokens (human-readable: K/M/B) |
81
+ | `out` | Output tokens (human-readable: K/M/B) |
82
+ | `stall` | Accumulated stall time × stall count (shown only when stalls exist) |
83
+ | `$/M` | Blended $/million-tokens rate for this turn (shown only when a cost source is available) |
77
84
 
78
85
  When TPS can't be determined (burst delivery, too few chunks), the field shows `—`:
79
86
 
80
87
  ```
81
- TPS — · TTFT 0.8s · 1.3s · in 291 · out 46
88
+ TPS — · TTFT 0.8s · 1.3s · in 291 · out 46 · $2.59/M
82
89
  ```
83
90
 
91
+ ### $/M-tokens rate
92
+
93
+ The trailing `$/M` segment is a blended per-turn rate: `effectiveCost / (totalTokens / 1_000_000)`, shown to two decimals. Only one cost source contributes per turn (no double-counting):
94
+
95
+ - When [pi-neuralwatt-provider](https://github.com/monotykamary/pi-neuralwatt-provider) reports the **energy-billed cost** for the turn (what you're actually invoiced), that wins outright.
96
+ - Otherwise the **list-price compute cost** from `message.usage.cost.total` (derived from the model's pricing table via pi-ai) is used.
97
+ - When neither is available (no pricing, zero tokens), the segment is omitted.
98
+
99
+ The cross-extension handoff is event-based: pi-neuralwatt-provider emits `neuralwatt:turn-energy` with `{ costUsd, energyJoules, turnIndex }` in its own `turn_end`; pi-tps subscribes at load and stashes `costUsd` keyed by `turnIndex`. pi dispatches `turn_end` handlers sequentially, so if the neuralwatt provider was registered before pi-tps the value is already cached when pi-tps's `turn_end` runs; if not, pi-tps falls back to the list-price rate for that one turn (the neuralwatt widget still shows billing separately). No latency is added — pi-tps reads the cache synchronously, never awaits.
100
+
84
101
  Human-readable scaling (for token counts):
85
102
 
86
103
  - `< 1K`: raw integer (`567`)
@@ -186,29 +203,30 @@ pi.events.on('tps:telemetry', (data) => {
186
203
 
187
204
  The event payload:
188
205
 
189
- | Field | Type | Description |
190
- | --------------------- | ---------------- | --------------------------------------------------- |
191
- | `tps` | `number \| null` | Tokens per second, or null when unidentifiable |
192
- | `model.provider` | `string` | Provider name (e.g. `openai`) |
193
- | `model.modelId` | `string` | Model identifier (e.g. `gpt-4`) |
194
- | `tokens.input` | `number` | Input tokens (summed across all assistant messages) |
195
- | `tokens.output` | `number` | Output tokens generated by the LLM |
196
- | `tokens.cacheRead` | `number` | Cache-read tokens (provider-dependent) |
197
- | `tokens.cacheWrite` | `number` | Cache-write tokens (provider-dependent) |
198
- | `tokens.total` | `number` | Total tokens (input + output + cache) |
199
- | `timing.ttftMs` | `number \| null` | Time to first token in milliseconds |
200
- | `timing.totalMs` | `number` | Total wall-clock time from request to completion |
201
- | `timing.generationMs` | `number` | Streaming wall clock (message_start → message_end) |
202
- | `timing.streamMs` | `number \| null` | Inter-update span: first → last streaming update |
203
- | `timing.stallMs` | `number` | Accumulated inference stall time in ms |
204
- | `timing.stallCount` | `number` | Number of discrete stall events |
205
- | `timing.messageCount` | `number` | Assistant messages in this turn |
206
- | `cost.input` | `number \| null` | Input token cost |
207
- | `cost.output` | `number \| null` | Output token cost |
208
- | `cost.cacheRead` | `number \| null` | Cache-read token cost |
209
- | `cost.cacheWrite` | `number \| null` | Cache-write token cost |
210
- | `cost.total` | `number \| null` | Total cost for this turn |
211
- | `timestamp` | `number` | Unix timestamp (ms) when telemetry was computed |
206
+ | Field | Type | Description |
207
+ | --------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
208
+ | `tps` | `number \| null` | Tokens per second, or null when unidentifiable |
209
+ | `model.provider` | `string` | Provider name (e.g. `openai`) |
210
+ | `model.modelId` | `string` | Model identifier (e.g. `gpt-4`) |
211
+ | `tokens.input` | `number` | Input tokens (summed across all assistant messages) |
212
+ | `tokens.output` | `number` | Output tokens generated by the LLM |
213
+ | `tokens.cacheRead` | `number` | Cache-read tokens (provider-dependent) |
214
+ | `tokens.cacheWrite` | `number` | Cache-write tokens (provider-dependent) |
215
+ | `tokens.total` | `number` | Total tokens (input + output + cache) |
216
+ | `timing.ttftMs` | `number \| null` | Time to first token in milliseconds |
217
+ | `timing.totalMs` | `number` | Total wall-clock time from request to completion |
218
+ | `timing.generationMs` | `number` | Streaming wall clock (message_start → message_end) |
219
+ | `timing.streamMs` | `number \| null` | Inter-update span: first → last streaming update |
220
+ | `timing.stallMs` | `number` | Accumulated inference stall time in ms |
221
+ | `timing.stallCount` | `number` | Number of discrete stall events |
222
+ | `timing.messageCount` | `number` | Assistant messages in this turn |
223
+ | `cost.input` | `number \| null` | Input token cost |
224
+ | `cost.output` | `number \| null` | Output token cost |
225
+ | `cost.cacheRead` | `number \| null` | Cache-read token cost |
226
+ | `cost.cacheWrite` | `number \| null` | Cache-write token cost |
227
+ | `cost.total` | `number \| null` | Total cost for this turn |
228
+ | `timestamp` | `number` | Unix timestamp (ms) when telemetry was computed |
229
+ | `rateUsdPerMTokens` | `number \| null` | Blended $/M-tokens rate for this turn (Neuralwatt billed cost when available, else list-price compute cost). `null` when no cost source is usable or tokens are zero. |
212
230
 
213
231
  When `cost` is unavailable (provider doesn't report it), the entire `cost` object is `null`.
214
232
 
@@ -0,0 +1,334 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import type { AssistantMessage } from '@earendil-works/pi-ai';
3
+ import { createTestFixture, activateExtension, tick, makeAssistantMessage } from './helpers';
4
+
5
+ /**
6
+ * Coverage for the blended $/M-tokens notification field:
7
+ * rateUsdPerMTokens = effectiveCost / (tokens.total / 1_000_000)
8
+ *
9
+ * effectiveCost is:
10
+ * - the Neuralwatt billed cost when stashed via the `neuralwatt:turn-energy`
11
+ * event (energy-based, what the user actually pays)
12
+ * - otherwise the list-price compute cost from message.usage.cost.total
13
+ *
14
+ * Only one source contributes per turn (no double-counting): when both are
15
+ * present the billed cost wins outright.
16
+ */
17
+
18
+ function makeMessageWithCost(opts: {
19
+ input: number;
20
+ output: number;
21
+ costTotal: number;
22
+ provider?: string;
23
+ model?: string;
24
+ }): AssistantMessage {
25
+ const { input, output, costTotal, provider = 'openai', model = 'gpt-4' } = opts;
26
+ return {
27
+ role: 'assistant',
28
+ content: [{ type: 'text', text: 'Hello' }],
29
+ api: 'openai-completions',
30
+ provider,
31
+ model,
32
+ usage: {
33
+ input,
34
+ output,
35
+ cacheRead: 0,
36
+ cacheWrite: 0,
37
+ totalTokens: input + output,
38
+ cost: {
39
+ input: 0,
40
+ output: 0,
41
+ cacheRead: 0,
42
+ cacheWrite: 0,
43
+ total: costTotal,
44
+ },
45
+ },
46
+ stopReason: 'stop',
47
+ timestamp: Date.now(),
48
+ };
49
+ }
50
+
51
+ /** Drive a minimal turn that yields primary-branch null TPS (burst) but valid telemetry. */
52
+ async function runBurstTurn(
53
+ fixture: ReturnType<typeof createTestFixture>,
54
+ message: AssistantMessage,
55
+ turnIndex = 0
56
+ ) {
57
+ const { handlers, mockCtx } = fixture;
58
+ handlers['turn_start']?.({ type: 'turn_start', turnIndex, timestamp: Date.now() });
59
+ await tick(50);
60
+ handlers['message_start']?.({ type: 'message_start', message });
61
+ await tick(50);
62
+ handlers['message_update']?.({
63
+ type: 'message_update',
64
+ message,
65
+ assistantMessageEvent: { type: 'text_delta', delta: 't' },
66
+ });
67
+ handlers['message_end']?.({ type: 'message_end', message });
68
+ handlers['turn_end']?.({ type: 'turn_end', turnIndex, message, toolResults: [] }, mockCtx);
69
+ }
70
+
71
+ describe('pi-tps extension — blended $/M-tokens rate', () => {
72
+ let fixture: ReturnType<typeof createTestFixture>;
73
+
74
+ beforeEach(async () => {
75
+ fixture = createTestFixture();
76
+ await activateExtension(fixture);
77
+ });
78
+
79
+ afterEach(() => {
80
+ vi.restoreAllMocks();
81
+ });
82
+
83
+ it('shows list-price $/M rate in the banner from message.usage.cost.total', async () => {
84
+ // 1000 in + 1000 out = 2000 tokens; cost.total = $0.008 → $4.00/M
85
+ const message = makeMessageWithCost({ input: 1000, output: 1000, costTotal: 0.008 });
86
+
87
+ await runBurstTurn(fixture, message);
88
+
89
+ const { notifySpy, appendEntrySpy } = fixture;
90
+ expect(notifySpy).toHaveBeenCalledOnce();
91
+ const banner = notifySpy.mock.calls[0][0] as string;
92
+ expect(banner).toContain('$4.00/M');
93
+ expect(banner).not.toMatch(/\$.*\/M.*\$.*\/M/); // exactly one rate segment
94
+
95
+ const [, data] = appendEntrySpy.mock.calls[0];
96
+ expect(data.rateUsdPerMTokens).toBe(4.0);
97
+ });
98
+
99
+ it('uses Neuralwatt billed cost over list-price when the energy event fires first', async () => {
100
+ // Same token volume, but billed cost differs from list price.
101
+ // 2000 tokens; list cost.total = $0.008 ($4.00/M); billed = $0.006 ($3.00/M)
102
+ const message = makeMessageWithCost({
103
+ input: 1000,
104
+ output: 1000,
105
+ costTotal: 0.008,
106
+ provider: 'neuralwatt',
107
+ model: 'moonshotai/Kimi-K2.5',
108
+ });
109
+
110
+ // Simulate the neuralwatt provider's turn_end running BEFORE ours: it emits
111
+ // the per-turn energy event, which our listener stashes keyed by turnIndex.
112
+ fixture.emitEvent('neuralwatt:turn-energy', {
113
+ costUsd: 0.006,
114
+ energyJoules: 21.6,
115
+ turnIndex: 0,
116
+ });
117
+
118
+ await runBurstTurn(fixture, message, 0);
119
+
120
+ const { notifySpy, appendEntrySpy } = fixture;
121
+ expect(notifySpy).toHaveBeenCalledOnce();
122
+ const banner = notifySpy.mock.calls[0][0] as string;
123
+ // Billed ($3.00/M) wins over list-price ($4.00/M)
124
+ expect(banner).toContain('$3.00/M');
125
+ expect(banner).not.toContain('$4.00/M');
126
+
127
+ const [, data] = appendEntrySpy.mock.calls[0];
128
+ expect(data.rateUsdPerMTokens).toBe(3.0);
129
+ // The cost block still carries the list-price compute cost separately.
130
+ expect(data.cost).toEqual(expect.objectContaining({ total: 0.008 }));
131
+ });
132
+
133
+ it('null rate when totalTokens is zero (degenerate)', async () => {
134
+ const message = makeMessageWithCost({ input: 0, output: 1000, costTotal: 0.008 });
135
+ message.usage.totalTokens = 0;
136
+
137
+ await runBurstTurn(fixture, message);
138
+
139
+ const { appendEntrySpy, notifySpy } = fixture;
140
+ const banner = notifySpy.mock.calls[0][0] as string;
141
+ expect(banner).not.toMatch(/\$.*\/M/);
142
+ const [, data] = appendEntrySpy.mock.calls[0];
143
+ expect(data.rateUsdPerMTokens).toBeNull();
144
+ });
145
+
146
+ it('null rate when cost is unavailable', async () => {
147
+ // No usage.cost at all → effectiveCost null → rate null.
148
+ const message = makeAssistantMessage({ output: 500, input: 500 });
149
+ // Strip the cost block that makeAssistantMessage adds.
150
+ (message.usage as any).cost = null;
151
+ // Allow the isAssistantMessage guard to still pass: it only checks input/output.
152
+ await runBurstTurn(fixture, message);
153
+
154
+ const { appendEntrySpy } = fixture;
155
+ const [, data] = appendEntrySpy.mock.calls[0];
156
+ expect(data.rateUsdPerMTokens).toBeNull();
157
+ expect(data.cost).toBeNull();
158
+ });
159
+
160
+ it('falls back to list-price rate when billed-cost event misses (out-of-order load)', async () => {
161
+ // Neuralwatt turn but the energy event never arrives (provider loaded after
162
+ // us). Must not block or crash — falls back to the list-price compute rate.
163
+ const message = makeMessageWithCost({
164
+ input: 500,
165
+ output: 500,
166
+ costTotal: 0.004, // $4.00/M for 1000 tokens
167
+ provider: 'neuralwatt',
168
+ model: 'moonshotai/Kimi-K2.5',
169
+ });
170
+
171
+ await runBurstTurn(fixture, message);
172
+
173
+ const { notifySpy } = fixture;
174
+ const banner = notifySpy.mock.calls[0][0] as string;
175
+ expect(banner).toContain('$4.00/M');
176
+ });
177
+
178
+ it('ignores neuralwatt:turn-energy payloads lacking a numeric turnIndex', async () => {
179
+ // Defensive: malformed event must not pollute the cache.
180
+ fixture.emitEvent('neuralwatt:turn-energy', { costUsd: 0.006, energyJoules: 21.6 }); // no turnIndex
181
+ const message = makeMessageWithCost({ input: 1000, output: 1000, costTotal: 0.008 });
182
+
183
+ await runBurstTurn(fixture, message);
184
+
185
+ const { notifySpy } = fixture;
186
+ const banner = notifySpy.mock.calls[0][0] as string;
187
+ // Falls back to list-price since no valid turnIndex-keyed entry landed.
188
+ expect(banner).toContain('$4.00/M');
189
+ });
190
+
191
+ it('rehydrates a rate segment from structured telemetry on session resume', async () => {
192
+ const { handlers, notifySpy, mockEntries } = fixture;
193
+
194
+ mockEntries.push({
195
+ type: 'custom',
196
+ customType: 'tps',
197
+ data: {
198
+ model: { provider: 'openai', modelId: 'gpt-4' },
199
+ tokens: { input: 1000, output: 1000, cacheRead: 0, cacheWrite: 0, total: 2000 },
200
+ timing: {
201
+ ttftMs: 1000,
202
+ totalMs: 3000,
203
+ generationMs: 2000,
204
+ stallMs: 0,
205
+ stallCount: 0,
206
+ messageCount: 1,
207
+ },
208
+ tps: 10.0,
209
+ rateUsdPerMTokens: 4.0,
210
+ timestamp: Date.now(),
211
+ },
212
+ });
213
+
214
+ handlers['session_start']?.({ reason: 'resume' }, fixture.mockCtx);
215
+ await tick();
216
+
217
+ expect(notifySpy).toHaveBeenCalledOnce();
218
+ const banner = notifySpy.mock.calls[0][0] as string;
219
+ expect(banner).toContain('$4.00/M');
220
+ });
221
+
222
+ it('rehydrates older telemetry without a rate field without crashing (omits the segment)', async () => {
223
+ const { handlers, notifySpy, mockEntries } = fixture;
224
+
225
+ mockEntries.push({
226
+ type: 'custom',
227
+ customType: 'tps',
228
+ data: {
229
+ model: { provider: 'openai', modelId: 'gpt-4' },
230
+ tokens: { input: 50, output: 100, cacheRead: 0, cacheWrite: 0, total: 150 },
231
+ timing: {
232
+ ttftMs: 1000,
233
+ totalMs: 3000,
234
+ generationMs: 2000,
235
+ stallMs: 0,
236
+ stallCount: 0,
237
+ messageCount: 1,
238
+ },
239
+ tps: 10.0,
240
+ // rateUsdPerMTokens intentionally absent (pre-feature entry)
241
+ timestamp: Date.now(),
242
+ },
243
+ });
244
+
245
+ handlers['session_start']?.({ reason: 'resume' }, fixture.mockCtx);
246
+ await tick();
247
+
248
+ expect(notifySpy).toHaveBeenCalledOnce();
249
+ const banner = notifySpy.mock.calls[0][0] as string;
250
+ expect(banner).not.toMatch(/\$.*\/M/);
251
+ });
252
+
253
+ it('falls back to a persisted neuralwatt-energy entry when the live event is missed', async () => {
254
+ const now = Date.now();
255
+ const message = makeMessageWithCost({
256
+ input: 1000,
257
+ output: 1000,
258
+ costTotal: 0.008, // list price: $4.00/M
259
+ provider: 'neuralwatt',
260
+ model: 'moonshotai/Kimi-K2.5',
261
+ });
262
+
263
+ // Provider appended its energy entry before pi-tps handled turn_end.
264
+ fixture.mockEntries.push({
265
+ type: 'custom',
266
+ customType: 'neuralwatt-energy',
267
+ data: { energy_joules: 21.6, cost_usd: 0.006 }, // $3.00/M for 2000 tokens
268
+ timestamp: now,
269
+ });
270
+
271
+ const { handlers, notifySpy, appendEntrySpy } = fixture;
272
+ handlers['turn_start']?.({ type: 'turn_start', turnIndex: 0, timestamp: now - 100 });
273
+ await tick(50);
274
+ handlers['message_start']?.({ type: 'message_start', message });
275
+ await tick(50);
276
+ handlers['message_update']?.({
277
+ type: 'message_update',
278
+ message,
279
+ assistantMessageEvent: { type: 'text_delta', delta: 'H' },
280
+ });
281
+ handlers['message_end']?.({ type: 'message_end', message });
282
+ handlers['turn_end']?.(
283
+ { type: 'turn_end', turnIndex: 0, message, toolResults: [] },
284
+ fixture.mockCtx
285
+ );
286
+
287
+ expect(notifySpy).toHaveBeenCalledOnce();
288
+ const banner = notifySpy.mock.calls[0][0] as string;
289
+ expect(banner).toContain('$3.00/M');
290
+ expect(banner).not.toContain('$4.00/M');
291
+
292
+ const [, data] = appendEntrySpy.mock.calls[0];
293
+ expect(data.rateUsdPerMTokens).toBe(3.0);
294
+ });
295
+
296
+ it('ignores stale neuralwatt-energy entries from before this turn', async () => {
297
+ const now = Date.now();
298
+ const message = makeMessageWithCost({
299
+ input: 1000,
300
+ output: 1000,
301
+ costTotal: 0.008, // list price: $4.00/M
302
+ provider: 'neuralwatt',
303
+ model: 'moonshotai/Kimi-K2.5',
304
+ });
305
+
306
+ fixture.mockEntries.push({
307
+ type: 'custom',
308
+ customType: 'neuralwatt-energy',
309
+ data: { energy_joules: 1, cost_usd: 9999 }, // absurd cost, should be ignored
310
+ timestamp: now - 1000,
311
+ });
312
+
313
+ const { handlers, notifySpy } = fixture;
314
+ handlers['turn_start']?.({ type: 'turn_start', turnIndex: 0, timestamp: now });
315
+ await tick(50);
316
+ handlers['message_start']?.({ type: 'message_start', message });
317
+ await tick(50);
318
+ handlers['message_update']?.({
319
+ type: 'message_update',
320
+ message,
321
+ assistantMessageEvent: { type: 'text_delta', delta: 'H' },
322
+ });
323
+ handlers['message_end']?.({ type: 'message_end', message });
324
+ handlers['turn_end']?.(
325
+ { type: 'turn_end', turnIndex: 0, message, toolResults: [] },
326
+ fixture.mockCtx
327
+ );
328
+
329
+ expect(notifySpy).toHaveBeenCalledOnce();
330
+ const banner = notifySpy.mock.calls[0][0] as string;
331
+ expect(banner).toContain('$4.00/M');
332
+ expect(banner).not.toContain('$9999');
333
+ });
334
+ });
@@ -77,6 +77,8 @@ export interface TestFixture {
77
77
  notifySpy: ReturnType<typeof vi.fn>;
78
78
  appendEntrySpy: ReturnType<typeof vi.fn>;
79
79
  eventsEmitSpy: ReturnType<typeof vi.fn>;
80
+ /** Emit an event on the mock event bus, dispatching to registered listeners (and the emit spy). */
81
+ emitEvent: (event: string, payload: unknown) => void;
80
82
  registerCommandSpy: ReturnType<typeof vi.fn>;
81
83
  mockEntries: Array<{ type?: string; role?: string; customType?: string; data?: unknown }>;
82
84
  mockCtx: ExtensionContext;
@@ -123,15 +125,35 @@ export function createTestFixture(): TestFixture {
123
125
  } as any as ExtensionContext;
124
126
 
125
127
  const eventsEmitSpy = vi.fn();
128
+ // Real event bus: listeners registered via events.on are dispatched on emit.
129
+ // eventsEmitSpy still captures every emit for assertions (e.g. 'tps:telemetry').
130
+ const eventListeners = new Map<string, ((payload: unknown) => void)[]>();
131
+ const dispatchEvent = (event: string, payload: unknown) => {
132
+ eventsEmitSpy(event, payload);
133
+ for (const listener of eventListeners.get(event) ?? []) {
134
+ try {
135
+ listener(payload);
136
+ } catch {
137
+ // listener errors shouldn't break emit dispatch in tests
138
+ }
139
+ }
140
+ };
126
141
 
127
142
  const mockPi: Partial<ExtensionAPI> = {
128
- on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
143
+ on: vi.fn((event: string, handler: any) => {
129
144
  handlers[event] = handler;
130
145
  return mockPi as ExtensionAPI;
131
146
  }),
132
147
  appendEntry: appendEntrySpy,
133
148
  registerCommand: registerCommandSpy,
134
- events: { emit: eventsEmitSpy, on: vi.fn() },
149
+ events: {
150
+ emit: dispatchEvent,
151
+ on: vi.fn((event: string, listener: (payload: unknown) => void) => {
152
+ const list = eventListeners.get(event) ?? [];
153
+ list.push(listener);
154
+ eventListeners.set(event, list);
155
+ }),
156
+ } as any,
135
157
  };
136
158
 
137
159
  return {
@@ -141,6 +163,7 @@ export function createTestFixture(): TestFixture {
141
163
  notifySpy,
142
164
  appendEntrySpy,
143
165
  eventsEmitSpy,
166
+ emitEvent: dispatchEvent,
144
167
  registerCommandSpy,
145
168
  mockEntries,
146
169
  mockCtx,