@oh-my-pi/pi-coding-agent 17.1.5 → 17.1.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.
- package/CHANGELOG.md +54 -0
- package/dist/{CHANGELOG-qs3vd6xf.md → CHANGELOG-5k4dq4g6.md} +54 -0
- package/dist/cli.js +3342 -3274
- package/dist/types/capability/index.d.ts +1 -1
- package/dist/types/capability/types.d.ts +23 -1
- package/dist/types/config/settings-schema.d.ts +39 -2
- package/dist/types/cursor.d.ts +2 -1
- package/dist/types/extensibility/extensions/runner.d.ts +4 -0
- package/dist/types/extensibility/shared-events.d.ts +18 -1
- package/dist/types/internal-urls/mcp-protocol.d.ts +3 -2
- package/dist/types/internal-urls/parse.d.ts +12 -0
- package/dist/types/internal-urls/router.d.ts +6 -0
- package/dist/types/internal-urls/types.d.ts +6 -0
- package/dist/types/lsp/config.d.ts +1 -0
- package/dist/types/lsp/types.d.ts +2 -0
- package/dist/types/mcp/manager.d.ts +5 -0
- package/dist/types/mcp/tool-bridge.d.ts +13 -0
- package/dist/types/modes/components/custom-editor.d.ts +5 -0
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
- package/dist/types/sdk.d.ts +3 -1
- package/dist/types/session/agent-session-types.d.ts +8 -6
- package/dist/types/session/agent-session.d.ts +28 -1
- package/dist/types/session/model-controls.d.ts +4 -1
- package/dist/types/session/session-advisors.d.ts +7 -1
- package/dist/types/session/session-tools.d.ts +44 -6
- package/dist/types/session/streaming-output.d.ts +7 -2
- package/dist/types/session/tool-choice-queue.d.ts +6 -4
- package/dist/types/session/turn-recovery.d.ts +5 -3
- package/dist/types/task/index.d.ts +1 -1
- package/dist/types/task/types.d.ts +3 -11
- package/dist/types/thinking.d.ts +21 -2
- package/dist/types/tools/index.d.ts +8 -3
- package/dist/types/tools/output-meta.d.ts +5 -0
- package/dist/types/tools/read.d.ts +9 -1
- package/dist/types/tools/xdev.d.ts +53 -67
- package/dist/types/utils/cpuprofile.d.ts +51 -0
- package/dist/types/utils/inspect-image-mode.d.ts +29 -0
- package/dist/types/utils/profile-tree.d.ts +47 -0
- package/dist/types/utils/sample-profile.d.ts +67 -0
- package/dist/types/utils/title-generator.d.ts +17 -16
- package/package.json +12 -12
- package/src/capability/index.ts +43 -12
- package/src/capability/mcp.ts +21 -0
- package/src/capability/types.ts +20 -1
- package/src/cli/read-cli.ts +44 -2
- package/src/config/settings-schema.ts +42 -2
- package/src/config/settings.ts +35 -0
- package/src/cursor.ts +4 -3
- package/src/discovery/builtin-rules/index.ts +2 -0
- package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
- package/src/eval/py/runner.py +16 -2
- package/src/extensibility/extensions/runner.ts +117 -5
- package/src/extensibility/extensions/types.ts +0 -1
- package/src/extensibility/extensions/wrapper.ts +74 -42
- package/src/extensibility/hooks/tool-wrapper.ts +11 -4
- package/src/extensibility/hooks/types.ts +0 -1
- package/src/extensibility/shared-events.ts +18 -1
- package/src/internal-urls/mcp-protocol.ts +17 -3
- package/src/internal-urls/parse.ts +31 -0
- package/src/internal-urls/router.ts +24 -4
- package/src/internal-urls/types.ts +6 -0
- package/src/live/transport.ts +2 -2
- package/src/lsp/client.ts +2 -2
- package/src/lsp/config.ts +4 -0
- package/src/lsp/types.ts +2 -0
- package/src/mcp/config.ts +26 -14
- package/src/mcp/manager.ts +26 -9
- package/src/mcp/tool-bridge.ts +52 -1
- package/src/memories/index.ts +25 -6
- package/src/modes/components/custom-editor.ts +39 -16
- package/src/modes/components/status-line/segments.ts +3 -1
- package/src/modes/components/tips.txt +2 -1
- package/src/modes/components/tool-execution.ts +8 -7
- package/src/modes/controllers/command-controller.ts +10 -10
- package/src/modes/controllers/extension-ui-controller.ts +7 -7
- package/src/modes/controllers/selector-controller.ts +7 -2
- package/src/modes/rpc/rpc-mode.ts +60 -47
- package/src/prompts/steering/user-interjection.md +2 -5
- package/src/prompts/tools/task.md +4 -2
- package/src/sdk.ts +64 -58
- package/src/session/agent-session-types.ts +8 -5
- package/src/session/agent-session.ts +144 -11
- package/src/session/model-controls.ts +48 -12
- package/src/session/session-advisors.ts +30 -14
- package/src/session/session-listing.ts +66 -4
- package/src/session/session-tools.ts +162 -54
- package/src/session/streaming-output.ts +18 -6
- package/src/session/tool-choice-queue.ts +19 -4
- package/src/session/turn-recovery.ts +25 -6
- package/src/slash-commands/builtin-registry.ts +69 -0
- package/src/task/executor.ts +11 -3
- package/src/task/index.ts +43 -32
- package/src/task/types.ts +12 -17
- package/src/thinking.ts +68 -5
- package/src/tools/bash.ts +16 -9
- package/src/tools/index.ts +36 -16
- package/src/tools/output-meta.ts +20 -0
- package/src/tools/read.ts +89 -15
- package/src/tools/write.ts +16 -7
- package/src/tools/xdev.ts +198 -210
- package/src/utils/cpuprofile.ts +235 -0
- package/src/utils/inspect-image-mode.ts +39 -0
- package/src/utils/profile-tree.ts +111 -0
- package/src/utils/sample-profile.ts +437 -0
- package/src/utils/title-generator.ts +88 -34
- package/dist/types/advisor/__tests__/advisor.test.d.ts +0 -1
- package/dist/types/advisor/__tests__/config.test.d.ts +0 -1
- package/dist/types/advisor/__tests__/emission-guard.test.d.ts +0 -1
- package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +0 -1
- package/dist/types/cli/update-cli.test.d.ts +0 -1
- package/dist/types/config/__tests__/model-registry.test.d.ts +0 -1
- package/dist/types/eval/__tests__/agent-bridge.test.d.ts +0 -1
- package/dist/types/eval/__tests__/bridge-timeout.test.d.ts +0 -1
- package/dist/types/eval/__tests__/budget-bridge.test.d.ts +0 -1
- package/dist/types/eval/__tests__/completion-bridge.test.d.ts +0 -1
- package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +0 -1
- package/dist/types/eval/__tests__/idle-timeout.test.d.ts +0 -1
- package/dist/types/eval/__tests__/js-context-manager.test.d.ts +0 -1
- package/dist/types/eval/__tests__/julia-prelude.test.d.ts +0 -1
- package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +0 -1
- package/dist/types/eval/__tests__/prelude-agent.test.d.ts +0 -1
- package/dist/types/eval/__tests__/process-entry-import.test.d.ts +0 -1
- package/dist/types/eval/py/__tests__/prelude.test.d.ts +0 -1
- package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +0 -1
- package/dist/types/hindsight/client.test.d.ts +0 -1
- package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +0 -1
- package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +0 -1
- package/dist/types/launch/broker-list-order.test.d.ts +0 -1
- package/dist/types/launch/broker-output-snapshot.test.d.ts +0 -1
- package/dist/types/launch/protocol.test.d.ts +0 -1
- package/dist/types/launch/spawn-options.test.d.ts +0 -1
- package/dist/types/launch/terminal-output.test.d.ts +0 -1
- package/dist/types/live/protocol.test.d.ts +0 -1
- package/dist/types/mcp/config-writer.test.d.ts +0 -1
- package/dist/types/mcp/smithery-auth.test.d.ts +0 -1
- package/dist/types/mcp/smithery-registry.test.d.ts +0 -1
- package/dist/types/mcp/transports/stdio.test.d.ts +0 -1
- package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +0 -1
- package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +0 -1
- package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +0 -1
- package/dist/types/modes/components/__tests__/skill-message.test.d.ts +0 -1
- package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +0 -1
- package/dist/types/modes/components/custom-editor.test.d.ts +0 -1
- package/dist/types/modes/components/login-dialog.test.d.ts +0 -1
- package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +0 -1
- package/dist/types/modes/components/status-line/component.test.d.ts +0 -1
- package/dist/types/modes/components/tool-execution.test.d.ts +0 -1
- package/dist/types/modes/controllers/extension-ui-controller.test.d.ts +0 -1
- package/dist/types/modes/noninteractive-dispose.test.d.ts +0 -1
- package/dist/types/modes/print-mode.test.d.ts +0 -1
- package/dist/types/modes/session-teardown.test.d.ts +0 -1
- package/dist/types/modes/theme/mermaid-rendering.test.d.ts +0 -1
- package/dist/types/modes/utils/transcript-render-helpers.test.d.ts +0 -1
- package/dist/types/modes/warp-events.test.d.ts +0 -1
- package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +0 -1
- package/dist/types/plan-mode/model-transition.test.d.ts +0 -1
- package/dist/types/plan-mode/reentry-prompt.test.d.ts +0 -1
- package/dist/types/session/agent-session-error-log.test.d.ts +0 -1
- package/dist/types/session/blob-store.test.d.ts +0 -1
- package/dist/types/session/messages.test.d.ts +0 -1
- package/dist/types/session/session-context.test.d.ts +0 -1
- package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +0 -1
- package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +0 -1
- package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +0 -1
- package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +0 -1
- package/dist/types/system-prompt.test.d.ts +0 -1
- package/dist/types/task/render.test.d.ts +0 -1
- package/dist/types/task/spawn-policy.test.d.ts +0 -1
- package/dist/types/tools/__tests__/eval-description.test.d.ts +0 -1
- package/dist/types/tools/__tests__/glob.test.d.ts +0 -1
- package/dist/types/tools/__tests__/json-tree.test.d.ts +0 -1
- package/dist/types/tools/__tests__/vibe-render.test.d.ts +0 -1
- package/dist/types/tools/hub/launch-compat.test.d.ts +0 -1
- package/dist/types/vibe/__tests__/token-rate.test.d.ts +0 -1
- package/src/advisor/__tests__/advisor.test.ts +0 -4889
- package/src/advisor/__tests__/config.test.ts +0 -349
- package/src/advisor/__tests__/emission-guard.test.ts +0 -147
- package/src/cli/__tests__/auth-gateway-catalog.test.ts +0 -111
- package/src/cli/update-cli.test.ts +0 -28
- package/src/config/__tests__/model-registry.test.ts +0 -182
- package/src/eval/__tests__/agent-bridge.test.ts +0 -1509
- package/src/eval/__tests__/bridge-timeout.test.ts +0 -170
- package/src/eval/__tests__/budget-bridge.test.ts +0 -80
- package/src/eval/__tests__/completion-bridge.test.ts +0 -412
- package/src/eval/__tests__/helpers-local-roots.test.ts +0 -55
- package/src/eval/__tests__/idle-timeout.test.ts +0 -80
- package/src/eval/__tests__/js-context-manager.test.ts +0 -456
- package/src/eval/__tests__/julia-prelude.test.ts +0 -66
- package/src/eval/__tests__/kernel-spawn.test.ts +0 -115
- package/src/eval/__tests__/prelude-agent.test.ts +0 -156
- package/src/eval/__tests__/process-entry-import.test.ts +0 -137
- package/src/eval/py/__tests__/prelude.test.ts +0 -104
- package/src/eval/py/__tests__/runner-shell-output.test.ts +0 -157
- package/src/hindsight/client.test.ts +0 -75
- package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +0 -141
- package/src/internal-urls/__tests__/ssh-protocol.test.ts +0 -331
- package/src/launch/broker-list-order.test.ts +0 -89
- package/src/launch/broker-output-snapshot.test.ts +0 -126
- package/src/launch/protocol.test.ts +0 -59
- package/src/launch/spawn-options.test.ts +0 -31
- package/src/launch/terminal-output.test.ts +0 -107
- package/src/live/protocol.test.ts +0 -140
- package/src/mcp/config-writer.test.ts +0 -43
- package/src/mcp/smithery-auth.test.ts +0 -29
- package/src/mcp/smithery-registry.test.ts +0 -51
- package/src/mcp/transports/stdio.test.ts +0 -427
- package/src/modes/components/__tests__/dynamic-border.test.ts +0 -55
- package/src/modes/components/__tests__/move-overlay.test.ts +0 -252
- package/src/modes/components/__tests__/pause-screen.test.ts +0 -143
- package/src/modes/components/__tests__/skill-message.test.ts +0 -94
- package/src/modes/components/custom-editor-plugin-ctor.test.ts +0 -36
- package/src/modes/components/custom-editor.test.ts +0 -510
- package/src/modes/components/login-dialog.test.ts +0 -56
- package/src/modes/components/status-line/component.jj-cache.test.ts +0 -229
- package/src/modes/components/status-line/component.test.ts +0 -84
- package/src/modes/components/tool-execution.test.ts +0 -162
- package/src/modes/controllers/extension-ui-controller.test.ts +0 -250
- package/src/modes/noninteractive-dispose.test.ts +0 -73
- package/src/modes/print-mode.test.ts +0 -71
- package/src/modes/session-teardown.test.ts +0 -219
- package/src/modes/theme/mermaid-rendering.test.ts +0 -53
- package/src/modes/utils/transcript-render-helpers.test.ts +0 -38
- package/src/modes/warp-events.test.ts +0 -794
- package/src/plan-mode/approved-plan-prompt.test.ts +0 -36
- package/src/plan-mode/model-transition.test.ts +0 -60
- package/src/plan-mode/reentry-prompt.test.ts +0 -41
- package/src/session/agent-session-error-log.test.ts +0 -59
- package/src/session/blob-store.test.ts +0 -56
- package/src/session/messages.test.ts +0 -282
- package/src/session/session-context.test.ts +0 -384
- package/src/ssh/__tests__/connection-manager-args.test.ts +0 -191
- package/src/ssh/__tests__/connection-manager-timeout.test.ts +0 -61
- package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +0 -105
- package/src/ssh/__tests__/sshfs-mount.test.ts +0 -13
- package/src/system-prompt.test.ts +0 -236
- package/src/task/render.test.ts +0 -290
- package/src/task/spawn-policy.test.ts +0 -62
- package/src/tools/__tests__/eval-description.test.ts +0 -18
- package/src/tools/__tests__/glob.test.ts +0 -37
- package/src/tools/__tests__/json-tree.test.ts +0 -35
- package/src/tools/__tests__/vibe-render.test.ts +0 -210
- package/src/tools/hub/launch-compat.test.ts +0 -40
- package/src/vibe/__tests__/token-rate.test.ts +0 -96
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
__resetWindowsConsoleProbeCache,
|
|
4
|
-
consoleAttachedViaTTY,
|
|
5
|
-
hostHasInheritableConsole,
|
|
6
|
-
shouldDetachKernel,
|
|
7
|
-
shouldHideKernelWindow,
|
|
8
|
-
} from "../py/spawn-options";
|
|
9
|
-
|
|
10
|
-
describe("shouldDetachKernel", () => {
|
|
11
|
-
it("starts POSIX kernels in a new session", () => {
|
|
12
|
-
expect(shouldDetachKernel("darwin")).toBe(true);
|
|
13
|
-
expect(shouldDetachKernel("linux")).toBe(true);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("leaves Windows console inheritance to windowsHide", () => {
|
|
17
|
-
expect(shouldDetachKernel("win32")).toBe(false);
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* `shouldHideKernelWindow` decides whether the long-lived Python kernel
|
|
23
|
-
* subprocess is spawned with `windowsHide: true`. On Windows, Bun maps that
|
|
24
|
-
* option to `CREATE_NO_WINDOW`, which detaches the child from any inherited
|
|
25
|
-
* console — breaking both (a) `LoadLibraryExW` for NumPy/pandas native
|
|
26
|
-
* extensions and (b) SIGINT delivery via `GenerateConsoleCtrlEvent`. See
|
|
27
|
-
* issue #1960. The tests below pin the three layered concerns the PR review
|
|
28
|
-
* surfaced:
|
|
29
|
-
*
|
|
30
|
-
* 1. `shouldHideKernelWindow` — pure predicate over a single boolean.
|
|
31
|
-
* 2. `consoleAttachedViaTTY` — the TTY-OR fallback used when the Win32 FFI
|
|
32
|
-
* probe is unavailable; covers the partial-redirection cases.
|
|
33
|
-
* 3. `hostHasInheritableConsole` — the integration boundary. Off-Windows it
|
|
34
|
-
* short-circuits to the TTY fallback; on Windows it is expected to
|
|
35
|
-
* consult `kernel32!GetConsoleWindow()` first, which is the authoritative
|
|
36
|
-
* signal even for the all-stdio-redirected case.
|
|
37
|
-
*/
|
|
38
|
-
describe("shouldHideKernelWindow", () => {
|
|
39
|
-
it("inherits the host console on Windows when one is attached", () => {
|
|
40
|
-
// Reporter's repro: omp launched in Windows Terminal, host has a
|
|
41
|
-
// console, kernel must inherit so `import pandas` doesn't deadlock in
|
|
42
|
-
// `_multiarray_umath` and SIGINT can recover the cell.
|
|
43
|
-
expect(shouldHideKernelWindow({ platform: "win32", hostHasInheritableConsole: true })).toBe(false);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("hides on Windows only when the host has no console at all (true service / daemon)", () => {
|
|
47
|
-
// CREATE_NO_WINDOW here suppresses the console window Windows would
|
|
48
|
-
// otherwise auto-allocate for the console-app Python kernel.
|
|
49
|
-
expect(shouldHideKernelWindow({ platform: "win32", hostHasInheritableConsole: false })).toBe(true);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("never sets windowsHide off-Windows (the option is a Win32-only flag)", () => {
|
|
53
|
-
// On POSIX `windowsHide` is a no-op; the predicate must return false
|
|
54
|
-
// everywhere off-Windows so the spawn site matches pre-fix behavior.
|
|
55
|
-
expect(shouldHideKernelWindow({ platform: "linux", hostHasInheritableConsole: true })).toBe(false);
|
|
56
|
-
expect(shouldHideKernelWindow({ platform: "linux", hostHasInheritableConsole: false })).toBe(false);
|
|
57
|
-
expect(shouldHideKernelWindow({ platform: "darwin", hostHasInheritableConsole: true })).toBe(false);
|
|
58
|
-
expect(shouldHideKernelWindow({ platform: "darwin", hostHasInheritableConsole: false })).toBe(false);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
describe("consoleAttachedViaTTY (FFI fallback heuristic)", () => {
|
|
63
|
-
// The OR of three TTY signals correctly classifies the realistic shell
|
|
64
|
-
// redirection scenarios that motivated widening the check beyond stdout
|
|
65
|
-
// in the first review pass (PR #1961). The all-three-redirected case
|
|
66
|
-
// (false here) is the gap that the Win32 FFI probe in
|
|
67
|
-
// `hostHasInheritableConsole` is meant to close — this fallback is best-
|
|
68
|
-
// effort.
|
|
69
|
-
|
|
70
|
-
it("treats a fully interactive launch as console-attached", () => {
|
|
71
|
-
expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: true, stderrIsTTY: true })).toBe(true);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("treats `omp -p '...' > out.txt` (stdout-only redirect) as console-attached", () => {
|
|
75
|
-
// The reviewer's first-pass repro: stdout off the terminal, stdin
|
|
76
|
-
// and stderr still attached. OR keeps the console.
|
|
77
|
-
expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: false, stderrIsTTY: true })).toBe(true);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("treats stdin-only redirects (`< in.txt`) as console-attached", () => {
|
|
81
|
-
expect(consoleAttachedViaTTY({ stdinIsTTY: false, stdoutIsTTY: true, stderrIsTTY: true })).toBe(true);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("treats stderr-only redirects (`2> err.log`) as console-attached", () => {
|
|
85
|
-
expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: true, stderrIsTTY: false })).toBe(true);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("returns false only when none of stdin/stdout/stderr is a TTY", () => {
|
|
89
|
-
// This is the gap: a real Windows Terminal session with all three
|
|
90
|
-
// streams redirected (`omp ... < in > out 2> err`) lands here.
|
|
91
|
-
// `hostHasInheritableConsole` uses the Win32 FFI probe to recover
|
|
92
|
-
// the right answer in that scenario; this helper is the fallback.
|
|
93
|
-
expect(consoleAttachedViaTTY({ stdinIsTTY: false, stdoutIsTTY: false, stderrIsTTY: false })).toBe(false);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
describe("hostHasInheritableConsole", () => {
|
|
98
|
-
afterEach(() => {
|
|
99
|
-
__resetWindowsConsoleProbeCache();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
if (process.platform !== "win32") {
|
|
103
|
-
it("matches the TTY-OR fallback off-Windows", () => {
|
|
104
|
-
// Off-Windows, `windowsHide` is a no-op anyway, but we still
|
|
105
|
-
// expose `hostHasInheritableConsole` symmetrically. Confirm it
|
|
106
|
-
// degrades to the same OR the call site would compute by hand.
|
|
107
|
-
const tty = consoleAttachedViaTTY({
|
|
108
|
-
stdinIsTTY: !!process.stdin.isTTY,
|
|
109
|
-
stdoutIsTTY: !!process.stdout.isTTY,
|
|
110
|
-
stderrIsTTY: !!process.stderr.isTTY,
|
|
111
|
-
});
|
|
112
|
-
expect(hostHasInheritableConsole()).toBe(tty);
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
});
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import * as vm from "node:vm";
|
|
3
|
-
import { JAVASCRIPT_PRELUDE_SOURCE } from "../js/shared/prelude";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The eval `agent()` helper grows a `handle` option that turns its bare
|
|
7
|
-
* text result into a DAG node dict carrying the spawned agent's recoverable
|
|
8
|
-
* `agent://<id>` handle, so a downstream `pipeline`/`parallel` stage can wire
|
|
9
|
-
* the transcript by reference instead of re-inlining it. These lock the node
|
|
10
|
-
* shape, backward compatibility of the default path, the schema interaction,
|
|
11
|
-
* and the no-`details` fallback (the helper must never throw).
|
|
12
|
-
*
|
|
13
|
-
* The prelude source is executed verbatim in a throwaway VM context with only
|
|
14
|
-
* the host bridge (`__omp_call_tool__`) stubbed — no worker, no kernel — so the
|
|
15
|
-
* test runs against the real shipped helper, not a re-implementation.
|
|
16
|
-
*/
|
|
17
|
-
function loadPrelude(callTool: (name: string, args: unknown) => Promise<unknown>): Record<string, unknown> {
|
|
18
|
-
const sandbox: Record<string, unknown> = { __omp_call_tool__: callTool };
|
|
19
|
-
vm.createContext(sandbox);
|
|
20
|
-
vm.runInContext(JAVASCRIPT_PRELUDE_SOURCE, sandbox);
|
|
21
|
-
return sandbox;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
type AgentHelper = (prompt: string, opts?: Record<string, unknown>) => Promise<unknown>;
|
|
25
|
-
|
|
26
|
-
describe("eval js agent() handle", () => {
|
|
27
|
-
it("returns a DAG node carrying the agent:// handle when handle is set", async () => {
|
|
28
|
-
let seenName: string | undefined;
|
|
29
|
-
let seenArgs: Record<string, unknown> | undefined;
|
|
30
|
-
const sandbox = loadPrelude(async (name, args) => {
|
|
31
|
-
seenName = name;
|
|
32
|
-
seenArgs = args as Record<string, unknown>;
|
|
33
|
-
return { text: "hello world", details: { agent: "task", id: "abc123", model: "m", structured: false } };
|
|
34
|
-
});
|
|
35
|
-
const node = await (sandbox.agent as AgentHelper)("say hi", { handle: true });
|
|
36
|
-
expect(seenName).toBe("__agent__");
|
|
37
|
-
expect(seenArgs?.handle).toBe(true);
|
|
38
|
-
expect(node).toEqual({
|
|
39
|
-
text: "hello world",
|
|
40
|
-
output: "hello world",
|
|
41
|
-
handle: "agent://abc123",
|
|
42
|
-
id: "abc123",
|
|
43
|
-
agent: "task",
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("returns bare text by default (backward compatible)", async () => {
|
|
48
|
-
const sandbox = loadPrelude(async () => ({
|
|
49
|
-
text: "hello world",
|
|
50
|
-
details: { agent: "task", id: "abc123", structured: false },
|
|
51
|
-
}));
|
|
52
|
-
const out = await (sandbox.agent as AgentHelper)("say hi");
|
|
53
|
-
expect(out).toBe("hello world");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("keeps positional isolation controls stable while appending schemaMode", async () => {
|
|
57
|
-
let seenArgs: Record<string, unknown> | undefined;
|
|
58
|
-
const sandbox = loadPrelude(async (_name, args) => {
|
|
59
|
-
seenArgs = args as Record<string, unknown>;
|
|
60
|
-
return { text: '{"ok":true}', details: { agent: "task", id: "legacy", structured: false } };
|
|
61
|
-
});
|
|
62
|
-
const positionalAgent = sandbox.agent as (
|
|
63
|
-
prompt: string,
|
|
64
|
-
options?: unknown,
|
|
65
|
-
...rest: unknown[]
|
|
66
|
-
) => Promise<unknown>;
|
|
67
|
-
const schema = { type: "object", properties: { ok: { type: "boolean" } } };
|
|
68
|
-
|
|
69
|
-
await positionalAgent("scout", "reviewer", "p/model", "Legacy", schema, true, false, true, "strict");
|
|
70
|
-
|
|
71
|
-
expect(seenArgs).toEqual({
|
|
72
|
-
prompt: "scout",
|
|
73
|
-
agent: "reviewer",
|
|
74
|
-
model: "p/model",
|
|
75
|
-
label: "Legacy",
|
|
76
|
-
schema,
|
|
77
|
-
isolated: true,
|
|
78
|
-
apply: false,
|
|
79
|
-
merge: true,
|
|
80
|
-
schemaMode: "strict",
|
|
81
|
-
handle: false,
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("carries the parsed object under data when schema and handle combine", async () => {
|
|
86
|
-
const payload = JSON.stringify({ k: 1 });
|
|
87
|
-
const sandbox = loadPrelude(async () => ({
|
|
88
|
-
text: payload,
|
|
89
|
-
details: { agent: "task", id: "id-9", structured: true },
|
|
90
|
-
}));
|
|
91
|
-
const node = (await (sandbox.agent as AgentHelper)("emit", {
|
|
92
|
-
schema: { type: "object" },
|
|
93
|
-
handle: true,
|
|
94
|
-
})) as Record<string, unknown>;
|
|
95
|
-
expect(node.handle).toBe("agent://id-9");
|
|
96
|
-
expect(node.data).toEqual({ k: 1 });
|
|
97
|
-
expect(node.text).toBe(payload);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("falls back to a null handle without throwing when the bridge omits details", async () => {
|
|
101
|
-
const sandbox = loadPrelude(async () => ({ text: "lonely" }));
|
|
102
|
-
const node = await (sandbox.agent as AgentHelper)("x", { handle: true });
|
|
103
|
-
expect(node).toEqual({ text: "lonely", output: "lonely", handle: null, id: null, agent: null });
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("exposes patchPath/branchName/nestedPatches/changesApplied/isolated/isolationSummary on the handle", async () => {
|
|
107
|
-
const payload = JSON.stringify({ ok: true });
|
|
108
|
-
const sandbox = loadPrelude(async () => ({
|
|
109
|
-
text: payload,
|
|
110
|
-
details: {
|
|
111
|
-
agent: "task",
|
|
112
|
-
id: "iso-1",
|
|
113
|
-
structured: true,
|
|
114
|
-
isolated: true,
|
|
115
|
-
patchPath: "/artifacts/iso-1.patch",
|
|
116
|
-
changesApplied: null,
|
|
117
|
-
nestedPatches: [{ relativePath: "nested", patch: "diff --git a/file b/file\n" }],
|
|
118
|
-
isolationSummary: "Isolation: changes captured at `/artifacts/iso-1.patch` (apply=false). Not applied.",
|
|
119
|
-
},
|
|
120
|
-
}));
|
|
121
|
-
const node = (await (sandbox.agent as AgentHelper)("scout", {
|
|
122
|
-
schema: { type: "object" },
|
|
123
|
-
isolated: true,
|
|
124
|
-
apply: false,
|
|
125
|
-
handle: true,
|
|
126
|
-
})) as Record<string, unknown>;
|
|
127
|
-
expect(node.handle).toBe("agent://iso-1");
|
|
128
|
-
expect(node.data).toEqual({ ok: true });
|
|
129
|
-
expect(node.isolated).toBe(true);
|
|
130
|
-
expect(node.patchPath).toBe("/artifacts/iso-1.patch");
|
|
131
|
-
expect(node.nestedPatches).toEqual([{ relativePath: "nested", patch: "diff --git a/file b/file\n" }]);
|
|
132
|
-
expect(node.changesApplied).toBeNull();
|
|
133
|
-
expect(node.isolationSummary).toContain("/artifacts/iso-1.patch");
|
|
134
|
-
expect("branchName" in node).toBe(false);
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
describe("eval js read() URI delegation", () => {
|
|
139
|
-
it("appends line selectors to delegated URI paths", async () => {
|
|
140
|
-
const calls: Array<{ name: string; args: unknown }> = [];
|
|
141
|
-
const sandbox = loadPrelude(async (name, args) => {
|
|
142
|
-
calls.push({ name, args });
|
|
143
|
-
return { text: "resource contents" };
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
const result = await vm.runInContext(`read("mcp://server/resource", { offset: 10, limit: 5 })`, sandbox);
|
|
147
|
-
|
|
148
|
-
expect(result).toBe("resource contents");
|
|
149
|
-
expect(calls).toEqual([
|
|
150
|
-
{
|
|
151
|
-
name: "read",
|
|
152
|
-
args: { path: "mcp://server/resource:10-14" },
|
|
153
|
-
},
|
|
154
|
-
]);
|
|
155
|
-
});
|
|
156
|
-
});
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { expect, it } from "bun:test";
|
|
2
|
-
import * as fs from "node:fs";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import { TempDir } from "@oh-my-pi/pi-utils";
|
|
5
|
-
|
|
6
|
-
it("imports the CLI entry graph without loading dotenv before profile bootstrap", async () => {
|
|
7
|
-
using tempDir = TempDir.createSync("@omp-js-process-import-");
|
|
8
|
-
await Bun.write(path.join(tempDir.path(), ".env"), "OMP_PROCESS_ENTRY_ENV_PROBE=loaded-too-early\n");
|
|
9
|
-
const env = Object.fromEntries(
|
|
10
|
-
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
11
|
-
);
|
|
12
|
-
delete env.OMP_PROCESS_ENTRY_ENV_PROBE;
|
|
13
|
-
env.HOME = tempDir.path();
|
|
14
|
-
const fixture = path.resolve(import.meta.dir, "../../../test/fixtures/js-process-entry-import.ts");
|
|
15
|
-
const proc = Bun.spawn([process.execPath, fixture], {
|
|
16
|
-
env,
|
|
17
|
-
stdout: "pipe",
|
|
18
|
-
stderr: "pipe",
|
|
19
|
-
});
|
|
20
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
21
|
-
proc.exited,
|
|
22
|
-
new Response(proc.stdout).text(),
|
|
23
|
-
new Response(proc.stderr).text(),
|
|
24
|
-
]);
|
|
25
|
-
expect(exitCode).toBe(0);
|
|
26
|
-
expect(stdout).toBe("");
|
|
27
|
-
expect(stderr).toBe("");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
async function pingComputerWorker(
|
|
31
|
-
entry: string,
|
|
32
|
-
id: string,
|
|
33
|
-
argv: string[] = ["__omp_worker_computer"],
|
|
34
|
-
): Promise<unknown> {
|
|
35
|
-
const worker = new Worker(entry, {
|
|
36
|
-
type: "module",
|
|
37
|
-
argv,
|
|
38
|
-
});
|
|
39
|
-
const response = Promise.withResolvers<unknown>();
|
|
40
|
-
worker.addEventListener("message", event => response.resolve(event.data));
|
|
41
|
-
worker.addEventListener("error", event => response.reject(event.error ?? new Error(event.message)));
|
|
42
|
-
worker.postMessage({ type: "ping", id });
|
|
43
|
-
try {
|
|
44
|
-
return await response.promise;
|
|
45
|
-
} finally {
|
|
46
|
-
worker.terminate();
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
it("starts ordinary CLI paths without loading the native computer addon", async () => {
|
|
51
|
-
const cliPath = path.resolve(import.meta.dir, "../../cli.ts");
|
|
52
|
-
for (const args of [
|
|
53
|
-
["--no-addons", cliPath, "--version"],
|
|
54
|
-
[cliPath, "--help"],
|
|
55
|
-
]) {
|
|
56
|
-
const proc = Bun.spawn([process.execPath, ...args], {
|
|
57
|
-
stdout: "pipe",
|
|
58
|
-
stderr: "pipe",
|
|
59
|
-
});
|
|
60
|
-
const [exitCode, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
|
61
|
-
expect(exitCode, `${args.at(-1)}: ${stderr}`).toBe(0);
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("dispatches the computer worker through the CLI host selector in a child process", async () => {
|
|
66
|
-
const fixture = path.resolve(import.meta.dir, "../../../test/fixtures/computer-worker-cli-selector.ts");
|
|
67
|
-
const proc = Bun.spawn([process.execPath, "--no-addons", fixture], {
|
|
68
|
-
stdout: "pipe",
|
|
69
|
-
stderr: "pipe",
|
|
70
|
-
});
|
|
71
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
72
|
-
proc.exited,
|
|
73
|
-
new Response(proc.stdout).text(),
|
|
74
|
-
new Response(proc.stderr).text(),
|
|
75
|
-
]);
|
|
76
|
-
expect(exitCode, stderr).toBe(0);
|
|
77
|
-
expect(stdout).toBe('{"type":"pong","id":"computer-cli-selector"}\n');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("loads the computer worker module directly outside a declared CLI host", async () => {
|
|
81
|
-
const entry = new URL("../../tools/computer/worker-entry.ts", import.meta.url).href;
|
|
82
|
-
const response = await pingComputerWorker(entry, "computer-direct-module", []);
|
|
83
|
-
expect(response).toEqual({ type: "pong", id: "computer-direct-module" });
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("dispatches the computer worker from a single npm-style host bundle", async () => {
|
|
87
|
-
const packageDir = path.resolve(import.meta.dir, "../../..");
|
|
88
|
-
const outDir = fs.mkdtempSync(path.join(packageDir, ".computer-worker-bundle-"));
|
|
89
|
-
try {
|
|
90
|
-
const output = await Bun.build({
|
|
91
|
-
entrypoints: [path.join(packageDir, "test/fixtures/computer-worker-bundled-host.ts")],
|
|
92
|
-
outdir: outDir,
|
|
93
|
-
naming: "cli.js",
|
|
94
|
-
target: "bun",
|
|
95
|
-
external: ["@oh-my-pi/pi-natives"],
|
|
96
|
-
define: { "process.env.PI_BUNDLED": JSON.stringify("true") },
|
|
97
|
-
throw: false,
|
|
98
|
-
});
|
|
99
|
-
expect(output.logs).toEqual([]);
|
|
100
|
-
expect(output.outputs.map(file => path.basename(file.path))).toEqual(["cli.js"]);
|
|
101
|
-
const response = await pingComputerWorker(output.outputs[0]!.path, "computer-npm-bundle");
|
|
102
|
-
expect(response).toEqual({ type: "pong", id: "computer-npm-bundle" });
|
|
103
|
-
} finally {
|
|
104
|
-
fs.rmSync(outDir, { recursive: true, force: true });
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("keeps non-computer selectors isolated in a compiled single-entry worker host", async () => {
|
|
109
|
-
using tempDir = TempDir.createSync("@omp-compiled-worker-selector-");
|
|
110
|
-
const packageDir = path.resolve(import.meta.dir, "../../..");
|
|
111
|
-
const outfile = path.join(tempDir.path(), process.platform === "win32" ? "worker-host.exe" : "worker-host");
|
|
112
|
-
const build = Bun.spawn(
|
|
113
|
-
[
|
|
114
|
-
process.execPath,
|
|
115
|
-
"build",
|
|
116
|
-
"--compile",
|
|
117
|
-
"--target=bun",
|
|
118
|
-
`--outfile=${outfile}`,
|
|
119
|
-
path.join(packageDir, "test/fixtures/compiled-worker-selector-host.ts"),
|
|
120
|
-
],
|
|
121
|
-
{ cwd: packageDir, stdout: "pipe", stderr: "pipe" },
|
|
122
|
-
);
|
|
123
|
-
const [buildExitCode, buildStderr] = await Promise.all([build.exited, new Response(build.stderr).text()]);
|
|
124
|
-
expect(buildExitCode, buildStderr).toBe(0);
|
|
125
|
-
const proc = Bun.spawn([outfile], {
|
|
126
|
-
cwd: packageDir,
|
|
127
|
-
stdout: "pipe",
|
|
128
|
-
stderr: "pipe",
|
|
129
|
-
});
|
|
130
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
131
|
-
proc.exited,
|
|
132
|
-
new Response(proc.stdout).text(),
|
|
133
|
-
new Response(proc.stderr).text(),
|
|
134
|
-
]);
|
|
135
|
-
expect(exitCode, stderr).toBe(0);
|
|
136
|
-
expect(stdout).toBe('{"ok":true,"kind":"pong"}\n');
|
|
137
|
-
});
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { PYTHON_PRELUDE } from "../prelude";
|
|
3
|
-
|
|
4
|
-
const pythonPath = Bun.env.PYTHON ?? "python3";
|
|
5
|
-
|
|
6
|
-
async function runPrelude(
|
|
7
|
-
code: string,
|
|
8
|
-
env: Record<string, string>,
|
|
9
|
-
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
10
|
-
const prelude = PYTHON_PRELUDE.replace(
|
|
11
|
-
"from __future__ import annotations",
|
|
12
|
-
"from __future__ import annotations\n__omp_display = lambda *args, **kwargs: None",
|
|
13
|
-
);
|
|
14
|
-
const script = `${prelude}\n${code}`;
|
|
15
|
-
const proc = Bun.spawn([pythonPath, "-c", script], {
|
|
16
|
-
stdout: "pipe",
|
|
17
|
-
stderr: "pipe",
|
|
18
|
-
env: { ...process.env, ...env },
|
|
19
|
-
});
|
|
20
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
21
|
-
new Response(proc.stdout).text(),
|
|
22
|
-
new Response(proc.stderr).text(),
|
|
23
|
-
proc.exited,
|
|
24
|
-
]);
|
|
25
|
-
return { stdout, stderr, exitCode };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe("python prelude", () => {
|
|
29
|
-
it("exposes read(path, offset?, limit?) with positional optional args", () => {
|
|
30
|
-
// The eval docs advertise `read(path, offset?=1, limit?=None)`. A
|
|
31
|
-
// keyword-only signature (`def read(path, *, offset=1, limit=None)`)
|
|
32
|
-
// makes `read("file", 10)` raise `TypeError: read() takes 1 positional
|
|
33
|
-
// argument but 2 were given`, which agents in the wild repeatedly hit.
|
|
34
|
-
// Lock the contract so the helper accepts both positional and keyword
|
|
35
|
-
// forms.
|
|
36
|
-
const match = PYTHON_PRELUDE.match(/def\s+read\(([^)]+)\)/);
|
|
37
|
-
expect(match).not.toBeNull();
|
|
38
|
-
const signature = match?.[1] ?? "";
|
|
39
|
-
expect(signature).not.toContain("*,");
|
|
40
|
-
expect(signature).toContain("offset");
|
|
41
|
-
expect(signature).toContain("limit");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("appends line selectors to delegated URI paths", async () => {
|
|
45
|
-
const requests: unknown[] = [];
|
|
46
|
-
const server = Bun.serve({
|
|
47
|
-
hostname: "127.0.0.1",
|
|
48
|
-
port: 0,
|
|
49
|
-
fetch: async request => {
|
|
50
|
-
requests.push(await request.json());
|
|
51
|
-
return Response.json({
|
|
52
|
-
ok: true,
|
|
53
|
-
value: { text: "resource contents", details: { resolvedPath: "/tmp/resource.txt" } },
|
|
54
|
-
});
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
const result = await runPrelude(
|
|
60
|
-
[`print(read("artifact://21", 3, 2))`, `print(read("mcp://server/resource", 10, 5))`].join("\n"),
|
|
61
|
-
{
|
|
62
|
-
PI_TOOL_BRIDGE_URL: server.url.toString(),
|
|
63
|
-
PI_TOOL_BRIDGE_TOKEN: "test-token",
|
|
64
|
-
PI_TOOL_BRIDGE_SESSION: "test-session",
|
|
65
|
-
},
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
expect(result).toEqual({
|
|
69
|
-
stdout: "resource contents\nresource contents\n",
|
|
70
|
-
stderr: "",
|
|
71
|
-
exitCode: 0,
|
|
72
|
-
});
|
|
73
|
-
expect(requests).toEqual([
|
|
74
|
-
{
|
|
75
|
-
session: "test-session",
|
|
76
|
-
run: null,
|
|
77
|
-
name: "read",
|
|
78
|
-
args: { path: "artifact://21:3-4" },
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
session: "test-session",
|
|
82
|
-
run: null,
|
|
83
|
-
name: "read",
|
|
84
|
-
args: { path: "mcp://server/resource:10-14" },
|
|
85
|
-
},
|
|
86
|
-
]);
|
|
87
|
-
} finally {
|
|
88
|
-
server.stop(true);
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("exposes isolation artifacts on the agent() handle node", () => {
|
|
93
|
-
// agent(..., handle=True) is the only escape hatch for
|
|
94
|
-
// recovering apply=False patch/branch/nested artifacts (the bare
|
|
95
|
-
// schema return is just the parsed object), so the helper MUST
|
|
96
|
-
// translate the bridge's camelCase details onto the node — otherwise
|
|
97
|
-
// an isolated apply=False workflow loses captured nested patches.
|
|
98
|
-
expect(PYTHON_PRELUDE).toContain('("patchPath", "patch_path")');
|
|
99
|
-
expect(PYTHON_PRELUDE).toContain('("branchName", "branch_name")');
|
|
100
|
-
expect(PYTHON_PRELUDE).toContain('("nestedPatches", "nested_patches")');
|
|
101
|
-
expect(PYTHON_PRELUDE).toContain('("changesApplied", "changes_applied")');
|
|
102
|
-
expect(PYTHON_PRELUDE).toContain('("isolationSummary", "isolation_summary")');
|
|
103
|
-
});
|
|
104
|
-
});
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
|
|
4
|
-
interface RunnerFrame {
|
|
5
|
-
type?: string;
|
|
6
|
-
id?: string;
|
|
7
|
-
data?: string;
|
|
8
|
-
status?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const pythonPath = Bun.env.PYTHON ?? "python3";
|
|
12
|
-
const runnerPath = path.resolve(import.meta.dir, "..", "runner.py");
|
|
13
|
-
const repoRoot = path.resolve(import.meta.dir, "../../../../../..");
|
|
14
|
-
const encoder = new TextEncoder();
|
|
15
|
-
|
|
16
|
-
function shellQuote(value: string): string {
|
|
17
|
-
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
async function runCell(code: string): Promise<RunnerFrame[]> {
|
|
21
|
-
const proc = Bun.spawn([pythonPath, "-u", runnerPath], {
|
|
22
|
-
cwd: repoRoot,
|
|
23
|
-
stdin: "pipe",
|
|
24
|
-
stdout: "pipe",
|
|
25
|
-
stderr: "pipe",
|
|
26
|
-
env: {
|
|
27
|
-
...process.env,
|
|
28
|
-
PYTHONUNBUFFERED: "1",
|
|
29
|
-
PYTHONIOENCODING: "utf-8",
|
|
30
|
-
},
|
|
31
|
-
});
|
|
32
|
-
const stderr = new Response(proc.stderr).text();
|
|
33
|
-
const reader = proc.stdout.getReader();
|
|
34
|
-
const decoder = new TextDecoder();
|
|
35
|
-
let pending = "";
|
|
36
|
-
const frames: RunnerFrame[] = [];
|
|
37
|
-
|
|
38
|
-
async function readFrame(): Promise<RunnerFrame> {
|
|
39
|
-
while (true) {
|
|
40
|
-
const newline = pending.indexOf("\n");
|
|
41
|
-
if (newline >= 0) {
|
|
42
|
-
const line = pending.slice(0, newline);
|
|
43
|
-
pending = pending.slice(newline + 1);
|
|
44
|
-
return JSON.parse(line) as RunnerFrame;
|
|
45
|
-
}
|
|
46
|
-
const { value, done } = await reader.read();
|
|
47
|
-
if (done) {
|
|
48
|
-
throw new Error(`Python runner exited before done frame: ${await stderr}`);
|
|
49
|
-
}
|
|
50
|
-
pending += decoder.decode(value, { stream: true });
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
proc.stdin.write(encoder.encode(`${JSON.stringify({ id: "r1", code })}\n`));
|
|
56
|
-
proc.stdin.flush();
|
|
57
|
-
while (true) {
|
|
58
|
-
const frame = await readFrame();
|
|
59
|
-
frames.push(frame);
|
|
60
|
-
if (frame.type === "done") break;
|
|
61
|
-
}
|
|
62
|
-
proc.stdin.write(encoder.encode(`${JSON.stringify({ type: "exit" })}\n`));
|
|
63
|
-
proc.stdin.end();
|
|
64
|
-
const exitCode = await proc.exited;
|
|
65
|
-
if (exitCode !== 0) {
|
|
66
|
-
throw new Error(`Python runner exited ${exitCode}: ${await stderr}`);
|
|
67
|
-
}
|
|
68
|
-
return frames;
|
|
69
|
-
} finally {
|
|
70
|
-
try {
|
|
71
|
-
reader.releaseLock();
|
|
72
|
-
} catch {
|
|
73
|
-
// Reader may already be released by stream closure.
|
|
74
|
-
}
|
|
75
|
-
try {
|
|
76
|
-
proc.kill("SIGKILL");
|
|
77
|
-
} catch {
|
|
78
|
-
// Process already exited.
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
describe("Python runner shell output streaming", () => {
|
|
84
|
-
it("streams !cmd output chunks before the child process exits", async () => {
|
|
85
|
-
const child = [
|
|
86
|
-
"import sys,time",
|
|
87
|
-
"sys.stdout.write('first\\n')",
|
|
88
|
-
"sys.stdout.flush()",
|
|
89
|
-
"time.sleep(0.2)",
|
|
90
|
-
"sys.stdout.write('second\\n')",
|
|
91
|
-
"sys.stdout.flush()",
|
|
92
|
-
].join(";");
|
|
93
|
-
const frames = await runCell(
|
|
94
|
-
[
|
|
95
|
-
`result = !${pythonPath} -c ${shellQuote(child)}`,
|
|
96
|
-
"print('return=' + str(result.returncode) + ' lines=' + repr(list(result)))",
|
|
97
|
-
].join("\n"),
|
|
98
|
-
);
|
|
99
|
-
const stdout = frames.filter(frame => frame.type === "stdout").map(frame => frame.data);
|
|
100
|
-
|
|
101
|
-
expect(stdout[0]).toBe("first\n");
|
|
102
|
-
expect(stdout.join("")).toContain("second\n");
|
|
103
|
-
expect(stdout.join("")).toContain("return=0 lines=['first', 'second']");
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("caps !cmd output and captured result by line count with a truncation notice", async () => {
|
|
107
|
-
const child = ["import sys", "sys.stdout.write(('x' + chr(10)) * 3100)", "sys.stdout.flush()"].join(";");
|
|
108
|
-
const frames = await runCell(
|
|
109
|
-
[
|
|
110
|
-
`result = !${pythonPath} -c ${shellQuote(child)}`,
|
|
111
|
-
"print('captured=' + str(len(result)) + ' return=' + str(result.returncode))",
|
|
112
|
-
].join("\n"),
|
|
113
|
-
);
|
|
114
|
-
const stdout = frames
|
|
115
|
-
.filter(frame => frame.type === "stdout")
|
|
116
|
-
.map(frame => frame.data)
|
|
117
|
-
.join("");
|
|
118
|
-
|
|
119
|
-
expect(stdout).toContain("[output truncated: shell helper exceeded");
|
|
120
|
-
expect(stdout).toContain("captured=3000 return=0");
|
|
121
|
-
expect(stdout).not.toContain("captured=3100");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("caps newline-free !cmd output by bytes with a truncation notice", async () => {
|
|
125
|
-
const child = ["import sys", "sys.stdout.write('z' * (1024 * 1024 + 17))", "sys.stdout.flush()"].join(";");
|
|
126
|
-
const frames = await runCell(
|
|
127
|
-
[
|
|
128
|
-
`result = !${pythonPath} -c ${shellQuote(child)}`,
|
|
129
|
-
"print('capturedChars=' + str(len(result.n)) + ' return=' + str(result.returncode))",
|
|
130
|
-
].join("\n"),
|
|
131
|
-
);
|
|
132
|
-
const stdout = frames
|
|
133
|
-
.filter(frame => frame.type === "stdout")
|
|
134
|
-
.map(frame => frame.data)
|
|
135
|
-
.join("");
|
|
136
|
-
|
|
137
|
-
expect(stdout).toContain("[output truncated: shell helper exceeded");
|
|
138
|
-
expect(stdout).toContain("capturedChars=1048576 return=0");
|
|
139
|
-
expect(stdout).not.toContain("capturedChars=1048593");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("streams newline-free %%bash output without waiting for EOF", async () => {
|
|
143
|
-
const child = [
|
|
144
|
-
"import sys,time",
|
|
145
|
-
"sys.stdout.write('first')",
|
|
146
|
-
"sys.stdout.flush()",
|
|
147
|
-
"time.sleep(0.2)",
|
|
148
|
-
"sys.stdout.write('second')",
|
|
149
|
-
"sys.stdout.flush()",
|
|
150
|
-
].join(";");
|
|
151
|
-
const frames = await runCell(`%%bash\n${pythonPath} -c ${shellQuote(child)}`);
|
|
152
|
-
const stdout = frames.filter(frame => frame.type === "stdout").map(frame => frame.data);
|
|
153
|
-
|
|
154
|
-
expect(stdout[0]).toBe("first");
|
|
155
|
-
expect(stdout.join("")).toBe("firstsecond");
|
|
156
|
-
});
|
|
157
|
-
});
|