@oh-my-pi/pi-coding-agent 16.1.14 → 16.1.16

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 (111) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/cli.js +3705 -4015
  3. package/dist/types/advisor/runtime.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/settings-schema.d.ts +32 -1
  9. package/dist/types/eval/agent-bridge.d.ts +19 -0
  10. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  11. package/dist/types/eval/js/shared/types.d.ts +1 -1
  12. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/export/share.d.ts +8 -1
  15. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  16. package/dist/types/main.d.ts +2 -0
  17. package/dist/types/mcp/transports/stdio.d.ts +12 -1
  18. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  19. package/dist/types/modes/components/session-selector.d.ts +25 -0
  20. package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
  21. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  22. package/dist/types/secrets/obfuscator.d.ts +3 -3
  23. package/dist/types/task/isolation-runner.d.ts +128 -0
  24. package/dist/types/task/worktree.d.ts +14 -1
  25. package/dist/types/thinking.d.ts +15 -0
  26. package/dist/types/tools/eval-render.d.ts +3 -0
  27. package/dist/types/tools/eval.d.ts +11 -17
  28. package/dist/types/tools/todo.d.ts +26 -28
  29. package/dist/types/tui/output-block.d.ts +8 -0
  30. package/dist/types/utils/image-resize.d.ts +2 -0
  31. package/dist/types/utils/shell-snapshot.d.ts +10 -0
  32. package/dist/types/web/search/providers/exa.d.ts +2 -0
  33. package/dist/types/web/search/providers/perplexity.d.ts +17 -3
  34. package/package.json +12 -12
  35. package/scripts/build-binary.ts +18 -4
  36. package/src/advisor/__tests__/advisor.test.ts +114 -0
  37. package/src/advisor/runtime.ts +129 -1
  38. package/src/cli/args.ts +4 -5
  39. package/src/cli/flag-tables.ts +3 -3
  40. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  41. package/src/cli/gallery-fixtures/shell.ts +15 -23
  42. package/src/cli/session-picker.ts +17 -3
  43. package/src/commands/launch.ts +3 -3
  44. package/src/config/model-registry.ts +12 -4
  45. package/src/config/settings-schema.ts +37 -1
  46. package/src/edit/renderer.ts +34 -12
  47. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  48. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  49. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  50. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  51. package/src/eval/agent-bridge.ts +301 -71
  52. package/src/eval/jl/prelude.jl +32 -227
  53. package/src/eval/jl/runner.jl +38 -12
  54. package/src/eval/js/shared/helpers.ts +1 -114
  55. package/src/eval/js/shared/prelude.txt +13 -27
  56. package/src/eval/js/shared/runtime.ts +0 -6
  57. package/src/eval/js/shared/types.ts +1 -1
  58. package/src/eval/js/worker-protocol.ts +1 -1
  59. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  60. package/src/eval/py/executor.ts +1 -1
  61. package/src/eval/py/prelude.py +47 -105
  62. package/src/eval/py/runner.py +0 -6
  63. package/src/eval/rb/prelude.rb +21 -189
  64. package/src/eval/rb/runner.rb +116 -9
  65. package/src/exec/bash-executor.ts +44 -0
  66. package/src/export/html/tool-views.generated.js +29 -29
  67. package/src/export/share.ts +51 -28
  68. package/src/internal-urls/docs-index.generated.txt +1 -1
  69. package/src/internal-urls/local-protocol.ts +100 -53
  70. package/src/main.ts +15 -4
  71. package/src/mcp/transports/stdio.ts +20 -4
  72. package/src/modes/acp/acp-event-mapper.ts +7 -2
  73. package/src/modes/components/custom-editor.test.ts +22 -0
  74. package/src/modes/components/custom-editor.ts +10 -1
  75. package/src/modes/components/footer.ts +4 -3
  76. package/src/modes/components/plugin-settings.ts +7 -1
  77. package/src/modes/components/session-selector.ts +143 -29
  78. package/src/modes/components/status-line/component.ts +5 -1
  79. package/src/modes/components/status-line/context-thresholds.ts +11 -3
  80. package/src/modes/components/status-line/segments.ts +1 -1
  81. package/src/modes/components/status-line/types.ts +1 -0
  82. package/src/modes/controllers/command-controller.ts +6 -0
  83. package/src/modes/rpc/rpc-mode.ts +6 -0
  84. package/src/modes/utils/copy-targets.ts +7 -2
  85. package/src/prompts/system/system-prompt.md +6 -6
  86. package/src/prompts/system/workflow-notice.md +3 -3
  87. package/src/prompts/tools/bash.md +18 -2
  88. package/src/prompts/tools/eval.md +19 -19
  89. package/src/prompts/tools/search.md +1 -0
  90. package/src/prompts/tools/todo.md +1 -1
  91. package/src/secrets/obfuscator.ts +3 -9
  92. package/src/session/agent-session.ts +264 -52
  93. package/src/slash-commands/builtin-registry.ts +2 -1
  94. package/src/task/index.ts +61 -207
  95. package/src/task/isolation-runner.ts +354 -0
  96. package/src/task/worktree.ts +46 -9
  97. package/src/thinking.ts +20 -0
  98. package/src/tools/ask.ts +44 -38
  99. package/src/tools/bash.ts +9 -2
  100. package/src/tools/browser/tab-worker.ts +1 -1
  101. package/src/tools/eval-render.ts +34 -27
  102. package/src/tools/eval.ts +100 -103
  103. package/src/tools/index.ts +8 -1
  104. package/src/tools/read.ts +136 -60
  105. package/src/tools/todo.ts +60 -64
  106. package/src/tui/code-cell.ts +1 -1
  107. package/src/tui/output-block.ts +11 -0
  108. package/src/utils/image-resize.ts +30 -0
  109. package/src/utils/shell-snapshot.ts +63 -1
  110. package/src/web/search/providers/exa.ts +85 -1
  111. package/src/web/search/providers/perplexity.ts +18 -6
