@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,55 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
typecheck:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Setup Node.js
|
|
17
|
+
uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: '22'
|
|
20
|
+
cache: 'npm'
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: npm ci
|
|
24
|
+
|
|
25
|
+
- name: Type check
|
|
26
|
+
run: npm run typecheck
|
|
27
|
+
|
|
28
|
+
test:
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
needs: typecheck
|
|
31
|
+
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v4
|
|
34
|
+
|
|
35
|
+
- name: Setup Node.js
|
|
36
|
+
uses: actions/setup-node@v4
|
|
37
|
+
with:
|
|
38
|
+
node-version: '22'
|
|
39
|
+
cache: 'npm'
|
|
40
|
+
|
|
41
|
+
- name: Install dependencies
|
|
42
|
+
run: npm ci
|
|
43
|
+
|
|
44
|
+
- name: Run tests
|
|
45
|
+
run: npm test
|
|
46
|
+
|
|
47
|
+
- name: Run tests with coverage
|
|
48
|
+
run: npm run test:coverage
|
|
49
|
+
|
|
50
|
+
- name: Upload coverage
|
|
51
|
+
uses: actions/upload-artifact@v4
|
|
52
|
+
with:
|
|
53
|
+
name: coverage
|
|
54
|
+
path: coverage/
|
|
55
|
+
if: always()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
019e1a55-4f8b-7797-849c-eb2b21f4fc7a
|
package/.prettierrc
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 📊 pi-tps
|
|
4
|
+
|
|
5
|
+
**Tokens-per-second tracker for [pi](https://github.com/earendil-works/pi-coding-agent)**
|
|
6
|
+
|
|
7
|
+
_Generation speed, TTFT, stall detection, and cost — after every agent turn._
|
|
8
|
+
|
|
9
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
_Originally from [badlogic/pi-mono](https://github.com/badlogic/pi-mono/blob/main/.pi/extensions/tps.ts). Packaged as an installable pi extension._
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pi install https://github.com/monotykamary/pi-tps
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What's included
|
|
27
|
+
|
|
28
|
+
| | |
|
|
29
|
+
| ------------- | ---------------------------------------------------------------------- |
|
|
30
|
+
| **Extension** | Tracks TPS, TTFT, stall time, token usage, and cost after each turn |
|
|
31
|
+
| **Export** | `/tps-export` command — dump telemetry as JSONL with session structure |
|
|
32
|
+
|
|
33
|
+
### Features
|
|
34
|
+
|
|
35
|
+
- **Accurate TPS**: Uses `performance.now()` sub-millisecond timing; excludes TTFT, tool-execution gaps, and network latency from generation speed
|
|
36
|
+
- **Stall detection**: Detects inference pauses (GPU queuing, request queuing) and subtracts them from generation TPS — no inflated rates
|
|
37
|
+
- **Burst discrimination**: Distinguishes genuine streaming from buffer-flush dispatch; shows `—` when the rate is structurally unidentifiable
|
|
38
|
+
- **Multi-message turns**: Aggregates tokens and timing across tool-call chains within one turn
|
|
39
|
+
- **Notification banner**: Shows a transient popup with TPS, TTFT, total time, tokens, and stalls
|
|
40
|
+
- **Persisted notifications**: Restored on session resume and `/tree` navigation (structured + legacy backward compatible)
|
|
41
|
+
- **Export command**: Dump telemetry as JSONL with automatic tree re-chaining for web inspectors
|
|
42
|
+
- **Extensible**: Emits `tps:telemetry` events so other extensions can react to telemetry
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pi install https://github.com/monotykamary/pi-tps
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
<details>
|
|
51
|
+
<summary>Manual install</summary>
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cp -r extensions/pi-tps ~/.pi/agent/extensions/
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Then `/reload` in pi.
|
|
58
|
+
|
|
59
|
+
</details>
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Output format
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
TPS 42.5 tok/s · TTFT 1.2s · 29.7s · in 567 · out 1.2K · stall 4.3s×1
|
|
67
|
+
```
|
|
68
|
+
|
|
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) |
|
|
77
|
+
|
|
78
|
+
When TPS can't be determined (burst delivery, too few chunks), the field shows `—`:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
TPS — · TTFT 0.8s · 1.3s · in 291 · out 46
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Human-readable scaling (for token counts):
|
|
85
|
+
|
|
86
|
+
- `< 1K`: raw integer (`567`)
|
|
87
|
+
- `≥ 1K`: one decimal, drops `.0` (`1.2K`, `2K`, `15.3K`)
|
|
88
|
+
- `≥ 1M`: same pattern (`1.5M`)
|
|
89
|
+
- `≥ 1B`: same pattern (`1.2B`)
|
|
90
|
+
|
|
91
|
+
Duration formatting:
|
|
92
|
+
|
|
93
|
+
- `< 60s`: one decimal (`2.3s`, `45.0s`)
|
|
94
|
+
- `≥ 60s`: up to two units with no decimals (`1m 30s`, `2h 15m`, `3d 12h`, `1w 3d`, `1mo 0d`, `1y 0d`)
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## How it works
|
|
99
|
+
|
|
100
|
+
The extension hooks into pi's lifecycle events. The critical detail: `message_start` fires at stream creation (before any tokens), so **TTFT is measured at the first `message_update`**, which carries the first real token content.
|
|
101
|
+
|
|
102
|
+
### Event sequence
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
turn_start → request sent to LLM, timer starts
|
|
106
|
+
message_start → stream created, stall-tracking reset for this message
|
|
107
|
+
message_update (1) → first token arrives → TTFT captured
|
|
108
|
+
message_update (N) → streaming tokens arrive → inter-update span & stall detection
|
|
109
|
+
message_end → message complete, generation time accumulated
|
|
110
|
+
turn_end → telemetry computed and displayed
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Timing breakdown
|
|
114
|
+
|
|
115
|
+
| Phase | Measured by |
|
|
116
|
+
| --------------- | -------------------------------------------------------------------------------------------- |
|
|
117
|
+
| **TTFT** | `turn_start` → first `message_update` |
|
|
118
|
+
| **Generation** | per-message wall clock (`message_start` → `message_end`), summed across messages in the turn |
|
|
119
|
+
| **Stream span** | first `message_update` (post-TTFT) → last `message_update` — the pure streaming window |
|
|
120
|
+
| **Total** | `turn_start` → last `message_end` in the turn |
|
|
121
|
+
|
|
122
|
+
This approach excludes:
|
|
123
|
+
|
|
124
|
+
- **Network latency** (included in TTFT)
|
|
125
|
+
- **Tool-execution gaps** between messages (stall clock resets on each `message_start`)
|
|
126
|
+
- **Server queue time** (included in TTFT)
|
|
127
|
+
|
|
128
|
+
### Stall detection
|
|
129
|
+
|
|
130
|
+
Every `message_update` (after TTFT) measures the gap since the last update. Gaps ≥ 500ms are classified as inference stalls:
|
|
131
|
+
|
|
132
|
+
- The full gap is accumulated as `stallMs`
|
|
133
|
+
- Consecutive stalled updates count as one stall event
|
|
134
|
+
- Stalls are subtracted from the streaming window when computing generation TPS
|
|
135
|
+
- The stall clock resets at each `message_start`, so tool-execution gaps between messages are never counted as stalls
|
|
136
|
+
|
|
137
|
+
When a stall occurs **before** the first stream update (common in request-queuing scenarios), the TPS algorithm detects the artifact and falls back to a conservative estimate rather than producing an inflated rate.
|
|
138
|
+
|
|
139
|
+
### TPS algorithm (three-branch gate)
|
|
140
|
+
|
|
141
|
+
The extension uses a defense-in-depth strategy to produce reliable TPS:
|
|
142
|
+
|
|
143
|
+
1. **Primary** — Requires ≥5 streaming updates with ≥1ms average inter-chunk gap and stall time < active generation time. Subtracts stalls from the streaming window for pure generation speed.
|
|
144
|
+
|
|
145
|
+
2. **Fallback** — When primary conditions fail but ≥2 updates exist and total generation time ≥50ms. Uses the full generation window (includes TTFT, so it underestimates — safe by design). Applies partial stall reduction when stalls dominate.
|
|
146
|
+
|
|
147
|
+
3. **Null** — Returns `null` (displayed as `—`) when the timing is structurally unidentifiable: burst delivery (all tokens arrive in the same tick), too few chunks, or generation time too short for a reliable rate.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Rehydration
|
|
152
|
+
|
|
153
|
+
When you resume a session (or navigate branches with `/tree`), pi-tps restores the most recent TPS notification — so you can see your last turn's stats after a reload.
|
|
154
|
+
|
|
155
|
+
Supports both the current structured `TurnTelemetry` format and legacy `{ message, timestamp }` entries for backward compatibility with session files created by earlier versions.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Export command
|
|
160
|
+
|
|
161
|
+
Dump telemetry as JSONL for inspection or analysis:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
/tps-export # current branch, all custom entries
|
|
165
|
+
/tps-export --full # all branches in the session
|
|
166
|
+
/tps-export tps # current branch, filter by customType "tps"
|
|
167
|
+
/tps-export tps --full # all branches, filter by customType "tps"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Each exported file is written to `~/.cache/pi-telemetry/pi-telemetry-{scope}-{sessionId}-{timestamp}.jsonl`.
|
|
171
|
+
|
|
172
|
+
The exporter includes **structural entries** (model_change, branch_summary) alongside telemetry entries so the exported tree is fully resolvable — the web inspector can show model switches and branch points. Parent IDs are automatically re-chained to point to the nearest ancestor that's included in the export, producing a self-contained tree.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Telemetry event
|
|
177
|
+
|
|
178
|
+
After each turn, pi-tps emits a `tps:telemetry` event on pi's shared event bus. Other extensions can listen to build custom widgets, dashboards, or cost trackers.
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
pi.events.on('tps:telemetry', (data) => {
|
|
182
|
+
// data matches the TurnTelemetry structure below
|
|
183
|
+
console.log(data.tps, data.tokens, data.timing);
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The event payload:
|
|
188
|
+
|
|
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 |
|
|
212
|
+
|
|
213
|
+
When `cost` is unavailable (provider doesn't report it), the entire `cost` object is `null`.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Testing
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
# Install dependencies
|
|
221
|
+
npm install
|
|
222
|
+
|
|
223
|
+
# Run tests
|
|
224
|
+
npm test
|
|
225
|
+
|
|
226
|
+
# Run tests with coverage
|
|
227
|
+
npm run test:coverage
|
|
228
|
+
|
|
229
|
+
# Type check
|
|
230
|
+
npm run typecheck
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = { extends: ['@commitlint/config-conventional'] };
|
|
@@ -0,0 +1,307 @@
|
|
|
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, tick } from './helpers';
|
|
5
|
+
|
|
6
|
+
vi.mock('child_process', () => ({ execSync: vi.fn() }));
|
|
7
|
+
|
|
8
|
+
describe('pi-tps extension — 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: 'custom',
|
|
22
|
+
customType: 'neuralwatt-energy',
|
|
23
|
+
data: { energy_joules: 100 },
|
|
24
|
+
id: '2',
|
|
25
|
+
parentId: null,
|
|
26
|
+
timestamp: '2026-01-01T00:00:01Z',
|
|
27
|
+
},
|
|
28
|
+
{ type: 'message', role: 'user', content: 'hello' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const allEntries = [
|
|
32
|
+
...branchEntries,
|
|
33
|
+
{
|
|
34
|
+
type: 'custom',
|
|
35
|
+
customType: 'tps',
|
|
36
|
+
data: { tps: 20 },
|
|
37
|
+
id: '3',
|
|
38
|
+
parentId: null,
|
|
39
|
+
timestamp: '2026-01-01T00:00:02Z',
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
fixture = createTestFixture();
|
|
45
|
+
await activateExtension(fixture);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
// Clean up any pi-telemetry files written by the export handler
|
|
50
|
+
for (const call of fixture.notifySpy.mock.calls) {
|
|
51
|
+
const msg = call[0] as string;
|
|
52
|
+
if (typeof msg === 'string' && msg.includes('→ ')) {
|
|
53
|
+
const filepath = msg.split('→ ')[1];
|
|
54
|
+
if (filepath && existsSync(filepath)) {
|
|
55
|
+
try {
|
|
56
|
+
unlinkSync(filepath);
|
|
57
|
+
} catch {
|
|
58
|
+
/* ignore */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
vi.restoreAllMocks();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should export current branch custom entries by default', async () => {
|
|
67
|
+
const exportCtx = {
|
|
68
|
+
...fixture.mockCtx,
|
|
69
|
+
sessionManager: {
|
|
70
|
+
getBranch: vi.fn().mockReturnValue(branchEntries),
|
|
71
|
+
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
72
|
+
},
|
|
73
|
+
} as ExtensionCommandContext;
|
|
74
|
+
|
|
75
|
+
await fixture.commands['tps-export'].handler('', exportCtx);
|
|
76
|
+
|
|
77
|
+
expect(fixture.notifySpy).toHaveBeenCalledOnce();
|
|
78
|
+
const msg = fixture.notifySpy.mock.calls[0][0] as string;
|
|
79
|
+
expect(msg).toContain('Exported 2 telemetry');
|
|
80
|
+
expect(msg).toContain('pi-telemetry-branch-');
|
|
81
|
+
expect(msg).toContain('/pi-telemetry/');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should export full session with --full flag', async () => {
|
|
85
|
+
const exportCtx = {
|
|
86
|
+
...fixture.mockCtx,
|
|
87
|
+
sessionManager: {
|
|
88
|
+
getEntries: vi.fn().mockReturnValue(allEntries),
|
|
89
|
+
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
90
|
+
},
|
|
91
|
+
} as ExtensionCommandContext;
|
|
92
|
+
|
|
93
|
+
await fixture.commands['tps-export'].handler('--full', exportCtx);
|
|
94
|
+
|
|
95
|
+
expect(fixture.notifySpy).toHaveBeenCalledOnce();
|
|
96
|
+
const msg = fixture.notifySpy.mock.calls[0][0] as string;
|
|
97
|
+
expect(msg).toContain('Exported 3 telemetry');
|
|
98
|
+
expect(msg).toContain('pi-telemetry-full-');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should combine --full with customType filter', async () => {
|
|
102
|
+
const exportCtx = {
|
|
103
|
+
...fixture.mockCtx,
|
|
104
|
+
sessionManager: {
|
|
105
|
+
getEntries: vi.fn().mockReturnValue(allEntries),
|
|
106
|
+
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
107
|
+
},
|
|
108
|
+
} as ExtensionCommandContext;
|
|
109
|
+
|
|
110
|
+
await fixture.commands['tps-export'].handler('tps --full', exportCtx);
|
|
111
|
+
|
|
112
|
+
expect(fixture.notifySpy).toHaveBeenCalledOnce();
|
|
113
|
+
const msg = fixture.notifySpy.mock.calls[0][0] as string;
|
|
114
|
+
expect(msg).toContain('Exported 2 telemetry');
|
|
115
|
+
expect(msg).toContain('pi-telemetry-full-tps-');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should filter branch by customType', async () => {
|
|
119
|
+
const exportCtx = {
|
|
120
|
+
...fixture.mockCtx,
|
|
121
|
+
sessionManager: {
|
|
122
|
+
getBranch: vi.fn().mockReturnValue(branchEntries),
|
|
123
|
+
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
124
|
+
},
|
|
125
|
+
} as ExtensionCommandContext;
|
|
126
|
+
|
|
127
|
+
await fixture.commands['tps-export'].handler('tps', exportCtx);
|
|
128
|
+
|
|
129
|
+
expect(fixture.notifySpy).toHaveBeenCalledOnce();
|
|
130
|
+
const msg = fixture.notifySpy.mock.calls[0][0] as string;
|
|
131
|
+
expect(msg).toContain('Exported 1 telemetry');
|
|
132
|
+
expect(msg).toContain('pi-telemetry-branch-tps-');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should show warning when no matching entries found', async () => {
|
|
136
|
+
const exportCtx = {
|
|
137
|
+
...fixture.mockCtx,
|
|
138
|
+
sessionManager: {
|
|
139
|
+
getBranch: vi.fn().mockReturnValue([{ type: 'message', role: 'user', content: 'hello' }]),
|
|
140
|
+
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
141
|
+
},
|
|
142
|
+
} as ExtensionCommandContext;
|
|
143
|
+
|
|
144
|
+
await fixture.commands['tps-export'].handler('nonexistent', exportCtx);
|
|
145
|
+
|
|
146
|
+
expect(fixture.notifySpy).toHaveBeenCalledOnce();
|
|
147
|
+
const msg = fixture.notifySpy.mock.calls[0][0] as string;
|
|
148
|
+
expect(msg).toContain('No matching entries found');
|
|
149
|
+
expect(msg).toContain('current-branch');
|
|
150
|
+
expect(fixture.notifySpy).toHaveBeenCalledWith(msg, 'warning');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should use exact customType match (neuralwatt-energy, not energy)', async () => {
|
|
154
|
+
const exportCtx = {
|
|
155
|
+
...fixture.mockCtx,
|
|
156
|
+
sessionManager: {
|
|
157
|
+
getBranch: vi.fn().mockReturnValue([
|
|
158
|
+
{
|
|
159
|
+
type: 'custom',
|
|
160
|
+
customType: 'neuralwatt-energy',
|
|
161
|
+
data: { energy_joules: 100 },
|
|
162
|
+
id: '1',
|
|
163
|
+
parentId: null,
|
|
164
|
+
timestamp: '2026-01-01T00:00:00Z',
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
type: 'custom',
|
|
168
|
+
customType: 'energy',
|
|
169
|
+
data: { joules: 50 },
|
|
170
|
+
id: '2',
|
|
171
|
+
parentId: null,
|
|
172
|
+
timestamp: '2026-01-01T00:00:01Z',
|
|
173
|
+
},
|
|
174
|
+
]),
|
|
175
|
+
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
176
|
+
},
|
|
177
|
+
} as ExtensionCommandContext;
|
|
178
|
+
|
|
179
|
+
await fixture.commands['tps-export'].handler('neuralwatt-energy', exportCtx);
|
|
180
|
+
|
|
181
|
+
expect(fixture.notifySpy).toHaveBeenCalledOnce();
|
|
182
|
+
const msg = fixture.notifySpy.mock.calls[0][0] as string;
|
|
183
|
+
expect(msg).toContain('Exported 1 telemetry');
|
|
184
|
+
expect(msg).toContain('pi-telemetry-branch-neuralwatt-energy-');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should include model_change entries and re-chain parentIds', async () => {
|
|
188
|
+
const entriesWithModelChange = [
|
|
189
|
+
{
|
|
190
|
+
type: 'model_change',
|
|
191
|
+
id: 'mc1',
|
|
192
|
+
parentId: null,
|
|
193
|
+
timestamp: '2026-01-01T00:00:00Z',
|
|
194
|
+
provider: 'test',
|
|
195
|
+
modelId: 'test-model',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
type: 'message',
|
|
199
|
+
id: 'msg1',
|
|
200
|
+
parentId: 'mc1',
|
|
201
|
+
timestamp: '2026-01-01T00:00:01Z',
|
|
202
|
+
role: 'user',
|
|
203
|
+
content: 'hello',
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
type: 'custom',
|
|
207
|
+
customType: 'tps',
|
|
208
|
+
data: { tps: 10 },
|
|
209
|
+
id: 'tps1',
|
|
210
|
+
parentId: 'msg1',
|
|
211
|
+
timestamp: '2026-01-01T00:00:02Z',
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
type: 'message',
|
|
215
|
+
id: 'msg2',
|
|
216
|
+
parentId: 'tps1',
|
|
217
|
+
timestamp: '2026-01-01T00:00:03Z',
|
|
218
|
+
role: 'assistant',
|
|
219
|
+
content: 'hi',
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
type: 'model_change',
|
|
223
|
+
id: 'mc2',
|
|
224
|
+
parentId: 'msg2',
|
|
225
|
+
timestamp: '2026-01-01T00:00:04Z',
|
|
226
|
+
provider: 'other',
|
|
227
|
+
modelId: 'other-model',
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
type: 'custom',
|
|
231
|
+
customType: 'tps',
|
|
232
|
+
data: { tps: 20 },
|
|
233
|
+
id: 'tps2',
|
|
234
|
+
parentId: 'mc2',
|
|
235
|
+
timestamp: '2026-01-01T00:00:05Z',
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
const exportCtx = {
|
|
239
|
+
...fixture.mockCtx,
|
|
240
|
+
sessionManager: {
|
|
241
|
+
getBranch: vi.fn().mockReturnValue(entriesWithModelChange),
|
|
242
|
+
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
243
|
+
},
|
|
244
|
+
} as ExtensionCommandContext;
|
|
245
|
+
|
|
246
|
+
await fixture.commands['tps-export'].handler('', exportCtx);
|
|
247
|
+
|
|
248
|
+
expect(fixture.notifySpy).toHaveBeenCalledOnce();
|
|
249
|
+
const msg = fixture.notifySpy.mock.calls[0][0] as string;
|
|
250
|
+
expect(msg).toContain('2 telemetry + 2 structural');
|
|
251
|
+
|
|
252
|
+
const filepath = msg.split('→ ')[1];
|
|
253
|
+
const fs = await import('fs');
|
|
254
|
+
const content = fs.readFileSync(filepath, 'utf8');
|
|
255
|
+
const lines = content
|
|
256
|
+
.trim()
|
|
257
|
+
.split('\n')
|
|
258
|
+
.map((l: string) => JSON.parse(l));
|
|
259
|
+
|
|
260
|
+
expect(lines.find((l: any) => l.id === 'mc1').parentId).toBeNull();
|
|
261
|
+
expect(lines.find((l: any) => l.id === 'tps1').parentId).toBe('mc1');
|
|
262
|
+
expect(lines.find((l: any) => l.id === 'mc2').parentId).toBe('tps1');
|
|
263
|
+
expect(lines.find((l: any) => l.id === 'tps2').parentId).toBe('mc2');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should include structural entries even with customType filter', async () => {
|
|
267
|
+
const entriesWithModelChange = [
|
|
268
|
+
{
|
|
269
|
+
type: 'model_change',
|
|
270
|
+
id: 'mc1',
|
|
271
|
+
parentId: null,
|
|
272
|
+
timestamp: '2026-01-01T00:00:00Z',
|
|
273
|
+
provider: 'test',
|
|
274
|
+
modelId: 'test-model',
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
type: 'custom',
|
|
278
|
+
customType: 'tps',
|
|
279
|
+
data: { tps: 10 },
|
|
280
|
+
id: 'tps1',
|
|
281
|
+
parentId: 'mc1',
|
|
282
|
+
timestamp: '2026-01-01T00:00:01Z',
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
type: 'custom',
|
|
286
|
+
customType: 'neuralwatt-energy',
|
|
287
|
+
data: { energy_joules: 100 },
|
|
288
|
+
id: 'ne1',
|
|
289
|
+
parentId: 'tps1',
|
|
290
|
+
timestamp: '2026-01-01T00:00:02Z',
|
|
291
|
+
},
|
|
292
|
+
];
|
|
293
|
+
const exportCtx = {
|
|
294
|
+
...fixture.mockCtx,
|
|
295
|
+
sessionManager: {
|
|
296
|
+
getBranch: vi.fn().mockReturnValue(entriesWithModelChange),
|
|
297
|
+
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
298
|
+
},
|
|
299
|
+
} as ExtensionCommandContext;
|
|
300
|
+
|
|
301
|
+
await fixture.commands['tps-export'].handler('tps', exportCtx);
|
|
302
|
+
|
|
303
|
+
expect(fixture.notifySpy).toHaveBeenCalledOnce();
|
|
304
|
+
const msg = fixture.notifySpy.mock.calls[0][0] as string;
|
|
305
|
+
expect(msg).toContain('1 telemetry + 1 structural');
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createTestFixture, activateExtension } from './helpers';
|
|
3
|
+
|
|
4
|
+
describe('pi-tps extension — setup', () => {
|
|
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
|
+
it('should register all required event handlers and commands', () => {
|
|
17
|
+
const { mockPi, registerCommandSpy } = fixture;
|
|
18
|
+
|
|
19
|
+
expect(mockPi.on).toHaveBeenCalledWith('session_start', expect.any(Function));
|
|
20
|
+
expect(mockPi.on).toHaveBeenCalledWith('session_tree', expect.any(Function));
|
|
21
|
+
expect(mockPi.on).toHaveBeenCalledWith('turn_start', expect.any(Function));
|
|
22
|
+
expect(mockPi.on).toHaveBeenCalledWith('message_start', expect.any(Function));
|
|
23
|
+
expect(mockPi.on).toHaveBeenCalledWith('message_update', expect.any(Function));
|
|
24
|
+
expect(mockPi.on).toHaveBeenCalledWith('message_end', expect.any(Function));
|
|
25
|
+
expect(mockPi.on).toHaveBeenCalledWith('turn_end', expect.any(Function));
|
|
26
|
+
expect(registerCommandSpy).toHaveBeenCalledWith(
|
|
27
|
+
'tps-export',
|
|
28
|
+
expect.objectContaining({
|
|
29
|
+
description: expect.any(String),
|
|
30
|
+
handler: expect.any(Function),
|
|
31
|
+
})
|
|
32
|
+
);
|
|
33
|
+
expect(registerCommandSpy).toHaveBeenCalledWith(
|
|
34
|
+
'session-export',
|
|
35
|
+
expect.objectContaining({
|
|
36
|
+
description: expect.any(String),
|
|
37
|
+
handler: expect.any(Function),
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|