@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.
- package/CHANGELOG.md +105 -0
- package/docs/python-repl.md +77 -0
- package/examples/hooks/snake.ts +7 -7
- package/package.json +5 -5
- package/src/bun-imports.d.ts +6 -0
- package/src/cli/args.ts +7 -0
- package/src/cli/setup-cli.ts +231 -0
- package/src/cli.ts +2 -0
- package/src/core/agent-session.ts +118 -15
- package/src/core/bash-executor.ts +3 -84
- package/src/core/compaction/compaction.ts +10 -5
- package/src/core/extensions/index.ts +2 -0
- package/src/core/extensions/loader.ts +13 -1
- package/src/core/extensions/runner.ts +50 -2
- package/src/core/extensions/types.ts +67 -2
- package/src/core/keybindings.ts +51 -1
- package/src/core/prompt-templates.ts +15 -0
- package/src/core/python-executor-display.test.ts +42 -0
- package/src/core/python-executor-lifecycle.test.ts +99 -0
- package/src/core/python-executor-mapping.test.ts +41 -0
- package/src/core/python-executor-per-call.test.ts +49 -0
- package/src/core/python-executor-session.test.ts +103 -0
- package/src/core/python-executor-streaming.test.ts +77 -0
- package/src/core/python-executor-timeout.test.ts +35 -0
- package/src/core/python-executor.lifecycle.test.ts +139 -0
- package/src/core/python-executor.result.test.ts +49 -0
- package/src/core/python-executor.test.ts +180 -0
- package/src/core/python-executor.ts +313 -0
- package/src/core/python-gateway-coordinator.ts +832 -0
- package/src/core/python-kernel-display.test.ts +54 -0
- package/src/core/python-kernel-env.test.ts +138 -0
- package/src/core/python-kernel-session.test.ts +87 -0
- package/src/core/python-kernel-ws.test.ts +104 -0
- package/src/core/python-kernel.lifecycle.test.ts +249 -0
- package/src/core/python-kernel.test.ts +461 -0
- package/src/core/python-kernel.ts +1182 -0
- package/src/core/python-modules.test.ts +102 -0
- package/src/core/python-modules.ts +110 -0
- package/src/core/python-prelude.py +889 -0
- package/src/core/python-prelude.test.ts +140 -0
- package/src/core/python-prelude.ts +3 -0
- package/src/core/sdk.ts +24 -6
- package/src/core/session-manager.ts +174 -82
- package/src/core/settings-manager-python.test.ts +23 -0
- package/src/core/settings-manager.ts +202 -0
- package/src/core/streaming-output.test.ts +26 -0
- package/src/core/streaming-output.ts +100 -0
- package/src/core/system-prompt.python.test.ts +17 -0
- package/src/core/system-prompt.ts +3 -1
- package/src/core/timings.ts +1 -1
- package/src/core/tools/bash.ts +13 -2
- package/src/core/tools/edit-diff.ts +9 -1
- package/src/core/tools/index.test.ts +50 -23
- package/src/core/tools/index.ts +83 -1
- package/src/core/tools/python-execution.test.ts +68 -0
- package/src/core/tools/python-fallback.test.ts +72 -0
- package/src/core/tools/python-renderer.test.ts +36 -0
- package/src/core/tools/python-tool-mode.test.ts +43 -0
- package/src/core/tools/python.test.ts +121 -0
- package/src/core/tools/python.ts +760 -0
- package/src/core/tools/renderers.ts +2 -0
- package/src/core/tools/schema-validation.test.ts +1 -0
- package/src/core/tools/task/executor.ts +146 -3
- package/src/core/tools/task/worker-protocol.ts +32 -2
- package/src/core/tools/task/worker.ts +182 -15
- package/src/index.ts +6 -0
- package/src/main.ts +136 -40
- package/src/modes/interactive/components/custom-editor.ts +16 -31
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
- package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
- package/src/modes/interactive/components/history-search.ts +5 -8
- package/src/modes/interactive/components/hook-editor.ts +3 -4
- package/src/modes/interactive/components/hook-input.ts +3 -3
- package/src/modes/interactive/components/hook-selector.ts +5 -15
- package/src/modes/interactive/components/index.ts +1 -0
- package/src/modes/interactive/components/keybinding-hints.ts +66 -0
- package/src/modes/interactive/components/model-selector.ts +53 -66
- package/src/modes/interactive/components/oauth-selector.ts +5 -5
- package/src/modes/interactive/components/session-selector.ts +29 -23
- package/src/modes/interactive/components/settings-defs.ts +404 -196
- package/src/modes/interactive/components/settings-selector.ts +14 -10
- package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
- package/src/modes/interactive/components/tool-execution.ts +8 -0
- package/src/modes/interactive/components/tree-selector.ts +29 -23
- package/src/modes/interactive/components/user-message-selector.ts +6 -17
- package/src/modes/interactive/controllers/command-controller.ts +86 -37
- package/src/modes/interactive/controllers/event-controller.ts +8 -0
- package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
- package/src/modes/interactive/controllers/input-controller.ts +42 -6
- package/src/modes/interactive/interactive-mode.ts +56 -30
- package/src/modes/interactive/theme/theme-schema.json +2 -2
- package/src/modes/interactive/types.ts +6 -1
- package/src/modes/interactive/utils/ui-helpers.ts +2 -1
- package/src/modes/print-mode.ts +23 -0
- package/src/modes/rpc/rpc-mode.ts +21 -0
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/system/system-prompt.md +32 -1
- package/src/prompts/tools/python.md +91 -0
package/src/core/keybindings.ts
CHANGED
|
@@ -29,7 +29,8 @@ export type AppAction =
|
|
|
29
29
|
| "externalEditor"
|
|
30
30
|
| "historySearch"
|
|
31
31
|
| "followUp"
|
|
32
|
-
| "dequeue"
|
|
32
|
+
| "dequeue"
|
|
33
|
+
| "pasteImage";
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* All configurable actions.
|
|
@@ -61,6 +62,7 @@ export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
|
|
|
61
62
|
externalEditor: "ctrl+g",
|
|
62
63
|
followUp: "ctrl+enter",
|
|
63
64
|
dequeue: "alt+up",
|
|
65
|
+
pasteImage: "ctrl+v",
|
|
64
66
|
};
|
|
65
67
|
|
|
66
68
|
/**
|
|
@@ -87,12 +89,60 @@ const APP_ACTIONS: AppAction[] = [
|
|
|
87
89
|
"externalEditor",
|
|
88
90
|
"followUp",
|
|
89
91
|
"dequeue",
|
|
92
|
+
"pasteImage",
|
|
90
93
|
];
|
|
91
94
|
|
|
92
95
|
function isAppAction(action: string): action is AppAction {
|
|
93
96
|
return APP_ACTIONS.includes(action as AppAction);
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Key hint formatting utilities for UI labels.
|
|
101
|
+
*/
|
|
102
|
+
const MODIFIER_LABELS: Record<string, string> = {
|
|
103
|
+
ctrl: "Ctrl",
|
|
104
|
+
shift: "Shift",
|
|
105
|
+
alt: "Alt",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const KEY_LABELS: Record<string, string> = {
|
|
109
|
+
esc: "Esc",
|
|
110
|
+
escape: "Esc",
|
|
111
|
+
enter: "Enter",
|
|
112
|
+
return: "Enter",
|
|
113
|
+
space: "Space",
|
|
114
|
+
tab: "Tab",
|
|
115
|
+
backspace: "Backspace",
|
|
116
|
+
delete: "Delete",
|
|
117
|
+
home: "Home",
|
|
118
|
+
end: "End",
|
|
119
|
+
pageup: "PgUp",
|
|
120
|
+
pagedown: "PgDn",
|
|
121
|
+
up: "Up",
|
|
122
|
+
down: "Down",
|
|
123
|
+
left: "Left",
|
|
124
|
+
right: "Right",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function formatKeyPart(part: string): string {
|
|
128
|
+
const lower = part.toLowerCase();
|
|
129
|
+
const modifier = MODIFIER_LABELS[lower];
|
|
130
|
+
if (modifier) return modifier;
|
|
131
|
+
const label = KEY_LABELS[lower];
|
|
132
|
+
if (label) return label;
|
|
133
|
+
if (part.length === 1) return part.toUpperCase();
|
|
134
|
+
return `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function formatKeyHint(key: KeyId): string {
|
|
138
|
+
return key.split("+").map(formatKeyPart).join("+");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function formatKeyHints(keys: KeyId | KeyId[]): string {
|
|
142
|
+
const list = Array.isArray(keys) ? keys : [keys];
|
|
143
|
+
return list.map(formatKeyHint).join("/");
|
|
144
|
+
}
|
|
145
|
+
|
|
96
146
|
/**
|
|
97
147
|
* Manages all keybindings (app + editor).
|
|
98
148
|
*/
|
|
@@ -335,6 +335,21 @@ export function substituteArgs(content: string, args: string[]): string {
|
|
|
335
335
|
return args[index] ?? "";
|
|
336
336
|
});
|
|
337
337
|
|
|
338
|
+
result = result.replace(/\$@\[(\d+)(?::(\d*)?)?\]/g, (_, startRaw: string, lengthRaw?: string) => {
|
|
339
|
+
const start = Number.parseInt(startRaw, 10);
|
|
340
|
+
if (!Number.isFinite(start) || start < 1) return "";
|
|
341
|
+
const startIndex = start - 1;
|
|
342
|
+
if (startIndex >= args.length) return "";
|
|
343
|
+
|
|
344
|
+
if (lengthRaw === undefined || lengthRaw === "") {
|
|
345
|
+
return args.slice(startIndex).join(" ");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const length = Number.parseInt(lengthRaw, 10);
|
|
349
|
+
if (!Number.isFinite(length) || length <= 0) return "";
|
|
350
|
+
return args.slice(startIndex, startIndex + length).join(" ");
|
|
351
|
+
});
|
|
352
|
+
|
|
338
353
|
// Pre-compute all args joined (optimization)
|
|
339
354
|
const allArgs = args.join(" ");
|
|
340
355
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { executePythonWithKernel, type PythonKernelExecutor } from "./python-executor";
|
|
3
|
+
import type { KernelDisplayOutput, KernelExecuteOptions, KernelExecuteResult } from "./python-kernel";
|
|
4
|
+
|
|
5
|
+
class FakeKernel implements PythonKernelExecutor {
|
|
6
|
+
private result: KernelExecuteResult;
|
|
7
|
+
private onExecute: (options?: KernelExecuteOptions) => Promise<void> | void;
|
|
8
|
+
|
|
9
|
+
constructor(result: KernelExecuteResult, onExecute: (options?: KernelExecuteOptions) => Promise<void> | void) {
|
|
10
|
+
this.result = result;
|
|
11
|
+
this.onExecute = onExecute;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async execute(_code: string, options?: KernelExecuteOptions): Promise<KernelExecuteResult> {
|
|
15
|
+
await this.onExecute(options);
|
|
16
|
+
return this.result;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("executePythonWithKernel display outputs", () => {
|
|
21
|
+
it("aggregates display outputs in order", async () => {
|
|
22
|
+
const outputs: KernelDisplayOutput[] = [
|
|
23
|
+
{ type: "json", data: { foo: "bar" } },
|
|
24
|
+
{ type: "image", data: "abc", mimeType: "image/png" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const kernel = new FakeKernel(
|
|
28
|
+
{ status: "ok", cancelled: false, timedOut: false, stdinRequested: false },
|
|
29
|
+
async (options) => {
|
|
30
|
+
if (!options?.onDisplay) return;
|
|
31
|
+
for (const output of outputs) {
|
|
32
|
+
await options.onDisplay(output);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const result = await executePythonWithKernel(kernel, "print('hi')");
|
|
38
|
+
|
|
39
|
+
expect(result.exitCode).toBe(0);
|
|
40
|
+
expect(result.displayOutputs).toEqual(outputs);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
+
import { disposeAllKernelSessions, executePython } from "./python-executor";
|
|
3
|
+
import type { KernelExecuteResult } from "./python-kernel";
|
|
4
|
+
import * as pythonKernel from "./python-kernel";
|
|
5
|
+
|
|
6
|
+
class FakeKernel {
|
|
7
|
+
execute = vi.fn(async () => this.result);
|
|
8
|
+
shutdown = vi.fn(async () => {});
|
|
9
|
+
ping = vi.fn(async () => true);
|
|
10
|
+
alive = true;
|
|
11
|
+
|
|
12
|
+
constructor(private readonly result: KernelExecuteResult) {}
|
|
13
|
+
|
|
14
|
+
isAlive(): boolean {
|
|
15
|
+
return this.alive;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const OK_RESULT: KernelExecuteResult = {
|
|
20
|
+
status: "ok",
|
|
21
|
+
cancelled: false,
|
|
22
|
+
timedOut: false,
|
|
23
|
+
stdinRequested: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
await disposeAllKernelSessions();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("executePython lifecycle", () => {
|
|
32
|
+
it("starts and shuts down per-call kernels", async () => {
|
|
33
|
+
const kernel = new FakeKernel(OK_RESULT);
|
|
34
|
+
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
|
35
|
+
const startSpy = vi
|
|
36
|
+
.spyOn(pythonKernel.PythonKernel, "start")
|
|
37
|
+
.mockResolvedValue(kernel as unknown as pythonKernel.PythonKernel);
|
|
38
|
+
|
|
39
|
+
await executePython("print('hi')", { kernelMode: "per-call", cwd: process.cwd() });
|
|
40
|
+
|
|
41
|
+
expect(startSpy).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(kernel.execute).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(kernel.shutdown).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("reuses session kernels until reset", async () => {
|
|
47
|
+
const kernel = new FakeKernel(OK_RESULT);
|
|
48
|
+
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
|
49
|
+
const startSpy = vi
|
|
50
|
+
.spyOn(pythonKernel.PythonKernel, "start")
|
|
51
|
+
.mockResolvedValue(kernel as unknown as pythonKernel.PythonKernel);
|
|
52
|
+
|
|
53
|
+
await executePython("1 + 1", { kernelMode: "session", sessionId: "test-session", cwd: process.cwd() });
|
|
54
|
+
await executePython("2 + 2", { kernelMode: "session", sessionId: "test-session", cwd: process.cwd() });
|
|
55
|
+
|
|
56
|
+
expect(startSpy).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(kernel.execute).toHaveBeenCalledTimes(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("resets session kernels when requested", async () => {
|
|
61
|
+
const kernel = new FakeKernel(OK_RESULT);
|
|
62
|
+
const kernelNext = new FakeKernel(OK_RESULT);
|
|
63
|
+
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
|
64
|
+
const startSpy = vi
|
|
65
|
+
.spyOn(pythonKernel.PythonKernel, "start")
|
|
66
|
+
.mockResolvedValueOnce(kernel as unknown as pythonKernel.PythonKernel)
|
|
67
|
+
.mockResolvedValueOnce(kernelNext as unknown as pythonKernel.PythonKernel);
|
|
68
|
+
|
|
69
|
+
await executePython("1 + 1", { kernelMode: "session", sessionId: "reset-session", cwd: process.cwd() });
|
|
70
|
+
await executePython("2 + 2", {
|
|
71
|
+
kernelMode: "session",
|
|
72
|
+
sessionId: "reset-session",
|
|
73
|
+
reset: true,
|
|
74
|
+
cwd: process.cwd(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(startSpy).toHaveBeenCalledTimes(2);
|
|
78
|
+
expect(kernel.shutdown).toHaveBeenCalledTimes(1);
|
|
79
|
+
expect(kernelNext.execute).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("restarts session kernels when they are dead", async () => {
|
|
83
|
+
const kernel = new FakeKernel(OK_RESULT);
|
|
84
|
+
const kernelNext = new FakeKernel(OK_RESULT);
|
|
85
|
+
kernel.alive = false;
|
|
86
|
+
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
|
87
|
+
const startSpy = vi
|
|
88
|
+
.spyOn(pythonKernel.PythonKernel, "start")
|
|
89
|
+
.mockResolvedValueOnce(kernel as unknown as pythonKernel.PythonKernel)
|
|
90
|
+
.mockResolvedValueOnce(kernelNext as unknown as pythonKernel.PythonKernel);
|
|
91
|
+
|
|
92
|
+
await executePython("1 + 1", { kernelMode: "session", sessionId: "dead-session", cwd: process.cwd() });
|
|
93
|
+
|
|
94
|
+
expect(startSpy).toHaveBeenCalledTimes(2);
|
|
95
|
+
expect(kernel.shutdown).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(kernel.execute).toHaveBeenCalledTimes(0);
|
|
97
|
+
expect(kernelNext.execute).toHaveBeenCalledTimes(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { executePythonWithKernel, type PythonKernelExecutor } from "./python-executor";
|
|
3
|
+
import type { KernelExecuteOptions, KernelExecuteResult } from "./python-kernel";
|
|
4
|
+
|
|
5
|
+
class FakeKernel implements PythonKernelExecutor {
|
|
6
|
+
constructor(
|
|
7
|
+
private result: KernelExecuteResult,
|
|
8
|
+
private onExecute: (options?: KernelExecuteOptions) => void = () => {},
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
async execute(_code: string, options?: KernelExecuteOptions): Promise<KernelExecuteResult> {
|
|
12
|
+
this.onExecute(options);
|
|
13
|
+
return this.result;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("executePythonWithKernel mapping", () => {
|
|
18
|
+
it("annotates timeout cancellations", async () => {
|
|
19
|
+
const kernel = new FakeKernel({ status: "ok", cancelled: true, timedOut: true, stdinRequested: false });
|
|
20
|
+
const result = await executePythonWithKernel(kernel, "sleep(10)", { timeout: 5000 });
|
|
21
|
+
|
|
22
|
+
expect(result.cancelled).toBe(true);
|
|
23
|
+
expect(result.exitCode).toBeUndefined();
|
|
24
|
+
expect(result.output).toContain("Command timed out after 5 seconds");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("maps error status to non-zero exit code", async () => {
|
|
28
|
+
const kernel = new FakeKernel(
|
|
29
|
+
{ status: "error", cancelled: false, timedOut: false, stdinRequested: false },
|
|
30
|
+
(options) => {
|
|
31
|
+
options?.onChunk?.("traceback\n");
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const result = await executePythonWithKernel(kernel, "1 / 0");
|
|
36
|
+
|
|
37
|
+
expect(result.exitCode).toBe(1);
|
|
38
|
+
expect(result.output).toContain("traceback");
|
|
39
|
+
expect(result.stdinRequested).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { executePython } from "./python-executor";
|
|
3
|
+
import type { KernelExecuteOptions, KernelExecuteResult } from "./python-kernel";
|
|
4
|
+
import { PythonKernel } from "./python-kernel";
|
|
5
|
+
|
|
6
|
+
interface KernelStub {
|
|
7
|
+
execute: (code: string, options?: KernelExecuteOptions) => Promise<KernelExecuteResult>;
|
|
8
|
+
shutdown: () => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("executePython (per-call)", () => {
|
|
12
|
+
it("shuts down kernel on timed-out cancellation", async () => {
|
|
13
|
+
process.env.OMP_PYTHON_SKIP_CHECK = "1";
|
|
14
|
+
|
|
15
|
+
let shutdownCalls = 0;
|
|
16
|
+
const kernel: KernelStub = {
|
|
17
|
+
execute: async () => ({
|
|
18
|
+
status: "ok",
|
|
19
|
+
cancelled: true,
|
|
20
|
+
timedOut: true,
|
|
21
|
+
stdinRequested: false,
|
|
22
|
+
}),
|
|
23
|
+
shutdown: async () => {
|
|
24
|
+
shutdownCalls += 1;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const kernelClass = PythonKernel as unknown as {
|
|
29
|
+
start: (options: { cwd: string }) => Promise<KernelStub>;
|
|
30
|
+
};
|
|
31
|
+
const originalStart = kernelClass.start;
|
|
32
|
+
kernelClass.start = async () => kernel;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const result = await executePython("sleep(10)", {
|
|
36
|
+
kernelMode: "per-call",
|
|
37
|
+
timeout: 2000,
|
|
38
|
+
cwd: "/tmp",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result.cancelled).toBe(true);
|
|
42
|
+
expect(result.exitCode).toBeUndefined();
|
|
43
|
+
expect(result.output).toContain("Command timed out after 2 seconds");
|
|
44
|
+
expect(shutdownCalls).toBe(1);
|
|
45
|
+
} finally {
|
|
46
|
+
kernelClass.start = originalStart;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
+
import { disposeAllKernelSessions, executePython } from "./python-executor";
|
|
3
|
+
import * as pythonKernel from "./python-kernel";
|
|
4
|
+
|
|
5
|
+
class FakeKernel {
|
|
6
|
+
executeCalls = 0;
|
|
7
|
+
shutdownCalls = 0;
|
|
8
|
+
alive = true;
|
|
9
|
+
constructor(private readonly shouldThrow: boolean = false) {}
|
|
10
|
+
|
|
11
|
+
isAlive(): boolean {
|
|
12
|
+
return this.alive;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async execute(): Promise<{ status: "ok"; cancelled: false; timedOut: false; stdinRequested: false }> {
|
|
16
|
+
this.executeCalls += 1;
|
|
17
|
+
if (this.shouldThrow) {
|
|
18
|
+
this.alive = false;
|
|
19
|
+
throw new Error("kernel crashed");
|
|
20
|
+
}
|
|
21
|
+
return { status: "ok", cancelled: false, timedOut: false, stdinRequested: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async ping(): Promise<boolean> {
|
|
25
|
+
return this.alive;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async shutdown(): Promise<void> {
|
|
29
|
+
this.shutdownCalls += 1;
|
|
30
|
+
this.alive = false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("executePython session lifecycle", () => {
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
await disposeAllKernelSessions();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("restarts session when kernel is not alive", async () => {
|
|
41
|
+
const kernel1 = new FakeKernel();
|
|
42
|
+
kernel1.alive = false;
|
|
43
|
+
const kernel2 = new FakeKernel();
|
|
44
|
+
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
|
45
|
+
const startSpy = vi
|
|
46
|
+
.spyOn(pythonKernel.PythonKernel, "start")
|
|
47
|
+
.mockResolvedValueOnce(kernel1 as unknown as pythonKernel.PythonKernel)
|
|
48
|
+
.mockResolvedValueOnce(kernel2 as unknown as pythonKernel.PythonKernel);
|
|
49
|
+
|
|
50
|
+
await executePython("print('hi')", { cwd: "/tmp", sessionId: "session-1", kernelMode: "session" });
|
|
51
|
+
|
|
52
|
+
expect(startSpy).toHaveBeenCalledTimes(2);
|
|
53
|
+
expect(kernel1.executeCalls).toBe(0);
|
|
54
|
+
expect(kernel1.shutdownCalls).toBe(1);
|
|
55
|
+
expect(kernel2.executeCalls).toBe(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("restarts after an execution failure when kernel is dead", async () => {
|
|
59
|
+
const kernel1 = new FakeKernel(true);
|
|
60
|
+
const kernel2 = new FakeKernel();
|
|
61
|
+
const starts = [kernel1, kernel2];
|
|
62
|
+
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
|
63
|
+
const startSpy = vi.spyOn(pythonKernel.PythonKernel, "start").mockImplementation(async () => {
|
|
64
|
+
const next = starts.shift();
|
|
65
|
+
if (!next) {
|
|
66
|
+
throw new Error("No kernel available");
|
|
67
|
+
}
|
|
68
|
+
return next as unknown as pythonKernel.PythonKernel;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await executePython("raise", { cwd: "/tmp", sessionId: "session-2", kernelMode: "session" });
|
|
72
|
+
|
|
73
|
+
expect(startSpy).toHaveBeenCalledTimes(2);
|
|
74
|
+
expect(kernel1.executeCalls).toBe(1);
|
|
75
|
+
expect(kernel2.executeCalls).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("resets existing session when requested", async () => {
|
|
79
|
+
const kernel1 = new FakeKernel();
|
|
80
|
+
const kernel2 = new FakeKernel();
|
|
81
|
+
const starts = [kernel1, kernel2];
|
|
82
|
+
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
|
83
|
+
const startSpy = vi.spyOn(pythonKernel.PythonKernel, "start").mockImplementation(async () => {
|
|
84
|
+
const next = starts.shift();
|
|
85
|
+
if (!next) {
|
|
86
|
+
throw new Error("No kernel available");
|
|
87
|
+
}
|
|
88
|
+
return next as unknown as pythonKernel.PythonKernel;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await executePython("print('one')", { cwd: "/tmp", sessionId: "session-3", kernelMode: "session" });
|
|
92
|
+
await executePython("print('two')", {
|
|
93
|
+
cwd: "/tmp",
|
|
94
|
+
sessionId: "session-3",
|
|
95
|
+
kernelMode: "session",
|
|
96
|
+
reset: true,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(startSpy).toHaveBeenCalledTimes(2);
|
|
100
|
+
expect(kernel1.shutdownCalls).toBe(1);
|
|
101
|
+
expect(kernel2.executeCalls).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { executePythonWithKernel, type PythonKernelExecutor } from "./python-executor";
|
|
4
|
+
import type { KernelExecuteOptions, KernelExecuteResult } from "./python-kernel";
|
|
5
|
+
import { DEFAULT_MAX_BYTES } from "./tools/truncate";
|
|
6
|
+
|
|
7
|
+
class FakeKernel implements PythonKernelExecutor {
|
|
8
|
+
private result: KernelExecuteResult;
|
|
9
|
+
private onExecute: (options?: KernelExecuteOptions) => void;
|
|
10
|
+
|
|
11
|
+
constructor(result: KernelExecuteResult, onExecute: (options?: KernelExecuteOptions) => void) {
|
|
12
|
+
this.result = result;
|
|
13
|
+
this.onExecute = onExecute;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async execute(_code: string, options?: KernelExecuteOptions): Promise<KernelExecuteResult> {
|
|
17
|
+
this.onExecute(options);
|
|
18
|
+
return this.result;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const cleanupPaths: string[] = [];
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
while (cleanupPaths.length > 0) {
|
|
26
|
+
const path = cleanupPaths.pop();
|
|
27
|
+
if (path) {
|
|
28
|
+
try {
|
|
29
|
+
rmSync(path, { force: true });
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("executePythonWithKernel streaming", () => {
|
|
36
|
+
it("truncates large output and writes full output file", async () => {
|
|
37
|
+
const largeOutput = "a".repeat(DEFAULT_MAX_BYTES + 128);
|
|
38
|
+
const kernel = new FakeKernel(
|
|
39
|
+
{ status: "ok", cancelled: false, timedOut: false, stdinRequested: false },
|
|
40
|
+
(options) => {
|
|
41
|
+
options?.onChunk?.(largeOutput);
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const result = await executePythonWithKernel(kernel, "print('hi')");
|
|
46
|
+
|
|
47
|
+
expect(result.truncated).toBe(true);
|
|
48
|
+
expect(result.fullOutputPath).toBeDefined();
|
|
49
|
+
expect(result.output.length).toBeLessThan(largeOutput.length);
|
|
50
|
+
if (result.fullOutputPath) {
|
|
51
|
+
cleanupPaths.push(result.fullOutputPath);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("annotates timed out runs", async () => {
|
|
56
|
+
const kernel = new FakeKernel({ status: "ok", cancelled: true, timedOut: true, stdinRequested: false }, () => {});
|
|
57
|
+
|
|
58
|
+
const result = await executePythonWithKernel(kernel, "sleep", { timeout: 2000 });
|
|
59
|
+
|
|
60
|
+
expect(result.cancelled).toBe(true);
|
|
61
|
+
expect(result.exitCode).toBeUndefined();
|
|
62
|
+
expect(result.output).toContain("Command timed out after 2 seconds");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("sanitizes ANSI and carriage returns", async () => {
|
|
66
|
+
const kernel = new FakeKernel(
|
|
67
|
+
{ status: "ok", cancelled: false, timedOut: false, stdinRequested: false },
|
|
68
|
+
(options) => {
|
|
69
|
+
options?.onChunk?.("\u001b[31mhello\r\n");
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const result = await executePythonWithKernel(kernel, "print('hello')");
|
|
74
|
+
|
|
75
|
+
expect(result.output).toBe("hello\n");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { executePythonWithKernel, type PythonKernelExecutor } from "./python-executor";
|
|
3
|
+
import type { KernelExecuteOptions, KernelExecuteResult } from "./python-kernel";
|
|
4
|
+
|
|
5
|
+
class FakeKernel implements PythonKernelExecutor {
|
|
6
|
+
private result: KernelExecuteResult;
|
|
7
|
+
private onExecute: (options?: KernelExecuteOptions) => void;
|
|
8
|
+
|
|
9
|
+
constructor(result: KernelExecuteResult, onExecute: (options?: KernelExecuteOptions) => void) {
|
|
10
|
+
this.result = result;
|
|
11
|
+
this.onExecute = onExecute;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async execute(_code: string, options?: KernelExecuteOptions): Promise<KernelExecuteResult> {
|
|
15
|
+
this.onExecute(options);
|
|
16
|
+
return this.result;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("executePythonWithKernel cancellation", () => {
|
|
21
|
+
it("annotates timeouts when cancelled", async () => {
|
|
22
|
+
const kernel = new FakeKernel(
|
|
23
|
+
{ status: "ok", cancelled: true, timedOut: true, stdinRequested: false },
|
|
24
|
+
(options) => {
|
|
25
|
+
options?.onChunk?.("tick\n");
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const result = await executePythonWithKernel(kernel, "sleep(10)", { timeout: 5000 });
|
|
30
|
+
|
|
31
|
+
expect(result.cancelled).toBe(true);
|
|
32
|
+
expect(result.exitCode).toBeUndefined();
|
|
33
|
+
expect(result.output).toContain("Command timed out after 5 seconds");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { disposeAllKernelSessions, executePython } from "./python-executor";
|
|
3
|
+
import { type KernelExecuteOptions, type KernelExecuteResult, PythonKernel } from "./python-kernel";
|
|
4
|
+
|
|
5
|
+
process.env.OMP_PYTHON_SKIP_CHECK = "1";
|
|
6
|
+
|
|
7
|
+
class FakeKernel {
|
|
8
|
+
private result: KernelExecuteResult;
|
|
9
|
+
private onExecute?: (options?: KernelExecuteOptions) => void;
|
|
10
|
+
private alive: boolean;
|
|
11
|
+
readonly executeCalls: string[] = [];
|
|
12
|
+
shutdownCalls = 0;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
result: KernelExecuteResult,
|
|
16
|
+
options: { alive?: boolean; onExecute?: (options?: KernelExecuteOptions) => void } = {},
|
|
17
|
+
) {
|
|
18
|
+
this.result = result;
|
|
19
|
+
this.onExecute = options.onExecute;
|
|
20
|
+
this.alive = options.alive ?? true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
isAlive(): boolean {
|
|
24
|
+
return this.alive;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async execute(code: string, options?: KernelExecuteOptions): Promise<KernelExecuteResult> {
|
|
28
|
+
this.executeCalls.push(code);
|
|
29
|
+
this.onExecute?.(options);
|
|
30
|
+
return this.result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async shutdown(): Promise<void> {
|
|
34
|
+
this.shutdownCalls += 1;
|
|
35
|
+
this.alive = false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async ping(): Promise<boolean> {
|
|
39
|
+
return this.alive;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const okResult: KernelExecuteResult = {
|
|
44
|
+
status: "ok",
|
|
45
|
+
cancelled: false,
|
|
46
|
+
timedOut: false,
|
|
47
|
+
stdinRequested: false,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("executePython session lifecycle", () => {
|
|
51
|
+
const originalStart = PythonKernel.start;
|
|
52
|
+
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
PythonKernel.start = originalStart;
|
|
55
|
+
await disposeAllKernelSessions();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("reuses a session kernel across calls", async () => {
|
|
59
|
+
let startCount = 0;
|
|
60
|
+
const kernel = new FakeKernel(okResult, { onExecute: (options) => options?.onChunk?.("ok\n") });
|
|
61
|
+
PythonKernel.start = async () => {
|
|
62
|
+
startCount += 1;
|
|
63
|
+
return kernel as unknown as PythonKernel;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const first = await executePython("print('one')", { sessionId: "session-1" });
|
|
67
|
+
const second = await executePython("print('two')", { sessionId: "session-1" });
|
|
68
|
+
|
|
69
|
+
expect(startCount).toBe(1);
|
|
70
|
+
expect(kernel.executeCalls).toEqual(["print('one')", "print('two')"]);
|
|
71
|
+
expect(first.output).toContain("ok");
|
|
72
|
+
expect(second.output).toContain("ok");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("restarts the session kernel when not alive", async () => {
|
|
76
|
+
const deadKernel = new FakeKernel(okResult, { alive: false });
|
|
77
|
+
const liveKernel = new FakeKernel(okResult, { onExecute: (options) => options?.onChunk?.("live\n") });
|
|
78
|
+
const kernels = [deadKernel, liveKernel];
|
|
79
|
+
let startCount = 0;
|
|
80
|
+
|
|
81
|
+
PythonKernel.start = async () => {
|
|
82
|
+
startCount += 1;
|
|
83
|
+
return kernels.shift() as unknown as PythonKernel;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const result = await executePython("print('restart')", { sessionId: "session-restart" });
|
|
87
|
+
|
|
88
|
+
expect(startCount).toBe(2);
|
|
89
|
+
expect(deadKernel.shutdownCalls).toBe(1);
|
|
90
|
+
expect(deadKernel.executeCalls).toEqual([]);
|
|
91
|
+
expect(liveKernel.executeCalls).toEqual(["print('restart')"]);
|
|
92
|
+
expect(result.output).toContain("live");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("resets the session kernel when requested", async () => {
|
|
96
|
+
const firstKernel = new FakeKernel(okResult);
|
|
97
|
+
const secondKernel = new FakeKernel(okResult);
|
|
98
|
+
const kernels = [firstKernel, secondKernel];
|
|
99
|
+
let startCount = 0;
|
|
100
|
+
|
|
101
|
+
PythonKernel.start = async () => {
|
|
102
|
+
startCount += 1;
|
|
103
|
+
return kernels.shift() as unknown as PythonKernel;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
await executePython("print('one')", { sessionId: "session-reset" });
|
|
107
|
+
await executePython("print('two')", { sessionId: "session-reset", reset: true });
|
|
108
|
+
|
|
109
|
+
expect(startCount).toBe(2);
|
|
110
|
+
expect(firstKernel.shutdownCalls).toBe(1);
|
|
111
|
+
expect(secondKernel.executeCalls).toEqual(["print('two')"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("uses per-call kernels when configured", async () => {
|
|
115
|
+
const kernelA = new FakeKernel(okResult);
|
|
116
|
+
const kernelB = new FakeKernel(okResult);
|
|
117
|
+
const kernels = [kernelA, kernelB];
|
|
118
|
+
let startCount = 0;
|
|
119
|
+
let shutdownCount = 0;
|
|
120
|
+
|
|
121
|
+
PythonKernel.start = async () => {
|
|
122
|
+
startCount += 1;
|
|
123
|
+
return kernels.shift() as unknown as PythonKernel;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
kernelA.shutdown = async () => {
|
|
127
|
+
shutdownCount += 1;
|
|
128
|
+
};
|
|
129
|
+
kernelB.shutdown = async () => {
|
|
130
|
+
shutdownCount += 1;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await executePython("print('one')", { kernelMode: "per-call" });
|
|
134
|
+
await executePython("print('two')", { kernelMode: "per-call" });
|
|
135
|
+
|
|
136
|
+
expect(startCount).toBe(2);
|
|
137
|
+
expect(shutdownCount).toBe(2);
|
|
138
|
+
});
|
|
139
|
+
});
|