@monotykamary/pi-ledger 0.2.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/LICENSE +21 -0
- package/README.md +406 -0
- package/extensions/pi-ledger/__tests__/helpers.ts +326 -0
- package/extensions/pi-ledger/__tests__/ledger.test.ts +2528 -0
- package/extensions/pi-ledger/index.ts +2698 -0
- package/package.json +70 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { vi } from 'vitest';
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { sidecarPathFor, type SidecarEvent } from '../index';
|
|
6
|
+
|
|
7
|
+
// Minimal fakes for instantiating a CustomEditor headlessly in tests: the
|
|
8
|
+
// editor wrapper delegates every keystroke to the base editor, which only
|
|
9
|
+
// needs these to process a key without a real terminal. See LedgerEditor.
|
|
10
|
+
const FAKE_TUI = {
|
|
11
|
+
requestRender() {},
|
|
12
|
+
requestGlobalRender() {},
|
|
13
|
+
getColumns() {
|
|
14
|
+
return 80;
|
|
15
|
+
},
|
|
16
|
+
getRows() {
|
|
17
|
+
return 24;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const FAKE_THEME = {
|
|
21
|
+
borderColor: '',
|
|
22
|
+
borderMutedColor: '',
|
|
23
|
+
text: '',
|
|
24
|
+
accent: '',
|
|
25
|
+
muted: '',
|
|
26
|
+
dim: '',
|
|
27
|
+
success: '',
|
|
28
|
+
error: '',
|
|
29
|
+
warning: '',
|
|
30
|
+
bg: (s: string) => s,
|
|
31
|
+
fg: (_c: string, s: string) => s,
|
|
32
|
+
};
|
|
33
|
+
// Recognizes sentinel editor keys for app actions so tests can drive the
|
|
34
|
+
// LedgerEditor wrapper's dequeue/followUp branches (matched by action id, as
|
|
35
|
+
// in production). Real keybindings are not needed headlessly.
|
|
36
|
+
const FAKE_KB = {
|
|
37
|
+
matches: (data: string, action: string) =>
|
|
38
|
+
(action === 'app.message.dequeue' && data === 'dequeue') ||
|
|
39
|
+
(action === 'app.message.followUp' && data === 'followUp'),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const TEST_SESSION_ID = '019fabcd-aaaa-bbbb-cccc-dddddddddddd';
|
|
43
|
+
|
|
44
|
+
export interface TestFixture {
|
|
45
|
+
mockPi: Partial<ExtensionAPI>;
|
|
46
|
+
handlers: Record<string, (...args: unknown[]) => unknown>;
|
|
47
|
+
commands: Record<
|
|
48
|
+
string,
|
|
49
|
+
{
|
|
50
|
+
description?: string;
|
|
51
|
+
handler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
52
|
+
getArgumentCompletions?: (prefix: string) => unknown;
|
|
53
|
+
}
|
|
54
|
+
>;
|
|
55
|
+
appendEntrySpy: ReturnType<typeof vi.fn>;
|
|
56
|
+
notifySpy: ReturnType<typeof vi.fn>;
|
|
57
|
+
setStatusSpy: ReturnType<typeof vi.fn>;
|
|
58
|
+
customSpy: ReturnType<typeof vi.fn>;
|
|
59
|
+
selectSpy: ReturnType<typeof vi.fn>;
|
|
60
|
+
inputSpy: ReturnType<typeof vi.fn>;
|
|
61
|
+
registerCommandSpy: ReturnType<typeof vi.fn>;
|
|
62
|
+
setEditorComponentSpy: ReturnType<typeof vi.fn>;
|
|
63
|
+
/** Send a keystroke into the editor wrapper installed via setEditorComponent. */
|
|
64
|
+
sendEditorKey: (data: string) => void;
|
|
65
|
+
emitEvent: (event: string, payload: unknown) => void;
|
|
66
|
+
/** Invoke a lifecycle handler registered via pi.on(name, fn) with (event, ctx). */
|
|
67
|
+
run: (name: string, event: unknown) => void;
|
|
68
|
+
/** Set the value that ctx.ui.custom()'s promise resolves with (for wizard accept/dismiss). */
|
|
69
|
+
setCustomResult: (value: unknown) => void;
|
|
70
|
+
/** Set the value ctx.ui.select() resolves with (for RPC wizard/settings). */
|
|
71
|
+
setSelectResult: (value: unknown) => void;
|
|
72
|
+
/** Set the value ctx.ui.input() resolves with (for RPC settings). */
|
|
73
|
+
setInputResult: (value: unknown) => void;
|
|
74
|
+
mockEntries: Array<{ type?: string; customType?: string; data?: unknown }>;
|
|
75
|
+
mockCtx: ExtensionContext;
|
|
76
|
+
/** Overwrite the per-session sidecar event log with `events` (simulates a resumed session). */
|
|
77
|
+
seedSidecar: (events: SidecarEvent[]) => void;
|
|
78
|
+
/** Read the session's sidecar event log. */
|
|
79
|
+
readSidecarEvents: () => SidecarEvent[];
|
|
80
|
+
/** Last sidecar event of a given kind (or undefined). */
|
|
81
|
+
lastSidecarEvent: (kind: SidecarEvent['kind']) => SidecarEvent | undefined;
|
|
82
|
+
/** Delete the session's sidecar file (simulate a missing/failed read). */
|
|
83
|
+
clearSidecar: () => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function makeTpsTelemetry(
|
|
87
|
+
overrides: {
|
|
88
|
+
generationMs?: number;
|
|
89
|
+
stallMs?: number;
|
|
90
|
+
input?: number;
|
|
91
|
+
output?: number;
|
|
92
|
+
total?: number;
|
|
93
|
+
provider?: string;
|
|
94
|
+
modelId?: string;
|
|
95
|
+
} = {}
|
|
96
|
+
): unknown {
|
|
97
|
+
const {
|
|
98
|
+
generationMs = 2000,
|
|
99
|
+
stallMs = 0,
|
|
100
|
+
input = 1000,
|
|
101
|
+
output = 500,
|
|
102
|
+
total = 1500,
|
|
103
|
+
provider = 'openai',
|
|
104
|
+
modelId = 'gpt-4',
|
|
105
|
+
} = overrides;
|
|
106
|
+
return {
|
|
107
|
+
model: { provider, modelId },
|
|
108
|
+
tokens: { input, output, total },
|
|
109
|
+
timing: {
|
|
110
|
+
generationMs,
|
|
111
|
+
stallMs,
|
|
112
|
+
ttftMs: 500,
|
|
113
|
+
totalMs: 2500,
|
|
114
|
+
streamMs: 1500,
|
|
115
|
+
stallCount: 0,
|
|
116
|
+
messageCount: 1,
|
|
117
|
+
},
|
|
118
|
+
tps: 250,
|
|
119
|
+
cost: null,
|
|
120
|
+
rateUsdPerMTokens: null,
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createTestFixture(): TestFixture {
|
|
126
|
+
const handlers: Record<string, (...args: unknown[]) => unknown> = {};
|
|
127
|
+
const commands: TestFixture['commands'] = {};
|
|
128
|
+
const appendEntrySpy = vi.fn();
|
|
129
|
+
const notifySpy = vi.fn();
|
|
130
|
+
const setStatusSpy = vi.fn();
|
|
131
|
+
const customSpy = vi.fn();
|
|
132
|
+
const selectSpy = vi.fn();
|
|
133
|
+
const inputSpy = vi.fn();
|
|
134
|
+
const registerCommandSpy = vi.fn((name: string, options: unknown) => {
|
|
135
|
+
commands[name] = options as TestFixture['commands'][string];
|
|
136
|
+
});
|
|
137
|
+
const setEditorComponentSpy = vi.fn();
|
|
138
|
+
let editorFactory:
|
|
139
|
+
| ((tui: unknown, theme: unknown, kb: unknown) => { handleInput(data: string): void })
|
|
140
|
+
| null = null;
|
|
141
|
+
let editorComponent: { handleInput(data: string): void } | null = null;
|
|
142
|
+
setEditorComponentSpy.mockImplementation((factory: unknown) => {
|
|
143
|
+
if (typeof factory === 'function') {
|
|
144
|
+
editorFactory = factory as typeof editorFactory;
|
|
145
|
+
editorComponent = null; // rebuild on reinstall (e.g. session reload)
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const mockEntries: Array<{ type?: string; customType?: string; data?: unknown }> = [];
|
|
150
|
+
|
|
151
|
+
let customResult: unknown = undefined;
|
|
152
|
+
const setCustomResult = (value: unknown) => {
|
|
153
|
+
customResult = value;
|
|
154
|
+
};
|
|
155
|
+
let selectResult: unknown = undefined;
|
|
156
|
+
const setSelectResult = (value: unknown) => {
|
|
157
|
+
selectResult = value;
|
|
158
|
+
};
|
|
159
|
+
let inputResult: unknown = undefined;
|
|
160
|
+
const setInputResult = (value: unknown) => {
|
|
161
|
+
inputResult = value;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const mockCtx = {
|
|
165
|
+
hasUI: true,
|
|
166
|
+
mode: 'tui',
|
|
167
|
+
cwd: '/tmp/project',
|
|
168
|
+
ui: {
|
|
169
|
+
notify: notifySpy,
|
|
170
|
+
setStatus: setStatusSpy,
|
|
171
|
+
custom: customSpy,
|
|
172
|
+
select: selectSpy,
|
|
173
|
+
input: inputSpy,
|
|
174
|
+
setEditorComponent: setEditorComponentSpy,
|
|
175
|
+
},
|
|
176
|
+
sessionManager: {
|
|
177
|
+
getEntries: vi.fn().mockReturnValue(mockEntries),
|
|
178
|
+
getBranch: vi.fn().mockReturnValue(mockEntries),
|
|
179
|
+
getSessionId: vi.fn().mockReturnValue('019fabcd-aaaa-bbbb-cccc-dddddddddddd'),
|
|
180
|
+
},
|
|
181
|
+
modelRegistry: undefined,
|
|
182
|
+
model: undefined,
|
|
183
|
+
isIdle: vi.fn().mockReturnValue(true),
|
|
184
|
+
isProjectTrusted: vi.fn().mockReturnValue(true),
|
|
185
|
+
signal: undefined,
|
|
186
|
+
abort: vi.fn(),
|
|
187
|
+
hasPendingMessages: vi.fn().mockReturnValue(false),
|
|
188
|
+
shutdown: vi.fn(),
|
|
189
|
+
getContextUsage: vi.fn(),
|
|
190
|
+
compact: vi.fn(),
|
|
191
|
+
getSystemPrompt: vi.fn(),
|
|
192
|
+
} as unknown as ExtensionContext;
|
|
193
|
+
|
|
194
|
+
const eventListeners = new Map<string, ((payload: unknown) => void)[]>();
|
|
195
|
+
const emitEvent = (event: string, payload: unknown) => {
|
|
196
|
+
for (const listener of eventListeners.get(event) ?? []) listener(payload);
|
|
197
|
+
};
|
|
198
|
+
const run = (name: string, event: unknown) => {
|
|
199
|
+
return handlers[name]?.(event, mockCtx);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const mockPi: Partial<ExtensionAPI> = {
|
|
203
|
+
on: vi.fn((event: string, handler: (...args: unknown[]) => unknown) => {
|
|
204
|
+
handlers[event] = handler;
|
|
205
|
+
return mockPi as ExtensionAPI;
|
|
206
|
+
}) as unknown as ExtensionAPI['on'],
|
|
207
|
+
appendEntry: appendEntrySpy,
|
|
208
|
+
registerCommand: registerCommandSpy as unknown as ExtensionAPI['registerCommand'],
|
|
209
|
+
events: {
|
|
210
|
+
on: vi.fn((event: string, listener: (payload: unknown) => void) => {
|
|
211
|
+
const list = eventListeners.get(event) ?? [];
|
|
212
|
+
list.push(listener);
|
|
213
|
+
eventListeners.set(event, list);
|
|
214
|
+
return () => {};
|
|
215
|
+
}) as unknown as ExtensionAPI['events']['on'],
|
|
216
|
+
emit: vi.fn(),
|
|
217
|
+
} as unknown as ExtensionAPI['events'],
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// ctx.ui.custom: by default resolve immediately without invoking the factory
|
|
221
|
+
// (avoids needing a terminal). Tests can set a result to simulate a choice.
|
|
222
|
+
customSpy.mockImplementation(() => Promise.resolve(customResult));
|
|
223
|
+
selectSpy.mockImplementation(() => Promise.resolve(selectResult));
|
|
224
|
+
inputSpy.mockImplementation(() => Promise.resolve(inputResult));
|
|
225
|
+
|
|
226
|
+
const sidecarFile = () => sidecarPathFor(TEST_SESSION_ID);
|
|
227
|
+
const seedSidecar = (events: SidecarEvent[]) => {
|
|
228
|
+
fs.mkdirSync(path.dirname(sidecarFile()), { recursive: true });
|
|
229
|
+
fs.writeFileSync(sidecarFile(), events.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
230
|
+
};
|
|
231
|
+
const readSidecarEvents = (): SidecarEvent[] => {
|
|
232
|
+
try {
|
|
233
|
+
return fs
|
|
234
|
+
.readFileSync(sidecarFile(), 'utf8')
|
|
235
|
+
.split('\n')
|
|
236
|
+
.filter((l) => l.trim())
|
|
237
|
+
.map((l) => JSON.parse(l) as SidecarEvent);
|
|
238
|
+
} catch {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const lastSidecarEvent = (kind: SidecarEvent['kind']): SidecarEvent | undefined => {
|
|
243
|
+
const events = readSidecarEvents();
|
|
244
|
+
for (let i = events.length - 1; i >= 0; i--) if (events[i]!.kind === kind) return events[i]!;
|
|
245
|
+
return undefined;
|
|
246
|
+
};
|
|
247
|
+
const clearSidecar = () => {
|
|
248
|
+
try {
|
|
249
|
+
fs.rmSync(sidecarFile(), { force: true });
|
|
250
|
+
} catch {
|
|
251
|
+
// ignore
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const sendEditorKey = (data: string) => {
|
|
256
|
+
if (!editorFactory) return;
|
|
257
|
+
if (!editorComponent) editorComponent = editorFactory(FAKE_TUI, FAKE_THEME, FAKE_KB);
|
|
258
|
+
editorComponent.handleInput(data);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
mockPi,
|
|
263
|
+
handlers,
|
|
264
|
+
commands,
|
|
265
|
+
appendEntrySpy,
|
|
266
|
+
notifySpy,
|
|
267
|
+
setStatusSpy,
|
|
268
|
+
customSpy,
|
|
269
|
+
selectSpy,
|
|
270
|
+
inputSpy,
|
|
271
|
+
registerCommandSpy,
|
|
272
|
+
setEditorComponentSpy,
|
|
273
|
+
sendEditorKey,
|
|
274
|
+
emitEvent,
|
|
275
|
+
run,
|
|
276
|
+
setCustomResult,
|
|
277
|
+
setSelectResult,
|
|
278
|
+
setInputResult,
|
|
279
|
+
mockEntries,
|
|
280
|
+
mockCtx,
|
|
281
|
+
seedSidecar,
|
|
282
|
+
readSidecarEvents,
|
|
283
|
+
lastSidecarEvent,
|
|
284
|
+
clearSidecar,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function activateExtension(fixture: TestFixture): Promise<void> {
|
|
289
|
+
const { default: ledgerExtension } = await import('../index.js');
|
|
290
|
+
ledgerExtension(fixture.mockPi as ExtensionAPI);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** An assistant message for the message_start/update/end hooks (fallback timing). */
|
|
294
|
+
export function makeAssistantMessage(
|
|
295
|
+
overrides: {
|
|
296
|
+
input?: number;
|
|
297
|
+
output?: number;
|
|
298
|
+
totalTokens?: number;
|
|
299
|
+
provider?: string;
|
|
300
|
+
model?: string;
|
|
301
|
+
} = {}
|
|
302
|
+
): unknown {
|
|
303
|
+
const {
|
|
304
|
+
input = 100,
|
|
305
|
+
output = 50,
|
|
306
|
+
totalTokens = 150,
|
|
307
|
+
provider = 'openai',
|
|
308
|
+
model = 'gpt-4',
|
|
309
|
+
} = overrides;
|
|
310
|
+
return {
|
|
311
|
+
role: 'assistant',
|
|
312
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
313
|
+
provider,
|
|
314
|
+
model,
|
|
315
|
+
usage: {
|
|
316
|
+
input,
|
|
317
|
+
output,
|
|
318
|
+
totalTokens,
|
|
319
|
+
cacheRead: 0,
|
|
320
|
+
cacheWrite: 0,
|
|
321
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
322
|
+
},
|
|
323
|
+
stopReason: 'stop',
|
|
324
|
+
timestamp: Date.now(),
|
|
325
|
+
};
|
|
326
|
+
}
|