@oh-my-pi/pi-coding-agent 5.5.0 → 5.6.70

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/docs/python-repl.md +77 -0
  3. package/examples/hooks/snake.ts +7 -7
  4. package/package.json +5 -5
  5. package/src/bun-imports.d.ts +6 -0
  6. package/src/cli/args.ts +7 -0
  7. package/src/cli/setup-cli.ts +231 -0
  8. package/src/cli.ts +2 -0
  9. package/src/core/agent-session.ts +118 -15
  10. package/src/core/bash-executor.ts +3 -84
  11. package/src/core/compaction/compaction.ts +10 -5
  12. package/src/core/extensions/index.ts +2 -0
  13. package/src/core/extensions/loader.ts +13 -1
  14. package/src/core/extensions/runner.ts +50 -2
  15. package/src/core/extensions/types.ts +67 -2
  16. package/src/core/keybindings.ts +51 -1
  17. package/src/core/prompt-templates.ts +15 -0
  18. package/src/core/python-executor-display.test.ts +42 -0
  19. package/src/core/python-executor-lifecycle.test.ts +99 -0
  20. package/src/core/python-executor-mapping.test.ts +41 -0
  21. package/src/core/python-executor-per-call.test.ts +49 -0
  22. package/src/core/python-executor-session.test.ts +103 -0
  23. package/src/core/python-executor-streaming.test.ts +77 -0
  24. package/src/core/python-executor-timeout.test.ts +35 -0
  25. package/src/core/python-executor.lifecycle.test.ts +139 -0
  26. package/src/core/python-executor.result.test.ts +49 -0
  27. package/src/core/python-executor.test.ts +180 -0
  28. package/src/core/python-executor.ts +313 -0
  29. package/src/core/python-gateway-coordinator.ts +832 -0
  30. package/src/core/python-kernel-display.test.ts +54 -0
  31. package/src/core/python-kernel-env.test.ts +138 -0
  32. package/src/core/python-kernel-session.test.ts +87 -0
  33. package/src/core/python-kernel-ws.test.ts +104 -0
  34. package/src/core/python-kernel.lifecycle.test.ts +249 -0
  35. package/src/core/python-kernel.test.ts +461 -0
  36. package/src/core/python-kernel.ts +1182 -0
  37. package/src/core/python-modules.test.ts +102 -0
  38. package/src/core/python-modules.ts +110 -0
  39. package/src/core/python-prelude.py +889 -0
  40. package/src/core/python-prelude.test.ts +140 -0
  41. package/src/core/python-prelude.ts +3 -0
  42. package/src/core/sdk.ts +24 -6
  43. package/src/core/session-manager.ts +174 -82
  44. package/src/core/settings-manager-python.test.ts +23 -0
  45. package/src/core/settings-manager.ts +202 -0
  46. package/src/core/streaming-output.test.ts +26 -0
  47. package/src/core/streaming-output.ts +100 -0
  48. package/src/core/system-prompt.python.test.ts +17 -0
  49. package/src/core/system-prompt.ts +3 -1
  50. package/src/core/timings.ts +1 -1
  51. package/src/core/tools/bash.ts +13 -2
  52. package/src/core/tools/edit-diff.ts +9 -1
  53. package/src/core/tools/index.test.ts +50 -23
  54. package/src/core/tools/index.ts +83 -1
  55. package/src/core/tools/python-execution.test.ts +68 -0
  56. package/src/core/tools/python-fallback.test.ts +72 -0
  57. package/src/core/tools/python-renderer.test.ts +36 -0
  58. package/src/core/tools/python-tool-mode.test.ts +43 -0
  59. package/src/core/tools/python.test.ts +121 -0
  60. package/src/core/tools/python.ts +760 -0
  61. package/src/core/tools/renderers.ts +2 -0
  62. package/src/core/tools/schema-validation.test.ts +1 -0
  63. package/src/core/tools/task/executor.ts +146 -3
  64. package/src/core/tools/task/worker-protocol.ts +32 -2
  65. package/src/core/tools/task/worker.ts +182 -15
  66. package/src/index.ts +6 -0
  67. package/src/main.ts +136 -40
  68. package/src/modes/interactive/components/custom-editor.ts +16 -31
  69. package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
  70. package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
  71. package/src/modes/interactive/components/history-search.ts +5 -8
  72. package/src/modes/interactive/components/hook-editor.ts +3 -4
  73. package/src/modes/interactive/components/hook-input.ts +3 -3
  74. package/src/modes/interactive/components/hook-selector.ts +5 -15
  75. package/src/modes/interactive/components/index.ts +1 -0
  76. package/src/modes/interactive/components/keybinding-hints.ts +66 -0
  77. package/src/modes/interactive/components/model-selector.ts +53 -66
  78. package/src/modes/interactive/components/oauth-selector.ts +5 -5
  79. package/src/modes/interactive/components/session-selector.ts +29 -23
  80. package/src/modes/interactive/components/settings-defs.ts +404 -196
  81. package/src/modes/interactive/components/settings-selector.ts +14 -10
  82. package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
  83. package/src/modes/interactive/components/tool-execution.ts +8 -0
  84. package/src/modes/interactive/components/tree-selector.ts +29 -23
  85. package/src/modes/interactive/components/user-message-selector.ts +6 -17
  86. package/src/modes/interactive/controllers/command-controller.ts +86 -37
  87. package/src/modes/interactive/controllers/event-controller.ts +8 -0
  88. package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
  89. package/src/modes/interactive/controllers/input-controller.ts +42 -6
  90. package/src/modes/interactive/interactive-mode.ts +56 -30
  91. package/src/modes/interactive/theme/theme-schema.json +2 -2
  92. package/src/modes/interactive/types.ts +6 -1
  93. package/src/modes/interactive/utils/ui-helpers.ts +2 -1
  94. package/src/modes/print-mode.ts +23 -0
  95. package/src/modes/rpc/rpc-mode.ts +21 -0
  96. package/src/prompts/agents/reviewer.md +1 -1
  97. package/src/prompts/system/system-prompt.md +32 -1
  98. package/src/prompts/tools/python.md +91 -0
