@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
@@ -24,6 +24,7 @@ export {
24
24
  } from "./lsp/index";
25
25
  export { createNotebookTool, type NotebookToolDetails } from "./notebook";
26
26
  export { createOutputTool, type OutputToolDetails } from "./output";
27
+ export { createPythonTool, type PythonToolDetails } from "./python";
27
28
  export { createReadTool, type ReadToolDetails } from "./read";
28
29
  export { reportFindingTool, type SubmitReviewDetails } from "./review";
29
30
  export { createSshTool, type SSHToolDetails } from "./ssh";
@@ -63,6 +64,9 @@ export { createWriteTool, type WriteToolDetails } from "./write";
63
64
 
64
65
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
65
66
  import type { EventBus } from "../event-bus";
67
+ import { logger } from "../logger";
68
+ import { getPreludeDocs, warmPythonEnvironment } from "../python-executor";
69
+ import { checkPythonKernelAvailability } from "../python-kernel";
66
70
  import type { BashInterceptorRule } from "../settings-manager";
67
71
  import { createAskTool } from "./ask";
68
72
  import { createBashTool } from "./bash";
@@ -76,6 +80,7 @@ import { createLsTool } from "./ls";
76
80
  import { createLspTool } from "./lsp/index";
77
81
  import { createNotebookTool } from "./notebook";
78
82
  import { createOutputTool } from "./output";
83
+ import { createPythonTool } from "./python";
79
84
  import { createReadTool } from "./read";
80
85
  import { reportFindingTool } from "./review";
81
86
  import { createSshTool } from "./ssh";
@@ -129,6 +134,9 @@ export interface ToolSession {
129
134
  getBashInterceptorEnabled(): boolean;
130
135
  getBashInterceptorSimpleLsEnabled(): boolean;
131
136
  getBashInterceptorRules(): BashInterceptorRule[];
137
+ getPythonToolMode?(): "ipy-only" | "bash-only" | "both";
138
+ getPythonKernelMode?(): "session" | "per-call";
139
+ getPythonSharedGateway?(): boolean;
132
140
  };
133
141
  }
134
142
 
@@ -137,6 +145,7 @@ type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
137
145
  export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
138
146
  ask: createAskTool,
139
147
  bash: createBashTool,
148
+ python: createPythonTool,
140
149
  calc: createCalculatorTool,
141
150
  ssh: createSshTool,
142
151
  edit: createEditTool,
@@ -162,6 +171,36 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
162
171
 
163
172
  export type ToolName = keyof typeof BUILTIN_TOOLS;
164
173
 
174
+ export type PythonToolMode = "ipy-only" | "bash-only" | "both";
175
+
176
+ /**
177
+ * Parse OMP_PY environment variable to determine Python tool mode.
178
+ * Returns null if not set or invalid.
179
+ *
180
+ * Values:
181
+ * - "0" or "bash" → bash-only
182
+ * - "1" or "py" → ipy-only
183
+ * - "mix" or "both" → both
184
+ */
185
+ function getPythonModeFromEnv(): PythonToolMode | null {
186
+ const value = process.env.OMP_PY?.toLowerCase();
187
+ if (!value) return null;
188
+
189
+ switch (value) {
190
+ case "0":
191
+ case "bash":
192
+ return "bash-only";
193
+ case "1":
194
+ case "py":
195
+ return "ipy-only";
196
+ case "mix":
197
+ case "both":
198
+ return "both";
199
+ default:
200
+ return null;
201
+ }
202
+ }
203
+
165
204
  /**
166
205
  * Create tools from BUILTIN_TOOLS registry.
167
206
  */
@@ -169,8 +208,51 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
169
208
  const includeComplete = session.requireCompleteTool === true;
170
209
  const enableLsp = session.enableLsp ?? true;
171
210
  const requestedTools = toolNames && toolNames.length > 0 ? [...new Set(toolNames)] : undefined;