@@ -1,4 +1,5 @@
1
1
  import { afterAll, afterEach, describe, expect, it, vi } from "bun:test";
2
+ import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import { TempDir } from "@oh-my-pi/pi-utils";
4
5
  import { Settings } from "../../config/settings";
@@ -6,6 +7,7 @@ import type { PlanModeState } from "../../plan-mode/state";
6
7
  import * as taskDiscovery from "../../task/discovery";
7
8
  import type { ExecutorOptions } from "../../task/executor";
8
9
  import * as taskExecutor from "../../task/executor";
10
+ import * as isolationRunner from "../../task/isolation-runner";
9
11
  import { AgentOutputManager } from "../../task/output-manager";
10
12
  import type { AgentDefinition, AgentProgress, SingleResult } from "../../task/types";
11
13
  import type { ToolSession } from "../../tools";
@@ -154,7 +156,7 @@ describe("runEvalAgent", () => {
154
156
  vi.restoreAllMocks();
155
157
  });
156
158
 
157
- it("resolves the default task agent and agentType overrides", async () => {
159
+ it("resolves the default task agent and agent overrides", async () => {
158
160
  mockAgents();
159
161
  const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
160
162
  singleResult(options, {
@@ -164,7 +166,7 @@ describe("runEvalAgent", () => {
164
166
  const session = makeSession();
165
167
 
166
168
  const defaultResult = await runEvalAgent({ prompt: "hello" }, { session });
167
- const overrideResult = await runEvalAgent({ prompt: "hello", agentType: "reviewer" }, { session });
169
+ const overrideResult = await runEvalAgent({ prompt: "hello", agent: "reviewer" }, { session });
168
170
 
169
171
  expect(defaultResult.text).toBe("task");
170
172
  expect(overrideResult.text).toBe("reviewer");
@@ -176,7 +178,7 @@ describe("runEvalAgent", () => {
176
178
  mockAgents([taskAgent]);
177
179
  vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
178
180
 
179
- await expect(runEvalAgent({ prompt: "hello", agentType: "missing" }, { session: makeSession() })).rejects.toThrow(
181
+ await expect(runEvalAgent({ prompt: "hello", agent: "missing" }, { session: makeSession() })).rejects.toThrow(
180
182
  'Unknown agent "missing"',
181
183
  );
182
184
  });
@@ -767,3 +769,460 @@ describe("agent() through eval runtimes", () => {
767
769
  expect(idle.signal.aborted).toBe(false);
768
770
  });
769
771
  });
772
+
773
+ describe("runEvalAgent isolation", () => {
774
+ afterEach(() => {
775
+ vi.restoreAllMocks();
776
+ });
777
+
778
+ function isolatedSession(overrides: Partial<Parameters<typeof Settings.isolated>[0]> = {}): ToolSession {
779
+ return makeSession({
780
+ settings: Settings.isolated({
781
+ "async.enabled": false,
782
+ "task.isolation.mode": "auto",
783
+ "task.isolation.merge": "patch",
784
+ ...overrides,
785
+ }),
786
+ });
787
+ }
788
+
789
+ function mockIsolationContext(): { repoRoot: string } {
790
+ const repoRoot = "/repo-root";
791
+ vi.spyOn(isolationRunner, "prepareIsolationContext").mockResolvedValue({
792
+ repoRoot,
793
+ baseline: {
794
+ root: { repoRoot, headCommit: "HEAD", staged: "", unstaged: "", untracked: [], untrackedPatch: "" },
795
+ nested: [],
796
+ },
797
+ });
798
+ return { repoRoot };
799
+ }
800
+
801
+ it("rejects isolated=true when task.isolation.mode is 'none'", async () => {
802
+ mockAgents();
803
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
804
+ const prepSpy = vi.spyOn(isolationRunner, "prepareIsolationContext");
805
+
806
+ const session = makeSession(); // default settings: isolation.mode === "none"
807
+
808
+ await expect(runEvalAgent({ prompt: "do work", isolated: true }, { session })).rejects.toThrow(
809
+ 'task.isolation.mode to be set; current mode is "none"',
810
+ );
811
+ expect(prepSpy).not.toHaveBeenCalled();
812
+ expect(runSpy).not.toHaveBeenCalled();
813
+ });
814
+
815
+ it("stays non-isolated by default even when task.isolation.mode is set; isolated=true opts in", async () => {
816
+ mockAgents();
817
+ mockIsolationContext();
818
+ const isolatedSpy = vi
819
+ .spyOn(isolationRunner, "runIsolatedSubprocess")
820
+ .mockImplementation(async opts => singleResult(opts.baseOptions, { output: "isolated-run" }));
821
+ const plainSpy = vi
822
+ .spyOn(taskExecutor, "runSubprocess")
823
+ .mockImplementation(async options => singleResult(options, { output: "plain-run" }));
824
+ const mergeSpy = vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
825
+ summary: "",
826
+ changesApplied: true,
827
+ hadAnyChanges: false,
828
+ mergedBranchForNestedPatches: false,
829
+ });
830
+
831
+ // Default (no isolated arg) — stays non-isolated even when settings allow it.
832
+ const defaultResult = await runEvalAgent({ prompt: "default" }, { session: isolatedSession() });
833
+ expect(plainSpy).toHaveBeenCalledTimes(1);
834
+ expect(isolatedSpy).not.toHaveBeenCalled();
835
+ expect(defaultResult.details.isolated).toBeUndefined();
836
+ expect(defaultResult.details.changesApplied).toBeUndefined();
837
+ expect(mergeSpy).not.toHaveBeenCalled();
838
+
839
+ // Explicit isolated=true — opt-in turns it on and surfaces merge details.
840
+ const explicitOn = await runEvalAgent({ prompt: "on", isolated: true }, { session: isolatedSession() });
841
+ expect(isolatedSpy).toHaveBeenCalledTimes(1);
842
+ expect(plainSpy).toHaveBeenCalledTimes(1);
843
+ expect(explicitOn.details.isolated).toBe(true);
844
+ expect(mergeSpy).toHaveBeenCalledTimes(1);
845
+ });
846
+
847
+ it("preserves temp artifacts for non-isolated handle outputs", async () => {
848
+ mockAgents();
849
+ const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
850
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
851
+
852
+ await runEvalAgent({ prompt: "plain handle", handle: true }, { session: makeSession() });
853
+
854
+ const removedArtifactsDir = rmSpy.mock.calls.some(
855
+ ([target]) => typeof target === "string" && target.includes("omp-eval-agent-"),
856
+ );
857
+ expect(removedArtifactsDir).toBe(false);
858
+ });
859
+
860
+ it("forwards merge=false as patch mode and passes the worktree cwd through baseOptions", async () => {
861
+ mockAgents();
862
+ const { repoRoot } = mockIsolationContext();
863
+ const isolatedSpy = vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
864
+ singleResult(opts.baseOptions, {
865
+ output: "isolated-run",
866
+ patchPath: `/artifacts/${opts.agentId}.patch`,
867
+ }),
868
+ );
869
+ vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
870
+ summary: "\n\nApplied patches: yes",
871
+ changesApplied: true,
872
+ hadAnyChanges: true,
873
+ mergedBranchForNestedPatches: false,
874
+ });
875
+
876
+ // Branch is the configured merge mode, but `merge: false` must demote to patch.
877
+ const session = isolatedSession({ "task.isolation.merge": "branch" });
878
+ const result = await runEvalAgent({ prompt: "migration", isolated: true, merge: false }, { session });
879
+
880
+ expect(isolatedSpy).toHaveBeenCalledTimes(1);
881
+ const isolatedCall = isolatedSpy.mock.calls[0]?.[0];
882
+ if (!isolatedCall) throw new Error("runIsolatedSubprocess was not called");
883
+ expect(isolatedCall.mergeMode).toBe("patch");
884
+ expect(isolatedCall.baseOptions.cwd).toBe(session.cwd);
885
+ expect(isolatedCall.context.repoRoot).toBe(repoRoot);
886
+ expect(result.details.patchPath).toMatch(/\.patch$/);
887
+ expect(result.text).toContain("Applied patches: yes");
888
+ });
889
+
890
+ it("keeps the timeout paused through isolation merge/apply so the cell can't abort mid-cherry-pick", async () => {
891
+ mockAgents();
892
+ mockIsolationContext();
893
+ const ops: string[] = [];
894
+ vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts => {
895
+ ops.push("subprocess");
896
+ return singleResult(opts.baseOptions, { output: "done", patchPath: `/artifacts/${opts.agentId}.patch` });
897
+ });
898
+ vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockImplementation(async () => {
899
+ ops.push("merge");
900
+ return {
901
+ summary: "\n\nMerged",
902
+ changesApplied: true,
903
+ hadAnyChanges: true,
904
+ mergedBranchForNestedPatches: false,
905
+ };
906
+ });
907
+
908
+ await runEvalAgent(
909
+ { prompt: "migration", isolated: true },
910
+ {
911
+ session: isolatedSession(),
912
+ emitStatus: event => {
913
+ if (event.op === EVAL_TIMEOUT_PAUSE_OP || event.op === EVAL_TIMEOUT_RESUME_OP) ops.push(event.op);
914
+ },
915
+ },
916
+ );
917
+
918
+ const pauseIdx = ops.indexOf(EVAL_TIMEOUT_PAUSE_OP);
919
+ const resumeIdx = ops.lastIndexOf(EVAL_TIMEOUT_RESUME_OP);
920
+ const mergeIdx = ops.indexOf("merge");
921
+ expect(pauseIdx).toBeGreaterThanOrEqual(0);
922
+ expect(resumeIdx).toBeGreaterThan(pauseIdx);
923
+ expect(mergeIdx).toBeGreaterThan(pauseIdx);
924
+ expect(mergeIdx).toBeLessThan(resumeIdx);
925
+ });
926
+
927
+ it("keeps the timeout paused through isolation baseline capture", async () => {
928
+ mockAgents();
929
+ const ops: string[] = [];
930
+ vi.spyOn(isolationRunner, "prepareIsolationContext").mockImplementation(async () => {
931
+ ops.push("prepare");
932
+ return {
933
+ repoRoot: "/repo-root",
934
+ baseline: {
935
+ root: {
936
+ repoRoot: "/repo-root",
937
+ headCommit: "HEAD",
938
+ staged: "",
939
+ unstaged: "",
940
+ untracked: [],
941
+ untrackedPatch: "",
942
+ },
943
+ nested: [],
944
+ },
945
+ };
946
+ });
947
+ vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
948
+ singleResult(opts.baseOptions, { output: "done", patchPath: `/artifacts/${opts.agentId}.patch` }),
949
+ );
950
+ vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
951
+ summary: "\n\nMerged",
952
+ changesApplied: true,
953
+ hadAnyChanges: true,
954
+ mergedBranchForNestedPatches: false,
955
+ });
956
+
957
+ await runEvalAgent(
958
+ { prompt: "scout", isolated: true },
959
+ {
960
+ session: isolatedSession(),
961
+ emitStatus: event => {
962
+ if (event.op === EVAL_TIMEOUT_PAUSE_OP || event.op === EVAL_TIMEOUT_RESUME_OP) ops.push(event.op);
963
+ },
964
+ },
965
+ );
966
+
967
+ const pauseIdx = ops.indexOf(EVAL_TIMEOUT_PAUSE_OP);
968
+ const resumeIdx = ops.lastIndexOf(EVAL_TIMEOUT_RESUME_OP);
969
+ const prepareIdx = ops.indexOf("prepare");
970
+ expect(pauseIdx).toBeGreaterThanOrEqual(0);
971
+ expect(prepareIdx).toBeGreaterThan(pauseIdx);
972
+ expect(prepareIdx).toBeLessThan(resumeIdx);
973
+ });
974
+
975
+ it("keeps schema-backed isolated output parseable by moving merge text into details", async () => {
976
+ mockAgents();
977
+ mockIsolationContext();
978
+ const structuredOutput = JSON.stringify({ status: "ok" });
979
+ vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
980
+ singleResult(opts.baseOptions, {
981
+ output: structuredOutput,
982
+ patchPath: `/artifacts/${opts.agentId}.patch`,
983
+ }),
984
+ );
985
+ vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
986
+ summary: "\n\nNo changes to apply.",
987
+ changesApplied: true,
988
+ hadAnyChanges: false,
989
+ mergedBranchForNestedPatches: false,
990
+ });
991
+
992
+ const result = await runEvalAgent(
993
+ {
994
+ prompt: "structured",
995
+ isolated: true,
996
+ schema: {
997
+ type: "object",
998
+ properties: { status: { type: "string" } },
999
+ required: ["status"],
1000
+ },
1001
+ },
1002
+ { session: isolatedSession() },
1003
+ );
1004
+
1005
+ expect(JSON.parse(result.text)).toEqual({ status: "ok" });
1006
+ expect(result.text).toBe(structuredOutput);
1007
+ expect(result.details.isolationSummary).toBe("No changes to apply.");
1008
+ });
1009
+
1010
+ it("throws when an isolated apply fails so schema callers cannot mistake it for success", async () => {
1011
+ mockAgents();
1012
+ mockIsolationContext();
1013
+ const structuredOutput = JSON.stringify({ status: "ok" });
1014
+ vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1015
+ singleResult(opts.baseOptions, {
1016
+ output: structuredOutput,
1017
+ patchPath: `/artifacts/${opts.agentId}.patch`,
1018
+ }),
1019
+ );
1020
+ vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1021
+ summary: "\n\n<system-notification>Patch apply failed: conflict in foo.ts</system-notification>",
1022
+ changesApplied: false,
1023
+ hadAnyChanges: false,
1024
+ mergedBranchForNestedPatches: false,
1025
+ });
1026
+
1027
+ await expect(
1028
+ runEvalAgent(
1029
+ {
1030
+ prompt: "structured",
1031
+ isolated: true,
1032
+ schema: {
1033
+ type: "object",
1034
+ properties: { status: { type: "string" } },
1035
+ required: ["status"],
1036
+ },
1037
+ },
1038
+ { session: isolatedSession() },
1039
+ ),
1040
+ ).rejects.toThrow(/isolated apply failed.*Patch apply failed.*Captured patch preserved at \/artifacts\//s);
1041
+ });
1042
+
1043
+ it("throws on apply failure for non-schema callers too instead of burying the warning in text", async () => {
1044
+ mockAgents();
1045
+ mockIsolationContext();
1046
+ vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1047
+ singleResult(opts.baseOptions, {
1048
+ output: "ran",
1049
+ branchName: `omp/task/${opts.agentId}`,
1050
+ }),
1051
+ );
1052
+ vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1053
+ summary: "\n\n<system-notification>Branch merge failed: omp/task/x.\nConflict: foo.ts</system-notification>",
1054
+ changesApplied: false,
1055
+ hadAnyChanges: false,
1056
+ mergedBranchForNestedPatches: false,
1057
+ });
1058
+
1059
+ const session = isolatedSession({ "task.isolation.merge": "branch" });
1060
+ await expect(runEvalAgent({ prompt: "scout", isolated: true }, { session })).rejects.toThrow(
1061
+ /isolated apply failed.*Branch merge failed.*Captured branch preserved as omp\/task\//s,
1062
+ );
1063
+ });
1064
+
1065
+ it("persists captured nested patches to a recoverable file before throwing on apply failure", async () => {
1066
+ mockAgents();
1067
+ mockIsolationContext();
1068
+ const nestedPatch = "diff --git a/file b/file\n";
1069
+ vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1070
+ singleResult(opts.baseOptions, {
1071
+ output: "ran",
1072
+ patchPath: `/artifacts/${opts.agentId}.patch`,
1073
+ nestedPatches: [{ relativePath: "sub/nested", patch: nestedPatch }],
1074
+ }),
1075
+ );
1076
+ vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1077
+ summary: "\n\n<system-notification>Patch apply failed: conflict in foo.ts</system-notification>",
1078
+ changesApplied: false,
1079
+ hadAnyChanges: false,
1080
+ mergedBranchForNestedPatches: false,
1081
+ });
1082
+
1083
+ let caught: Error | undefined;
1084
+ try {
1085
+ await runEvalAgent({ prompt: "scout", isolated: true }, { session: isolatedSession() });
1086
+ } catch (err) {
1087
+ caught = err as Error;
1088
+ }
1089
+ expect(caught).toBeDefined();
1090
+ const match = caught?.message.match(/(\/[^\s,]+?\.nested-0-sub_nested\.patch)/);
1091
+ expect(match).not.toBeNull();
1092
+ const persistedPath = match?.[1];
1093
+ expect(persistedPath).toBeDefined();
1094
+ const contents = await fs.readFile(persistedPath!, "utf-8");
1095
+ expect(contents).toBe(nestedPatch);
1096
+ await fs.rm(path.dirname(persistedPath!), { recursive: true, force: true });
1097
+ });
1098
+
1099
+ it("skips the merge phase when apply=false and surfaces the patch artifact instead", async () => {
1100
+ mockAgents();
1101
+ mockIsolationContext();
1102
+ vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1103
+ singleResult(opts.baseOptions, {
1104
+ output: "captured",
1105
+ patchPath: "/artifacts/captured.patch",
1106
+ }),
1107
+ );
1108
+ const mergeSpy = vi.spyOn(isolationRunner, "mergeIsolatedChanges");
1109
+
1110
+ const result = await runEvalAgent(
1111
+ { prompt: "scout", isolated: true, apply: false },
1112
+ { session: isolatedSession() },
1113
+ );
1114
+
1115
+ expect(mergeSpy).not.toHaveBeenCalled();
1116
+ expect(result.details.isolated).toBe(true);
1117
+ expect(result.details.changesApplied).toBeNull();
1118
+ expect(result.details.patchPath).toBe("/artifacts/captured.patch");
1119
+ expect(result.text).toContain("/artifacts/captured.patch");
1120
+ expect(result.text).toContain("apply=false");
1121
+ });
1122
+
1123
+ it("surfaces a captured branch name when apply=false and the run used branch mode", async () => {
1124
+ mockAgents();
1125
+ mockIsolationContext();
1126
+ vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1127
+ singleResult(opts.baseOptions, {
1128
+ output: "branched",
1129
+ branchName: `omp/task/${opts.agentId}`,
1130
+ }),
1131
+ );
1132
+ const mergeSpy = vi.spyOn(isolationRunner, "mergeIsolatedChanges");
1133
+
1134
+ const session = isolatedSession({ "task.isolation.merge": "branch" });
1135
+ const result = await runEvalAgent({ prompt: "scout", isolated: true, apply: false }, { session });
1136
+
1137
+ expect(mergeSpy).not.toHaveBeenCalled();
1138
+ expect(result.details.branchName).toMatch(/^omp\/task\//);
1139
+ expect(result.text).toContain("omp/task/");
1140
+ expect(result.text).toContain("apply=false");
1141
+ });
1142
+
1143
+ it("surfaces nested patches when apply=false captured branch-mode nested-only changes", async () => {
1144
+ mockAgents();
1145
+ mockIsolationContext();
1146
+ vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1147
+ singleResult(opts.baseOptions, {
1148
+ output: "nested-only",
1149
+ nestedPatches: [{ relativePath: "nested", patch: "diff --git a/file b/file\n" }],
1150
+ }),
1151
+ );
1152
+ const mergeSpy = vi.spyOn(isolationRunner, "mergeIsolatedChanges");
1153
+
1154
+ const session = isolatedSession({ "task.isolation.merge": "branch" });
1155
+ const result = await runEvalAgent({ prompt: "scout", isolated: true, apply: false }, { session });
1156
+
1157
+ expect(mergeSpy).not.toHaveBeenCalled();
1158
+ expect(result.details.branchName).toBeUndefined();
1159
+ expect(result.details.patchPath).toBeUndefined();
1160
+ expect(result.details.nestedPatches).toEqual([{ relativePath: "nested", patch: "diff --git a/file b/file\n" }]);
1161
+ expect(result.text).toContain("nested repository");
1162
+ expect(result.text).toContain("apply=false");
1163
+ });
1164
+
1165
+ it("preserves the temp artifacts dir when apply=false so details.patchPath remains valid", async () => {
1166
+ mockAgents();
1167
+ mockIsolationContext();
1168
+ const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
1169
+ vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1170
+ singleResult(opts.baseOptions, { output: "captured", patchPath: `/artifacts/${opts.agentId}.patch` }),
1171
+ );
1172
+
1173
+ const result = await runEvalAgent(
1174
+ { prompt: "scout", isolated: true, apply: false },
1175
+ { session: isolatedSession() },
1176
+ );
1177
+
1178
+ expect(result.details.patchPath).toMatch(/\.patch$/);
1179
+ const removedArtifactsDir = rmSpy.mock.calls.some(
1180
+ ([target]) => typeof target === "string" && target.includes("omp-eval-agent-"),
1181
+ );
1182
+ expect(removedArtifactsDir).toBe(false);
1183
+ });
1184
+
1185
+ it("still cleans the temp artifacts dir when apply succeeds", async () => {
1186
+ mockAgents();
1187
+ mockIsolationContext();
1188
+ const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
1189
+ vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1190
+ singleResult(opts.baseOptions, { output: "captured", patchPath: `/artifacts/${opts.agentId}.patch` }),
1191
+ );
1192
+ vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1193
+ summary: "\n\nApplied",
1194
+ changesApplied: true,
1195
+ hadAnyChanges: true,
1196
+ mergedBranchForNestedPatches: false,
1197
+ });
1198
+
1199
+ await runEvalAgent({ prompt: "scout", isolated: true }, { session: isolatedSession() });
1200
+
1201
+ const removedArtifactsDir = rmSpy.mock.calls.some(
1202
+ ([target]) => typeof target === "string" && target.includes("omp-eval-agent-"),
1203
+ );
1204
+ expect(removedArtifactsDir).toBe(true);
1205
+ });
1206
+
1207
+ it("preserves the temp artifacts dir after a successful apply when handle is requested", async () => {
1208
+ mockAgents();
1209
+ mockIsolationContext();
1210
+ const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
1211
+ vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1212
+ singleResult(opts.baseOptions, { output: "captured", patchPath: `/artifacts/${opts.agentId}.patch` }),
1213
+ );
1214
+ vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1215
+ summary: "\n\nApplied",
1216
+ changesApplied: true,
1217
+ hadAnyChanges: true,
1218
+ mergedBranchForNestedPatches: false,
1219
+ });
1220
+
1221
+ await runEvalAgent({ prompt: "scout", isolated: true, handle: true }, { session: isolatedSession() });
1222
+
1223
+ const removedArtifactsDir = rmSpy.mock.calls.some(
1224
+ ([target]) => typeof target === "string" && target.includes("omp-eval-agent-"),
1225
+ );
1226
+ expect(removedArtifactsDir).toBe(false);
1227
+ });
1228
+ });
@@ -4,7 +4,7 @@ import { TempDir } from "@oh-my-pi/pi-utils/temp";
4
4
  import { createHelpers, type HelperContext } from "../js/shared/helpers";
