@monotykamary/pi-ledger 0.2.0 → 0.4.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/README.md +6 -2
- package/extensions/pi-ledger/__tests__/helpers.ts +8 -1
- package/extensions/pi-ledger/__tests__/in-process-session-observer.test.ts +555 -0
- package/extensions/pi-ledger/__tests__/ledger.test.ts +125 -0
- package/extensions/pi-ledger/__tests__/nested-agent-telemetry.test.ts +867 -0
- package/extensions/pi-ledger/__tests__/subprocess-observer.test.ts +364 -0
- package/extensions/pi-ledger/in-process-session-observer.ts +890 -0
- package/extensions/pi-ledger/index.ts +121 -1
- package/extensions/pi-ledger/nested-agent-telemetry.ts +1271 -0
- package/extensions/pi-ledger/subprocess-observer.ts +694 -0
- package/package.json +1 -1
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
classifyPiSubprocess,
|
|
5
|
+
installSubprocessTelemetryObserver,
|
|
6
|
+
type DiagnosticsChannelLike,
|
|
7
|
+
type SubprocessAssistantUsageEvent,
|
|
8
|
+
type SubprocessLifecycleEvent,
|
|
9
|
+
type SubprocessMalformedLineEvent,
|
|
10
|
+
type SubprocessToolIntervalEvent,
|
|
11
|
+
} from '../subprocess-observer';
|
|
12
|
+
|
|
13
|
+
class FakeChannel implements DiagnosticsChannelLike {
|
|
14
|
+
readonly listeners = new Set<(message: unknown, name?: string | symbol) => void>();
|
|
15
|
+
|
|
16
|
+
subscribe(listener: (message: unknown, name?: string | symbol) => void): void {
|
|
17
|
+
this.listeners.add(listener);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
unsubscribe(listener: (message: unknown, name?: string | symbol) => void): boolean {
|
|
21
|
+
return this.listeners.delete(listener);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
publish(child: FakeChild): void {
|
|
25
|
+
for (const listener of [...this.listeners]) listener({ process: child }, 'child_process');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class FakeChild extends EventEmitter {
|
|
30
|
+
pid = 4242;
|
|
31
|
+
spawnfile: string | null = null;
|
|
32
|
+
spawnargs: string[] | undefined;
|
|
33
|
+
|
|
34
|
+
constructor(readonly stdout: EventEmitter | null = new EventEmitter()) {
|
|
35
|
+
super();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
spawn(spawnfile: string, spawnargs: string[]): void {
|
|
39
|
+
this.spawnfile = spawnfile;
|
|
40
|
+
this.spawnargs = spawnargs;
|
|
41
|
+
this.emit('spawn');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assistantLine(overrides: Record<string, unknown> = {}): string {
|
|
46
|
+
return `${JSON.stringify({
|
|
47
|
+
type: 'message_end',
|
|
48
|
+
message: {
|
|
49
|
+
role: 'assistant',
|
|
50
|
+
content: [{ type: 'text', text: 'private 😀 response text' }],
|
|
51
|
+
provider: 'openai',
|
|
52
|
+
model: 'gpt-child',
|
|
53
|
+
responseId: 'response-1',
|
|
54
|
+
usage: {
|
|
55
|
+
input: 120,
|
|
56
|
+
output: 30,
|
|
57
|
+
cacheRead: 10,
|
|
58
|
+
cacheWrite: 5,
|
|
59
|
+
reasoning: 4,
|
|
60
|
+
totalTokens: 165,
|
|
61
|
+
cost: { total: 99 },
|
|
62
|
+
},
|
|
63
|
+
timestamp: 1234,
|
|
64
|
+
...overrides,
|
|
65
|
+
},
|
|
66
|
+
})}\n`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function rpcChild(
|
|
70
|
+
channel: FakeChannel,
|
|
71
|
+
stdout: EventEmitter | null = new EventEmitter()
|
|
72
|
+
): FakeChild {
|
|
73
|
+
const child = new FakeChild(stdout);
|
|
74
|
+
channel.publish(child);
|
|
75
|
+
child.spawn('/usr/local/bin/pi', ['/usr/local/bin/pi', '--mode', 'rpc']);
|
|
76
|
+
return child;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function send(stdout: EventEmitter, value: unknown): void {
|
|
80
|
+
stdout.emit('data', Buffer.from(`${JSON.stringify(value)}\n`));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('classifyPiSubprocess', () => {
|
|
84
|
+
it('recognizes Pi RPC and JSON launch forms conservatively', () => {
|
|
85
|
+
expect(classifyPiSubprocess('/opt/bin/pi', ['/opt/bin/pi', '--mode', 'rpc'])).toBe('rpc');
|
|
86
|
+
expect(classifyPiSubprocess('pi.exe', ['pi.exe', '--mode=json'])).toBe('json');
|
|
87
|
+
expect(
|
|
88
|
+
classifyPiSubprocess('node', [
|
|
89
|
+
'node',
|
|
90
|
+
'/repo/node_modules/@earendil-works/pi-coding-agent/dist/cli.js',
|
|
91
|
+
'--mode',
|
|
92
|
+
'rpc',
|
|
93
|
+
])
|
|
94
|
+
).toBe('rpc');
|
|
95
|
+
expect(
|
|
96
|
+
classifyPiSubprocess('node', [
|
|
97
|
+
'node',
|
|
98
|
+
'/repo/node_modules/@earendil-works/pi-coding-agent/dist/rpc-entry.js',
|
|
99
|
+
])
|
|
100
|
+
).toBe('rpc');
|
|
101
|
+
expect(classifyPiSubprocess('npx', ['npx', 'pi', '--mode=json'])).toBe('json');
|
|
102
|
+
|
|
103
|
+
expect(classifyPiSubprocess('pi', ['pi'])).toBeUndefined();
|
|
104
|
+
expect(
|
|
105
|
+
classifyPiSubprocess('node', ['node', '/tmp/worker.js', '--mode', 'rpc'])
|
|
106
|
+
).toBeUndefined();
|
|
107
|
+
expect(classifyPiSubprocess('other', ['other', 'pi', '--mode', 'json'])).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('subprocess JSONL observation', () => {
|
|
112
|
+
it('parses chunks and split UTF-8 while recovering from malformed and overflow lines', () => {
|
|
113
|
+
const channel = new FakeChannel();
|
|
114
|
+
const usage: SubprocessAssistantUsageEvent[] = [];
|
|
115
|
+
const malformed: SubprocessMalformedLineEvent[] = [];
|
|
116
|
+
const handle = installSubprocessTelemetryObserver({
|
|
117
|
+
maxLineBytes: 512,
|
|
118
|
+
dependencies: { channel, now: () => 9000 },
|
|
119
|
+
onAssistantUsage: (event) => usage.push(event),
|
|
120
|
+
onMalformedLine: (event) => malformed.push(event),
|
|
121
|
+
});
|
|
122
|
+
const child = rpcChild(channel);
|
|
123
|
+
const stdout = child.stdout!;
|
|
124
|
+
const encoded = Buffer.from(assistantLine());
|
|
125
|
+
const split = encoded.indexOf(Buffer.from('😀')) + 2;
|
|
126
|
+
|
|
127
|
+
stdout.emit('data', encoded.subarray(0, split));
|
|
128
|
+
stdout.emit('data', encoded.subarray(split));
|
|
129
|
+
stdout.emit('data', Buffer.from('{not-json}\n'));
|
|
130
|
+
stdout.emit('data', Buffer.alloc(700, 'x'));
|
|
131
|
+
stdout.emit('data', Buffer.from(`\n${assistantLine({ responseId: 'response-2' })}`));
|
|
132
|
+
|
|
133
|
+
expect(usage).toHaveLength(2);
|
|
134
|
+
expect(usage[0]).toEqual({
|
|
135
|
+
type: 'assistant_usage',
|
|
136
|
+
pid: 4242,
|
|
137
|
+
mode: 'rpc',
|
|
138
|
+
timestamp: 1234,
|
|
139
|
+
model: { provider: 'openai', modelId: 'gpt-child' },
|
|
140
|
+
responseId: 'response-1',
|
|
141
|
+
usage: {
|
|
142
|
+
input: 120,
|
|
143
|
+
output: 30,
|
|
144
|
+
cacheRead: 10,
|
|
145
|
+
cacheWrite: 5,
|
|
146
|
+
reasoning: 4,
|
|
147
|
+
totalTokens: 165,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
expect(usage[1]!.responseId).toBe('response-2');
|
|
151
|
+
expect(malformed.map(({ reason }) => reason)).toEqual(['invalid_json', 'line_too_long']);
|
|
152
|
+
expect(malformed[1]!.lineBytes).toBe(700);
|
|
153
|
+
expect(JSON.stringify({ usage, malformed })).not.toContain('private');
|
|
154
|
+
expect(JSON.stringify({ usage, malformed })).not.toContain('😀');
|
|
155
|
+
handle.unsubscribe();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('preserves the stdout consumer, exact emit call contract, and passive stream state', () => {
|
|
159
|
+
const channel = new FakeChannel();
|
|
160
|
+
const usage = vi.fn();
|
|
161
|
+
const stdout = new EventEmitter();
|
|
162
|
+
const consumer = vi.fn();
|
|
163
|
+
stdout.on('data', consumer);
|
|
164
|
+
const calls: Array<{ receiver: unknown; args: unknown[] }> = [];
|
|
165
|
+
const returnValue = { exact: true };
|
|
166
|
+
const baseEmit = stdout.emit;
|
|
167
|
+
const original = function (this: unknown, ...args: unknown[]): unknown {
|
|
168
|
+
calls.push({ receiver: this, args });
|
|
169
|
+
Reflect.apply(baseEmit, this, args);
|
|
170
|
+
return returnValue;
|
|
171
|
+
};
|
|
172
|
+
Object.defineProperty(stdout, 'emit', { value: original, writable: true, configurable: true });
|
|
173
|
+
const listenersBefore = stdout.listenerCount('data');
|
|
174
|
+
installSubprocessTelemetryObserver({ dependencies: { channel }, onAssistantUsage: usage });
|
|
175
|
+
rpcChild(channel, stdout);
|
|
176
|
+
const chunk = Buffer.from(assistantLine());
|
|
177
|
+
|
|
178
|
+
const actual = Reflect.apply(stdout.emit, stdout, ['data', chunk, 'extra']);
|
|
179
|
+
|
|
180
|
+
expect(actual).toBe(returnValue);
|
|
181
|
+
expect(calls.at(-1)).toEqual({ receiver: stdout, args: ['data', chunk, 'extra'] });
|
|
182
|
+
expect(consumer).toHaveBeenCalledWith(chunk, 'extra');
|
|
183
|
+
expect(stdout.listenerCount('data')).toBe(listenersBefore);
|
|
184
|
+
expect(usage).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('measures the union of parallel tools with an injected clock', () => {
|
|
188
|
+
const channel = new FakeChannel();
|
|
189
|
+
const intervals: SubprocessToolIntervalEvent[] = [];
|
|
190
|
+
let now = 0;
|
|
191
|
+
installSubprocessTelemetryObserver({
|
|
192
|
+
dependencies: { channel, now: () => now },
|
|
193
|
+
onToolInterval: (event) => intervals.push(event),
|
|
194
|
+
});
|
|
195
|
+
const child = rpcChild(channel);
|
|
196
|
+
const stdout = child.stdout!;
|
|
197
|
+
stdout.emit('data', Buffer.from(assistantLine()));
|
|
198
|
+
|
|
199
|
+
now = 10;
|
|
200
|
+
send(stdout, { type: 'tool_execution_start', toolCallId: 'call-a', toolName: 'bash' });
|
|
201
|
+
now = 20;
|
|
202
|
+
send(stdout, { type: 'tool_execution_start', toolCallId: 'call-b', toolName: 'read' });
|
|
203
|
+
now = 30;
|
|
204
|
+
send(stdout, { type: 'tool_execution_end', toolCallId: 'call-a', result: { text: 'private' } });
|
|
205
|
+
expect(intervals).toEqual([]);
|
|
206
|
+
now = 50;
|
|
207
|
+
send(stdout, { type: 'tool_execution_end', toolCallId: 'call-b' });
|
|
208
|
+
|
|
209
|
+
expect(intervals).toEqual([
|
|
210
|
+
{
|
|
211
|
+
type: 'tool_interval',
|
|
212
|
+
pid: 4242,
|
|
213
|
+
mode: 'rpc',
|
|
214
|
+
model: { provider: 'openai', modelId: 'gpt-child' },
|
|
215
|
+
responseId: 'response-1',
|
|
216
|
+
startedAt: 10,
|
|
217
|
+
endedAt: 50,
|
|
218
|
+
durationMs: 40,
|
|
219
|
+
toolCalls: [
|
|
220
|
+
{ toolCallId: 'call-a', toolName: 'bash' },
|
|
221
|
+
{ toolCallId: 'call-b', toolName: 'read' },
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
]);
|
|
225
|
+
expect(JSON.stringify(intervals)).not.toContain('private');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('ignores non-Pi children and handles a classified child without stdout', () => {
|
|
229
|
+
const channel = new FakeChannel();
|
|
230
|
+
const lifecycle: SubprocessLifecycleEvent[] = [];
|
|
231
|
+
const handle = installSubprocessTelemetryObserver({
|
|
232
|
+
dependencies: { channel, now: () => 10 },
|
|
233
|
+
onLifecycle: (event) => lifecycle.push(event),
|
|
234
|
+
});
|
|
235
|
+
const nonPiStdout = new EventEmitter();
|
|
236
|
+
const original = nonPiStdout.emit;
|
|
237
|
+
const nonPi = new FakeChild(nonPiStdout);
|
|
238
|
+
channel.publish(nonPi);
|
|
239
|
+
nonPi.spawn('/usr/bin/node', ['/usr/bin/node', '/tmp/worker.js', '--mode', 'rpc']);
|
|
240
|
+
nonPiStdout.emit('data', Buffer.from(assistantLine()));
|
|
241
|
+
|
|
242
|
+
const noStdout = rpcChild(channel, null);
|
|
243
|
+
noStdout.emit('close', 0, null);
|
|
244
|
+
|
|
245
|
+
expect(nonPiStdout.emit).toBe(original);
|
|
246
|
+
expect(lifecycle.map(({ phase }) => phase)).toEqual(['spawn', 'close']);
|
|
247
|
+
expect(lifecycle[1]).toMatchObject({ pid: 4242, mode: 'rpc', code: 0, signal: null });
|
|
248
|
+
expect(handle.installed).toBe(true);
|
|
249
|
+
handle.unsubscribe();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('unsubscribes idempotently and restores emit on unsubscribe or stream close', () => {
|
|
253
|
+
const channel = new FakeChannel();
|
|
254
|
+
const usage = vi.fn();
|
|
255
|
+
const firstStdout = new EventEmitter();
|
|
256
|
+
const firstOriginal = firstStdout.emit;
|
|
257
|
+
const firstHadOwnEmit = Object.hasOwn(firstStdout, 'emit');
|
|
258
|
+
const handle = installSubprocessTelemetryObserver({
|
|
259
|
+
dependencies: { channel },
|
|
260
|
+
onAssistantUsage: usage,
|
|
261
|
+
});
|
|
262
|
+
rpcChild(channel, firstStdout);
|
|
263
|
+
|
|
264
|
+
expect(firstStdout.emit).not.toBe(firstOriginal);
|
|
265
|
+
handle.unsubscribe();
|
|
266
|
+
handle.unsubscribe();
|
|
267
|
+
expect(channel.listeners).toHaveLength(0);
|
|
268
|
+
expect(firstStdout.emit).toBe(firstOriginal);
|
|
269
|
+
expect(Object.hasOwn(firstStdout, 'emit')).toBe(firstHadOwnEmit);
|
|
270
|
+
firstStdout.emit('data', Buffer.from(assistantLine()));
|
|
271
|
+
expect(usage).not.toHaveBeenCalled();
|
|
272
|
+
|
|
273
|
+
const secondChannel = new FakeChannel();
|
|
274
|
+
const secondStdout = new EventEmitter();
|
|
275
|
+
const secondOriginal = secondStdout.emit;
|
|
276
|
+
const second = installSubprocessTelemetryObserver({ dependencies: { channel: secondChannel } });
|
|
277
|
+
rpcChild(secondChannel, secondStdout);
|
|
278
|
+
secondStdout.emit('close');
|
|
279
|
+
expect(secondStdout.emit).toBe(secondOriginal);
|
|
280
|
+
expect(() => second.unsubscribe()).not.toThrow();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('restores the exact emit method after stacked observers uninstall in either order', () => {
|
|
284
|
+
for (const order of ['first-second', 'second-first'] as const) {
|
|
285
|
+
const channel = new FakeChannel();
|
|
286
|
+
const stdout = new EventEmitter();
|
|
287
|
+
const original = stdout.emit;
|
|
288
|
+
const hadOwnEmit = Object.hasOwn(stdout, 'emit');
|
|
289
|
+
const first = installSubprocessTelemetryObserver({ dependencies: { channel } });
|
|
290
|
+
const second = installSubprocessTelemetryObserver({ dependencies: { channel } });
|
|
291
|
+
rpcChild(channel, stdout);
|
|
292
|
+
|
|
293
|
+
expect(stdout.emit).not.toBe(original);
|
|
294
|
+
if (order === 'first-second') {
|
|
295
|
+
first.unsubscribe();
|
|
296
|
+
second.unsubscribe();
|
|
297
|
+
} else {
|
|
298
|
+
second.unsubscribe();
|
|
299
|
+
first.unsubscribe();
|
|
300
|
+
}
|
|
301
|
+
expect(stdout.emit).toBe(original);
|
|
302
|
+
expect(Object.hasOwn(stdout, 'emit')).toBe(hadOwnEmit);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('cleans up a child when its stdout emit method cannot be wrapped', () => {
|
|
307
|
+
const channel = new FakeChannel();
|
|
308
|
+
const stdout = new EventEmitter();
|
|
309
|
+
Object.defineProperty(stdout, 'emit', {
|
|
310
|
+
value: stdout.emit,
|
|
311
|
+
writable: false,
|
|
312
|
+
configurable: false,
|
|
313
|
+
});
|
|
314
|
+
const child = new FakeChild(stdout);
|
|
315
|
+
const errors = vi.fn();
|
|
316
|
+
const handle = installSubprocessTelemetryObserver({
|
|
317
|
+
dependencies: { channel },
|
|
318
|
+
onError: errors,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
channel.publish(child);
|
|
322
|
+
child.spawn('/usr/local/bin/pi', ['/usr/local/bin/pi', '--mode', 'rpc']);
|
|
323
|
+
|
|
324
|
+
expect(errors.mock.calls[0]![0].phase).toBe('attach');
|
|
325
|
+
expect(child.listenerCount('spawn')).toBe(0);
|
|
326
|
+
expect(child.listenerCount('error')).toBe(0);
|
|
327
|
+
expect(child.listenerCount('close')).toBe(0);
|
|
328
|
+
expect(() => handle.unsubscribe()).not.toThrow();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('restores pending observations and degrades when the channel is unavailable', () => {
|
|
332
|
+
const channel = new FakeChannel();
|
|
333
|
+
const pending = new FakeChild();
|
|
334
|
+
const handle = installSubprocessTelemetryObserver({ dependencies: { channel } });
|
|
335
|
+
channel.publish(pending);
|
|
336
|
+
expect(pending.listenerCount('spawn')).toBe(1);
|
|
337
|
+
|
|
338
|
+
handle.unsubscribe();
|
|
339
|
+
expect(pending.listenerCount('spawn')).toBe(0);
|
|
340
|
+
pending.spawn('pi', ['pi', '--mode', 'rpc']);
|
|
341
|
+
expect(Object.hasOwn(pending.stdout!, 'emit')).toBe(false);
|
|
342
|
+
|
|
343
|
+
const unavailable = installSubprocessTelemetryObserver({ dependencies: { channel: null } });
|
|
344
|
+
expect(unavailable).toMatchObject({ installed: false, incompatibility: 'diagnostics_channel' });
|
|
345
|
+
expect(() => unavailable.unsubscribe()).not.toThrow();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('contains observer callback failures instead of throwing into stdout', () => {
|
|
349
|
+
const channel = new FakeChannel();
|
|
350
|
+
const callbackError = new Error('consumer failed');
|
|
351
|
+
const errors = vi.fn();
|
|
352
|
+
installSubprocessTelemetryObserver({
|
|
353
|
+
dependencies: { channel },
|
|
354
|
+
onAssistantUsage: () => {
|
|
355
|
+
throw callbackError;
|
|
356
|
+
},
|
|
357
|
+
onError: errors,
|
|
358
|
+
});
|
|
359
|
+
const child = rpcChild(channel);
|
|
360
|
+
|
|
361
|
+
expect(() => child.stdout!.emit('data', Buffer.from(assistantLine()))).not.toThrow();
|
|
362
|
+
expect(errors).toHaveBeenCalledWith({ phase: 'callback', error: callbackError });
|
|
363
|
+
});
|
|
364
|
+
});
|