211
+ const pythonMode = getPythonModeFromEnv() ?? session.settings?.getPythonToolMode?.() ?? "ipy-only";
212
+ let pythonAvailable = true;
213
+ const shouldCheckPython =
214
+ pythonMode !== "bash-only" &&
215
+ (requestedTools === undefined || requestedTools.includes("python") || pythonMode === "ipy-only");
216
+ const isTestEnv = process.env.BUN_ENV === "test" || process.env.NODE_ENV === "test";
217
+ if (shouldCheckPython) {
218
+ const availability = await checkPythonKernelAvailability(session.cwd);
219
+ pythonAvailable = availability.ok;
220
+ if (!availability.ok) {
221
+ logger.warn("Python kernel unavailable, falling back to bash", {
222
+ reason: availability.reason,
223
+ });
224
+ } else if (!isTestEnv && getPreludeDocs().length === 0) {
225
+ const sessionFile = session.getSessionFile?.() ?? undefined;
226
+ const warmSessionId = sessionFile ? `session:${sessionFile}:workdir:${session.cwd}` : `cwd:${session.cwd}`;
227
+ void warmPythonEnvironment(session.cwd, warmSessionId, session.settings?.getPythonSharedGateway?.()).catch(
228
+ (err) => {
229
+ logger.warn("Failed to warm Python environment", {
230
+ error: err instanceof Error ? err.message : String(err),
231
+ });
232
+ },
233
+ );
234
+ }
235
+ }
236
+
237
+ const effectiveMode = pythonAvailable ? pythonMode : "bash-only";
238
+ const allowBash = effectiveMode !== "ipy-only";
239
+ const allowPython = effectiveMode !== "bash-only";
240
+ if (
241
+ requestedTools &&
242
+ allowBash &&
243
+ !allowPython &&
244
+ requestedTools.includes("python") &&
245
+ !requestedTools.includes("bash")
246
+ ) {
247
+ requestedTools.push("bash");
248
+ }
172
249
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
173
- const isToolAllowed = (name: string) => (name === "lsp" ? enableLsp : true);
250
+ const isToolAllowed = (name: string) => {
251
+ if (name === "lsp") return enableLsp;
252
+ if (name === "bash") return allowBash;
253
+ if (name === "python") return allowPython;
254
+ return true;
255
+ };
174
256
  if (includeComplete && requestedTools && !requestedTools.includes("complete")) {
175
257
  requestedTools.push("complete");
176
258
  }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it, vi } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import * as pythonExecutor from "../python-executor";