5
5
 
6
6
  /**
7
- * The eval helpers (`read`/`write`/`append`) must substitute injected on-disk
7
+ * The eval helpers (`read`/`write`) must substitute injected on-disk
8
8
  * roots for internal-URL schemes. Without it, `write("local://x.md")` hits a
9
9
  * stdlib `path.resolve` that collapses `local://` to `local:/`, creating a junk
10
10
  * `local:` directory under the cwd instead of landing where `read local://x.md`
@@ -20,7 +20,7 @@ function makeCtx(cwd: string, roots: Record<string, string>): HelperContext {
20
20
  }
21
21
 
22
22
  describe("eval js helpers internal-url resolution", () => {
23
- it("writes, reads, and appends local:// under the injected root", async () => {
23
+ it("writes and reads local:// under the injected root", async () => {
24
24
  using tmp = TempDir.createSync("@eval-helpers-local-");
25
25
  const root = path.join(tmp.path(), "local");
26
26
  const helpers = createHelpers(makeCtx(tmp.path(), { local: root }));
@@ -30,9 +30,6 @@ describe("eval js helpers internal-url resolution", () => {
30
30
  expect(await Bun.file(written).text()).toBe("hello");
31
31
  expect(await helpers.read("local://notes/merge-map.md")).toBe("hello");
32
32
 
33
- await helpers.append("local://notes/merge-map.md", " world");
34
- expect(await helpers.read("local://notes/merge-map.md")).toBe("hello world");
35
-
36
33
  // Regression: no literal `local:` directory created under the cwd.
37
34
  expect(await Bun.file(path.join(tmp.path(), "local:")).exists()).toBe(false);
38
35
  expect(await Bun.file(path.join(tmp.path(), "local:", "notes", "merge-map.md")).exists()).toBe(false);
@@ -11,35 +11,6 @@ describe.skipIf(!HAS_JULIA)("eval Julia prelude helpers", () => {
11
11
  await disposeJuliaKernelSessionsByOwner(OWNER_ID);
12
12
  });
13
13
 
14
- it("supports tree keyword options and unified diff", async () => {
15
- using tempDir = TempDir.createSync("@omp-eval-julia-helpers-");
16
- await Bun.write(path.join(tempDir.path(), "a.txt"), "same\nold\n");
17
- await Bun.write(path.join(tempDir.path(), "b.txt"), "same\nnew\n");
18
- await Bun.write(path.join(tempDir.path(), "dir", "child.txt"), "child");
19
-
20
- const result = await executeJulia(
21
- `
22
- d = diff("a.txt", "b.txt")
23
- println("DIFF_DELETE=", occursin("-old", d))
24
- println("DIFF_ADD=", occursin("+new", d))
25
- t = tree(".", max_depth=2)
26
- println("TREE_CHILD=", occursin("child.txt", t))
27
- nothing
28
- `,
29
- {
30
- cwd: tempDir.path(),
31
- sessionId: `julia-prelude-diff:${crypto.randomUUID()}`,
32
- kernelOwnerId: OWNER_ID,
33
- reset: true,
34
- },
35
- );
36
-
37
- expect(result.exitCode).toBe(0);
38
- expect(result.output).toContain("DIFF_DELETE=true");
39
- expect(result.output).toContain("DIFF_ADD=true");
40
- expect(result.output).toContain("TREE_CHILD=true");
41
- });
42
-
43
14
  it("supports output ranges, JSON queries, metadata, and ANSI stripping", async () => {
44
15
  using tempDir = TempDir.createSync("@omp-eval-julia-output-");
45
16
  const artifactsDir = path.join(tempDir.path(), "session-artifacts");
@@ -73,5 +44,5 @@ nothing
73
44
  expect(result.output).toContain("STRIPPED=red");
74
45
  expect(result.output).toContain("META=alpha:true");
75
46
  expect(result.output).toContain("MULTI=2:alpha:json");
76
- });
47
+ }, 30_000);
77
48
  });
@@ -3,7 +3,7 @@ import * as vm from "node:vm";
3
3
  import { JAVASCRIPT_PRELUDE_SOURCE } from "../js/shared/prelude";
4
4
 
5
5
  /**
6
- * The eval `agent()` helper grows a `returnHandle` option that turns its bare
6
+ * The eval `agent()` helper grows a `handle` option that turns its bare
7
7
  * text result into a DAG node dict carrying the spawned agent's recoverable
8
8
  * `agent://<id>` handle, so a downstream `pipeline`/`parallel` stage can wire
9
9
  * the transcript by reference instead of re-inlining it. These lock the node
@@ -23,15 +23,18 @@ function loadPrelude(callTool: (name: string, args: unknown) => Promise<unknown>
23
23
 
24
24
  type AgentHelper = (prompt: string, opts?: Record<string, unknown>) => Promise<unknown>;
25
25
 
26
- describe("eval js agent() returnHandle", () => {
27
- it("returns a DAG node carrying the agent:// handle when returnHandle is set", async () => {
26
+ describe("eval js agent() handle", () => {
27
+ it("returns a DAG node carrying the agent:// handle when handle is set", async () => {
28
28
  let seenName: string | undefined;
29
- const sandbox = loadPrelude(async name => {
29
+ let seenArgs: Record<string, unknown> | undefined;
30
+ const sandbox = loadPrelude(async (name, args) => {
30
31
  seenName = name;
32
+ seenArgs = args as Record<string, unknown>;
31
33
  return { text: "hello world", details: { agent: "task", id: "abc123", model: "m", structured: false } };
32
34
  });
33
- const node = await (sandbox.agent as AgentHelper)("say hi", { returnHandle: true });
35
+ const node = await (sandbox.agent as AgentHelper)("say hi", { handle: true });
34
36
  expect(seenName).toBe("__agent__");
37
+ expect(seenArgs?.handle).toBe(true);
35
38
  expect(node).toEqual({
36
39
  text: "hello world",
37
40
  output: "hello world",
@@ -50,7 +53,7 @@ describe("eval js agent() returnHandle", () => {
50
53
  expect(out).toBe("hello world");
51
54
  });
52
55
 
53
- it("carries the parsed object under data when schema and returnHandle combine", async () => {
56
+ it("carries the parsed object under data when schema and handle combine", async () => {
54
57
  const payload = JSON.stringify({ k: 1 });
55
58
  const sandbox = loadPrelude(async () => ({
56
59
  text: payload,
@@ -58,7 +61,7 @@ describe("eval js agent() returnHandle", () => {
58
61
  }));
59
62
  const node = (await (sandbox.agent as AgentHelper)("emit", {
60
63
  schema: { type: "object" },
61
- returnHandle: true,
64
+ handle: true,
62
65
  })) as Record<string, unknown>;
63
66
  expect(node.handle).toBe("agent://id-9");
64
67
  expect(node.data).toEqual({ k: 1 });
@@ -67,7 +70,38 @@ describe("eval js agent() returnHandle", () => {
67
70
 
68
71
  it("falls back to a null handle without throwing when the bridge omits details", async () => {
69
72
  const sandbox = loadPrelude(async () => ({ text: "lonely" }));
70
- const node = await (sandbox.agent as AgentHelper)("x", { returnHandle: true });
73
+ const node = await (sandbox.agent as AgentHelper)("x", { handle: true });
71
74
  expect(node).toEqual({ text: "lonely", output: "lonely", handle: null, id: null, agent: null });
72
75
  });
76
+
77
+ it("exposes patchPath/branchName/nestedPatches/changesApplied/isolated/isolationSummary on the handle", async () => {
78
+ const payload = JSON.stringify({ ok: true });
79
+ const sandbox = loadPrelude(async () => ({
80
+ text: payload,
81
+ details: {
82
+ agent: "task",
83
+ id: "iso-1",
84
+ structured: true,
85
+ isolated: true,
86
+ patchPath: "/artifacts/iso-1.patch",
87
+ changesApplied: null,
88
+ nestedPatches: [{ relativePath: "nested", patch: "diff --git a/file b/file\n" }],
89
+ isolationSummary: "Isolation: changes captured at `/artifacts/iso-1.patch` (apply=false). Not applied.",
90
+ },
91
+ }));
92
+ const node = (await (sandbox.agent as AgentHelper)("scout", {
93
+ schema: { type: "object" },
94
+ isolated: true,
95
+ apply: false,
96
+ handle: true,
97
+ })) as Record<string, unknown>;
98
+ expect(node.handle).toBe("agent://iso-1");
99
+ expect(node.data).toEqual({ ok: true });
100
+ expect(node.isolated).toBe(true);
101
+ expect(node.patchPath).toBe("/artifacts/iso-1.patch");
102
+ expect(node.nestedPatches).toEqual([{ relativePath: "nested", patch: "diff --git a/file b/file\n" }]);
103
+ expect(node.changesApplied).toBeNull();
104
+ expect(node.isolationSummary).toContain("/artifacts/iso-1.patch");
105
+ expect("branchName" in node).toBe(false);
106
+ });
73
107
  });