@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.
- package/.github/FUNDING.yml +4 -0
- package/.github/workflows/test.yml +55 -0
- package/.pi/autoresearch/session-id +1 -0
- package/.prettierrc +7 -0
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/commitlint.config.cjs +1 -0
- package/extensions/pi-tps/__tests__/export-command.test.ts +307 -0
- package/extensions/pi-tps/__tests__/extension-setup.test.ts +41 -0
- package/extensions/pi-tps/__tests__/format-duration.test.ts +83 -0
- package/extensions/pi-tps/__tests__/helpers.ts +154 -0
- package/extensions/pi-tps/__tests__/precision-timing.test.ts +701 -0
- package/extensions/pi-tps/__tests__/rehydration.test.ts +266 -0
- package/extensions/pi-tps/__tests__/session-export.test.ts +204 -0
- package/extensions/pi-tps/__tests__/stall-detection.test.ts +209 -0
- package/extensions/pi-tps/__tests__/stall-reduction.test.ts +139 -0
- package/extensions/pi-tps/__tests__/telemetry-flow.test.ts +654 -0
- package/extensions/pi-tps/index.ts +734 -0
- package/knip.json +10 -0
- package/npm-shrinkwrap.json +6923 -0
- package/package.json +54 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +15 -0
|
@@ -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
|
+
});
|