6
+ import type { ToolSession } from "./index";
7
+ import { createPythonTool } from "./python";
8
+
9
+ function createSession(cwd: string): ToolSession {
10
+ return {
11
+ cwd,
12
+ hasUI: false,
13
+ getSessionFile: () => "session-file",
14
+ getSessionSpawns: () => "*",
15
+ settings: {
16
+ getImageAutoResize: () => true,
17
+ getLspFormatOnWrite: () => true,
18
+ getLspDiagnosticsOnWrite: () => true,
19
+ getLspDiagnosticsOnEdit: () => false,
20
+ getEditFuzzyMatch: () => true,
21
+ getGitToolEnabled: () => true,
22
+ getBashInterceptorEnabled: () => true,
23
+ getBashInterceptorSimpleLsEnabled: () => true,
24
+ getBashInterceptorRules: () => [],
25
+ getPythonToolMode: () => "ipy-only",
26
+ getPythonKernelMode: () => "per-call",
27
+ },
28
+ };
29
+ }
30
+
31
+ describe("python tool execution", () => {
32
+ it("passes kernel options from settings and args", async () => {
33
+ const tempDir = mkdtempSync(join(tmpdir(), "python-tool-"));
34
+ const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
35
+ output: "ok",
36
+ exitCode: 0,
37
+ cancelled: false,
38
+ truncated: false,
39
+ displayOutputs: [],
40
+ stdinRequested: false,
41
+ });
42
+
43
+ const tool = createPythonTool(createSession(tempDir));
44
+ const result = await tool.execute(
45
+ "call-id",
46
+ { code: "print('hi')", timeout: 5, workdir: tempDir, reset: true },
47
+ undefined,
48
+ undefined,
49
+ undefined,
50
+ );
51
+
52
+ expect(executeSpy).toHaveBeenCalledWith(
53
+ "print('hi')",
54
+ expect.objectContaining({
55
+ cwd: tempDir,
56
+ timeout: 5000,
57
+ sessionId: `session:session-file:workdir:${tempDir}`,
58
+ kernelMode: "per-call",
59
+ reset: true,
60
+ }),
61
+ );
62
+ const text = result.content.find((item) => item.type === "text")?.text;
63
+ expect(text).toBe("ok");
64
+
65
+ executeSpy.mockRestore();
66
+ rmSync(tempDir, { recursive: true, force: true });
67
+ });
68
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it, vi } from "bun:test";
2
+ import * as pythonKernelModule from "../python-kernel";
3
+ import type { ToolSession } from "./index";
4
+ import { createTools } from "./index";
5
+
6
+ function createTestSession(overrides: Partial<ToolSession> = {}): ToolSession {
7
+ return {
8
+ cwd: "/tmp/test",
9
+ hasUI: false,
10
+ getSessionFile: () => null,
11
+ getSessionSpawns: () => "*",
12
+ ...overrides,
13
+ };
14
+ }
15
+
16
+ function createBaseSettings(overrides: Partial<NonNullable<ToolSession["settings"]>> = {}) {
17
+ return {
18
+ getImageAutoResize: () => true,
19
+ getLspFormatOnWrite: () => true,
20
+ getLspDiagnosticsOnWrite: () => true,
21
+ getLspDiagnosticsOnEdit: () => false,
22
+ getEditFuzzyMatch: () => true,
23
+ getGitToolEnabled: () => true,
24
+ getBashInterceptorEnabled: () => true,
25
+ getBashInterceptorSimpleLsEnabled: () => true,
26
+ getBashInterceptorRules: () => [],
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ describe("createTools python fallback", () => {
32
+ it("falls back to bash-only when kernel unavailable", async () => {
33
+ const availabilitySpy = vi
34
+ .spyOn(pythonKernelModule, "checkPythonKernelAvailability")
35
+ .mockResolvedValue({ ok: false, reason: "unavailable" });
36
+
37
+ const session = createTestSession({
38
+ settings: createBaseSettings({
39
+ getPythonToolMode: () => "ipy-only",
40
+ getPythonKernelMode: () => "session",
41
+ }),
42
+ });
43
+
44
+ const tools = await createTools(session, ["python"]);
45
+ const names = tools.map((tool) => tool.name).sort();
46
+
47
+ expect(names).toEqual(["bash"]);
48
+
49
+ availabilitySpy.mockRestore();
50
+ });
51
+
52
+ it("keeps bash when python mode is both but unavailable", async () => {
53
+ const availabilitySpy = vi
54
+ .spyOn(pythonKernelModule, "checkPythonKernelAvailability")
55
+ .mockResolvedValue({ ok: false, reason: "unavailable" });
56
+
57
+ const session = createTestSession({
58
+ settings: createBaseSettings({
59
+ getPythonToolMode: () => "both",
60
+ getPythonKernelMode: () => "session",
61
+ }),
62
+ });
63
+
64
+ const tools = await createTools(session);
65
+ const names = tools.map((tool) => tool.name);
66
+
67
+ expect(names).toContain("bash");
68
+ expect(names).not.toContain("python");
69
+
70
+ availabilitySpy.mockRestore();
71
+ });
72
+ });
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import stripAnsi from "strip-ansi";
3
+ import { getThemeByName } from "../../modes/interactive/theme/theme";
4
+ import { pythonToolRenderer } from "./python";
5
+ import { truncateTail } from "./truncate";
6
+
7
+ describe("pythonToolRenderer", () => {
8
+ it("renders truncated output when collapsed and full output when expanded", () => {
9
+ const theme = getThemeByName("dark");
10
+ expect(theme).toBeDefined();
11
+ const uiTheme = theme!;
12
+
13
+ const fullOutput = ["line 1", "line 2", "line 3", "line 4"].join("\n");
14
+ const truncation = truncateTail(fullOutput, { maxLines: 2, maxBytes: 128 });
15
+
16
+ const result = {
17
+ content: [{ type: "text", text: truncation.content }],
18
+ details: {
19
+ truncation,
20
+ fullOutput,
21
+ },
22
+ };
23
+
24
+ const collapsed = pythonToolRenderer.renderResult(result, { expanded: false, isPartial: false }, uiTheme);
25
+ const collapsedLines = stripAnsi(collapsed.render(80).join("\n"));
26
+ expect(collapsedLines).toContain("line 4");
27
+ expect(collapsedLines).not.toContain("line 1");
28
+ expect(collapsedLines).toContain("Truncated:");
29
+
30
+ const expanded = pythonToolRenderer.renderResult(result, { expanded: true, isPartial: false }, uiTheme);
31
+ const expandedLines = stripAnsi(expanded.render(80).join("\n"));
32
+ expect(expandedLines).toContain("line 1");
33
+ expect(expandedLines).toContain("line 4");
34
+ expect(expandedLines).not.toContain("Truncated:");
35
+ });
36
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { createTools, type ToolSession } from "./index";
3
+
4
+ function createSession(overrides: Partial<ToolSession> = {}): ToolSession {
5
+ return {
6
+ cwd: "/tmp/test",
7
+ hasUI: false,
8
+ getSessionFile: () => null,
9
+ getSessionSpawns: () => "*",
10
+ settings: {
11
+ getImageAutoResize: () => true,
12
+ getLspFormatOnWrite: () => true,
13
+ getLspDiagnosticsOnWrite: () => true,
14
+ getLspDiagnosticsOnEdit: () => false,
15
+ getEditFuzzyMatch: () => true,
16
+ getGitToolEnabled: () => true,
17
+ getBashInterceptorEnabled: () => true,
18
+ getBashInterceptorSimpleLsEnabled: () => true,
19
+ getBashInterceptorRules: () => [],
20
+ getPythonToolMode: () => "bash-only",
21
+ getPythonKernelMode: () => "session",
22
+ },
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ describe("createTools python fallback", () => {
28
+ it("falls back to bash when python is requested but disabled", async () => {
29
+ const previous = process.env.OMP_PYTHON_SKIP_CHECK;
30
+ process.env.OMP_PYTHON_SKIP_CHECK = "1";
31
+ const session = createSession();
32
+ const tools = await createTools(session, ["python"]);
33
+ const names = tools.map((tool) => tool.name);
34
+
35
+ expect(names).toEqual(["bash"]);
36
+
37
+ if (previous === undefined) {
38
+ delete process.env.OMP_PYTHON_SKIP_CHECK;
39
+ } else {
40
+ process.env.OMP_PYTHON_SKIP_CHECK = previous;
41
+ }
42
+ });
43
+ });
@@ -0,0 +1,121 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from "bun:test";
2
+ import * as pythonExecutor from "../python-executor";
3
+ import { createTools, type ToolSession } from "./index";
4
+ import { createPythonTool } from "./python";
5
+
6
+ let previousSkipCheck: string | undefined;
7
+
8
+ beforeAll(() => {
9
+ previousSkipCheck = process.env.OMP_PYTHON_SKIP_CHECK;
10
+ process.env.OMP_PYTHON_SKIP_CHECK = "1";
11
+ });
12
+
13
+ afterAll(() => {
14
+ if (previousSkipCheck === undefined) {
15
+ delete process.env.OMP_PYTHON_SKIP_CHECK;
16
+ return;
17
+ }
18
+ process.env.OMP_PYTHON_SKIP_CHECK = previousSkipCheck;
19
+ });
20
+
21
+ function createSession(overrides: Partial<ToolSession> = {}): ToolSession {
22
+ return {
23
+ cwd: "/tmp/test",
24
+ hasUI: false,
25
+ getSessionFile: () => null,
26
+ getSessionSpawns: () => "*",
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function createSettings(toolMode: "ipy-only" | "bash-only" | "both") {
32
+ return {
33
+ getImageAutoResize: () => true,
34
+ getLspFormatOnWrite: () => true,
35
+ getLspDiagnosticsOnWrite: () => true,
36
+ getLspDiagnosticsOnEdit: () => false,
37
+ getEditFuzzyMatch: () => true,
38
+ getGitToolEnabled: () => true,
39
+ getBashInterceptorEnabled: () => true,
40
+ getBashInterceptorSimpleLsEnabled: () => true,
41
+ getBashInterceptorRules: () => [],
42
+ getPythonToolMode: () => toolMode,
43
+ getPythonKernelMode: () => "session" as const,
44
+ };
45
+ }
46
+
47
+ describe("python tool schema", () => {
48
+ it("exposes expected parameters", () => {
49
+ const tool = createPythonTool(createSession());
50
+ const schema = tool.parameters as {
51
+ type: string;
52
+ properties: Record<string, { type: string; description?: string }>;
53
+ required?: string[];
54
+ };
55
+
56
+ expect(schema.type).toBe("object");
57
+ expect(schema.properties.code.type).toBe("string");
58
+ expect(schema.properties.timeout.type).toBe("number");
59
+ expect(schema.properties.workdir.type).toBe("string");
60
+ expect(schema.properties.reset.type).toBe("boolean");
61
+ expect(schema.required).toEqual(["code"]);
62
+ });
63
+ });
64
+
65
+ describe("python tool docs template", () => {
66
+ it("renders dynamic helper docs", () => {
67
+ const docs = [
68
+ {
69
+ name: "read",
70
+ signature: "(path)",
71
+ docstring: "Read file contents.",
72
+ category: "File I/O",
73
+ },
74
+ ];
75
+ const spy = vi.spyOn(pythonExecutor, "getPreludeDocs").mockReturnValue(docs);
76
+
77
+ const tool = createPythonTool(createSession());
78
+
79
+ expect(tool.description).toContain("### File I/O");
80
+ expect(tool.description).toContain("read(path)");
81
+ expect(tool.description).toContain("Read file contents.");
82
+
83
+ spy.mockRestore();
84
+ });
85
+
86
+ it("renders fallback when docs are unavailable", () => {
87
+ const spy = vi.spyOn(pythonExecutor, "getPreludeDocs").mockReturnValue([]);
88
+
89
+ const tool = createPythonTool(createSession());
90
+
91
+ expect(tool.description).toContain("Documentation unavailable — Python kernel failed to start");
92
+
93
+ spy.mockRestore();
94
+ });
95
+ });
96
+
97
+ describe("python tool exposure", () => {
98
+ it("includes python only in ipy-only mode", async () => {
99
+ const session = createSession({ settings: createSettings("ipy-only") });
100
+ const tools = await createTools(session);
101
+ const names = tools.map((tool) => tool.name);
102
+ expect(names).toContain("python");
103
+ expect(names).not.toContain("bash");
104
+ });
105
+
106
+ it("includes bash only in bash-only mode", async () => {
107
+ const session = createSession({ settings: createSettings("bash-only") });
108
+ const tools = await createTools(session);
109
+ const names = tools.map((tool) => tool.name);
110
+ expect(names).toContain("bash");
111
+ expect(names).not.toContain("python");
112
+ });
113
+
114
+ it("includes bash and python in both mode", async () => {
115
+ const session = createSession({ settings: createSettings("both") });
116
+ const tools = await createTools(session);
117
+ const names = tools.map((tool) => tool.name);
118
+ expect(names).toContain("bash");
119
+ expect(names).toContain("python");
120
+ });
121
+ });