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

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 (96) hide show
  1. package/CHANGELOG.md +98 -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 +549 -0
  36. package/src/core/python-kernel.ts +1178 -0
  37. package/src/core/python-prelude.py +889 -0
  38. package/src/core/python-prelude.test.ts +140 -0
  39. package/src/core/python-prelude.ts +3 -0
  40. package/src/core/sdk.ts +24 -6
  41. package/src/core/session-manager.ts +174 -82
  42. package/src/core/settings-manager-python.test.ts +23 -0
  43. package/src/core/settings-manager.ts +202 -0
  44. package/src/core/streaming-output.test.ts +26 -0
  45. package/src/core/streaming-output.ts +100 -0
  46. package/src/core/system-prompt.python.test.ts +17 -0
  47. package/src/core/system-prompt.ts +3 -1
  48. package/src/core/timings.ts +1 -1
  49. package/src/core/tools/bash.ts +13 -2
  50. package/src/core/tools/edit-diff.ts +9 -1
  51. package/src/core/tools/index.test.ts +50 -23
  52. package/src/core/tools/index.ts +83 -1
  53. package/src/core/tools/python-execution.test.ts +68 -0
  54. package/src/core/tools/python-fallback.test.ts +72 -0
  55. package/src/core/tools/python-renderer.test.ts +36 -0
  56. package/src/core/tools/python-tool-mode.test.ts +43 -0
  57. package/src/core/tools/python.test.ts +121 -0
  58. package/src/core/tools/python.ts +760 -0
  59. package/src/core/tools/renderers.ts +2 -0
  60. package/src/core/tools/schema-validation.test.ts +1 -0
  61. package/src/core/tools/task/executor.ts +146 -3
  62. package/src/core/tools/task/worker-protocol.ts +32 -2
  63. package/src/core/tools/task/worker.ts +182 -15
  64. package/src/index.ts +6 -0
  65. package/src/main.ts +136 -40
  66. package/src/modes/interactive/components/custom-editor.ts +16 -31
  67. package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
  68. package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
  69. package/src/modes/interactive/components/history-search.ts +5 -8
  70. package/src/modes/interactive/components/hook-editor.ts +3 -4
  71. package/src/modes/interactive/components/hook-input.ts +3 -3
  72. package/src/modes/interactive/components/hook-selector.ts +5 -15
  73. package/src/modes/interactive/components/index.ts +1 -0
  74. package/src/modes/interactive/components/keybinding-hints.ts +66 -0
  75. package/src/modes/interactive/components/model-selector.ts +53 -66
  76. package/src/modes/interactive/components/oauth-selector.ts +5 -5
  77. package/src/modes/interactive/components/session-selector.ts +29 -23
  78. package/src/modes/interactive/components/settings-defs.ts +404 -196
  79. package/src/modes/interactive/components/settings-selector.ts +14 -10
  80. package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
  81. package/src/modes/interactive/components/tool-execution.ts +8 -0
  82. package/src/modes/interactive/components/tree-selector.ts +29 -23
  83. package/src/modes/interactive/components/user-message-selector.ts +6 -17
  84. package/src/modes/interactive/controllers/command-controller.ts +86 -37
  85. package/src/modes/interactive/controllers/event-controller.ts +8 -0
  86. package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
  87. package/src/modes/interactive/controllers/input-controller.ts +42 -6
  88. package/src/modes/interactive/interactive-mode.ts +56 -30
  89. package/src/modes/interactive/theme/theme-schema.json +2 -2
  90. package/src/modes/interactive/types.ts +6 -1
  91. package/src/modes/interactive/utils/ui-helpers.ts +2 -1
  92. package/src/modes/print-mode.ts +23 -0
  93. package/src/modes/rpc/rpc-mode.ts +21 -0
  94. package/src/prompts/agents/reviewer.md +1 -1
  95. package/src/prompts/system/system-prompt.md +32 -1
  96. package/src/prompts/tools/python.md +91 -0
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { type KernelDisplayOutput, PythonKernel } from "./python-kernel";
3
+
4
+ const renderDisplay = (
5
+ PythonKernel as unknown as {
6
+ prototype: {
7
+ renderDisplay: (content: Record<string, unknown>) => {
8
+ text: string;
9
+ outputs: KernelDisplayOutput[];
10
+ };
11
+ };
12
+ }
13
+ ).prototype.renderDisplay;
14
+
15
+ describe("PythonKernel display rendering", () => {
16
+ it("normalizes text/plain output and returns no display outputs", () => {
17
+ const { text, outputs } = renderDisplay.call({} as PythonKernel, {
18
+ data: { "text/plain": "hello" },
19
+ });
20
+
21
+ expect(text).toBe("hello\n");
22
+ expect(outputs).toHaveLength(0);
23
+ });
24
+
25
+ it("collects image and json display outputs without text", () => {
26
+ const { text, outputs } = renderDisplay.call({} as PythonKernel, {
27
+ data: { "image/png": "abc", "application/json": { foo: "bar" } },
28
+ });
29
+
30
+ expect(text).toBe("");
31
+ expect(outputs).toEqual([
32
+ { type: "image", data: "abc", mimeType: "image/png" },
33
+ { type: "json", data: { foo: "bar" } },
34
+ ]);
35
+ });
36
+
37
+ it("converts text/html to markdown", () => {
38
+ const { text, outputs } = renderDisplay.call({} as PythonKernel, {
39
+ data: { "text/html": "<p><strong>Hello</strong></p>" },
40
+ });
41
+
42
+ expect(outputs).toHaveLength(0);
43
+ expect(text).toBe("**Hello**\n");
44
+ });
45
+
46
+ it("combines text/plain with json output", () => {
47
+ const { text, outputs } = renderDisplay.call({} as PythonKernel, {
48
+ data: { "text/plain": "value", "application/json": { ok: true } },
49
+ });
50
+
51
+ expect(text).toBe("value\n");
52
+ expect(outputs).toEqual([{ type: "json", data: { ok: true } }]);
53
+ });
54
+ });
@@ -0,0 +1,138 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
2
+ import * as shell from "../utils/shell";
3
+ import * as shellSnapshot from "../utils/shell-snapshot";
4
+ import { PythonKernel } from "./python-kernel";
5
+ import { PYTHON_PRELUDE } from "./python-prelude";
6
+
7
+ class FakeWebSocket {
8
+ static OPEN = 1;
9
+ static CLOSED = 3;
10
+ readyState = FakeWebSocket.OPEN;
11
+ binaryType = "arraybuffer";
12
+ url: string;
13
+ onopen?: () => void;
14
+ onerror?: (event: unknown) => void;
15
+ onclose?: () => void;
16
+ onmessage?: (event: { data: ArrayBuffer }) => void;
17
+
18
+ constructor(url: string) {
19
+ this.url = url;
20
+ queueMicrotask(() => {
21
+ this.onopen?.();
22
+ });
23
+ }
24
+
25
+ send(_data: ArrayBuffer) {}
26
+
27
+ close() {
28
+ this.readyState = FakeWebSocket.CLOSED;
29
+ this.onclose?.();
30
+ }
31
+ }
32
+
33
+ describe("PythonKernel.start (local gateway)", () => {
34
+ const originalEnv = { ...process.env };
35
+ const originalFetch = globalThis.fetch;
36
+ const originalWebSocket = globalThis.WebSocket;
37
+
38
+ beforeEach(() => {
39
+ process.env.BUN_ENV = "test";
40
+ delete process.env.OMP_PYTHON_GATEWAY_URL;
41
+ delete process.env.OMP_PYTHON_GATEWAY_TOKEN;
42
+ globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
43
+ });
44
+
45
+ afterEach(() => {
46
+ for (const key of Object.keys(process.env)) {
47
+ if (!(key in originalEnv)) {
48
+ delete process.env[key];
49
+ }
50
+ }
51
+ for (const [key, value] of Object.entries(originalEnv)) {
52
+ process.env[key] = value;
53
+ }
54
+ globalThis.fetch = originalFetch;
55
+ globalThis.WebSocket = originalWebSocket;
56
+ vi.restoreAllMocks();
57
+ });
58
+
59
+ it("filters environment variables before spawning gateway", async () => {
60
+ const fetchSpy = vi.fn(async (input: string | URL, init?: RequestInit) => {
61
+ const url = typeof input === "string" ? input : input.toString();
62
+ if (url.endsWith("/api/kernelspecs")) {
63
+ return new Response(JSON.stringify({}), { status: 200 });
64
+ }
65
+ if (url.endsWith("/api/kernels") && init?.method === "POST") {
66
+ return new Response(JSON.stringify({ id: "kernel-1" }), { status: 201 });
67
+ }
68
+ return new Response("", { status: 200 });
69
+ });
70
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
71
+
72
+ const shellSpy = vi.spyOn(shell, "getShellConfig").mockResolvedValue({
73
+ shell: "/bin/bash",
74
+ args: ["-lc"],
75
+ env: {
76
+ PATH: "/bin",
77
+ HOME: "/home/test",
78
+ OPENAI_API_KEY: "secret",
79
+ UNSAFE_TOKEN: "nope",
80
+ OMP_CUSTOM: "1",
81
+ LC_ALL: "en_US.UTF-8",
82
+ },
83
+ prefix: undefined,
84
+ });
85
+ const snapshotSpy = vi.spyOn(shellSnapshot, "getOrCreateSnapshot").mockResolvedValue(null);
86
+ const whichSpy = vi.spyOn(Bun, "which").mockReturnValue("/usr/bin/python");
87
+
88
+ let spawnEnv: Record<string, string | undefined> | undefined;
89
+ let spawnArgs: string[] | undefined;
90
+ const spawnSpy = vi.spyOn(Bun, "spawn").mockImplementation(((...args: unknown[]) => {
91
+ const [cmd, options] = args as [string[] | { cmd: string[] }, { env?: Record<string, string | undefined> }?];
92
+ spawnArgs = Array.isArray(cmd) ? cmd : cmd.cmd;
93
+ spawnEnv = options?.env;
94
+ return { pid: 1234, exited: Promise.resolve(0) } as unknown as Bun.Subprocess;
95
+ }) as unknown as typeof Bun.spawn);
96
+
97
+ const executeSpy = vi
98
+ .spyOn(PythonKernel.prototype, "execute")
99
+ .mockResolvedValue({ status: "ok", cancelled: false, timedOut: false, stdinRequested: false });
100
+
101
+ const kernel = await PythonKernel.start({ cwd: "/tmp/project", env: { CUSTOM_VAR: "ok" } });
102
+
103
+ const createCall = fetchSpy.mock.calls.find(([input, init]) => {
104
+ const url = typeof input === "string" ? input : input.toString();
105
+ return url.endsWith("/api/kernels") && init?.method === "POST";
106
+ });
107
+ expect(createCall).toBeDefined();
108
+ if (createCall) {
109
+ expect(JSON.parse(String(createCall[1]?.body ?? "{}"))).toEqual({ name: "python3" });
110
+ }
111
+
112
+ expect(spawnArgs).toContain("kernel_gateway");
113
+ expect(spawnEnv?.PATH).toBe("/bin");
114
+ expect(spawnEnv?.HOME).toBe("/home/test");
115
+ expect(spawnEnv?.OMP_CUSTOM).toBe("1");
116
+ expect(spawnEnv?.LC_ALL).toBe("en_US.UTF-8");
117
+ expect(spawnEnv?.CUSTOM_VAR).toBe("ok");
118
+ expect(spawnEnv?.OPENAI_API_KEY).toBeUndefined();
119
+ expect(spawnEnv?.UNSAFE_TOKEN).toBeUndefined();
120
+ expect(spawnEnv?.PYTHONPATH).toBe("/tmp/project");
121
+
122
+ expect(executeSpy).toHaveBeenCalledWith(
123
+ PYTHON_PRELUDE,
124
+ expect.objectContaining({
125
+ silent: true,
126
+ storeHistory: false,
127
+ }),
128
+ );
129
+
130
+ await kernel.shutdown();
131
+
132
+ shellSpy.mockRestore();
133
+ snapshotSpy.mockRestore();
134
+ whichSpy.mockRestore();
135
+ spawnSpy.mockRestore();
136
+ executeSpy.mockRestore();
137
+ });
138
+ });
@@ -0,0 +1,87 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { disposeAllKernelSessions, executePython } from "./python-executor";
3
+ import type { KernelExecuteOptions, KernelExecuteResult } from "./python-kernel";
4
+ import { PythonKernel } from "./python-kernel";
5
+
6
+ class FakeKernel {
7
+ executeCalls = 0;
8
+ shutdownCalls = 0;
9
+ alive = true;
10
+ readonly id: string;
11
+
12
+ constructor(id: string) {
13
+ this.id = id;
14
+ }
15
+
16
+ async execute(_code: string, options?: KernelExecuteOptions): Promise<KernelExecuteResult> {
17
+ this.executeCalls += 1;
18
+ options?.onChunk?.("ok\n");
19
+ return { status: "ok", cancelled: false, timedOut: false, stdinRequested: false };
20
+ }
21
+
22
+ async shutdown(): Promise<void> {
23
+ this.shutdownCalls += 1;
24
+ this.alive = false;
25
+ }
26
+
27
+ isAlive(): boolean {
28
+ return this.alive;
29
+ }
30
+
31
+ async ping(): Promise<boolean> {
32
+ return this.alive;
33
+ }
34
+ }
35
+
36
+ describe("executePython kernel reuse", () => {
37
+ const originalStart = PythonKernel.start;
38
+ let startCalls = 0;
39
+ let kernels: FakeKernel[] = [];
40
+
41
+ beforeEach(() => {
42
+ process.env.OMP_PYTHON_SKIP_CHECK = "1";
43
+ startCalls = 0;
44
+ kernels = [];
45
+ PythonKernel.start = (async () => {
46
+ startCalls += 1;
47
+ const kernel = new FakeKernel(`kernel-${startCalls}`);
48
+ kernels.push(kernel);
49
+ return kernel as unknown as PythonKernel;
50
+ }) as typeof PythonKernel.start;
51
+ });
52
+
53
+ afterEach(async () => {
54
+ PythonKernel.start = originalStart;
55
+ await disposeAllKernelSessions();
56
+ });
57
+
58
+ it("reuses kernels for session mode", async () => {
59
+ await executePython("print('one')", { cwd: "/tmp", sessionId: "session-a", kernelMode: "session" });
60
+ await executePython("print('two')", { cwd: "/tmp", sessionId: "session-a", kernelMode: "session" });
61
+
62
+ expect(startCalls).toBe(1);
63
+ expect(kernels[0]?.executeCalls).toBe(2);
64
+ });
65
+
66
+ it("creates and disposes per-call kernels", async () => {
67
+ await executePython("print('one')", { cwd: "/tmp", kernelMode: "per-call" });
68
+ await executePython("print('two')", { cwd: "/tmp", kernelMode: "per-call" });
69
+
70
+ expect(startCalls).toBe(2);
71
+ expect(kernels[0]?.shutdownCalls).toBe(1);
72
+ expect(kernels[1]?.shutdownCalls).toBe(1);
73
+ });
74
+
75
+ it("resets the session kernel when requested", async () => {
76
+ await executePython("print('one')", { cwd: "/tmp", sessionId: "session-b", kernelMode: "session" });
77
+ await executePython("print('two')", {
78
+ cwd: "/tmp",
79
+ sessionId: "session-b",
80
+ kernelMode: "session",
81
+ reset: true,
82
+ });
83
+
84
+ expect(startCalls).toBe(2);
85
+ expect(kernels[0]?.shutdownCalls).toBe(1);
86
+ });
87
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { deserializeWebSocketMessage, type JupyterMessage, serializeWebSocketMessage } from "./python-kernel";
3
+
4
+ const encoder = new TextEncoder();
5
+
6
+ function buildFrame(message: Omit<JupyterMessage, "buffers">, buffers: Uint8Array[] = []): ArrayBuffer {
7
+ const msgBytes = encoder.encode(JSON.stringify(message));
8
+ const offsetCount = 1 + buffers.length;
9
+ const headerSize = 4 + offsetCount * 4;
10
+
11
+ let totalSize = headerSize + msgBytes.length;
12
+ for (const buffer of buffers) {
13
+ totalSize += buffer.length;
14
+ }
15
+
16
+ const frame = new ArrayBuffer(totalSize);
17
+ const view = new DataView(frame);
18
+ const bytes = new Uint8Array(frame);
19
+
20
+ view.setUint32(0, offsetCount, true);
21
+ view.setUint32(4, headerSize, true);
22
+ bytes.set(msgBytes, headerSize);
23
+
24
+ let offset = headerSize + msgBytes.length;
25
+ for (let i = 0; i < buffers.length; i++) {
26
+ view.setUint32(4 + (i + 1) * 4, offset, true);
27
+ bytes.set(buffers[i], offset);
28
+ offset += buffers[i].length;
29
+ }
30
+
31
+ return frame;
32
+ }
33
+
34
+ describe("deserializeWebSocketMessage", () => {
35
+ it("parses offset tables and buffers", () => {
36
+ const message = {
37
+ channel: "iopub",
38
+ header: {
39
+ msg_id: "msg-1",
40
+ session: "session-1",
41
+ username: "omp",
42
+ date: "2024-01-01T00:00:00Z",
43
+ msg_type: "stream",
44
+ version: "5.5",
45
+ },
46
+ parent_header: {},
47
+ metadata: {},
48
+ content: { text: "hello" },
49
+ };
50
+ const buffer = new Uint8Array([1, 2, 3]);
51
+ const frame = buildFrame(message, [buffer]);
52
+
53
+ const parsed = deserializeWebSocketMessage(frame);
54
+
55
+ expect(parsed).not.toBeNull();
56
+ expect(parsed?.header.msg_id).toBe("msg-1");
57
+ expect(parsed?.content).toEqual({ text: "hello" });
58
+ expect(parsed?.buffers?.[0]).toEqual(buffer);
59
+ });
60
+
61
+ it("returns null for invalid frames", () => {
62
+ const headerSize = 8;
63
+ const bytes = encoder.encode("not-json");
64
+ const frame = new ArrayBuffer(headerSize + bytes.length);
65
+ const view = new DataView(frame);
66
+ const data = new Uint8Array(frame);
67
+ view.setUint32(0, 1, true);
68
+ view.setUint32(4, headerSize, true);
69
+ data.set(bytes, headerSize);
70
+
71
+ expect(deserializeWebSocketMessage(frame)).toBeNull();
72
+ const emptyFrame = new ArrayBuffer(4);
73
+ new DataView(emptyFrame).setUint32(0, 0, true);
74
+ expect(deserializeWebSocketMessage(emptyFrame)).toBeNull();
75
+ });
76
+ });
77
+
78
+ describe("serializeWebSocketMessage", () => {
79
+ it("round trips message payloads", () => {
80
+ const message: JupyterMessage = {
81
+ channel: "shell",
82
+ header: {
83
+ msg_id: "msg-2",
84
+ session: "session-2",
85
+ username: "omp",
86
+ date: "2024-02-01T00:00:00Z",
87
+ msg_type: "execute_request",
88
+ version: "5.5",
89
+ },
90
+ parent_header: { parent: "root" },
91
+ metadata: { tag: "meta" },
92
+ content: { code: "print('hi')" },
93
+ buffers: [new Uint8Array([9, 8, 7])],
94
+ };
95
+
96
+ const frame = serializeWebSocketMessage(message);
97
+ const parsed = deserializeWebSocketMessage(frame);
98
+
99
+ expect(parsed).not.toBeNull();
100
+ expect(parsed?.header.msg_type).toBe("execute_request");
101
+ expect(parsed?.content).toEqual({ code: "print('hi')" });
102
+ expect(parsed?.buffers?.[0]).toEqual(new Uint8Array([9, 8, 7]));
103
+ });
104
+ });
@@ -0,0 +1,249 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { Subprocess } from "bun";
6
+ import { PythonKernel } from "./python-kernel";
7
+
8
+ type SpawnOptions = Parameters<typeof Bun.spawn>[1];
9
+
10
+ type FetchCall = { url: string; init?: RequestInit };
11
+
12
+ type FetchResponse = {
13
+ ok: boolean;
14
+ status: number;
15
+ json: () => Promise<unknown>;
16
+ text: () => Promise<string>;
17
+ };
18
+
19
+ type MockEnvironment = {
20
+ fetchCalls: FetchCall[];
21
+ spawnCalls: { cmd: string[]; options: SpawnOptions }[];
22
+ };
23
+
24
+ type MessageEventPayload = { data: ArrayBuffer };
25
+
26
+ type WebSocketHandler = (event: unknown) => void;
27
+
28
+ type WebSocketMessageHandler = (event: MessageEventPayload) => void;
29
+
30
+ class FakeWebSocket {
31
+ static OPEN = 1;
32
+ static CLOSED = 3;
33
+ static instances: FakeWebSocket[] = [];
34
+
35
+ readyState = FakeWebSocket.OPEN;
36
+ binaryType = "arraybuffer";
37
+ url: string;
38
+ sent: ArrayBuffer[] = [];
39
+
40
+ onopen: WebSocketHandler | null = null;
41
+ onerror: WebSocketHandler | null = null;
42
+ onclose: WebSocketHandler | null = null;
43
+ onmessage: WebSocketMessageHandler | null = null;
44
+
45
+ constructor(url: string) {
46
+ this.url = url;
47
+ FakeWebSocket.instances.push(this);
48
+ queueMicrotask(() => {
49
+ this.onopen?.(undefined);
50
+ });
51
+ }
52
+
53
+ send(data: ArrayBuffer): void {
54
+ this.sent.push(data);
55
+ }
56
+
57
+ close(): void {
58
+ this.readyState = FakeWebSocket.CLOSED;
59
+ this.onclose?.(undefined);
60
+ }
61
+ }
62
+
63
+ const createResponse = (options: { ok: boolean; status?: number; json?: unknown; text?: string }): FetchResponse => {
64
+ return {
65
+ ok: options.ok,
66
+ status: options.status ?? (options.ok ? 200 : 500),
67
+ json: async () => options.json ?? {},
68
+ text: async () => options.text ?? "",
69
+ };
70
+ };
71
+
72
+ const createTempDir = () => mkdtempSync(join(tmpdir(), "omp-python-kernel-"));
73
+
74
+ const createFakeProcess = (): Subprocess => {
75
+ const exited = new Promise<number>(() => undefined);
76
+ return { pid: 999999, exited } as Subprocess;
77
+ };
78
+
79
+ describe("PythonKernel gateway lifecycle", () => {
80
+ const originalFetch = globalThis.fetch;
81
+ const originalWebSocket = globalThis.WebSocket;
82
+ const originalSpawn = Bun.spawn;
83
+ const originalSleep = Bun.sleep;
84
+ const originalWhich = Bun.which;
85
+ const originalExecute = PythonKernel.prototype.execute;
86
+ const originalGatewayUrl = process.env.OMP_PYTHON_GATEWAY_URL;
87
+ const originalGatewayToken = process.env.OMP_PYTHON_GATEWAY_TOKEN;
88
+ const originalBunEnv = process.env.BUN_ENV;
89
+
90
+ let tempDir: string;
91
+ let env: MockEnvironment;
92
+
93
+ beforeEach(() => {
94
+ tempDir = createTempDir();
95
+ env = { fetchCalls: [], spawnCalls: [] };
96
+
97
+ process.env.BUN_ENV = "test";
98
+ delete process.env.OMP_PYTHON_GATEWAY_URL;
99
+ delete process.env.OMP_PYTHON_GATEWAY_TOKEN;
100
+
101
+ FakeWebSocket.instances = [];
102
+ globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
103
+
104
+ Bun.spawn = ((cmd: string[] | string, options?: SpawnOptions) => {
105
+ const normalized = Array.isArray(cmd) ? cmd : [cmd];
106
+ env.spawnCalls.push({ cmd: normalized, options: options ?? {} });
107
+ return createFakeProcess();
108
+ }) as typeof Bun.spawn;
109
+
110
+ Bun.sleep = (async () => undefined) as typeof Bun.sleep;
111
+
112
+ Bun.which = (() => "/usr/bin/python") as typeof Bun.which;
113
+
114
+ Object.defineProperty(PythonKernel.prototype, "execute", {
115
+ value: (async () => ({
116
+ status: "ok",
117
+ cancelled: false,
118
+ timedOut: false,
119
+ stdinRequested: false,
120
+ })) as typeof PythonKernel.prototype.execute,
121
+ configurable: true,
122
+ });
123
+ });
124
+
125
+ afterEach(() => {
126
+ if (tempDir) {
127
+ rmSync(tempDir, { recursive: true, force: true });
128
+ }
129
+
130
+ if (originalBunEnv === undefined) {
131
+ delete process.env.BUN_ENV;
132
+ } else {
133
+ process.env.BUN_ENV = originalBunEnv;
134
+ }
135
+ if (originalGatewayUrl === undefined) {
136
+ delete process.env.OMP_PYTHON_GATEWAY_URL;
137
+ } else {
138
+ process.env.OMP_PYTHON_GATEWAY_URL = originalGatewayUrl;
139
+ }
140
+ if (originalGatewayToken === undefined) {
141
+ delete process.env.OMP_PYTHON_GATEWAY_TOKEN;
142
+ } else {
143
+ process.env.OMP_PYTHON_GATEWAY_TOKEN = originalGatewayToken;
144
+ }
145
+
146
+ globalThis.fetch = originalFetch;
147
+ globalThis.WebSocket = originalWebSocket;
148
+
149
+ Bun.spawn = originalSpawn;
150
+ Bun.sleep = originalSleep;
151
+ Bun.which = originalWhich;
152
+ Object.defineProperty(PythonKernel.prototype, "execute", { value: originalExecute, configurable: true });
153
+ });
154
+
155
+ it("starts local gateway, polls readiness, interrupts, and shuts down", async () => {
156
+ let kernelspecAttempts = 0;
157
+ globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
158
+ const url = String(input);
159
+ env.fetchCalls.push({ url, init });
160
+
161
+ if (url.endsWith("/api/kernelspecs")) {
162
+ kernelspecAttempts += 1;
163
+ const ok = kernelspecAttempts >= 2;
164
+ return createResponse({ ok }) as unknown as Response;
165
+ }
166
+
167
+ if (url.endsWith("/api/kernels") && init?.method === "POST") {
168
+ return createResponse({ ok: true, json: { id: "kernel-123" } }) as unknown as Response;
169
+ }
170
+
171
+ return createResponse({ ok: true }) as unknown as Response;
172
+ }) as typeof fetch;
173
+
174
+ const kernel = await PythonKernel.start({ cwd: tempDir, useSharedGateway: false });
175
+
176
+ expect(env.spawnCalls).toHaveLength(1);
177
+ expect(env.spawnCalls[0].cmd).toEqual(
178
+ expect.arrayContaining([
179
+ "-m",
180
+ "kernel_gateway",
181
+ "--KernelGatewayApp.allow_origin=*",
182
+ "--JupyterApp.answer_yes=true",
183
+ ]),
184
+ );
185
+ expect(env.fetchCalls.filter((call) => call.url.endsWith("/api/kernelspecs"))).toHaveLength(2);
186
+ expect(env.fetchCalls.some((call) => call.url.endsWith("/api/kernels") && call.init?.method === "POST")).toBe(
187
+ true,
188
+ );
189
+
190
+ await kernel.interrupt();
191
+ expect(env.fetchCalls.some((call) => call.url.includes("/interrupt") && call.init?.method === "POST")).toBe(true);
192
+ expect(FakeWebSocket.instances[0]?.sent.length).toBe(1);
193
+
194
+ await kernel.shutdown();
195
+ expect(env.fetchCalls.some((call) => call.init?.method === "DELETE")).toBe(true);
196
+ expect(kernel.isAlive()).toBe(false);
197
+ });
198
+
199
+ it("throws when gateway readiness never succeeds", async () => {
200
+ const originalNow = Date.now;
201
+ let now = 0;
202
+ Date.now = () => {
203
+ now += 1000;
204
+ return now;
205
+ };
206
+
207
+ try {
208
+ globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
209
+ const url = String(input);
210
+ env.fetchCalls.push({ url, init });
211
+ if (url.endsWith("/api/kernelspecs")) {
212
+ return createResponse({ ok: false, status: 503 }) as unknown as Response;
213
+ }
214
+ return createResponse({ ok: true }) as unknown as Response;
215
+ }) as typeof fetch;
216
+
217
+ await expect(PythonKernel.start({ cwd: tempDir, useSharedGateway: false })).rejects.toThrow(
218
+ "Kernel gateway failed to start",
219
+ );
220
+ expect(env.spawnCalls).toHaveLength(3);
221
+ } finally {
222
+ Date.now = originalNow;
223
+ }
224
+ });
225
+
226
+ it("does not throw when shutdown API fails", async () => {
227
+ let kernelspecAttempts = 0;
228
+ globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
229
+ const url = String(input);
230
+ env.fetchCalls.push({ url, init });
231
+ if (url.endsWith("/api/kernelspecs")) {
232
+ kernelspecAttempts += 1;
233
+ const ok = kernelspecAttempts >= 1;
234
+ return createResponse({ ok }) as unknown as Response;
235
+ }
236
+ if (url.endsWith("/api/kernels") && init?.method === "POST") {
237
+ return createResponse({ ok: true, json: { id: "kernel-456" } }) as unknown as Response;
238
+ }
239
+ if (init?.method === "DELETE") {
240
+ throw new Error("delete failed");
241
+ }
242
+ return createResponse({ ok: true }) as unknown as Response;
243
+ }) as typeof fetch;
244
+
245
+ const kernel = await PythonKernel.start({ cwd: tempDir });
246
+
247
+ await expect(kernel.shutdown()).resolves.toBeUndefined();
248
+ });
249
+ });