@monotykamary/pi-tps 1.0.0

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.
@@ -0,0 +1,266 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { createTestFixture, activateExtension, tick } from './helpers';
3
+
4
+ describe('pi-tps extension — rehydration', () => {
5
+ let fixture: ReturnType<typeof createTestFixture>;
6
+
7
+ beforeEach(async () => {
8
+ fixture = createTestFixture();
9
+ await activateExtension(fixture);
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ /** Helper to make a structured TPS entry */
17
+ function makeTpsEntry(
18
+ data: {
19
+ provider?: string;
20
+ modelId?: string;
21
+ input?: number;
22
+ output?: number;
23
+ cacheRead?: number;
24
+ cacheWrite?: number;
25
+ total?: number;
26
+ ttftMs?: number;
27
+ totalMs?: number;
28
+ generationMs?: number;
29
+ stallMs?: number;
30
+ stallCount?: number;
31
+ messageCount?: number;
32
+ tps?: number;
33
+ } = {}
34
+ ) {
35
+ return {
36
+ type: 'custom' as const,
37
+ customType: 'tps',
38
+ data: {
39
+ model: { provider: data.provider ?? 'openai', modelId: data.modelId ?? 'gpt-4' },
40
+ tokens: {
41
+ input: data.input ?? 10,
42
+ output: data.output ?? 20,
43
+ cacheRead: data.cacheRead ?? 0,
44
+ cacheWrite: data.cacheWrite ?? 0,
45
+ total: data.total ?? 30,
46
+ },
47
+ timing: {
48
+ ttftMs: data.ttftMs ?? 1000,
49
+ totalMs: data.totalMs ?? 3000,
50
+ generationMs: data.generationMs ?? 2000,
51
+ stallMs: data.stallMs ?? 0,
52
+ stallCount: data.stallCount ?? 0,
53
+ messageCount: data.messageCount ?? 1,
54
+ },
55
+ tps: data.tps ?? 10.0,
56
+ timestamp: Date.now(),
57
+ },
58
+ };
59
+ }
60
+
61
+ it('should restore notification on session resume from structured telemetry', async () => {
62
+ const { handlers, notifySpy, mockEntries } = fixture;
63
+
64
+ mockEntries.push(
65
+ makeTpsEntry({
66
+ input: 50,
67
+ output: 100,
68
+ total: 150,
69
+ ttftMs: 1200,
70
+ totalMs: 5000,
71
+ generationMs: 4000,
72
+ tps: 25.0,
73
+ })
74
+ );
75
+
76
+ handlers['session_start']?.({ reason: 'resume' }, fixture.mockCtx);
77
+ await tick();
78
+
79
+ expect(notifySpy).toHaveBeenCalledOnce();
80
+ const msg = notifySpy.mock.calls[0][0] as string;
81
+ expect(msg).toMatch(/TPS 25\.0 tok\/s/);
82
+ expect(msg).toMatch(/TTFT 1\.2s/);
83
+ expect(msg).toMatch(/out 100/);
84
+ expect(msg).toMatch(/in 50/);
85
+ expect(notifySpy).toHaveBeenCalledWith(msg, 'info');
86
+ });
87
+
88
+ it('should rehydrate legacy entries when no structured telemetry exists', async () => {
89
+ const { handlers, notifySpy, mockEntries } = fixture;
90
+
91
+ mockEntries.push({
92
+ type: 'custom',
93
+ customType: 'tps',
94
+ data: {
95
+ message: 'TPS 37.9 tok/s · TTFT 1s · 27s · out 998 · in 917',
96
+ timestamp: Date.now() - 500,
97
+ },
98
+ });
99
+
100
+ handlers['session_start']?.({ reason: 'resume' }, fixture.mockCtx);
101
+ await tick();
102
+
103
+ expect(notifySpy).toHaveBeenCalledOnce();
104
+ const msg = notifySpy.mock.calls[0][0];
105
+ expect(msg).toContain('TPS 37.9');
106
+ });
107
+
108
+ it('should prefer structured telemetry over legacy entries', async () => {
109
+ const { handlers, notifySpy, mockEntries } = fixture;
110
+
111
+ mockEntries.push(
112
+ {
113
+ type: 'custom',
114
+ customType: 'tps',
115
+ data: {
116
+ message: 'TPS 37.9 tok/s · TTFT 1s · 27s · out 998 · in 917',
117
+ timestamp: Date.now() - 1000,
118
+ },
119
+ },
120
+ makeTpsEntry({
121
+ input: 273,
122
+ output: 51,
123
+ total: 324,
124
+ ttftMs: 1000,
125
+ totalMs: 3800,
126
+ generationMs: 2400,
127
+ stallMs: 1400,
128
+ stallCount: 1,
129
+ tps: 18.0,
130
+ })
131
+ );
132
+
133
+ handlers['session_start']?.({ reason: 'resume' }, fixture.mockCtx);
134
+ await tick();
135
+
136
+ expect(notifySpy).toHaveBeenCalledOnce();
137
+ const msg = notifySpy.mock.calls[0][0];
138
+ expect(msg).toContain('TPS 18.0');
139
+ expect(msg).toContain('TTFT 1.0s');
140
+ expect(msg).toContain('stall 1.4s×1');
141
+ expect(msg).not.toContain('TPS 37.9');
142
+ });
143
+
144
+ it('should restore notification on session startup (continuing previous session)', async () => {
145
+ const { handlers, notifySpy, mockEntries } = fixture;
146
+
147
+ mockEntries.push(makeTpsEntry({ tps: 10.0 }));
148
+
149
+ handlers['session_start']?.({ reason: 'startup' }, fixture.mockCtx);
150
+ await tick();
151
+
152
+ expect(notifySpy).toHaveBeenCalledOnce();
153
+ expect(notifySpy.mock.calls[0][0]).toMatch(/TPS 10\.0 tok\/s/);
154
+ });
155
+
156
+ it('should restore notification on session reload', async () => {
157
+ const { handlers, notifySpy, mockEntries } = fixture;
158
+
159
+ mockEntries.push(makeTpsEntry({ tps: 10.0 }));
160
+
161
+ handlers['session_start']?.({ reason: 'reload' }, fixture.mockCtx);
162
+ await tick();
163
+
164
+ expect(notifySpy).toHaveBeenCalledOnce();
165
+ });
166
+
167
+ it('should restore notification on tree navigation', async () => {
168
+ const { handlers, notifySpy, mockEntries } = fixture;
169
+
170
+ mockEntries.push(makeTpsEntry({ tps: 10.0 }));
171
+
172
+ handlers['session_tree']?.({ newLeafId: 'abc123', oldLeafId: 'def456' }, fixture.mockCtx);
173
+ await tick();
174
+
175
+ expect(notifySpy).toHaveBeenCalledOnce();
176
+ });
177
+
178
+ it('should rehydrate most recent entry, preferring structured over legacy', async () => {
179
+ const { handlers, notifySpy, mockEntries } = fixture;
180
+
181
+ mockEntries.push(
182
+ {
183
+ type: 'custom',
184
+ customType: 'tps',
185
+ data: {
186
+ message: 'TPS 10.0 tok/s · TTFT 1s · 5s · out 100 · in 50',
187
+ timestamp: Date.now() - 3000,
188
+ },
189
+ },
190
+ makeTpsEntry({
191
+ provider: 'a',
192
+ modelId: 'a-1',
193
+ input: 5,
194
+ output: 10,
195
+ total: 15,
196
+ ttftMs: 5000,
197
+ totalMs: 10000,
198
+ generationMs: 8000,
199
+ tps: 1.2,
200
+ }),
201
+ {
202
+ type: 'custom',
203
+ customType: 'tps',
204
+ data: {
205
+ message: 'TPS 15.0 tok/s · TTFT 2s · 10s · out 200 · in 100',
206
+ timestamp: Date.now() - 1000,
207
+ },
208
+ },
209
+ makeTpsEntry({
210
+ provider: 'b',
211
+ modelId: 'b-1',
212
+ input: 50,
213
+ output: 500,
214
+ total: 550,
215
+ ttftMs: 2000,
216
+ totalMs: 8000,
217
+ generationMs: 6000,
218
+ stallMs: 500,
219
+ stallCount: 1,
220
+ messageCount: 2,
221
+ tps: 83.3,
222
+ })
223
+ );
224
+
225
+ handlers['session_start']?.({ reason: 'resume' }, fixture.mockCtx);
226
+ await tick();
227
+
228
+ expect(notifySpy).toHaveBeenCalledOnce();
229
+ const msg = notifySpy.mock.calls[0][0];
230
+ expect(msg).toContain('TPS 83.3');
231
+ expect(msg).toContain('stall');
232
+ });
233
+
234
+ it('should rehydrate most recent legacy entry when no structured entry follows', async () => {
235
+ const { handlers, notifySpy, mockEntries } = fixture;
236
+
237
+ mockEntries.push(
238
+ makeTpsEntry({
239
+ provider: 'a',
240
+ modelId: 'a-1',
241
+ input: 5,
242
+ output: 10,
243
+ total: 15,
244
+ ttftMs: 5000,
245
+ totalMs: 10000,
246
+ generationMs: 8000,
247
+ tps: 1.2,
248
+ }),
249
+ {
250
+ type: 'custom',
251
+ customType: 'tps',
252
+ data: {
253
+ message: 'TPS 37.9 tok/s · TTFT 1s · 27s · out 998 · in 917',
254
+ timestamp: Date.now() - 1000,
255
+ },
256
+ }
257
+ );
258
+
259
+ handlers['session_start']?.({ reason: 'resume' }, fixture.mockCtx);
260
+ await tick();
261
+
262
+ expect(notifySpy).toHaveBeenCalledOnce();
263
+ const msg = notifySpy.mock.calls[0][0];
264
+ expect(msg).toContain('TPS 37.9');
265
+ });
266
+ });
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
3
+ import { unlinkSync, existsSync } from 'fs';
4
+ import { createTestFixture, activateExtension } from './helpers';
5
+
6
+ vi.mock('child_process', () => ({ execSync: vi.fn() }));
7
+
8
+ describe('pi-tps extension — session-export command', () => {
9
+ let fixture: ReturnType<typeof createTestFixture>;
10
+
11
+ const branchEntries = [
12
+ {
13
+ type: 'custom',
14
+ customType: 'tps',
15
+ data: { tps: 10 },
16
+ id: '1',
17
+ parentId: null,
18
+ timestamp: '2026-01-01T00:00:00Z',
19
+ },
20
+ {
21
+ type: 'message',
22
+ role: 'user',
23
+ content: 'hello',
24
+ id: '2',
25
+ parentId: '1',
26
+ timestamp: '2026-01-01T00:00:01Z',
27
+ },
28
+ {
29
+ type: 'model_change',
30
+ provider: 'openai',
31
+ modelId: 'gpt-4',
32
+ id: '3',
33
+ parentId: '2',
34
+ timestamp: '2026-01-01T00:00:02Z',
35
+ },
36
+ ];
37
+
38
+ const allEntries = [
39
+ ...branchEntries,
40
+ {
41
+ type: 'custom',
42
+ customType: 'tps',
43
+ data: { tps: 20 },
44
+ id: '4',
45
+ parentId: '3',
46
+ timestamp: '2026-01-01T00:00:03Z',
47
+ },
48
+ {
49
+ type: 'message',
50
+ role: 'assistant',
51
+ content: 'hi',
52
+ id: '5',
53
+ parentId: '4',
54
+ timestamp: '2026-01-01T00:00:04Z',
55
+ },
56
+ ];
57
+
58
+ beforeEach(async () => {
59
+ fixture = createTestFixture();
60
+ await activateExtension(fixture);
61
+ });
62
+
63
+ afterEach(() => {
64
+ for (const call of fixture.notifySpy.mock.calls) {
65
+ const msg = call[0] as string;
66
+ if (typeof msg === 'string' && msg.includes('→ ')) {
67
+ const filepath = msg.split('→ ')[1];
68
+ if (filepath && existsSync(filepath)) {
69
+ try {
70
+ unlinkSync(filepath);
71
+ } catch {
72
+ /* ignore */
73
+ }
74
+ }
75
+ }
76
+ }
77
+ vi.restoreAllMocks();
78
+ });
79
+
80
+ it('should export all entry types from current branch by default', async () => {
81
+ const exportCtx = {
82
+ ...fixture.mockCtx,
83
+ sessionManager: {
84
+ getBranch: vi.fn().mockReturnValue(branchEntries),
85
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
86
+ },
87
+ } as ExtensionCommandContext;
88
+
89
+ await fixture.commands['session-export'].handler('', exportCtx);
90
+
91
+ expect(fixture.notifySpy).toHaveBeenCalledOnce();
92
+ const msg = fixture.notifySpy.mock.calls[0][0] as string;
93
+ expect(msg).toContain('Exported 3 entries');
94
+ expect(msg).toContain('pi-session-branch-');
95
+ expect(msg).toContain('/pi-sessions/');
96
+ });
97
+
98
+ it('should export all entries from full session with --full flag', async () => {
99
+ const exportCtx = {
100
+ ...fixture.mockCtx,
101
+ sessionManager: {
102
+ getEntries: vi.fn().mockReturnValue(allEntries),
103
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
104
+ },
105
+ } as ExtensionCommandContext;
106
+
107
+ await fixture.commands['session-export'].handler('--full', exportCtx);
108
+
109
+ expect(fixture.notifySpy).toHaveBeenCalledOnce();
110
+ const msg = fixture.notifySpy.mock.calls[0][0] as string;
111
+ expect(msg).toContain('Exported 5 entries');
112
+ expect(msg).toContain('pi-session-full-');
113
+ });
114
+
115
+ it('should include all entry types — messages, custom, model_change', async () => {
116
+ const exportCtx = {
117
+ ...fixture.mockCtx,
118
+ sessionManager: {
119
+ getBranch: vi.fn().mockReturnValue(branchEntries),
120
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
121
+ },
122
+ } as ExtensionCommandContext;
123
+
124
+ await fixture.commands['session-export'].handler('', exportCtx);
125
+
126
+ const msg = fixture.notifySpy.mock.calls[0][0] as string;
127
+ const filepath = msg.split('→ ')[1];
128
+ const fs = await import('fs');
129
+ const content = fs.readFileSync(filepath, 'utf8');
130
+ const lines = content
131
+ .trim()
132
+ .split('\n')
133
+ .map((l: string) => JSON.parse(l));
134
+
135
+ const types = lines.map((l: any) => l.type);
136
+ expect(types).toContain('custom');
137
+ expect(types).toContain('message');
138
+ expect(types).toContain('model_change');
139
+ expect(lines).toHaveLength(3);
140
+ });
141
+
142
+ it('should preserve parentIds without re-chaining (full session export)', async () => {
143
+ const exportCtx = {
144
+ ...fixture.mockCtx,
145
+ sessionManager: {
146
+ getEntries: vi.fn().mockReturnValue(allEntries),
147
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
148
+ },
149
+ } as ExtensionCommandContext;
150
+
151
+ await fixture.commands['session-export'].handler('--full', exportCtx);
152
+
153
+ const msg = fixture.notifySpy.mock.calls[0][0] as string;
154
+ const filepath = msg.split('→ ')[1];
155
+ const fs = await import('fs');
156
+ const content = fs.readFileSync(filepath, 'utf8');
157
+ const lines = content
158
+ .trim()
159
+ .split('\n')
160
+ .map((l: string) => JSON.parse(l));
161
+
162
+ // Verify the parentId chain is preserved as-is
163
+ expect(lines.find((l: any) => l.id === '1').parentId).toBeNull();
164
+ expect(lines.find((l: any) => l.id === '2').parentId).toBe('1');
165
+ expect(lines.find((l: any) => l.id === '3').parentId).toBe('2');
166
+ expect(lines.find((l: any) => l.id === '4').parentId).toBe('3');
167
+ expect(lines.find((l: any) => l.id === '5').parentId).toBe('4');
168
+ });
169
+
170
+ it('should show warning when no entries found', async () => {
171
+ const exportCtx = {
172
+ ...fixture.mockCtx,
173
+ sessionManager: {
174
+ getBranch: vi.fn().mockReturnValue([]),
175
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
176
+ },
177
+ } as ExtensionCommandContext;
178
+
179
+ await fixture.commands['session-export'].handler('', exportCtx);
180
+
181
+ expect(fixture.notifySpy).toHaveBeenCalledOnce();
182
+ const msg = fixture.notifySpy.mock.calls[0][0] as string;
183
+ expect(msg).toContain('No entries found');
184
+ expect(msg).toContain('current-branch');
185
+ expect(fixture.notifySpy).toHaveBeenCalledWith(msg, 'warning');
186
+ });
187
+
188
+ it('should show warning with --full when no entries found', async () => {
189
+ const exportCtx = {
190
+ ...fixture.mockCtx,
191
+ sessionManager: {
192
+ getEntries: vi.fn().mockReturnValue([]),
193
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
194
+ },
195
+ } as ExtensionCommandContext;
196
+
197
+ await fixture.commands['session-export'].handler('--full', exportCtx);
198
+
199
+ expect(fixture.notifySpy).toHaveBeenCalledOnce();
200
+ const msg = fixture.notifySpy.mock.calls[0][0] as string;
201
+ expect(msg).toContain('No entries found');
202
+ expect(msg).toContain('all-entries');
203
+ });
204
+ });
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import type { AssistantMessage } from '@earendil-works/pi-ai';
3
+ import { createTestFixture, activateExtension, tick } from './helpers';
4
+ import type { TurnStartEvent, TurnEndEvent, MessageUpdateEvent } from './helpers';
5
+
6
+ describe('pi-tps extension — stall detection', () => {
7
+ let fixture: ReturnType<typeof createTestFixture>;
8
+
9
+ beforeEach(async () => {
10
+ fixture = createTestFixture();
11
+ await activateExtension(fixture);
12
+ });
13
+
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ it('should detect stalls between message_update events', async () => {
19
+ const { handlers, notifySpy, appendEntrySpy } = fixture;
20
+
21
+ const assistantMessage: AssistantMessage = {
22
+ role: 'assistant',
23
+ content: [{ type: 'text', text: 'Response with a stall' }],
24
+ api: 'openai-completions',
25
+ provider: 'deepseek',
26
+ model: 'deepseek-v4',
27
+ usage: {
28
+ input: 50,
29
+ output: 100,
30
+ cacheRead: 0,
31
+ cacheWrite: 0,
32
+ totalTokens: 150,
33
+ cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0, total: 0.003 },
34
+ },
35
+ stopReason: 'stop',
36
+ timestamp: Date.now(),
37
+ };
38
+
39
+ const updateEvent: MessageUpdateEvent = {
40
+ type: 'message_update',
41
+ message: assistantMessage,
42
+ assistantMessageEvent: { type: 'text_delta', delta: 't' },
43
+ };
44
+
45
+ handlers['turn_start']?.({ type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
46
+ await tick(200); // TTFT
47
+ handlers['message_start']?.({ type: 'message_start', message: assistantMessage });
48
+
49
+ // Normal streaming
50
+ handlers['message_update']?.(updateEvent);
51
+ await tick(100);
52
+ handlers['message_update']?.(updateEvent);
53
+
54
+ // STALL: 800ms gap (> 500ms threshold)
55
+ await tick(800);
56
+ handlers['message_update']?.(updateEvent);
57
+
58
+ // Normal streaming resumes
59
+ await tick(100);
60
+ handlers['message_update']?.(updateEvent);
61
+
62
+ // Another stall: 600ms gap
63
+ await tick(600);
64
+ handlers['message_update']?.(updateEvent);
65
+
66
+ await tick(50);
67
+ handlers['message_end']?.({ type: 'message_end', message: assistantMessage });
68
+ handlers['turn_end']?.(
69
+ { type: 'turn_end', turnIndex: 0, message: assistantMessage, toolResults: [] },
70
+ fixture.mockCtx
71
+ );
72
+
73
+ expect(notifySpy).toHaveBeenCalledOnce();
74
+ const notification = notifySpy.mock.calls[0][0] as string;
75
+
76
+ expect(notification).toMatch(/stall \d+\.\ds×2/);
77
+ expect(notification).toContain('TPS');
78
+
79
+ const [, data] = appendEntrySpy.mock.calls[0];
80
+ expect(data.timing.stallCount).toBe(2);
81
+ expect(data.timing.stallMs).toBeGreaterThanOrEqual(1300);
82
+ });
83
+
84
+ it('should not flag short gaps as stalls', async () => {
85
+ const { handlers, notifySpy, appendEntrySpy } = fixture;
86
+
87
+ const assistantMessage: AssistantMessage = {
88
+ role: 'assistant',
89
+ content: [{ type: 'text', text: 'Smooth' }],
90
+ api: 'openai-completions',
91
+ provider: 'openai',
92
+ model: 'gpt-4',
93
+ usage: {
94
+ input: 10,
95
+ output: 20,
96
+ cacheRead: 0,
97
+ cacheWrite: 0,
98
+ totalTokens: 30,
99
+ cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0, total: 0.003 },
100
+ },
101
+ stopReason: 'stop',
102
+ timestamp: Date.now(),
103
+ };
104
+
105
+ const updateEvent: MessageUpdateEvent = {
106
+ type: 'message_update',
107
+ message: assistantMessage,
108
+ assistantMessageEvent: { type: 'text_delta', delta: 't' },
109
+ };
110
+
111
+ handlers['turn_start']?.({ type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
112
+ await tick(100);
113
+ handlers['message_start']?.({ type: 'message_start', message: assistantMessage });
114
+
115
+ // Short gaps (< 500ms)
116
+ handlers['message_update']?.(updateEvent);
117
+ await tick(200);
118
+ handlers['message_update']?.(updateEvent);
119
+ await tick(300);
120
+ handlers['message_update']?.(updateEvent);
121
+ await tick(400); // borderline but < 500
122
+ handlers['message_update']?.(updateEvent);
123
+
124
+ await tick(50);
125
+ handlers['message_end']?.({ type: 'message_end', message: assistantMessage });
126
+ handlers['turn_end']?.(
127
+ { type: 'turn_end', turnIndex: 0, message: assistantMessage, toolResults: [] },
128
+ fixture.mockCtx
129
+ );
130
+
131
+ const notification = notifySpy.mock.calls[0][0] as string;
132
+ expect(notification).not.toContain('stall');
133
+
134
+ const [, data] = appendEntrySpy.mock.calls[0];
135
+ expect(data.timing.stallCount).toBe(0);
136
+ expect(data.timing.stallMs).toBe(0);
137
+ });
138
+
139
+ it('should not produce inflated TPS when stall occurs before first stream update', async () => {
140
+ // This is the bug scenario: a large gap after TTFT causes the stall
141
+ // detector to fire on the first streaming update, but that means
142
+ // firstStreamUpdateMs is set AFTER the stall, making streamMs tiny.
143
+ const { handlers, notifySpy, appendEntrySpy } = fixture;
144
+
145
+ const assistantMessage: AssistantMessage = {
146
+ role: 'assistant',
147
+ content: [{ type: 'text', text: 'Stall before stream' }],
148
+ api: 'openai-completions',
149
+ provider: 'deepseek',
150
+ model: 'deepseek-v4',
151
+ usage: {
152
+ input: 50,
153
+ output: 100,
154
+ cacheRead: 0,
155
+ cacheWrite: 0,
156
+ totalTokens: 150,
157
+ cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0, total: 0.003 },
158
+ },
159
+ stopReason: 'stop',
160
+ timestamp: Date.now(),
161
+ };
162
+
163
+ const updateEvent: MessageUpdateEvent = {
164
+ type: 'message_update',
165
+ message: assistantMessage,
166
+ assistantMessageEvent: { type: 'text_delta', delta: 't' },
167
+ };
168
+
169
+ handlers['turn_start']?.({ type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
170
+ await tick(100);
171
+ handlers['message_start']?.({ type: 'message_start', message: assistantMessage });
172
+ await tick(200);
173
+ handlers['message_update']?.(updateEvent); // TTFT
174
+
175
+ // Large gap (stall before any stream update)
176
+ await tick(3000);
177
+ handlers['message_update']?.(updateEvent); // First stream update (after stall)
178
+
179
+ // Short burst of updates
180
+ await tick(50);
181
+ handlers['message_update']?.(updateEvent);
182
+ await tick(50);
183
+ handlers['message_update']?.(updateEvent);
184
+ await tick(50);
185
+ handlers['message_update']?.(updateEvent);
186
+ await tick(50);
187
+ handlers['message_update']?.(updateEvent);
188
+
189
+ await tick(50);
190
+ handlers['message_end']?.({ type: 'message_end', message: assistantMessage });
191
+ handlers['turn_end']?.(
192
+ { type: 'turn_end', turnIndex: 0, message: assistantMessage, toolResults: [] },
193
+ fixture.mockCtx
194
+ );
195
+
196
+ expect(notifySpy).toHaveBeenCalledOnce();
197
+ const [, data] = appendEntrySpy.mock.calls[0];
198
+
199
+ // Verify stall was detected
200
+ expect(data.timing.stallMs).toBeGreaterThanOrEqual(2000);
201
+ expect(data.timing.stallCount).toBeGreaterThanOrEqual(1);
202
+
203
+ // TPS must be sane (not in the thousands)
204
+ // With stall guard: stallMs >= streamMs → fallback branch
205
+ // Fallback: effectiveGenMs = generationMs - stallMs
206
+ expect(data.tps).not.toBeNull();
207
+ expect(data.tps!).toBeLessThan(500); // sane, not thousands
208
+ });
209
+ });