@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.1
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 +26 -0
- package/dist/cli.js +3336 -3318
- package/dist/types/advisor/advise-tool.d.ts +12 -1
- package/dist/types/advisor/runtime.d.ts +41 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/update-cli.d.ts +4 -1
- package/dist/types/cli/usage-cli.d.ts +3 -0
- package/dist/types/cli/usage-error.d.ts +4 -0
- package/dist/types/config/api-key-resolver.d.ts +2 -2
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/model-resolver.d.ts +8 -1
- package/dist/types/config/models-config.d.ts +1 -1
- package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
- package/dist/types/eval/bridge-timeout.d.ts +9 -1
- package/dist/types/eval/js/context-manager.d.ts +5 -3
- package/dist/types/eval/js/process-entry.d.ts +6 -0
- package/dist/types/eval/js/worker-core.d.ts +15 -1
- package/dist/types/eval/py/spawn-options.d.ts +10 -0
- package/dist/types/eval/py/tool-bridge.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/components/transcript-container.d.ts +3 -2
- package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
- package/dist/types/modes/orchestrate.d.ts +1 -1
- package/dist/types/modes/rpc/host-tools.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
- package/dist/types/modes/ultrathink.d.ts +1 -1
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
- package/dist/types/modes/workflow.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +6 -0
- package/dist/types/session/exit-diagnostics.d.ts +11 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
- package/dist/types/subprocess/worker-client.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +1 -0
- package/dist/types/web/search/provider.d.ts +10 -3
- package/dist/types/web/search/providers/codex.d.ts +5 -4
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +830 -42
- package/src/advisor/advise-tool.ts +17 -1
- package/src/advisor/runtime.ts +288 -67
- package/src/autolearn/controller.ts +15 -3
- package/src/cli/args.ts +12 -0
- package/src/cli/auth-broker-cli.ts +30 -11
- package/src/cli/auth-gateway-cli.ts +5 -1
- package/src/cli/dry-balance-cli.ts +14 -4
- package/src/cli/flag-tables.ts +21 -7
- package/src/cli/update-cli.ts +62 -11
- package/src/cli/usage-cli.ts +58 -5
- package/src/cli/usage-error.ts +7 -0
- package/src/cli.ts +23 -1
- package/src/commands/acp.ts +11 -2
- package/src/commands/launch.ts +12 -3
- package/src/commands/token.ts +3 -1
- package/src/config/api-key-resolver.ts +12 -3
- package/src/config/config-file.ts +30 -12
- package/src/config/model-registry.ts +7 -7
- package/src/config/model-resolver.ts +21 -7
- package/src/config/models-config.ts +1 -1
- package/src/eval/__tests__/agent-bridge.test.ts +19 -14
- package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
- package/src/eval/__tests__/js-context-manager.test.ts +158 -1
- package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
- package/src/eval/__tests__/process-entry-import.test.ts +27 -0
- package/src/eval/agent-bridge.ts +121 -116
- package/src/eval/bridge-timeout.ts +20 -2
- package/src/eval/executor-base.ts +85 -7
- package/src/eval/jl/kernel.ts +2 -1
- package/src/eval/js/context-manager.ts +109 -32
- package/src/eval/js/process-entry.ts +27 -0
- package/src/eval/js/shared/runtime.ts +1 -1
- package/src/eval/js/worker-core.ts +70 -9
- package/src/eval/js/worker-entry.ts +1 -1
- package/src/eval/py/kernel.ts +2 -1
- package/src/eval/py/spawn-options.ts +13 -0
- package/src/eval/py/tool-bridge.ts +13 -14
- package/src/eval/rb/kernel.ts +2 -1
- package/src/extensibility/custom-tools/types.ts +3 -0
- package/src/extensibility/extensions/runner.ts +3 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/plugins/manager.ts +21 -0
- package/src/internal-urls/memory-protocol.ts +13 -9
- package/src/lsp/client.ts +7 -1
- package/src/main.ts +29 -0
- package/src/mcp/tool-bridge.ts +57 -6
- package/src/modes/components/chat-transcript-builder.ts +22 -1
- package/src/modes/components/status-line/component.ts +10 -1
- package/src/modes/components/transcript-container.ts +110 -7
- package/src/modes/controllers/command-controller.ts +12 -4
- package/src/modes/controllers/event-controller.ts +80 -15
- package/src/modes/controllers/selector-controller.ts +15 -3
- package/src/modes/magic-keyword-boundary.ts +23 -0
- package/src/modes/orchestrate.ts +6 -5
- package/src/modes/print-mode.ts +9 -0
- package/src/modes/rpc/host-tools.ts +15 -0
- package/src/modes/rpc/rpc-mode.ts +123 -48
- package/src/modes/ultrathink.ts +6 -5
- package/src/modes/utils/transcript-render-helpers.ts +54 -0
- package/src/modes/utils/ui-helpers.ts +27 -1
- package/src/modes/workflow.ts +6 -5
- package/src/prompts/advisor/system.md +1 -0
- package/src/sdk.ts +33 -3
- package/src/session/agent-session.ts +239 -17
- package/src/session/exit-diagnostics.ts +108 -0
- package/src/session/streaming-output.ts +40 -12
- package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
- package/src/slash-commands/helpers/logout.ts +23 -3
- package/src/slash-commands/helpers/usage-report.ts +14 -2
- package/src/subprocess/worker-client.ts +9 -2
- package/src/task/executor.ts +8 -0
- package/src/task/render.test.ts +36 -0
- package/src/task/render.ts +55 -43
- package/src/tools/bash-skill-urls.ts +4 -1
- package/src/tools/bash.ts +1 -0
- package/src/tools/write.ts +82 -9
- package/src/tools/yield.ts +29 -1
- package/src/web/search/index.ts +39 -22
- package/src/web/search/provider.ts +33 -16
- package/src/web/search/providers/codex.ts +68 -21
|
@@ -258,7 +258,7 @@ export interface ProviderDiscoveryState {
|
|
|
258
258
|
error?: string;
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
/** Result of loading custom models
|
|
261
|
+
/** Result of loading custom models config. */
|
|
262
262
|
interface CustomModelsResult {
|
|
263
263
|
models?: CustomModelOverlay[];
|
|
264
264
|
overrides?: Map<string, ProviderOverride>;
|
|
@@ -309,7 +309,7 @@ interface CommandApiKeyResolution {
|
|
|
309
309
|
value?: string;
|
|
310
310
|
}
|
|
311
311
|
/**
|
|
312
|
-
* Resolve a models.yml secret/config value to an actual value.
|
|
312
|
+
* Resolve a models.yml/models.yaml secret/config value to an actual value.
|
|
313
313
|
* `!cmd` runs a shell command and returns trimmed stdout, otherwise env vars are
|
|
314
314
|
* checked first and the input falls back to a literal value.
|
|
315
315
|
*/
|
|
@@ -822,7 +822,7 @@ export class ModelRegistry {
|
|
|
822
822
|
}
|
|
823
823
|
|
|
824
824
|
/**
|
|
825
|
-
* Reload models from disk (built-in + custom
|
|
825
|
+
* Reload models from disk (built-in + custom config).
|
|
826
826
|
*/
|
|
827
827
|
async refresh(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
|
|
828
828
|
this.#reloadStaticModels();
|
|
@@ -938,7 +938,7 @@ export class ModelRegistry {
|
|
|
938
938
|
#reloadStaticModels(): void {
|
|
939
939
|
const currentMtime = this.#modelsConfigFile.getMtimeMs();
|
|
940
940
|
if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) {
|
|
941
|
-
//
|
|
941
|
+
// Models config unchanged since last load; reloading would be redundant.
|
|
942
942
|
return;
|
|
943
943
|
}
|
|
944
944
|
this.#modelsConfigFile.invalidate();
|
|
@@ -962,14 +962,14 @@ export class ModelRegistry {
|
|
|
962
962
|
}
|
|
963
963
|
|
|
964
964
|
/**
|
|
965
|
-
* Get any error from loading models
|
|
965
|
+
* Get any error from loading custom models config (undefined if no error).
|
|
966
966
|
*/
|
|
967
967
|
getError(): ConfigError | undefined {
|
|
968
968
|
return this.#configError;
|
|
969
969
|
}
|
|
970
970
|
|
|
971
971
|
#loadModels() {
|
|
972
|
-
// Load custom
|
|
972
|
+
// Load custom config first (to know which providers to override).
|
|
973
973
|
const {
|
|
974
974
|
models: customModels = [],
|
|
975
975
|
overrides = new Map(),
|
|
@@ -1909,7 +1909,7 @@ export class ModelRegistry {
|
|
|
1909
1909
|
|
|
1910
1910
|
/**
|
|
1911
1911
|
* Get all models (built-in + custom).
|
|
1912
|
-
* If
|
|
1912
|
+
* If custom config had errors, returns only built-in models.
|
|
1913
1913
|
*/
|
|
1914
1914
|
getAll(): Model<Api>[] {
|
|
1915
1915
|
return this.#models;
|
|
@@ -1209,20 +1209,27 @@ export function resolveModelOverride(
|
|
|
1209
1209
|
modelPatterns: string[],
|
|
1210
1210
|
modelRegistry: ModelLookupRegistry,
|
|
1211
1211
|
settings?: Settings,
|
|
1212
|
-
): { model?: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel; explicitThinkingLevel: boolean } {
|
|
1212
|
+
): { model?: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel; explicitThinkingLevel: boolean; warning?: string } {
|
|
1213
1213
|
if (modelPatterns.length === 0) return { explicitThinkingLevel: false };
|
|
1214
1214
|
const availableModels = modelRegistry.getAvailable();
|
|
1215
1215
|
const matchPreferences = getModelMatchPreferences(settings);
|
|
1216
|
+
let warning: string | undefined;
|
|
1216
1217
|
for (const pattern of modelPatterns) {
|
|
1217
|
-
const {
|
|
1218
|
+
const {
|
|
1219
|
+
model,
|
|
1220
|
+
thinkingLevel,
|
|
1221
|
+
explicitThinkingLevel,
|
|
1222
|
+
warning: patternWarning,
|
|
1223
|
+
} = resolveModelRoleValue(pattern, availableModels, {
|
|
1218
1224
|
settings,
|
|
1219
1225
|
matchPreferences,
|
|
1220
1226
|
});
|
|
1221
1227
|
if (model) {
|
|
1222
|
-
return { model, thinkingLevel, explicitThinkingLevel };
|
|
1228
|
+
return { model, thinkingLevel, explicitThinkingLevel, warning: patternWarning };
|
|
1223
1229
|
}
|
|
1230
|
+
if (!warning && patternWarning) warning = patternWarning;
|
|
1224
1231
|
}
|
|
1225
|
-
return { explicitThinkingLevel: false };
|
|
1232
|
+
return { explicitThinkingLevel: false, warning };
|
|
1226
1233
|
}
|
|
1227
1234
|
|
|
1228
1235
|
/**
|
|
@@ -1236,6 +1243,11 @@ export function resolveModelOverride(
|
|
|
1236
1243
|
* `modelRoles.task` pointing at an unqualified id whose only available
|
|
1237
1244
|
* provider variant has no configured credentials — see #985).
|
|
1238
1245
|
*
|
|
1246
|
+
* `sessionId` is forwarded to `getApiKey` so that session-sticky OAuth
|
|
1247
|
+
* credentials resolve correctly during the pre-flight auth check. Without it,
|
|
1248
|
+
* providers with multiple OAuth accounts may return `undefined` even though
|
|
1249
|
+
* the credential is usable once the subagent session starts — see #5325.
|
|
1250
|
+
*
|
|
1239
1251
|
* Keyless-by-design providers (llama.cpp, ollama, lm-studio) advertise the
|
|
1240
1252
|
* `kNoAuth` sentinel from `getApiKey` to signal that they do not require
|
|
1241
1253
|
* credentials. Those are treated as authenticated here so an explicitly
|
|
@@ -1251,18 +1263,20 @@ export async function resolveModelOverrideWithAuthFallback(
|
|
|
1251
1263
|
parentActiveModelPattern: string | undefined,
|
|
1252
1264
|
modelRegistry: ModelLookupRegistry & Pick<ModelRegistry, "getApiKey">,
|
|
1253
1265
|
settings?: Settings,
|
|
1266
|
+
sessionId?: string,
|
|
1254
1267
|
): Promise<{
|
|
1255
1268
|
model?: Model<Api>;
|
|
1256
1269
|
thinkingLevel?: ConfiguredThinkingLevel;
|
|
1257
1270
|
explicitThinkingLevel: boolean;
|
|
1258
1271
|
authFallbackUsed: boolean;
|
|
1272
|
+
warning?: string;
|
|
1259
1273
|
}> {
|
|
1260
1274
|
const primary = resolveModelOverride(modelPatterns, modelRegistry, settings);
|
|
1261
1275
|
if (!primary.model || !parentActiveModelPattern) {
|
|
1262
1276
|
return { ...primary, authFallbackUsed: false };
|
|
1263
1277
|
}
|
|
1264
1278
|
|
|
1265
|
-
const primaryKey = await modelRegistry.getApiKey(primary.model);
|
|
1279
|
+
const primaryKey = await modelRegistry.getApiKey(primary.model, sessionId);
|
|
1266
1280
|
if (primaryKey === kNoAuth || isAuthenticated(primaryKey)) {
|
|
1267
1281
|
return { ...primary, authFallbackUsed: false };
|
|
1268
1282
|
}
|
|
@@ -1274,12 +1288,12 @@ export async function resolveModelOverrideWithAuthFallback(
|
|
|
1274
1288
|
if (modelsAreEqual(fallback.model, primary.model)) {
|
|
1275
1289
|
return { ...primary, authFallbackUsed: false };
|
|
1276
1290
|
}
|
|
1277
|
-
const fallbackKey = await modelRegistry.getApiKey(fallback.model);
|
|
1291
|
+
const fallbackKey = await modelRegistry.getApiKey(fallback.model, sessionId);
|
|
1278
1292
|
if (!isAuthenticated(fallbackKey)) {
|
|
1279
1293
|
return { ...primary, authFallbackUsed: false };
|
|
1280
1294
|
}
|
|
1281
1295
|
|
|
1282
|
-
return { ...fallback, authFallbackUsed: true };
|
|
1296
|
+
return { ...fallback, authFallbackUsed: true, warning: primary.warning ?? fallback.warning };
|
|
1283
1297
|
}
|
|
1284
1298
|
|
|
1285
1299
|
/**
|
|
@@ -603,21 +603,22 @@ describe("agent() through eval runtimes", () => {
|
|
|
603
603
|
});
|
|
604
604
|
const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-interrupt", settings);
|
|
605
605
|
mockAgents();
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
// unwind via KeyboardInterrupt instead of being hard-killed (which used to
|
|
611
|
-
// surface "[kernel] Python kernel shutdown" and lose all session state).
|
|
606
|
+
// Each kernel worker thread blocks in a synchronous `urllib` bridge call,
|
|
607
|
+
// joined by `parallel()`'s ThreadPoolExecutor exit. The host must keep
|
|
608
|
+
// those already-started calls attached until they settle, then interrupt
|
|
609
|
+
// the kernel before `parallel()` launches another wave.
|
|
612
610
|
let inFlight = 0;
|
|
611
|
+
let completed = 0;
|
|
613
612
|
let markSaturated: (() => void) | undefined;
|
|
614
613
|
const saturated = new Promise<void>(resolve => {
|
|
615
614
|
markSaturated = resolve;
|
|
616
615
|
});
|
|
617
|
-
|
|
616
|
+
const releaseAgents = Promise.withResolvers<void>();
|
|
617
|
+
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
|
|
618
618
|
// task.maxConcurrency=6 → six bridge calls block at once; signal then.
|
|
619
619
|
if (++inFlight >= 6) markSaturated?.();
|
|
620
|
-
await
|
|
620
|
+
await releaseAgents.promise;
|
|
621
|
+
completed++;
|
|
621
622
|
return singleResult(options, { output: options.assignment ?? "" });
|
|
622
623
|
});
|
|
623
624
|
|
|
@@ -640,8 +641,7 @@ describe("agent() through eval runtimes", () => {
|
|
|
640
641
|
// bridge calls (condition-driven) instead of waiting a fixed wall second.
|
|
641
642
|
void saturated.then(() => ac.abort(new Error("external interrupt")));
|
|
642
643
|
|
|
643
|
-
const
|
|
644
|
-
const result = await executePython(
|
|
644
|
+
const resultPromise = executePython(
|
|
645
645
|
"import json\nprint(json.dumps(parallel([lambda n=n: agent(str(n)) for n in range(12)])))",
|
|
646
646
|
{
|
|
647
647
|
cwd: tempDir.path(),
|
|
@@ -653,13 +653,18 @@ describe("agent() through eval runtimes", () => {
|
|
|
653
653
|
signal: ac.signal,
|
|
654
654
|
},
|
|
655
655
|
);
|
|
656
|
-
|
|
656
|
+
await saturated;
|
|
657
|
+
await Promise.resolve();
|
|
658
|
+
expect(completed).toBe(0);
|
|
659
|
+
releaseAgents.resolve();
|
|
660
|
+
const result = await resultPromise;
|
|
657
661
|
|
|
658
|
-
// Cancelled, but cleanly: no hard-kill,
|
|
659
|
-
//
|
|
662
|
+
// Cancelled, but cleanly: no hard-kill, no orphaned bridge calls, and no
|
|
663
|
+
// second fan-out wave started after the deferred abort was delivered.
|
|
660
664
|
expect(result.cancelled).toBe(true);
|
|
661
665
|
expect(result.output).not.toContain("Python kernel shutdown");
|
|
662
|
-
expect(
|
|
666
|
+
expect(completed).toBe(6);
|
|
667
|
+
expect(runSpy).toHaveBeenCalledTimes(6);
|
|
663
668
|
|
|
664
669
|
// The persistent kernel survived the interrupt: prior state is intact.
|
|
665
670
|
const after = await executePython("print(PREP_MARKER)", {
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
isEvalTimeoutControlEvent,
|
|
6
6
|
withBridgeTimeoutPause,
|
|
7
7
|
} from "../bridge-timeout";
|
|
8
|
+
import { executeWithKernelBase, type GenericKernel } from "../executor-base";
|
|
8
9
|
import type { JsStatusEvent } from "../js/shared/types";
|
|
10
|
+
import type { KernelDisplayOutput } from "../py/display";
|
|
9
11
|
|
|
10
12
|
describe("withBridgeTimeoutPause", () => {
|
|
11
13
|
it("emits one pause before the operation and one resume after it settles", async () => {
|
|
@@ -17,10 +19,12 @@ describe("withBridgeTimeoutPause", () => {
|
|
|
17
19
|
await Bun.sleep(80);
|
|
18
20
|
return "done";
|
|
19
21
|
},
|
|
22
|
+
{ deferExternalAbort: true },
|
|
20
23
|
);
|
|
21
24
|
|
|
22
25
|
expect(value).toBe("done");
|
|
23
26
|
expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
|
|
27
|
+
expect(events.every(event => event.deferExternalAbort === true)).toBe(true);
|
|
24
28
|
|
|
25
29
|
const settledCount = events.length;
|
|
26
30
|
await Bun.sleep(40);
|
|
@@ -62,3 +66,105 @@ describe("withBridgeTimeoutPause", () => {
|
|
|
62
66
|
expect(isEvalTimeoutControlEvent({ op: "agent", id: "subagent-1" })).toBe(false);
|
|
63
67
|
});
|
|
64
68
|
});
|
|
69
|
+
|
|
70
|
+
class TestCancelledError extends Error {
|
|
71
|
+
readonly timedOut: boolean;
|
|
72
|
+
|
|
73
|
+
constructor(timedOut: boolean) {
|
|
74
|
+
super(timedOut ? "timed out" : "cancelled");
|
|
75
|
+
this.name = "TestCancelledError";
|
|
76
|
+
this.timedOut = timedOut;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it("defers external aborts until an in-flight agent bridge call resumes", async () => {
|
|
81
|
+
const abortController = new AbortController();
|
|
82
|
+
const entered = Promise.withResolvers<void>();
|
|
83
|
+
const triggerAbort = Promise.withResolvers<void>();
|
|
84
|
+
const observed = Promise.withResolvers<boolean>();
|
|
85
|
+
const release = Promise.withResolvers<void>();
|
|
86
|
+
const kernel: GenericKernel<Record<string, string | null>> = {
|
|
87
|
+
async execute(_code, options) {
|
|
88
|
+
entered.resolve();
|
|
89
|
+
await triggerAbort.promise;
|
|
90
|
+
options.onDisplay({
|
|
91
|
+
type: "status",
|
|
92
|
+
event: { op: EVAL_TIMEOUT_PAUSE_OP, deferExternalAbort: true },
|
|
93
|
+
} satisfies KernelDisplayOutput);
|
|
94
|
+
abortController.abort(new Error("external interrupt"));
|
|
95
|
+
observed.resolve(options.signal?.aborted ?? false);
|
|
96
|
+
await release.promise;
|
|
97
|
+
options.onDisplay({
|
|
98
|
+
type: "status",
|
|
99
|
+
event: { op: EVAL_TIMEOUT_RESUME_OP, deferExternalAbort: true },
|
|
100
|
+
} satisfies KernelDisplayOutput);
|
|
101
|
+
return { status: "ok", cancelled: false, timedOut: false };
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const resultPromise = executeWithKernelBase({
|
|
106
|
+
kernel,
|
|
107
|
+
code: "agent('slow')",
|
|
108
|
+
options: { signal: abortController.signal },
|
|
109
|
+
runIdPrefix: "test",
|
|
110
|
+
errorLogLabel: "test",
|
|
111
|
+
cancelledErrorClass: TestCancelledError,
|
|
112
|
+
buildKernelEnvPatch: () => ({}),
|
|
113
|
+
formatKernelTimeoutAnnotation: () => "kernel timed out",
|
|
114
|
+
formatTimeoutAnnotation: () => "timed out",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await entered.promise;
|
|
118
|
+
triggerAbort.resolve();
|
|
119
|
+
expect(await observed.promise).toBe(false);
|
|
120
|
+
release.resolve();
|
|
121
|
+
const result = await resultPromise;
|
|
122
|
+
expect(result.cancelled).toBe(true);
|
|
123
|
+
expect(result.exitCode).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does not defer external aborts for a completion bridge call", async () => {
|
|
127
|
+
const abortController = new AbortController();
|
|
128
|
+
const entered = Promise.withResolvers<void>();
|
|
129
|
+
const triggerAbort = Promise.withResolvers<void>();
|
|
130
|
+
const observed = Promise.withResolvers<boolean>();
|
|
131
|
+
const release = Promise.withResolvers<void>();
|
|
132
|
+
const kernel: GenericKernel<Record<string, string | null>> = {
|
|
133
|
+
async execute(_code, options) {
|
|
134
|
+
entered.resolve();
|
|
135
|
+
await triggerAbort.promise;
|
|
136
|
+
options.onDisplay({
|
|
137
|
+
type: "status",
|
|
138
|
+
event: { op: EVAL_TIMEOUT_PAUSE_OP },
|
|
139
|
+
} satisfies KernelDisplayOutput);
|
|
140
|
+
abortController.abort(new Error("external interrupt"));
|
|
141
|
+
observed.resolve(options.signal?.aborted ?? false);
|
|
142
|
+
await release.promise;
|
|
143
|
+
options.onDisplay({
|
|
144
|
+
type: "status",
|
|
145
|
+
event: { op: EVAL_TIMEOUT_RESUME_OP },
|
|
146
|
+
} satisfies KernelDisplayOutput);
|
|
147
|
+
return { status: "ok", cancelled: false, timedOut: false };
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const resultPromise = executeWithKernelBase({
|
|
152
|
+
kernel,
|
|
153
|
+
code: "completion('slow')",
|
|
154
|
+
options: { signal: abortController.signal },
|
|
155
|
+
runIdPrefix: "test",
|
|
156
|
+
errorLogLabel: "test",
|
|
157
|
+
cancelledErrorClass: TestCancelledError,
|
|
158
|
+
buildKernelEnvPatch: () => ({}),
|
|
159
|
+
formatKernelTimeoutAnnotation: () => "kernel timed out",
|
|
160
|
+
formatTimeoutAnnotation: () => "timed out",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await entered.promise;
|
|
164
|
+
triggerAbort.resolve();
|
|
165
|
+
expect(await observed.promise).toBe(true);
|
|
166
|
+
release.resolve();
|
|
167
|
+
const result = await resultPromise;
|
|
168
|
+
expect(result.cancelled).toBe(true);
|
|
169
|
+
expect(result.exitCode).toBeUndefined();
|
|
170
|
+
});
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
2
4
|
import { TempDir } from "@oh-my-pi/pi-utils";
|
|
3
5
|
import { Settings } from "../../config/settings";
|
|
4
6
|
import type { ToolSession } from "../../tools";
|
|
5
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
disposeAllVmContexts,
|
|
9
|
+
setJsEvalWorkerThreadForTests,
|
|
10
|
+
setWorkerCloseTimeoutMsForTests,
|
|
11
|
+
} from "../js/context-manager";
|
|
6
12
|
import { executeJs } from "../js/executor";
|
|
7
13
|
|
|
8
14
|
const originalWorker = globalThis.Worker;
|
|
@@ -181,7 +187,9 @@ function installFakeWorker(stats: FakeWorkerStats, behavior: FakeWorkerBehavior)
|
|
|
181
187
|
|
|
182
188
|
describe("JavaScript eval worker lifecycle", () => {
|
|
183
189
|
let restoreCloseTimeoutMs = 0;
|
|
190
|
+
let restoreWorkerThread = false;
|
|
184
191
|
beforeEach(() => {
|
|
192
|
+
restoreWorkerThread = setJsEvalWorkerThreadForTests(true);
|
|
185
193
|
// Shrink the graceful-close grace period so the "close acked but the worker
|
|
186
194
|
// never exits -> force terminate" contract is proven without a real 1s wait.
|
|
187
195
|
restoreCloseTimeoutMs = setWorkerCloseTimeoutMsForTests(1);
|
|
@@ -197,6 +205,7 @@ describe("JavaScript eval worker lifecycle", () => {
|
|
|
197
205
|
writable: true,
|
|
198
206
|
value: originalWorker,
|
|
199
207
|
});
|
|
208
|
+
setJsEvalWorkerThreadForTests(restoreWorkerThread);
|
|
200
209
|
});
|
|
201
210
|
|
|
202
211
|
it("exits a real worker on graceful close even with ref'ed user handles", async () => {
|
|
@@ -271,6 +280,73 @@ describe("JavaScript eval worker lifecycle", () => {
|
|
|
271
280
|
expect(stats.terminateCalls).toBe(1);
|
|
272
281
|
});
|
|
273
282
|
|
|
283
|
+
it("falls back to a Bun Worker when the subprocess cannot spawn", async () => {
|
|
284
|
+
using tempDir = TempDir.createSync("@omp-js-spawn-fallback-");
|
|
285
|
+
// Exercise the production ladder (process -> worker -> inline), not the
|
|
286
|
+
// worker-thread test seam the surrounding describe enables.
|
|
287
|
+
setJsEvalWorkerThreadForTests(false);
|
|
288
|
+
const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
|
|
289
|
+
installFakeWorker(stats, { exitOnClose: true, settleRuns: true });
|
|
290
|
+
const originalSpawn = Bun.spawn;
|
|
291
|
+
let spawnAttempts = 0;
|
|
292
|
+
Bun.spawn = ((): never => {
|
|
293
|
+
spawnAttempts++;
|
|
294
|
+
throw new Error("subprocess spawn unavailable");
|
|
295
|
+
}) as unknown as typeof Bun.spawn;
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const session = makeSession(tempDir.path());
|
|
299
|
+
const sessionId = `js-spawn-fallback:${crypto.randomUUID()}`;
|
|
300
|
+
// The fake Worker settles runs without executing the cell, so an empty
|
|
301
|
+
// output proves the middle rung handled it — the inline fallback would
|
|
302
|
+
// have actually evaluated the expression and printed 42.
|
|
303
|
+
const result = await executeJs("return String(6 * 7);", { cwd: tempDir.path(), sessionId, session });
|
|
304
|
+
expect(result.exitCode).toBe(0);
|
|
305
|
+
expect(result.output.trim()).toBe("");
|
|
306
|
+
expect(spawnAttempts).toBe(1);
|
|
307
|
+
} finally {
|
|
308
|
+
Bun.spawn = originalSpawn;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("falls back to a Bun Worker when the subprocess fails during initialization", async () => {
|
|
313
|
+
using tempDir = TempDir.createSync("@omp-js-init-fallback-");
|
|
314
|
+
// Exercise the production ladder (process -> worker -> inline), not the
|
|
315
|
+
// worker-thread test seam the surrounding describe enables.
|
|
316
|
+
setJsEvalWorkerThreadForTests(false);
|
|
317
|
+
const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
|
|
318
|
+
installFakeWorker(stats, { exitOnClose: true, settleRuns: true });
|
|
319
|
+
const originalSpawn = Bun.spawn;
|
|
320
|
+
let spawnAttempts = 0;
|
|
321
|
+
Bun.spawn = ((options: unknown) => {
|
|
322
|
+
spawnAttempts++;
|
|
323
|
+
const spawnOptions = options as {
|
|
324
|
+
onExit?: (proc: unknown, exitCode: number | null, signalCode: string | null) => void;
|
|
325
|
+
};
|
|
326
|
+
const fakeProcess = {
|
|
327
|
+
send: () => undefined,
|
|
328
|
+
kill: () => undefined,
|
|
329
|
+
unref: () => undefined,
|
|
330
|
+
};
|
|
331
|
+
queueMicrotask(() => spawnOptions.onExit?.(fakeProcess, 1, null));
|
|
332
|
+
return fakeProcess;
|
|
333
|
+
}) as unknown as typeof Bun.spawn;
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const session = makeSession(tempDir.path());
|
|
337
|
+
const sessionId = `js-init-fallback:${crypto.randomUUID()}`;
|
|
338
|
+
// The fake Worker settles runs without executing the cell, so empty
|
|
339
|
+
// output proves the middle rung handled the retry. Inline execution
|
|
340
|
+
// would evaluate the expression and print 42.
|
|
341
|
+
const result = await executeJs("return String(6 * 7);", { cwd: tempDir.path(), sessionId, session });
|
|
342
|
+
expect(result.exitCode).toBe(0);
|
|
343
|
+
expect(result.output.trim()).toBe("");
|
|
344
|
+
expect(spawnAttempts).toBe(1);
|
|
345
|
+
} finally {
|
|
346
|
+
Bun.spawn = originalSpawn;
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
274
350
|
it("falls back to the inline worker when the spawned worker errors during startup", async () => {
|
|
275
351
|
using tempDir = TempDir.createSync("@omp-js-worker-error-");
|
|
276
352
|
const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
|
|
@@ -289,3 +365,84 @@ describe("JavaScript eval worker lifecycle", () => {
|
|
|
289
365
|
expect(stats.terminateCalls).toBe(1);
|
|
290
366
|
});
|
|
291
367
|
});
|
|
368
|
+
|
|
369
|
+
describe.skipIf(process.platform === "win32")("JavaScript eval process isolation", () => {
|
|
370
|
+
afterEach(async () => {
|
|
371
|
+
await disposeAllVmContexts();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("runs spawned commands in the isolated POSIX process group", async () => {
|
|
375
|
+
using tempDir = TempDir.createSync("@omp-js-process-isolation-");
|
|
376
|
+
const session = makeSession(tempDir.path());
|
|
377
|
+
const evalSessionId = `js-isolation:${crypto.randomUUID()}`;
|
|
378
|
+
const result = await executeJs(
|
|
379
|
+
[
|
|
380
|
+
`const child = Bun.spawn(["/bin/sh", "-c", 'pgid=$(ps -o pgid= -p $$); printf "%s %s\\n" "$pgid" "$PPID"'], { stdout: "pipe" });`,
|
|
381
|
+
"return await new Response(child.stdout).text();",
|
|
382
|
+
].join("\n"),
|
|
383
|
+
{ cwd: tempDir.path(), sessionId: evalSessionId, session },
|
|
384
|
+
);
|
|
385
|
+
const [processGroupId, parentProcessId] = result.output.trim().split(/\s+/).map(Number);
|
|
386
|
+
expect(parentProcessId).not.toBe(process.pid);
|
|
387
|
+
expect(processGroupId).toBe(parentProcessId);
|
|
388
|
+
|
|
389
|
+
await executeJs("var saved = 41; function increment(value) { return value + 1; }", {
|
|
390
|
+
cwd: tempDir.path(),
|
|
391
|
+
sessionId: evalSessionId,
|
|
392
|
+
session,
|
|
393
|
+
});
|
|
394
|
+
const reused = await executeJs("return increment(saved);", {
|
|
395
|
+
cwd: tempDir.path(),
|
|
396
|
+
sessionId: evalSessionId,
|
|
397
|
+
session,
|
|
398
|
+
});
|
|
399
|
+
expect(reused.output.trim()).toBe("42");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("mirrors the session cwd onto the subprocess's real cwd", async () => {
|
|
403
|
+
using tempDir = TempDir.createSync("@omp-js-process-cwd-");
|
|
404
|
+
const session = makeSession(tempDir.path());
|
|
405
|
+
const evalSessionId = `js-cwd:${crypto.randomUUID()}`;
|
|
406
|
+
const result = await executeJs("return process.cwd();", {
|
|
407
|
+
cwd: tempDir.path(),
|
|
408
|
+
sessionId: evalSessionId,
|
|
409
|
+
session,
|
|
410
|
+
});
|
|
411
|
+
// process.chdir resolves symlinks (macOS tempdirs live under /var ->
|
|
412
|
+
// /private/var), so compare physical paths.
|
|
413
|
+
expect(result.output.trim()).toBe(fs.realpathSync(tempDir.path()));
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("still runs cells when the session cwd does not exist", async () => {
|
|
417
|
+
using tempDir = TempDir.createSync("@omp-js-process-cwd-missing-");
|
|
418
|
+
const missingCwd = path.join(tempDir.path(), "deleted");
|
|
419
|
+
const session = makeSession(missingCwd);
|
|
420
|
+
const result = await executeJs("return String(6 * 7);", {
|
|
421
|
+
cwd: missingCwd,
|
|
422
|
+
sessionId: `js-cwd-missing:${crypto.randomUUID()}`,
|
|
423
|
+
session,
|
|
424
|
+
});
|
|
425
|
+
expect(result.exitCode).toBe(0);
|
|
426
|
+
expect(result.output.trim()).toBe("42");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("keeps the isolated process alive after a stackless floated rejection", async () => {
|
|
430
|
+
using tempDir = TempDir.createSync("@omp-js-process-rejection-");
|
|
431
|
+
const session = makeSession(tempDir.path());
|
|
432
|
+
const evalSessionId = `js-rejection:${crypto.randomUUID()}`;
|
|
433
|
+
const rejected = await executeJs(
|
|
434
|
+
'var savedAfterRejection = 41; Promise.reject("stackless rejection"); await Bun.sleep(10);',
|
|
435
|
+
{ cwd: tempDir.path(), sessionId: evalSessionId, session },
|
|
436
|
+
);
|
|
437
|
+
expect(rejected.exitCode).toBe(1);
|
|
438
|
+
expect(rejected.output).toContain("Unhandled rejection (missing await?): stackless rejection");
|
|
439
|
+
|
|
440
|
+
const reused = await executeJs("return savedAfterRejection + 1;", {
|
|
441
|
+
cwd: tempDir.path(),
|
|
442
|
+
sessionId: evalSessionId,
|
|
443
|
+
session,
|
|
444
|
+
});
|
|
445
|
+
expect(reused.exitCode).toBe(0);
|
|
446
|
+
expect(reused.output.trim()).toBe("42");
|
|
447
|
+
});
|
|
448
|
+
});
|
|
@@ -3,9 +3,21 @@ import {
|
|
|
3
3
|
__resetWindowsConsoleProbeCache,
|
|
4
4
|
consoleAttachedViaTTY,
|
|
5
5
|
hostHasInheritableConsole,
|
|
6
|
+
shouldDetachKernel,
|
|
6
7
|
shouldHideKernelWindow,
|
|
7
8
|
} from "../py/spawn-options";
|
|
8
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
|
+
|
|
9
21
|
/**
|
|
10
22
|
* `shouldHideKernelWindow` decides whether the long-lived Python kernel
|
|
11
23
|
* subprocess is spawned with `windowsHide: true`. On Windows, Bun maps that
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { expect, it } from "bun:test";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { TempDir } from "@oh-my-pi/pi-utils";
|
|
4
|
+
|
|
5
|
+
it("imports the JS process entry without loading dotenv before profile bootstrap", async () => {
|
|
6
|
+
using tempDir = TempDir.createSync("@omp-js-process-import-");
|
|
7
|
+
await Bun.write(path.join(tempDir.path(), ".env"), "OMP_PROCESS_ENTRY_ENV_PROBE=loaded-too-early\n");
|
|
8
|
+
const env = Object.fromEntries(
|
|
9
|
+
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
10
|
+
);
|
|
11
|
+
delete env.OMP_PROCESS_ENTRY_ENV_PROBE;
|
|
12
|
+
env.HOME = tempDir.path();
|
|
13
|
+
const fixture = path.resolve(import.meta.dir, "../../../test/fixtures/js-process-entry-import.ts");
|
|
14
|
+
const proc = Bun.spawn([process.execPath, fixture], {
|
|
15
|
+
env,
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
});
|
|
19
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
20
|
+
proc.exited,
|
|
21
|
+
new Response(proc.stdout).text(),
|
|
22
|
+
new Response(proc.stderr).text(),
|
|
23
|
+
]);
|
|
24
|
+
expect(exitCode).toBe(0);
|
|
25
|
+
expect(stdout).toBe("");
|
|
26
|
+
expect(stderr).toBe("");
|
|
27
|
+
});
|