@monotykamary/pi-ledger 0.3.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/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__/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 +3 -0
- package/extensions/pi-ledger/nested-agent-telemetry.ts +1271 -0
- package/extensions/pi-ledger/subprocess-observer.ts +694 -0
- package/package.json +1 -1
|
@@ -201,7 +201,14 @@ export function createTestFixture(): TestFixture {
|
|
|
201
201
|
|
|
202
202
|
const mockPi: Partial<ExtensionAPI> = {
|
|
203
203
|
on: vi.fn((event: string, handler: (...args: unknown[]) => unknown) => {
|
|
204
|
-
handlers[event]
|
|
204
|
+
const previous = handlers[event];
|
|
205
|
+
handlers[event] = previous
|
|
206
|
+
? (...args: unknown[]) => {
|
|
207
|
+
const previousResult = previous(...args);
|
|
208
|
+
const result = handler(...args);
|
|
209
|
+
return result === undefined ? previousResult : result;
|
|
210
|
+
}
|
|
211
|
+
: handler;
|
|
205
212
|
return mockPi as ExtensionAPI;
|
|
206
213
|
}) as unknown as ExtensionAPI['on'],
|
|
207
214
|
appendEntry: appendEntrySpy,
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
installInProcessAgentSessionObserver,
|
|
4
|
+
type AgentSessionClassLike,
|
|
5
|
+
type InProcessAgentSessionObserverCallbacks,
|
|
6
|
+
type InProcessAgentSessionObserverHandle,
|
|
7
|
+
} from '../in-process-session-observer';
|
|
8
|
+
|
|
9
|
+
type FakeSessionEvent = Record<string, unknown>;
|
|
10
|
+
type FakeListener = (event: FakeSessionEvent) => void;
|
|
11
|
+
|
|
12
|
+
function assistantEnd(overrides: Record<string, unknown> = {}): FakeSessionEvent {
|
|
13
|
+
return {
|
|
14
|
+
type: 'message_end',
|
|
15
|
+
message: {
|
|
16
|
+
role: 'assistant',
|
|
17
|
+
content: [{ type: 'text', text: 'private response text' }],
|
|
18
|
+
provider: 'openai',
|
|
19
|
+
model: 'gpt-child',
|
|
20
|
+
responseId: 'response-1',
|
|
21
|
+
usage: {
|
|
22
|
+
input: 120,
|
|
23
|
+
output: 30,
|
|
24
|
+
cacheRead: 10,
|
|
25
|
+
cacheWrite: 5,
|
|
26
|
+
reasoning: 4,
|
|
27
|
+
totalTokens: 165,
|
|
28
|
+
cost: {
|
|
29
|
+
input: 0.1,
|
|
30
|
+
output: 0.2,
|
|
31
|
+
cacheRead: 0.01,
|
|
32
|
+
cacheWrite: 0.02,
|
|
33
|
+
total: 0.33,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
stopReason: 'toolUse',
|
|
37
|
+
timestamp: 1234,
|
|
38
|
+
...overrides,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toolStart(
|
|
44
|
+
toolCallId: string,
|
|
45
|
+
toolName = 'bash',
|
|
46
|
+
args: unknown = { command: 'private command' }
|
|
47
|
+
): FakeSessionEvent {
|
|
48
|
+
return { type: 'tool_execution_start', toolCallId, toolName, args };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toolEnd(toolCallId: string, toolName = 'bash'): FakeSessionEvent {
|
|
52
|
+
return {
|
|
53
|
+
type: 'tool_execution_end',
|
|
54
|
+
toolCallId,
|
|
55
|
+
toolName,
|
|
56
|
+
result: { content: [{ type: 'text', text: 'private tool output' }] },
|
|
57
|
+
isError: false,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createFakeAgentSessionClass() {
|
|
62
|
+
return class FakeAgentSession {
|
|
63
|
+
readonly listeners = new Set<FakeListener>();
|
|
64
|
+
readonly prompts: string[] = [];
|
|
65
|
+
subscribeCalls = 0;
|
|
66
|
+
unsubscribeCalls = 0;
|
|
67
|
+
promptFailure: unknown;
|
|
68
|
+
subscribeFailure: unknown;
|
|
69
|
+
unsubscribeFailure: unknown;
|
|
70
|
+
promptEvent?: FakeSessionEvent;
|
|
71
|
+
lastPromptResult?: Promise<void>;
|
|
72
|
+
onUnsubscribe?: () => void;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
readonly sessionId: string,
|
|
76
|
+
readonly sessionFile: string | undefined = `/sessions/${sessionId}.jsonl`,
|
|
77
|
+
readonly model: { provider: string; id: string } | undefined = {
|
|
78
|
+
provider: 'openai',
|
|
79
|
+
id: 'session-model',
|
|
80
|
+
}
|
|
81
|
+
) {}
|
|
82
|
+
|
|
83
|
+
prompt(text: string): Promise<void> {
|
|
84
|
+
this.prompts.push(text);
|
|
85
|
+
if (this.promptEvent) this.emit(this.promptEvent);
|
|
86
|
+
const result = this.promptFailure
|
|
87
|
+
? Promise.reject(this.promptFailure)
|
|
88
|
+
: Promise.resolve(undefined);
|
|
89
|
+
this.lastPromptResult = result;
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
subscribe(listener: FakeListener): () => void {
|
|
94
|
+
this.subscribeCalls += 1;
|
|
95
|
+
if (this.subscribeFailure) throw this.subscribeFailure;
|
|
96
|
+
this.listeners.add(listener);
|
|
97
|
+
return () => {
|
|
98
|
+
this.unsubscribeCalls += 1;
|
|
99
|
+
this.listeners.delete(listener);
|
|
100
|
+
this.onUnsubscribe?.();
|
|
101
|
+
if (this.unsubscribeFailure) throw this.unsubscribeFailure;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
emit(event: FakeSessionEvent): void {
|
|
106
|
+
for (const listener of [...this.listeners]) listener(event);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
dispose(): void {
|
|
110
|
+
this.listeners.clear();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const handles: InProcessAgentSessionObserverHandle[] = [];
|
|
116
|
+
|
|
117
|
+
function installFor(
|
|
118
|
+
AgentSession: AgentSessionClassLike,
|
|
119
|
+
callbacks: InProcessAgentSessionObserverCallbacks = {},
|
|
120
|
+
now: () => number = Date.now,
|
|
121
|
+
rootSessionIds?: Iterable<string>
|
|
122
|
+
): InProcessAgentSessionObserverHandle {
|
|
123
|
+
const handle = installInProcessAgentSessionObserver({
|
|
124
|
+
...callbacks,
|
|
125
|
+
rootSessionIds,
|
|
126
|
+
dependencies: { AgentSession, now },
|
|
127
|
+
});
|
|
128
|
+
handles.push(handle);
|
|
129
|
+
return handle;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
for (const handle of handles.splice(0).reverse()) handle.uninstall();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('in-process AgentSession observer', () => {
|
|
137
|
+
it('attaches one public listener before the first prompt and never wraps session instances', async () => {
|
|
138
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
139
|
+
const usage = vi.fn();
|
|
140
|
+
installFor(FakeAgentSession, { onAssistantUsage: usage });
|
|
141
|
+
const session = new FakeAgentSession('child-1');
|
|
142
|
+
session.promptEvent = assistantEnd();
|
|
143
|
+
|
|
144
|
+
await session.prompt('private first prompt');
|
|
145
|
+
await session.prompt('private second prompt');
|
|
146
|
+
|
|
147
|
+
expect(session.subscribeCalls).toBe(1);
|
|
148
|
+
expect(session.listeners).toHaveLength(1);
|
|
149
|
+
expect(session.prompts).toEqual(['private first prompt', 'private second prompt']);
|
|
150
|
+
expect(Object.hasOwn(session, 'prompt')).toBe(false);
|
|
151
|
+
expect(usage).toHaveBeenCalledTimes(2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('excludes tracked root IDs and can observe the same instance after it is untracked', async () => {
|
|
155
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
156
|
+
const usage = vi.fn();
|
|
157
|
+
const handle = installFor(FakeAgentSession, { onAssistantUsage: usage }, Date.now, [
|
|
158
|
+
'root-session',
|
|
159
|
+
]);
|
|
160
|
+
const root = new FakeAgentSession('root-session');
|
|
161
|
+
const child = new FakeAgentSession('child-session');
|
|
162
|
+
|
|
163
|
+
await root.prompt('root prompt');
|
|
164
|
+
await child.prompt('child prompt');
|
|
165
|
+
root.emit(assistantEnd());
|
|
166
|
+
child.emit(assistantEnd());
|
|
167
|
+
|
|
168
|
+
expect(root.subscribeCalls).toBe(0);
|
|
169
|
+
expect(child.subscribeCalls).toBe(1);
|
|
170
|
+
expect(usage).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(usage.mock.calls[0]![0].sessionId).toBe('child-session');
|
|
172
|
+
|
|
173
|
+
handle.removeRootSessionId('root-session');
|
|
174
|
+
await root.prompt('now observable');
|
|
175
|
+
root.emit(assistantEnd({ responseId: 'root-after-untrack' }));
|
|
176
|
+
|
|
177
|
+
expect(root.subscribeCalls).toBe(1);
|
|
178
|
+
expect(usage).toHaveBeenCalledTimes(2);
|
|
179
|
+
expect(usage.mock.calls[1]![0].responseId).toBe('root-after-untrack');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('reference-counts root exclusions across observer handles', async () => {
|
|
183
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
184
|
+
const firstUsage = vi.fn();
|
|
185
|
+
const secondUsage = vi.fn();
|
|
186
|
+
const first = installFor(FakeAgentSession, { onAssistantUsage: firstUsage }, Date.now, [
|
|
187
|
+
'shared-root',
|
|
188
|
+
]);
|
|
189
|
+
const second = installFor(FakeAgentSession, { onAssistantUsage: secondUsage }, Date.now, [
|
|
190
|
+
'shared-root',
|
|
191
|
+
]);
|
|
192
|
+
const session = new FakeAgentSession('shared-root');
|
|
193
|
+
|
|
194
|
+
await session.prompt('excluded by both');
|
|
195
|
+
first.removeRootSessionId('shared-root');
|
|
196
|
+
await session.prompt('still excluded by second');
|
|
197
|
+
|
|
198
|
+
expect(session.subscribeCalls).toBe(0);
|
|
199
|
+
|
|
200
|
+
second.removeRootSessionId('shared-root');
|
|
201
|
+
await session.prompt('now observable');
|
|
202
|
+
session.emit(assistantEnd());
|
|
203
|
+
|
|
204
|
+
expect(session.subscribeCalls).toBe(1);
|
|
205
|
+
expect(firstUsage).toHaveBeenCalledTimes(1);
|
|
206
|
+
expect(secondUsage).toHaveBeenCalledTimes(1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('keeps concurrent child sessions and their tool clocks isolated', async () => {
|
|
210
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
211
|
+
let now = 0;
|
|
212
|
+
const usage = vi.fn();
|
|
213
|
+
const intervals = vi.fn();
|
|
214
|
+
installFor(FakeAgentSession, { onAssistantUsage: usage, onToolInterval: intervals }, () => now);
|
|
215
|
+
const first = new FakeAgentSession('child-a');
|
|
216
|
+
const second = new FakeAgentSession('child-b');
|
|
217
|
+
await Promise.all([first.prompt('a'), second.prompt('b')]);
|
|
218
|
+
|
|
219
|
+
first.emit(assistantEnd({ responseId: 'response-a' }));
|
|
220
|
+
second.emit(assistantEnd({ responseId: 'response-b' }));
|
|
221
|
+
now = 10;
|
|
222
|
+
first.emit(toolStart('a-tool'));
|
|
223
|
+
now = 15;
|
|
224
|
+
second.emit(toolStart('b-tool'));
|
|
225
|
+
now = 25;
|
|
226
|
+
first.emit(toolEnd('a-tool'));
|
|
227
|
+
now = 40;
|
|
228
|
+
second.emit(toolEnd('b-tool'));
|
|
229
|
+
|
|
230
|
+
expect(usage.mock.calls.map((call) => call[0].sessionId)).toEqual(['child-a', 'child-b']);
|
|
231
|
+
expect(intervals).toHaveBeenCalledTimes(2);
|
|
232
|
+
expect(intervals.mock.calls[0]![0]).toMatchObject({
|
|
233
|
+
sessionId: 'child-a',
|
|
234
|
+
responseId: 'response-a',
|
|
235
|
+
startedAt: 10,
|
|
236
|
+
endedAt: 25,
|
|
237
|
+
durationMs: 15,
|
|
238
|
+
});
|
|
239
|
+
expect(intervals.mock.calls[1]![0]).toMatchObject({
|
|
240
|
+
sessionId: 'child-b',
|
|
241
|
+
responseId: 'response-b',
|
|
242
|
+
startedAt: 15,
|
|
243
|
+
endedAt: 40,
|
|
244
|
+
durationMs: 25,
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('normalizes assistant usage and parallel tools into text-free union intervals', async () => {
|
|
249
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
250
|
+
let now = 100;
|
|
251
|
+
const usage = vi.fn();
|
|
252
|
+
const intervals = vi.fn();
|
|
253
|
+
installFor(FakeAgentSession, { onAssistantUsage: usage, onToolInterval: intervals }, () => now);
|
|
254
|
+
const session = new FakeAgentSession('child-usage', '/tmp/child.jsonl');
|
|
255
|
+
await session.prompt('secret prompt text');
|
|
256
|
+
|
|
257
|
+
session.emit(assistantEnd());
|
|
258
|
+
session.emit(toolStart('call-a', 'bash'));
|
|
259
|
+
now = 120;
|
|
260
|
+
session.emit(toolStart('call-b', 'read'));
|
|
261
|
+
now = 150;
|
|
262
|
+
session.emit(toolEnd('call-a', 'bash'));
|
|
263
|
+
expect(intervals).not.toHaveBeenCalled();
|
|
264
|
+
now = 180;
|
|
265
|
+
session.emit(toolEnd('call-b', 'read'));
|
|
266
|
+
|
|
267
|
+
expect(usage).toHaveBeenCalledWith({
|
|
268
|
+
type: 'assistant_usage',
|
|
269
|
+
sessionId: 'child-usage',
|
|
270
|
+
sessionFile: '/tmp/child.jsonl',
|
|
271
|
+
model: { provider: 'openai', modelId: 'gpt-child' },
|
|
272
|
+
responseId: 'response-1',
|
|
273
|
+
timestamp: 1234,
|
|
274
|
+
stopReason: 'toolUse',
|
|
275
|
+
usage: {
|
|
276
|
+
input: 120,
|
|
277
|
+
output: 30,
|
|
278
|
+
cacheRead: 10,
|
|
279
|
+
cacheWrite: 5,
|
|
280
|
+
reasoning: 4,
|
|
281
|
+
totalTokens: 165,
|
|
282
|
+
cost: {
|
|
283
|
+
input: 0.1,
|
|
284
|
+
output: 0.2,
|
|
285
|
+
cacheRead: 0.01,
|
|
286
|
+
cacheWrite: 0.02,
|
|
287
|
+
total: 0.33,
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
expect(intervals).toHaveBeenCalledWith({
|
|
292
|
+
type: 'tool_interval',
|
|
293
|
+
sessionId: 'child-usage',
|
|
294
|
+
sessionFile: '/tmp/child.jsonl',
|
|
295
|
+
model: { provider: 'openai', modelId: 'gpt-child' },
|
|
296
|
+
responseId: 'response-1',
|
|
297
|
+
startedAt: 100,
|
|
298
|
+
endedAt: 180,
|
|
299
|
+
durationMs: 80,
|
|
300
|
+
toolCalls: [
|
|
301
|
+
{ toolCallId: 'call-a', toolName: 'bash' },
|
|
302
|
+
{ toolCallId: 'call-b', toolName: 'read' },
|
|
303
|
+
],
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const serialized = JSON.stringify({ usage: usage.mock.calls, intervals: intervals.mock.calls });
|
|
307
|
+
expect(serialized).not.toContain('private response text');
|
|
308
|
+
expect(serialized).not.toContain('private command');
|
|
309
|
+
expect(serialized).not.toContain('private tool output');
|
|
310
|
+
expect(serialized).not.toContain('secret prompt text');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('reference-counts installs and restores the exact prompt descriptor after the last uninstall', async () => {
|
|
314
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
315
|
+
const original = Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')!;
|
|
316
|
+
const firstUsage = vi.fn();
|
|
317
|
+
const secondUsage = vi.fn();
|
|
318
|
+
const first = installFor(FakeAgentSession, { onAssistantUsage: firstUsage });
|
|
319
|
+
const wrapped = Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')!;
|
|
320
|
+
const second = installFor(FakeAgentSession, { onAssistantUsage: secondUsage });
|
|
321
|
+
|
|
322
|
+
expect(Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')!.value).toBe(
|
|
323
|
+
wrapped.value
|
|
324
|
+
);
|
|
325
|
+
const session = new FakeAgentSession('child-refcount');
|
|
326
|
+
await session.prompt('one');
|
|
327
|
+
expect(session.subscribeCalls).toBe(1);
|
|
328
|
+
|
|
329
|
+
first.uninstall();
|
|
330
|
+
session.emit(assistantEnd());
|
|
331
|
+
expect(firstUsage).not.toHaveBeenCalled();
|
|
332
|
+
expect(secondUsage).toHaveBeenCalledTimes(1);
|
|
333
|
+
expect(Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')!.value).toBe(
|
|
334
|
+
wrapped.value
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
second.uninstall();
|
|
338
|
+
second.uninstall();
|
|
339
|
+
const restored = Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')!;
|
|
340
|
+
expect(restored).toEqual(original);
|
|
341
|
+
expect(restored.value).toBe(original.value);
|
|
342
|
+
expect(session.listeners).toHaveLength(0);
|
|
343
|
+
expect(session.unsubscribeCalls).toBe(1);
|
|
344
|
+
|
|
345
|
+
await session.prompt('without observer');
|
|
346
|
+
expect(session.subscribeCalls).toBe(1);
|
|
347
|
+
const reinstalled = installFor(FakeAgentSession, { onAssistantUsage: firstUsage });
|
|
348
|
+
await session.prompt('after reinstall');
|
|
349
|
+
expect(reinstalled.installed).toBe(true);
|
|
350
|
+
expect(session.subscribeCalls).toBe(2);
|
|
351
|
+
expect(session.listeners).toHaveLength(1);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('keeps the wrapper installed when the last uninstall reentrantly installs a subscriber', async () => {
|
|
355
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
356
|
+
const original = Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')!;
|
|
357
|
+
const first = installFor(FakeAgentSession);
|
|
358
|
+
const wrapped = Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')!;
|
|
359
|
+
const session = new FakeAgentSession('child-reentrant');
|
|
360
|
+
await session.prompt('attach first listener');
|
|
361
|
+
|
|
362
|
+
const usage = vi.fn();
|
|
363
|
+
let replacement: InProcessAgentSessionObserverHandle | undefined;
|
|
364
|
+
session.onUnsubscribe = () => {
|
|
365
|
+
session.onUnsubscribe = undefined;
|
|
366
|
+
replacement = installFor(FakeAgentSession, { onAssistantUsage: usage });
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
first.uninstall();
|
|
370
|
+
|
|
371
|
+
expect(replacement?.installed).toBe(true);
|
|
372
|
+
expect(Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')!.value).toBe(
|
|
373
|
+
wrapped.value
|
|
374
|
+
);
|
|
375
|
+
await session.prompt('attach replacement listener');
|
|
376
|
+
session.emit(assistantEnd());
|
|
377
|
+
expect(usage).toHaveBeenCalledTimes(1);
|
|
378
|
+
|
|
379
|
+
replacement!.uninstall();
|
|
380
|
+
expect(Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')).toEqual(original);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('shares one prototype wrapper across uncached module evaluations', async () => {
|
|
384
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
385
|
+
vi.resetModules();
|
|
386
|
+
const firstModule = await import('../in-process-session-observer.js');
|
|
387
|
+
const first = firstModule.installInProcessAgentSessionObserver({
|
|
388
|
+
dependencies: { AgentSession: FakeAgentSession, now: Date.now },
|
|
389
|
+
});
|
|
390
|
+
handles.push(first);
|
|
391
|
+
const wrapper = Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')!.value;
|
|
392
|
+
|
|
393
|
+
vi.resetModules();
|
|
394
|
+
const secondModule = await import('../in-process-session-observer.js');
|
|
395
|
+
const second = secondModule.installInProcessAgentSessionObserver({
|
|
396
|
+
dependencies: { AgentSession: FakeAgentSession, now: Date.now },
|
|
397
|
+
});
|
|
398
|
+
handles.push(second);
|
|
399
|
+
|
|
400
|
+
expect(Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')!.value).toBe(
|
|
401
|
+
wrapper
|
|
402
|
+
);
|
|
403
|
+
const session = new FakeAgentSession('child-reload');
|
|
404
|
+
await session.prompt('one listener');
|
|
405
|
+
expect(session.subscribeCalls).toBe(1);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('reattaches after an unsubscribe callback removes its listener and then throws', async () => {
|
|
409
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
410
|
+
const errors = vi.fn();
|
|
411
|
+
const first = installFor(FakeAgentSession, { onError: errors });
|
|
412
|
+
const session = new FakeAgentSession('child-throwing-unsubscribe');
|
|
413
|
+
await session.prompt('attach first listener');
|
|
414
|
+
const unsubscribeError = new Error('unsubscribe failed after removal');
|
|
415
|
+
session.unsubscribeFailure = unsubscribeError;
|
|
416
|
+
|
|
417
|
+
first.uninstall();
|
|
418
|
+
|
|
419
|
+
expect(session.listeners).toHaveLength(0);
|
|
420
|
+
expect(errors).toHaveBeenCalledWith({ phase: 'uninstall', error: unsubscribeError });
|
|
421
|
+
session.unsubscribeFailure = undefined;
|
|
422
|
+
const usage = vi.fn();
|
|
423
|
+
installFor(FakeAgentSession, { onAssistantUsage: usage });
|
|
424
|
+
await session.prompt('reattach listener');
|
|
425
|
+
session.emit(assistantEnd());
|
|
426
|
+
|
|
427
|
+
expect(session.subscribeCalls).toBe(2);
|
|
428
|
+
expect(session.listeners).toHaveLength(1);
|
|
429
|
+
expect(usage).toHaveBeenCalledTimes(1);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('preserves prompt rejection identity and retries a failed subscription without blocking prompts', async () => {
|
|
433
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
434
|
+
const errors = vi.fn();
|
|
435
|
+
installFor(FakeAgentSession, { onError: errors });
|
|
436
|
+
const session = new FakeAgentSession('child-errors');
|
|
437
|
+
const subscribeError = new Error('subscribe failed');
|
|
438
|
+
session.subscribeFailure = subscribeError;
|
|
439
|
+
|
|
440
|
+
const firstResult = session.prompt('still delivered');
|
|
441
|
+
expect(firstResult).toBe(session.lastPromptResult);
|
|
442
|
+
await expect(firstResult).resolves.toBeUndefined();
|
|
443
|
+
expect(session.subscribeCalls).toBe(1);
|
|
444
|
+
expect(errors).toHaveBeenCalledWith({ phase: 'attach', error: subscribeError });
|
|
445
|
+
|
|
446
|
+
session.subscribeFailure = undefined;
|
|
447
|
+
const promptError = new Error('prompt failed');
|
|
448
|
+
session.promptFailure = promptError;
|
|
449
|
+
const secondResult = session.prompt('rejected prompt');
|
|
450
|
+
expect(secondResult).toBe(session.lastPromptResult);
|
|
451
|
+
await expect(secondResult).rejects.toBe(promptError);
|
|
452
|
+
expect(session.subscribeCalls).toBe(2);
|
|
453
|
+
|
|
454
|
+
await expect(session.prompt('rejected again')).rejects.toBe(promptError);
|
|
455
|
+
expect(session.subscribeCalls).toBe(2);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('contains callback failures and tolerates session disposal', async () => {
|
|
459
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
460
|
+
const callbackError = new Error('consumer failed');
|
|
461
|
+
const errors = vi.fn();
|
|
462
|
+
const usage = vi.fn(() => {
|
|
463
|
+
throw callbackError;
|
|
464
|
+
});
|
|
465
|
+
const handle = installFor(FakeAgentSession, { onAssistantUsage: usage, onError: errors });
|
|
466
|
+
const session = new FakeAgentSession('child-dispose');
|
|
467
|
+
await session.prompt('attach');
|
|
468
|
+
|
|
469
|
+
expect(() => session.emit(assistantEnd())).not.toThrow();
|
|
470
|
+
expect(errors).toHaveBeenCalledWith({ phase: 'callback', error: callbackError });
|
|
471
|
+
session.dispose();
|
|
472
|
+
expect(session.listeners).toHaveLength(0);
|
|
473
|
+
expect(() => session.emit(assistantEnd())).not.toThrow();
|
|
474
|
+
expect(usage).toHaveBeenCalledTimes(1);
|
|
475
|
+
expect(() => handle.uninstall()).not.toThrow();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('rejects a malformed process-global patch state without touching the prototype', () => {
|
|
479
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
480
|
+
const seed = installFor(FakeAgentSession);
|
|
481
|
+
seed.uninstall();
|
|
482
|
+
|
|
483
|
+
const registry = Reflect.get(
|
|
484
|
+
globalThis,
|
|
485
|
+
Symbol.for('@monotykamary/pi-ledger/in-process-agent-session-observer/v1')
|
|
486
|
+
) as { patches: WeakMap<object, unknown> };
|
|
487
|
+
const malformed = Object.create(null) as object;
|
|
488
|
+
Object.defineProperty(malformed, 'brand', {
|
|
489
|
+
get() {
|
|
490
|
+
throw new Error('poisoned patch brand');
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
registry.patches.set(FakeAgentSession.prototype, malformed);
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
const before = Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')!;
|
|
497
|
+
const errors = vi.fn();
|
|
498
|
+
const handle = installFor(FakeAgentSession, { onError: errors });
|
|
499
|
+
|
|
500
|
+
expect(Object.isFrozen(registry)).toBe(true);
|
|
501
|
+
expect(handle).toMatchObject({ installed: false, incompatibility: 'prototype-conflict' });
|
|
502
|
+
expect(Object.getOwnPropertyDescriptor(FakeAgentSession.prototype, 'prompt')).toEqual(before);
|
|
503
|
+
expect(errors.mock.calls[0]![0].phase).toBe('install');
|
|
504
|
+
} finally {
|
|
505
|
+
registry.patches.delete(FakeAgentSession.prototype);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('degrades without patching when the public prototype contract is incompatible', async () => {
|
|
510
|
+
class NonWritableSession {
|
|
511
|
+
prompt(): Promise<void> {
|
|
512
|
+
return Promise.resolve();
|
|
513
|
+
}
|
|
514
|
+
subscribe(): () => void {
|
|
515
|
+
return () => undefined;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const descriptor = Object.getOwnPropertyDescriptor(NonWritableSession.prototype, 'prompt')!;
|
|
519
|
+
Object.defineProperty(NonWritableSession.prototype, 'prompt', {
|
|
520
|
+
...descriptor,
|
|
521
|
+
writable: false,
|
|
522
|
+
});
|
|
523
|
+
const before = Object.getOwnPropertyDescriptor(NonWritableSession.prototype, 'prompt')!;
|
|
524
|
+
const errors = vi.fn();
|
|
525
|
+
|
|
526
|
+
const handle = installFor(NonWritableSession, { onError: errors });
|
|
527
|
+
|
|
528
|
+
expect(handle).toMatchObject({ installed: false, incompatibility: 'prompt-descriptor' });
|
|
529
|
+
expect(Object.getOwnPropertyDescriptor(NonWritableSession.prototype, 'prompt')).toEqual(before);
|
|
530
|
+
await expect(new NonWritableSession().prompt()).resolves.toBeUndefined();
|
|
531
|
+
expect(errors.mock.calls[0]![0].phase).toBe('install');
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('keeps observing safe fields when optional metadata getters fail', async () => {
|
|
535
|
+
const FakeAgentSession = createFakeAgentSessionClass();
|
|
536
|
+
const metadataError = new Error('session file unavailable');
|
|
537
|
+
const errors = vi.fn();
|
|
538
|
+
const usage = vi.fn();
|
|
539
|
+
installFor(FakeAgentSession, { onAssistantUsage: usage, onError: errors });
|
|
540
|
+
const session = new FakeAgentSession('child-metadata');
|
|
541
|
+
Object.defineProperty(session, 'sessionFile', {
|
|
542
|
+
get() {
|
|
543
|
+
throw metadataError;
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
await session.prompt('attach');
|
|
548
|
+
expect(() => session.emit(assistantEnd())).not.toThrow();
|
|
549
|
+
|
|
550
|
+
expect(usage).toHaveBeenCalledWith(
|
|
551
|
+
expect.objectContaining({ sessionId: 'child-metadata', responseId: 'response-1' })
|
|
552
|
+
);
|
|
553
|
+
expect(errors).toHaveBeenCalledWith({ phase: 'metadata', error: metadataError });
|
|
554
|
+
});
|
|
555
|
+
});
|