@@ -0,0 +1,461 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
2
+ import { type KernelDisplayOutput, PythonKernel } from "./python-kernel";
3
+ import { PYTHON_PRELUDE } from "./python-prelude";
4
+
5
+ type JupyterMessage = {
6
+ channel: string;
7
+ header: {
8
+ msg_id: string;
9
+ session: string;
10
+ username: string;
11
+ date: string;
12
+ msg_type: string;
13
+ version: string;
14
+ };
15
+ parent_header: Record<string, unknown>;
16
+ metadata: Record<string, unknown>;
17
+ content: Record<string, unknown>;
18
+ buffers?: Uint8Array[];
19
+ };
20
+
21
+ const textEncoder = new TextEncoder();
22
+ const textDecoder = new TextDecoder();
23
+
24
+ function encodeMessage(msg: JupyterMessage): ArrayBuffer {
25
+ const msgText = JSON.stringify({
26
+ channel: msg.channel,
27
+ header: msg.header,
28
+ parent_header: msg.parent_header,
29
+ metadata: msg.metadata,
30
+ content: msg.content,
31
+ });
32
+ const msgBytes = textEncoder.encode(msgText);
33
+ const buffers = msg.buffers ?? [];
34
+ const offsetCount = 1 + buffers.length;
35
+ const headerSize = 4 + offsetCount * 4;
36
+ let totalSize = headerSize + msgBytes.length;
37
+ for (const buffer of buffers) {
38
+ totalSize += buffer.length;
39
+ }
40
+ const result = new ArrayBuffer(totalSize);
41
+ const view = new DataView(result);
42
+ const bytes = new Uint8Array(result);
43
+ view.setUint32(0, offsetCount, true);
44
+ let offset = headerSize;
45
+ view.setUint32(4, offset, true);
46
+ bytes.set(msgBytes, offset);
47
+ offset += msgBytes.length;
48
+ buffers.forEach((buffer, index) => {
49
+ view.setUint32(4 + (index + 1) * 4, offset, true);
50
+ bytes.set(buffer, offset);
51
+ offset += buffer.length;
52
+ });
53
+ return result;
54
+ }
55
+
56
+ function decodeMessage(data: ArrayBuffer): JupyterMessage {
57
+ const view = new DataView(data);
58
+ const offsetCount = view.getUint32(0, true);
59
+ const offsets: number[] = [];
60
+ for (let i = 0; i < offsetCount; i++) {
61
+ offsets.push(view.getUint32(4 + i * 4, true));
62
+ }
63
+ const msgStart = offsets[0];
64
+ const msgEnd = offsets.length > 1 ? offsets[1] : data.byteLength;
65
+ const msgBytes = new Uint8Array(data, msgStart, msgEnd - msgStart);
66
+ const msgText = textDecoder.decode(msgBytes);
67
+ return JSON.parse(msgText) as JupyterMessage;
68
+ }
69
+
70
+ function sendOkExecution(ws: FakeWebSocket, msgId: string, executionCount = 1) {
71
+ const reply: JupyterMessage = {
72
+ channel: "shell",
73
+ header: {
74
+ msg_id: `reply-${msgId}`,
75
+ session: "session",
76
+ username: "omp",
77
+ date: new Date().toISOString(),
78
+ msg_type: "execute_reply",
79
+ version: "5.5",
80
+ },
81
+ parent_header: { msg_id: msgId },
82
+ metadata: {},
83
+ content: { status: "ok", execution_count: executionCount },
84
+ };
85
+ const status: JupyterMessage = {
86
+ channel: "iopub",
87
+ header: {
88
+ msg_id: `status-${msgId}`,
89
+ session: "session",
90
+ username: "omp",
91
+ date: new Date().toISOString(),
92
+ msg_type: "status",
93
+ version: "5.5",
94
+ },
95
+ parent_header: { msg_id: msgId },
96
+ metadata: {},
97
+ content: { execution_state: "idle" },
98
+ };
99
+ ws.onmessage?.({ data: encodeMessage(reply) });
100
+ ws.onmessage?.({ data: encodeMessage(status) });
101
+ }
102
+
103
+ class FakeWebSocket {
104
+ static OPEN = 1;
105
+ static CLOSED = 3;
106
+ static lastInstance: FakeWebSocket | null = null;
107
+ readyState = FakeWebSocket.OPEN;
108
+ binaryType = "arraybuffer";
109
+ onopen?: () => void;
110
+ onmessage?: (event: { data: ArrayBuffer }) => void;
111
+ onerror?: (event: unknown) => void;
112
+ onclose?: () => void;
113
+ readonly url: string;
114
+ readonly sent: (ArrayBuffer | string)[] = [];
115
+ private handleSend: ((data: ArrayBuffer | string) => void) | null = null;
116
+ private pendingMessages: (ArrayBuffer | string)[] = [];
117
+
118
+ constructor(url: string) {
119
+ this.url = url;
120
+ FakeWebSocket.lastInstance = this;
121
+ queueMicrotask(() => this.onopen?.());
122
+ }
123
+
124
+ setSendHandler(handler: (data: ArrayBuffer | string) => void) {
125
+ this.handleSend = handler;
126
+ for (const msg of this.pendingMessages) {
127
+ handler(msg);
128
+ }
129
+ this.pendingMessages = [];
130
+ }
131
+
132
+ send(data: ArrayBuffer | string) {
133
+ this.sent.push(data);
134
+ if (this.handleSend) {
135
+ this.handleSend(data);
136
+ } else {
137
+ this.pendingMessages.push(data);
138
+ }
139
+ }
140
+
141
+ close() {
142
+ this.readyState = FakeWebSocket.CLOSED;
143
+ this.onclose?.();
144
+ }
145
+ }
146
+
147
+ describe("PythonKernel (external gateway)", () => {
148
+ const originalEnv = { ...process.env };
149
+ const originalFetch = globalThis.fetch;
150
+ const originalWebSocket = globalThis.WebSocket;
151
+
152
+ beforeEach(() => {
153
+ process.env.OMP_PYTHON_GATEWAY_URL = "http://gateway.test";
154
+ process.env.OMP_PYTHON_SKIP_CHECK = "1";
155
+ globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
156
+ });
157
+
158
+ afterEach(() => {
159
+ for (const key of Object.keys(process.env)) {
160
+ if (!(key in originalEnv)) {
161
+ delete process.env[key];
162
+ }
163
+ }
164
+ for (const [key, value] of Object.entries(originalEnv)) {
165
+ process.env[key] = value;
166
+ }
167
+ globalThis.fetch = originalFetch;
168
+ globalThis.WebSocket = originalWebSocket;
169
+ FakeWebSocket.lastInstance = null;
170
+ vi.restoreAllMocks();
171
+ });
172
+
173
+ it("executes code via websocket stream and display data", async () => {
174
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
175
+ if (url.endsWith("/api/kernels") && init?.method === "POST") {
176
+ return new Response(JSON.stringify({ id: "kernel-1" }), { status: 201 });
177
+ }
178
+ if (url.includes("/api/kernels/") && init?.method === "DELETE") {
179
+ return new Response("", { status: 204 });
180
+ }
181
+ return new Response("", { status: 200 });
182
+ });
183
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
184
+
185
+ let preludeSeen = false;
186
+
187
+ const kernelPromise = PythonKernel.start({ cwd: "/" });
188
+ await Bun.sleep(10);
189
+ const ws = FakeWebSocket.lastInstance;
190
+ if (!ws) throw new Error("WebSocket not initialized");
191
+ ws.setSendHandler((data) => {
192
+ const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
193
+ const code = String(msg.content.code ?? "");
194
+ if (!preludeSeen) {
195
+ expect(code).toBe(PYTHON_PRELUDE);
196
+ preludeSeen = true;
197
+ sendOkExecution(ws, msg.header.msg_id);
198
+ return;
199
+ }
200
+
201
+ if (code === "print('hello')") {
202
+ const stream: JupyterMessage = {
203
+ channel: "iopub",
204
+ header: {
205
+ msg_id: "stream-1",
206
+ session: "session",
207
+ username: "omp",
208
+ date: new Date().toISOString(),
209
+ msg_type: "stream",
210
+ version: "5.5",
211
+ },
212
+ parent_header: { msg_id: msg.header.msg_id },
213
+ metadata: {},
214
+ content: { text: "hello\n" },
215
+ };
216
+ const display: JupyterMessage = {
217
+ channel: "iopub",
218
+ header: {
219
+ msg_id: "display-1",
220
+ session: "session",
221
+ username: "omp",
222
+ date: new Date().toISOString(),
223
+ msg_type: "execute_result",
224
+ version: "5.5",
225
+ },
226
+ parent_header: { msg_id: msg.header.msg_id },
227
+ metadata: {},
228
+ content: {
229
+ data: {
230
+ "text/plain": "result",
231
+ "application/json": { answer: 42 },
232
+ },
233
+ },
234
+ };
235
+ const reply: JupyterMessage = {
236
+ channel: "shell",
237
+ header: {
238
+ msg_id: "reply-2",
239
+ session: "session",
240
+ username: "omp",
241
+ date: new Date().toISOString(),
242
+ msg_type: "execute_reply",
243
+ version: "5.5",
244
+ },
245
+ parent_header: { msg_id: msg.header.msg_id },
246
+ metadata: {},
247
+ content: { status: "ok", execution_count: 2 },
248
+ };
249
+ const status: JupyterMessage = {
250
+ channel: "iopub",
251
+ header: {
252
+ msg_id: "status-2",
253
+ session: "session",
254
+ username: "omp",
255
+ date: new Date().toISOString(),
256
+ msg_type: "status",
257
+ version: "5.5",
258
+ },
259
+ parent_header: { msg_id: msg.header.msg_id },
260
+ metadata: {},
261
+ content: { execution_state: "idle" },
262
+ };
263
+ ws.onmessage?.({ data: encodeMessage(stream) });
264
+ ws.onmessage?.({ data: encodeMessage(display) });
265
+ ws.onmessage?.({ data: encodeMessage(reply) });
266
+ ws.onmessage?.({ data: encodeMessage(status) });
267
+ return;
268
+ }
269
+
270
+ sendOkExecution(ws, msg.header.msg_id);
271
+ });
272
+
273
+ const kernel = await kernelPromise;
274
+ const chunks: string[] = [];
275
+ const displays: KernelDisplayOutput[] = [];
276
+
277
+ const result = await kernel.execute("print('hello')", {
278
+ onChunk: (text) => {
279
+ chunks.push(text);
280
+ },
281
+ onDisplay: (output) => {
282
+ displays.push(output);
283
+ },
284
+ });
285
+
286
+ expect(result.status).toBe("ok");
287
+ expect(chunks.join("")).toContain("hello");
288
+ expect(chunks.join("")).toContain("result");
289
+ expect(displays).toEqual([{ type: "json", data: { answer: 42 } }]);
290
+
291
+ await kernel.shutdown();
292
+ expect(fetchMock).toHaveBeenCalledWith("http://gateway.test/api/kernels/kernel-1", {
293
+ method: "DELETE",
294
+ headers: {},
295
+ });
296
+ });
297
+
298
+ it("marks kernel dead after repeated ping failures", async () => {
299
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
300
+ if (url.endsWith("/api/kernels") && init?.method === "POST") {
301
+ return new Response(JSON.stringify({ id: "kernel-2" }), { status: 201 });
302
+ }
303
+ if (url.includes("/api/kernels/kernel-2") && !init?.method) {
304
+ throw new Error("ping failed");
305
+ }
306
+ return new Response("", { status: 200 });
307
+ });
308
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
309
+
310
+ let preludeSeen = false;
311
+
312
+ const kernelPromise = PythonKernel.start({ cwd: "/" });
313
+ await Bun.sleep(10);
314
+ const ws = FakeWebSocket.lastInstance;
315
+ if (!ws) throw new Error("WebSocket not initialized");
316
+ ws.setSendHandler((data) => {
317
+ const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
318
+ const code = String(msg.content.code ?? "");
319
+ if (!preludeSeen) {
320
+ expect(code).toBe(PYTHON_PRELUDE);
321
+ preludeSeen = true;
322
+ }
323
+ sendOkExecution(ws, msg.header.msg_id);
324
+ });
325
+
326
+ const kernel = await kernelPromise;
327
+ const firstPing = await kernel.ping(1);
328
+ const secondPing = await kernel.ping(1);
329
+
330
+ expect(firstPing).toBe(false);
331
+ expect(secondPing).toBe(false);
332
+ expect(kernel.isAlive()).toBe(false);
333
+
334
+ await kernel.shutdown();
335
+ });
336
+
337
+ it("initializes the IPython prelude", async () => {
338
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
339
+ if (url.endsWith("/api/kernels") && init?.method === "POST") {
340
+ return new Response(JSON.stringify({ id: "kernel-3" }), { status: 201 });
341
+ }
342
+ return new Response("", { status: 200 });
343
+ });
344
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
345
+
346
+ let preludeSeen = false;
347
+
348
+ const kernelPromise = PythonKernel.start({ cwd: "/" });
349
+ await Bun.sleep(10);
350
+ const ws = FakeWebSocket.lastInstance;
351
+ if (!ws) throw new Error("WebSocket not initialized");
352
+ ws.setSendHandler((data) => {
353
+ const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
354
+ const code = String(msg.content.code ?? "");
355
+ if (!preludeSeen) {
356
+ expect(code).toBe(PYTHON_PRELUDE);
357
+ preludeSeen = true;
358
+ }
359
+ sendOkExecution(ws, msg.header.msg_id);
360
+ });
361
+
362
+ const kernel = await kernelPromise;
363
+ expect(kernel.isAlive()).toBe(true);
364
+ await kernel.shutdown();
365
+ });
366
+
367
+ it("introspects prelude helpers", async () => {
368
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
369
+ if (url.endsWith("/api/kernels") && init?.method === "POST") {
370
+ return new Response(JSON.stringify({ id: "kernel-4" }), { status: 201 });
371
+ }
372
+ return new Response("", { status: 200 });
373
+ });
374
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
375
+
376
+ const docs = [
377
+ {
378
+ name: "read",
379
+ signature: "(path, limit=None)",
380
+ docstring: "Read file contents.",
381
+ category: "File I/O",
382
+ },
383
+ ];
384
+ const payload = JSON.stringify(docs);
385
+
386
+ let preludeSeen = false;
387
+
388
+ const kernelPromise = PythonKernel.start({ cwd: "/" });
389
+ await Bun.sleep(10);
390
+ const ws = FakeWebSocket.lastInstance;
391
+ if (!ws) throw new Error("WebSocket not initialized");
392
+ ws.setSendHandler((data) => {
393
+ const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
394
+ const code = String(msg.content.code ?? "");
395
+ if (!preludeSeen) {
396
+ expect(code).toBe(PYTHON_PRELUDE);
397
+ preludeSeen = true;
398
+ sendOkExecution(ws, msg.header.msg_id);
399
+ return;
400
+ }
401
+
402
+ if (code.includes("__omp_prelude_docs__")) {
403
+ const stream: JupyterMessage = {
404
+ channel: "iopub",
405
+ header: {
406
+ msg_id: "stream-docs",
407
+ session: "session",
408
+ username: "omp",
409
+ date: new Date().toISOString(),
410
+ msg_type: "stream",
411
+ version: "5.5",
412
+ },
413
+ parent_header: { msg_id: msg.header.msg_id },
414
+ metadata: {},
415
+ content: { text: `${payload}\n` },
416
+ };
417
+ const reply: JupyterMessage = {
418
+ channel: "shell",
419
+ header: {
420
+ msg_id: "reply-docs",
421
+ session: "session",
422
+ username: "omp",
423
+ date: new Date().toISOString(),
424
+ msg_type: "execute_reply",
425
+ version: "5.5",
426
+ },
427
+ parent_header: { msg_id: msg.header.msg_id },
428
+ metadata: {},
429
+ content: { status: "ok", execution_count: 2 },
430
+ };
431
+ const status: JupyterMessage = {
432
+ channel: "iopub",
433
+ header: {
434
+ msg_id: "status-docs",
435
+ session: "session",
436
+ username: "omp",
437
+ date: new Date().toISOString(),
438
+ msg_type: "status",
439
+ version: "5.5",
440
+ },
441
+ parent_header: { msg_id: msg.header.msg_id },
442
+ metadata: {},
443
+ content: { execution_state: "idle" },
444
+ };
445
+ ws.onmessage?.({ data: encodeMessage(stream) });
446
+ ws.onmessage?.({ data: encodeMessage(reply) });
447
+ ws.onmessage?.({ data: encodeMessage(status) });
448
+ return;
449
+ }
450
+
451
+ sendOkExecution(ws, msg.header.msg_id);
452
+ });
453
+
454
+ const kernel = await kernelPromise;
455
+ const result = await kernel.introspectPrelude();
456
+ expect(result).toEqual(docs);
457
+ await kernel.shutdown();
458
+ });
459
+ });
460
+
461
+ // TODO: add coverage for gateway process exit handling once PythonKernel exposes a test hook.