@oh-my-pi/pi-coding-agent 17.1.5 → 17.1.6

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 (207) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/{CHANGELOG-qs3vd6xf.md → CHANGELOG-x9zt79k8.md} +31 -0
  3. package/dist/cli.js +2989 -2978
  4. package/dist/types/capability/index.d.ts +1 -1
  5. package/dist/types/capability/types.d.ts +23 -1
  6. package/dist/types/config/settings-schema.d.ts +23 -1
  7. package/dist/types/internal-urls/mcp-protocol.d.ts +3 -2
  8. package/dist/types/internal-urls/parse.d.ts +12 -0
  9. package/dist/types/internal-urls/router.d.ts +6 -0
  10. package/dist/types/internal-urls/types.d.ts +6 -0
  11. package/dist/types/lsp/config.d.ts +1 -0
  12. package/dist/types/lsp/types.d.ts +2 -0
  13. package/dist/types/mcp/manager.d.ts +5 -0
  14. package/dist/types/mcp/tool-bridge.d.ts +13 -0
  15. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  16. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  17. package/dist/types/sdk.d.ts +3 -1
  18. package/dist/types/session/agent-session-types.d.ts +3 -1
  19. package/dist/types/session/agent-session.d.ts +2 -0
  20. package/dist/types/session/model-controls.d.ts +3 -0
  21. package/dist/types/session/session-advisors.d.ts +7 -1
  22. package/dist/types/session/tool-choice-queue.d.ts +6 -4
  23. package/dist/types/session/turn-recovery.d.ts +4 -2
  24. package/dist/types/task/index.d.ts +1 -1
  25. package/dist/types/task/types.d.ts +3 -11
  26. package/dist/types/thinking.d.ts +21 -2
  27. package/dist/types/utils/title-generator.d.ts +17 -16
  28. package/package.json +12 -12
  29. package/src/capability/index.ts +43 -12
  30. package/src/capability/mcp.ts +21 -0
  31. package/src/capability/types.ts +20 -1
  32. package/src/cli/read-cli.ts +44 -2
  33. package/src/config/settings-schema.ts +27 -1
  34. package/src/eval/py/runner.py +16 -2
  35. package/src/extensibility/extensions/runner.ts +91 -5
  36. package/src/extensibility/extensions/types.ts +0 -1
  37. package/src/extensibility/hooks/types.ts +0 -1
  38. package/src/internal-urls/mcp-protocol.ts +17 -3
  39. package/src/internal-urls/parse.ts +31 -0
  40. package/src/internal-urls/router.ts +24 -4
  41. package/src/internal-urls/types.ts +6 -0
  42. package/src/live/transport.ts +2 -2
  43. package/src/lsp/client.ts +2 -2
  44. package/src/lsp/config.ts +4 -0
  45. package/src/lsp/types.ts +2 -0
  46. package/src/mcp/config.ts +26 -14
  47. package/src/mcp/manager.ts +26 -9
  48. package/src/mcp/tool-bridge.ts +52 -1
  49. package/src/memories/index.ts +25 -6
  50. package/src/modes/components/status-line/segments.ts +3 -1
  51. package/src/modes/controllers/command-controller.ts +10 -10
  52. package/src/modes/controllers/extension-ui-controller.ts +3 -3
  53. package/src/modes/rpc/rpc-mode.ts +60 -47
  54. package/src/prompts/steering/user-interjection.md +2 -5
  55. package/src/prompts/tools/task.md +4 -2
  56. package/src/sdk.ts +17 -8
  57. package/src/session/agent-session-types.ts +3 -0
  58. package/src/session/agent-session.ts +21 -4
  59. package/src/session/model-controls.ts +43 -7
  60. package/src/session/session-advisors.ts +30 -14
  61. package/src/session/session-tools.ts +4 -2
  62. package/src/session/tool-choice-queue.ts +19 -4
  63. package/src/session/turn-recovery.ts +21 -2
  64. package/src/task/executor.ts +11 -3
  65. package/src/task/index.ts +43 -32
  66. package/src/task/types.ts +12 -17
  67. package/src/thinking.ts +68 -5
  68. package/src/tools/read.ts +2 -2
  69. package/src/utils/title-generator.ts +88 -34
  70. package/dist/types/advisor/__tests__/advisor.test.d.ts +0 -1
  71. package/dist/types/advisor/__tests__/config.test.d.ts +0 -1
  72. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +0 -1
  73. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +0 -1
  74. package/dist/types/cli/update-cli.test.d.ts +0 -1
  75. package/dist/types/config/__tests__/model-registry.test.d.ts +0 -1
  76. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +0 -1
  77. package/dist/types/eval/__tests__/bridge-timeout.test.d.ts +0 -1
  78. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +0 -1
  79. package/dist/types/eval/__tests__/completion-bridge.test.d.ts +0 -1
  80. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +0 -1
  81. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +0 -1
  82. package/dist/types/eval/__tests__/js-context-manager.test.d.ts +0 -1
  83. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +0 -1
  84. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +0 -1
  85. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +0 -1
  86. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +0 -1
  87. package/dist/types/eval/py/__tests__/prelude.test.d.ts +0 -1
  88. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +0 -1
  89. package/dist/types/hindsight/client.test.d.ts +0 -1
  90. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +0 -1
  91. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +0 -1
  92. package/dist/types/launch/broker-list-order.test.d.ts +0 -1
  93. package/dist/types/launch/broker-output-snapshot.test.d.ts +0 -1
  94. package/dist/types/launch/protocol.test.d.ts +0 -1
  95. package/dist/types/launch/spawn-options.test.d.ts +0 -1
  96. package/dist/types/launch/terminal-output.test.d.ts +0 -1
  97. package/dist/types/live/protocol.test.d.ts +0 -1
  98. package/dist/types/mcp/config-writer.test.d.ts +0 -1
  99. package/dist/types/mcp/smithery-auth.test.d.ts +0 -1
  100. package/dist/types/mcp/smithery-registry.test.d.ts +0 -1
  101. package/dist/types/mcp/transports/stdio.test.d.ts +0 -1
  102. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +0 -1
  103. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +0 -1
  104. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +0 -1
  105. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +0 -1
  106. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +0 -1
  107. package/dist/types/modes/components/custom-editor.test.d.ts +0 -1
  108. package/dist/types/modes/components/login-dialog.test.d.ts +0 -1
  109. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +0 -1
  110. package/dist/types/modes/components/status-line/component.test.d.ts +0 -1
  111. package/dist/types/modes/components/tool-execution.test.d.ts +0 -1
  112. package/dist/types/modes/controllers/extension-ui-controller.test.d.ts +0 -1
  113. package/dist/types/modes/noninteractive-dispose.test.d.ts +0 -1
  114. package/dist/types/modes/print-mode.test.d.ts +0 -1
  115. package/dist/types/modes/session-teardown.test.d.ts +0 -1
  116. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +0 -1
  117. package/dist/types/modes/utils/transcript-render-helpers.test.d.ts +0 -1
  118. package/dist/types/modes/warp-events.test.d.ts +0 -1
  119. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +0 -1
  120. package/dist/types/plan-mode/model-transition.test.d.ts +0 -1
  121. package/dist/types/plan-mode/reentry-prompt.test.d.ts +0 -1
  122. package/dist/types/session/agent-session-error-log.test.d.ts +0 -1
  123. package/dist/types/session/blob-store.test.d.ts +0 -1
  124. package/dist/types/session/messages.test.d.ts +0 -1
  125. package/dist/types/session/session-context.test.d.ts +0 -1
  126. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +0 -1
  127. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +0 -1
  128. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +0 -1
  129. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +0 -1
  130. package/dist/types/system-prompt.test.d.ts +0 -1
  131. package/dist/types/task/render.test.d.ts +0 -1
  132. package/dist/types/task/spawn-policy.test.d.ts +0 -1
  133. package/dist/types/tools/__tests__/eval-description.test.d.ts +0 -1
  134. package/dist/types/tools/__tests__/glob.test.d.ts +0 -1
  135. package/dist/types/tools/__tests__/json-tree.test.d.ts +0 -1
  136. package/dist/types/tools/__tests__/vibe-render.test.d.ts +0 -1
  137. package/dist/types/tools/hub/launch-compat.test.d.ts +0 -1
  138. package/dist/types/vibe/__tests__/token-rate.test.d.ts +0 -1
  139. package/src/advisor/__tests__/advisor.test.ts +0 -4889
  140. package/src/advisor/__tests__/config.test.ts +0 -349
  141. package/src/advisor/__tests__/emission-guard.test.ts +0 -147
  142. package/src/cli/__tests__/auth-gateway-catalog.test.ts +0 -111
  143. package/src/cli/update-cli.test.ts +0 -28
  144. package/src/config/__tests__/model-registry.test.ts +0 -182
  145. package/src/eval/__tests__/agent-bridge.test.ts +0 -1509
  146. package/src/eval/__tests__/bridge-timeout.test.ts +0 -170
  147. package/src/eval/__tests__/budget-bridge.test.ts +0 -80
  148. package/src/eval/__tests__/completion-bridge.test.ts +0 -412
  149. package/src/eval/__tests__/helpers-local-roots.test.ts +0 -55
  150. package/src/eval/__tests__/idle-timeout.test.ts +0 -80
  151. package/src/eval/__tests__/js-context-manager.test.ts +0 -456
  152. package/src/eval/__tests__/julia-prelude.test.ts +0 -66
  153. package/src/eval/__tests__/kernel-spawn.test.ts +0 -115
  154. package/src/eval/__tests__/prelude-agent.test.ts +0 -156
  155. package/src/eval/__tests__/process-entry-import.test.ts +0 -137
  156. package/src/eval/py/__tests__/prelude.test.ts +0 -104
  157. package/src/eval/py/__tests__/runner-shell-output.test.ts +0 -157
  158. package/src/hindsight/client.test.ts +0 -75
  159. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +0 -141
  160. package/src/internal-urls/__tests__/ssh-protocol.test.ts +0 -331
  161. package/src/launch/broker-list-order.test.ts +0 -89
  162. package/src/launch/broker-output-snapshot.test.ts +0 -126
  163. package/src/launch/protocol.test.ts +0 -59
  164. package/src/launch/spawn-options.test.ts +0 -31
  165. package/src/launch/terminal-output.test.ts +0 -107
  166. package/src/live/protocol.test.ts +0 -140
  167. package/src/mcp/config-writer.test.ts +0 -43
  168. package/src/mcp/smithery-auth.test.ts +0 -29
  169. package/src/mcp/smithery-registry.test.ts +0 -51
  170. package/src/mcp/transports/stdio.test.ts +0 -427
  171. package/src/modes/components/__tests__/dynamic-border.test.ts +0 -55
  172. package/src/modes/components/__tests__/move-overlay.test.ts +0 -252
  173. package/src/modes/components/__tests__/pause-screen.test.ts +0 -143
  174. package/src/modes/components/__tests__/skill-message.test.ts +0 -94
  175. package/src/modes/components/custom-editor-plugin-ctor.test.ts +0 -36
  176. package/src/modes/components/custom-editor.test.ts +0 -510
  177. package/src/modes/components/login-dialog.test.ts +0 -56
  178. package/src/modes/components/status-line/component.jj-cache.test.ts +0 -229
  179. package/src/modes/components/status-line/component.test.ts +0 -84
  180. package/src/modes/components/tool-execution.test.ts +0 -162
  181. package/src/modes/controllers/extension-ui-controller.test.ts +0 -250
  182. package/src/modes/noninteractive-dispose.test.ts +0 -73
  183. package/src/modes/print-mode.test.ts +0 -71
  184. package/src/modes/session-teardown.test.ts +0 -219
  185. package/src/modes/theme/mermaid-rendering.test.ts +0 -53
  186. package/src/modes/utils/transcript-render-helpers.test.ts +0 -38
  187. package/src/modes/warp-events.test.ts +0 -794
  188. package/src/plan-mode/approved-plan-prompt.test.ts +0 -36
  189. package/src/plan-mode/model-transition.test.ts +0 -60
  190. package/src/plan-mode/reentry-prompt.test.ts +0 -41
  191. package/src/session/agent-session-error-log.test.ts +0 -59
  192. package/src/session/blob-store.test.ts +0 -56
  193. package/src/session/messages.test.ts +0 -282
  194. package/src/session/session-context.test.ts +0 -384
  195. package/src/ssh/__tests__/connection-manager-args.test.ts +0 -191
  196. package/src/ssh/__tests__/connection-manager-timeout.test.ts +0 -61
  197. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +0 -105
  198. package/src/ssh/__tests__/sshfs-mount.test.ts +0 -13
  199. package/src/system-prompt.test.ts +0 -236
  200. package/src/task/render.test.ts +0 -290
  201. package/src/task/spawn-policy.test.ts +0 -62
  202. package/src/tools/__tests__/eval-description.test.ts +0 -18
  203. package/src/tools/__tests__/glob.test.ts +0 -37
  204. package/src/tools/__tests__/json-tree.test.ts +0 -35
  205. package/src/tools/__tests__/vibe-render.test.ts +0 -210
  206. package/src/tools/hub/launch-compat.test.ts +0 -40
  207. package/src/vibe/__tests__/token-rate.test.ts +0 -96
@@ -1,1509 +0,0 @@
1
- import { afterAll, afterEach, describe, expect, it, vi } from "bun:test";
2
- import * as fs from "node:fs/promises";
3
- import * as path from "node:path";
4
- import { TempDir } from "@oh-my-pi/pi-utils";
5
- import { Settings } from "../../config/settings";
6
- import { AgentProtocolHandler } from "../../internal-urls/agent-protocol";
7
- import { resetRegisteredArtifactDirsForTests } from "../../internal-urls/registry-helpers";
8
- import type { PlanModeState } from "../../plan-mode/state";
9
- import { AgentRegistry } from "../../registry/agent-registry";
10
- import type { AgentSession } from "../../session/agent-session";
11
- import * as taskDiscovery from "../../task/discovery";
12
- import type { ExecutorOptions } from "../../task/executor";
13
- import * as taskExecutor from "../../task/executor";
14
- import * as isolationRunner from "../../task/isolation-runner";
15
- import { AgentOutputManager } from "../../task/output-manager";
16
- import type { AgentDefinition, AgentProgress, SingleResult, StructuredSubagentOutput } from "../../task/types";
17
- import type { ToolSession } from "../../tools";
18
- import { runEvalAgent } from "../agent-bridge";
19
- import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
20
- import { IdleTimeout } from "../idle-timeout";
21
- import { disposeAllVmContexts } from "../js/context-manager";
22
- import { executeJs } from "../js/executor";
23
- import { disposeAllKernelSessions, executePython } from "../py/executor";
24
-
25
- const taskAgent = {
26
- name: "task",
27
- description: "Task agent",
28
- systemPrompt: "Run the task.",
29
- source: "bundled",
30
- spawns: "*",
31
- model: ["@task"],
32
- } satisfies AgentDefinition;
33
-
34
- const reviewerAgent = {
35
- name: "reviewer",
36
- description: "Reviewer agent",
37
- systemPrompt: "Review the task.",
38
- source: "bundled",
39
- model: ["@smol"],
40
- } satisfies AgentDefinition;
41
-
42
- interface SessionOptions {
43
- cwd?: string;
44
- sessionFile?: string | null;
45
- artifactsDir?: string | null;
46
- spawns?: string | null;
47
- depth?: number;
48
- activeModel?: string;
49
- modelString?: string;
50
- enableLsp?: boolean;
51
- settings?: Settings;
52
- outputManager?: AgentOutputManager;
53
- planMode?: boolean;
54
- outputSchema?: unknown;
55
- }
56
-
57
- function makeSession(options: SessionOptions = {}): ToolSession {
58
- const settings =
59
- options.settings ??
60
- Settings.isolated({
61
- "async.enabled": false,
62
- "task.isolation.mode": "none",
63
- "task.enableLsp": true,
64
- });
65
- const artifactsDir = options.artifactsDir ?? null;
66
- return {
67
- cwd: options.cwd ?? process.cwd(),
68
- hasUI: false,
69
- settings,
70
- taskDepth: options.depth ?? 0,
71
- enableLsp: options.enableLsp ?? true,
72
- agentOutputManager: options.outputManager,
73
- getSessionFile: () => options.sessionFile ?? null,
74
- getSessionSpawns: () => options.spawns ?? "*",
75
- getActiveModelString: () => options.activeModel ?? "p/active",
76
- getModelString: () => options.modelString ?? "p/fallback",
77
- getArtifactsDir: () => artifactsDir,
78
- getSessionId: () => "test-session",
79
- getEvalSessionId: () => "test-eval-session",
80
- outputSchema: options.outputSchema,
81
- getPlanModeState: options.planMode
82
- ? () =>
83
- ({
84
- enabled: true,
85
- planFilePath: path.join(options.cwd ?? process.cwd(), "plan.md"),
86
- }) satisfies PlanModeState
87
- : undefined,
88
- };
89
- }
90
-
91
- function mockAgents(agents: AgentDefinition[] = [taskAgent, reviewerAgent]): void {
92
- vi.spyOn(taskDiscovery, "discoverAgents").mockResolvedValue({ agents, projectAgentsDir: null });
93
- }
94
-
95
- function singleResult(options: ExecutorOptions, overrides: Partial<SingleResult> = {}): SingleResult {
96
- return {
97
- index: options.index,
98
- id: options.id,
99
- agent: options.agent.name,
100
- agentSource: options.agent.source,
101
- task: options.task,
102
- assignment: options.assignment,
103
- description: options.description,
104
- exitCode: 0,
105
- output: "ok",
106
- stderr: "",
107
- truncated: false,
108
- durationMs: 1,
109
- tokens: 0,
110
- requests: 0,
111
- ...overrides,
112
- };
113
- }
114
-
115
- function makeEvalSession(
116
- tempDir: TempDir,
117
- prefix: string,
118
- settings?: Settings,
119
- ): { session: ToolSession; sessionFile: string; sessionId: string } {
120
- const sessionFile = path.join(tempDir.path(), "session.jsonl");
121
- const artifactsDir = sessionFile.slice(0, -6);
122
- const session = makeSession({
123
- cwd: tempDir.path(),
124
- sessionFile,
125
- artifactsDir,
126
- settings,
127
- outputManager: new AgentOutputManager(() => artifactsDir),
128
- });
129
- return { session, sessionFile, sessionId: `${prefix}:${crypto.randomUUID()}` };
130
- }
131
-
132
- /**
133
- * Spy `runSubprocess` so a `parallel()` fan-out overlaps deterministically: every
134
- * bridge call parks until the pool saturates at `limit` concurrent calls in flight,
135
- * then all proceed. Proves the pool reaches its ceiling without a wall-clock sleep —
136
- * the pool itself caps how many run at once, so an unbounded pool would drive
137
- * `maxInFlight` past `limit` and fail the bound.
138
- */
139
- function spyConcurrencyBarrier(limit: number): { maxInFlight: () => number } {
140
- let inFlight = 0;
141
- let max = 0;
142
- let saturate: (() => void) | undefined;
143
- const saturated = new Promise<void>(resolve => {
144
- saturate = resolve;
145
- });
146
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
147
- inFlight++;
148
- max = Math.max(max, inFlight);
149
- if (inFlight >= limit) saturate?.();
150
- try {
151
- await saturated;
152
- return singleResult(options, { output: options.assignment ?? "" });
153
- } finally {
154
- inFlight--;
155
- }
156
- });
157
- return { maxInFlight: () => max };
158
- }
159
-
160
- describe("runEvalAgent", () => {
161
- afterEach(() => {
162
- vi.restoreAllMocks();
163
- AgentRegistry.resetGlobalForTests();
164
- resetRegisteredArtifactDirsForTests();
165
- });
166
-
167
- it("resolves the default task agent and agent overrides", async () => {
168
- mockAgents();
169
- const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
170
- singleResult(options, {
171
- output: options.agent.name,
172
- }),
173
- );
174
- const session = makeSession();
175
-
176
- const defaultResult = await runEvalAgent({ prompt: "hello" }, { session });
177
- const overrideResult = await runEvalAgent({ prompt: "hello", agent: "reviewer" }, { session });
178
-
179
- expect(defaultResult.text).toBe("task");
180
- expect(overrideResult.text).toBe("reviewer");
181
- expect(runSpy.mock.calls[0]?.[0].agent.name).toBe("task");
182
- expect(runSpy.mock.calls[1]?.[0].agent.name).toBe("reviewer");
183
- });
184
-
185
- it("throws for an unknown agent", async () => {
186
- mockAgents([taskAgent]);
187
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
188
-
189
- await expect(runEvalAgent({ prompt: "hello", agent: "missing" }, { session: makeSession() })).rejects.toThrow(
190
- 'Unknown agent "missing"',
191
- );
192
- });
193
-
194
- it("enforces shared spawn restrictions", async () => {
195
- mockAgents();
196
- const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
197
-
198
- await expect(runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "" }) })).rejects.toThrow(
199
- "spawns disabled",
200
- );
201
- await expect(
202
- runEvalAgent({ prompt: "hello", agent: "task" }, { session: makeSession({ spawns: "reviewer" }) }),
203
- ).rejects.toThrow("Allowed: reviewer");
204
- expect(runSpy).not.toHaveBeenCalled();
205
- });
206
-
207
- it("defaults to the first allowed spawn under restricted eval policies", async () => {
208
- mockAgents();
209
- const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
210
- singleResult(options, {
211
- output: options.agent.name,
212
- }),
213
- );
214
-
215
- const result = await runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "reviewer,task" }) });
216
-
217
- expect(result.text).toBe("reviewer");
218
- expect(runSpy.mock.calls[0]?.[0].agent.name).toBe("reviewer");
219
- });
220
-
221
- it("honors task.maxRecursionDepth without an eval-specific ceiling", async () => {
222
- mockAgents();
223
- const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
224
-
225
- await expect(
226
- runEvalAgent(
227
- { prompt: "hello" },
228
- {
229
- session: makeSession({
230
- settings: Settings.isolated({
231
- "async.enabled": false,
232
- "task.isolation.mode": "none",
233
- "task.maxRecursionDepth": 0,
234
- }),
235
- }),
236
- },
237
- ),
238
- ).rejects.toThrow("maximum depth is 0");
239
-
240
- await runEvalAgent(
241
- { prompt: "hello" },
242
- {
243
- session: makeSession({
244
- depth: 3,
245
- settings: Settings.isolated({
246
- "async.enabled": false,
247
- "task.isolation.mode": "none",
248
- "task.maxRecursionDepth": -1,
249
- }),
250
- }),
251
- },
252
- );
253
- expect(runSpy).toHaveBeenCalledTimes(1);
254
- });
255
-
256
- it("runs plan-mode eval agents with an attenuated policy", async () => {
257
- mockAgents([{ ...taskAgent, tools: ["ast_grep", "write"] }]);
258
- const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
259
-
260
- await expect(
261
- runEvalAgent({ prompt: "hello" }, { session: makeSession({ planMode: true }) }),
262
- ).resolves.toMatchObject({
263
- text: "ok",
264
- });
265
- expect(runSpy).toHaveBeenCalledTimes(1);
266
- expect(runSpy.mock.calls[0]?.[0].agent.tools).toEqual(["read", "grep", "glob", "web_search", "ast_grep"]);
267
- expect(runSpy.mock.calls[0]?.[0].agent.spawns).toBeUndefined();
268
- await expect(
269
- runEvalAgent({ prompt: "unsafe", isolated: true }, { session: makeSession({ planMode: true }) }),
270
- ).rejects.toThrow("isolation, apply, and merge controls are unavailable in plan mode");
271
- expect(runSpy).toHaveBeenCalledTimes(1);
272
- });
273
-
274
- it("passes parent execution options and only sets outputSchema when schema is supplied", async () => {
275
- mockAgents();
276
- const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
277
- const abortController = new AbortController();
278
- const schema = { type: "object", properties: { ok: { type: "boolean" } } };
279
- const session = makeSession({
280
- depth: 2,
281
- activeModel: "p/current",
282
- modelString: "p/fallback",
283
- settings: Settings.isolated({
284
- "async.enabled": false,
285
- "task.isolation.mode": "none",
286
- "task.enableLsp": true,
287
- // Default task.maxRecursionDepth is 2, which would now (correctly)
288
- // block depth=2 — widen it so the test still exercises depth=2.
289
- "task.maxRecursionDepth": -1,
290
- }),
291
- });
292
-
293
- await runEvalAgent(
294
- { prompt: " hello ", label: "My Agent", model: "p/override", schema },
295
- { session, signal: abortController.signal },
296
- );
297
- await runEvalAgent({ prompt: "plain" }, { session });
298
-
299
- const firstOptions = runSpy.mock.calls[0]?.[0];
300
- const secondOptions = runSpy.mock.calls[1]?.[0];
301
- if (!firstOptions || !secondOptions) throw new Error("runSubprocess was not called");
302
- expect(firstOptions.taskDepth).toBe(2);
303
- expect(firstOptions.signal).toBe(abortController.signal);
304
- expect(firstOptions.parentActiveModelPattern).toBe("p/current");
305
- expect(firstOptions.outputSchema).toBe(schema);
306
- expect(firstOptions.outputSchemaOverridesAgent).toBe(true);
307
- expect(firstOptions.assignment).toBe("hello");
308
- expect(firstOptions.description).toBe("My Agent");
309
- expect(firstOptions.modelOverride).toEqual(["p/override"]);
310
- expect(secondOptions.outputSchema).toBeUndefined();
311
- expect(secondOptions.outputSchemaOverridesAgent).toBeUndefined();
312
- });
313
- it("returns host-parsed data for caller, agent, and inherited schemas", async () => {
314
- const agentSchema = { type: "object" };
315
- const sessionSchema = { type: "object" };
316
- const callerSchema = { type: "object" };
317
- const frontmatterAgent = { ...reviewerAgent, name: "structured", output: agentSchema };
318
- mockAgents([taskAgent, frontmatterAgent]);
319
- const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
320
- const source = options.outputSchemaOverridesAgent
321
- ? "caller"
322
- : options.agent.name === "structured"
323
- ? "agent"
324
- : "session";
325
- const structuredOutput: StructuredSubagentOutput = {
326
- source,
327
- mode: options.outputSchemaMode ?? "permissive",
328
- status: "valid",
329
- data: { source },
330
- };
331
- return singleResult(options, { output: "not JSON", structuredOutput });
332
- });
333
-
334
- const caller = await runEvalAgent(
335
- { prompt: "caller", schema: callerSchema, schemaMode: "strict" },
336
- { session: makeSession({ outputSchema: sessionSchema }) },
337
- );
338
- const frontmatter = await runEvalAgent(
339
- { prompt: "agent", agent: "structured" },
340
- { session: makeSession({ outputSchema: sessionSchema }) },
341
- );
342
- const inherited = await runEvalAgent(
343
- { prompt: "session" },
344
- { session: makeSession({ outputSchema: sessionSchema }) },
345
- );
346
-
347
- expect(caller.data).toEqual({ source: "caller" });
348
- expect(caller.details).toMatchObject({ schemaSource: "caller", schemaMode: "strict", schemaStatus: "valid" });
349
- expect(frontmatter.data).toEqual({ source: "agent" });
350
- expect(inherited.data).toEqual({ source: "session" });
351
- expect(runSpy.mock.calls.map(([options]) => options.outputSchema)).toEqual([
352
- callerSchema,
353
- agentSchema,
354
- sessionSchema,
355
- ]);
356
- });
357
-
358
- it("inherits non-plan LSP and IRC policy for bridge subagents", async () => {
359
- mockAgents();
360
- const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
361
- // makeSession() defaults to enableLsp: true and task.enableLsp: true.
362
- const session = makeSession();
363
-
364
- await runEvalAgent({ prompt: "hello" }, { session });
365
-
366
- const options = runSpy.mock.calls[0]?.[0];
367
- if (!options) throw new Error("runSubprocess was not called");
368
- expect(options.enableLsp).toBe(true);
369
- expect(options.enableIrc).toBe(true);
370
- expect(options.keepAlive).toBe(false);
371
- });
372
-
373
- it("registers temp artifact dirs for in-memory handle results so agent URLs resolve", async () => {
374
- mockAgents();
375
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
376
- if (!options.artifactsDir) throw new Error("artifactsDir missing");
377
- await fs.mkdir(options.artifactsDir, { recursive: true });
378
- await fs.writeFile(path.join(options.artifactsDir, `${options.id}.md`), "recoverable output");
379
- return singleResult(options, { output: "recoverable output" });
380
- });
381
-
382
- const result = await runEvalAgent({ prompt: "hello", handle: true }, { session: makeSession() });
383
- const resource = await new AgentProtocolHandler().resolve(new URL(`agent://${result.details.id}`) as never);
384
-
385
- expect(resource.content).toBe("recoverable output");
386
- });
387
-
388
- it("unregisters eval subagents through the bridge cleanup path", async () => {
389
- AgentRegistry.resetGlobalForTests();
390
- mockAgents();
391
- let disposed = false;
392
- const cleanupSession = {
393
- dispose: async () => {
394
- disposed = true;
395
- },
396
- } as unknown as AgentSession;
397
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
398
- AgentRegistry.global().register({
399
- id: options.id,
400
- displayName: options.id,
401
- kind: "sub",
402
- session: cleanupSession,
403
- status: "idle",
404
- });
405
- await taskExecutor.finalizeSubagentLifecycle({
406
- id: options.id,
407
- session: cleanupSession,
408
- aborted: false,
409
- keepAlive: options.keepAlive !== false,
410
- isolated: options.worktree !== undefined,
411
- agentIdleTtlMs: 0,
412
- reviveSession: null,
413
- });
414
- return singleResult(options);
415
- });
416
-
417
- await runEvalAgent({ prompt: "hello", label: "Cleanup" }, { session: makeSession() });
418
-
419
- expect(disposed).toBe(true);
420
- expect(AgentRegistry.global().get("Cleanup")).toBeUndefined();
421
- expect(
422
- AgentRegistry.global()
423
- .listVisibleTo("Main")
424
- .map(ref => ref.id),
425
- ).not.toContain("Cleanup");
426
- });
427
-
428
- it("maps successful and failed subagent results", async () => {
429
- mockAgents();
430
- const runSpy = vi.spyOn(taskExecutor, "runSubprocess");
431
- runSpy.mockImplementationOnce(async options =>
432
- singleResult(options, {
433
- id: "0-EvalAgent",
434
- output: "done",
435
- resolvedModel: "p/model",
436
- }),
437
- );
438
- runSpy.mockImplementationOnce(async options =>
439
- singleResult(options, {
440
- exitCode: 1,
441
- output: "",
442
- stderr: "stderr",
443
- error: "boom",
444
- }),
445
- );
446
-
447
- const result = await runEvalAgent({ prompt: "hello" }, { session: makeSession() });
448
- expect(result).toEqual({
449
- text: "done",
450
- details: { agent: "task", id: "0-EvalAgent", model: "p/model", structured: false },
451
- });
452
- await expect(runEvalAgent({ prompt: "fail" }, { session: makeSession() })).rejects.toThrow("boom");
453
- });
454
-
455
- // Regression: a runtime-limit abort returns exitCode=1, stderr="", error=undefined,
456
- // aborted=true, abortReason="Subagent runtime limit exceeded (...)". The previous
457
- // failure-message coalesce stopped at the empty `stderr` (since `??` only skips
458
- // nullish values) and shipped an empty error through the bridge — Python then
459
- // surfaced the generic `bridge call '__agent__' failed`. See #2006.
460
- it("surfaces abortReason for aborts that leave stderr empty", async () => {
461
- mockAgents();
462
- const runSpy = vi.spyOn(taskExecutor, "runSubprocess");
463
- runSpy.mockImplementationOnce(async options =>
464
- singleResult(options, {
465
- exitCode: 1,
466
- output: "",
467
- stderr: "",
468
- error: undefined,
469
- aborted: true,
470
- abortReason: "Subagent runtime limit exceeded (task.maxRuntimeMs=900000)",
471
- }),
472
- );
473
- runSpy.mockImplementationOnce(async options =>
474
- singleResult(options, {
475
- exitCode: 1,
476
- output: "",
477
- stderr: " ",
478
- error: " ",
479
- aborted: true,
480
- abortReason: "Cancelled by caller",
481
- }),
482
- );
483
- runSpy.mockImplementationOnce(async options =>
484
- singleResult(options, {
485
- exitCode: 1,
486
- output: "",
487
- stderr: "",
488
- error: undefined,
489
- }),
490
- );
491
-
492
- await expect(runEvalAgent({ prompt: "slow" }, { session: makeSession() })).rejects.toThrow(
493
- "Subagent runtime limit exceeded (task.maxRuntimeMs=900000)",
494
- );
495
- // Whitespace-only stderr/error must not mask abortReason either.
496
- await expect(runEvalAgent({ prompt: "cancelled" }, { session: makeSession() })).rejects.toThrow(
497
- "Cancelled by caller",
498
- );
499
- // Last resort: still produce a non-empty message even when nothing useful is set,
500
- // so Python never falls back to `bridge call '__agent__' failed`.
501
- await expect(runEvalAgent({ prompt: "blank" }, { session: makeSession() })).rejects.toThrow(
502
- "agent() subagent 'task' failed.",
503
- );
504
- });
505
- });
506
-
507
- describe("agent() through eval runtimes", () => {
508
- // One shared JS worker backs every agent() JavaScript test below. Spawning a
509
- // worker (thread + module-graph import) is fixed infrastructure cost, not
510
- // behavior under test; reusing it keeps the suite fast. Each run still threads
511
- // its own ToolSession (settings/mock are read live through the bridge per call)
512
- // and top-level `const`/`let` are demoted to `var`, so reuse never leaks state
513
- // these tests observe. Torn down in afterAll via disposeAllVmContexts().
514
- const sharedJsSessionId = "agent-bridge-shared-js";
515
-
516
- afterEach(() => {
517
- vi.restoreAllMocks();
518
- vi.useRealTimers();
519
- });
520
-
521
- afterAll(async () => {
522
- await disposeAllVmContexts();
523
- await disposeAllKernelSessions();
524
- });
525
-
526
- it("exposes agent() in JavaScript and parses structured output", async () => {
527
- using tempDir = TempDir.createSync("@omp-eval-agent-js-");
528
- const { session, sessionFile } = makeEvalSession(tempDir, "js-agent");
529
- mockAgents();
530
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
531
- singleResult(options, {
532
- output: options.outputSchema ? '{"ok":true,"n":3}' : "hello from agent",
533
- ...(options.outputSchema
534
- ? {
535
- structuredOutput: {
536
- source: "caller",
537
- mode: options.outputSchemaMode ?? "permissive",
538
- status: "valid",
539
- data: { ok: true, n: 3 },
540
- } satisfies StructuredSubagentOutput,
541
- }
542
- : {}),
543
- }),
544
- );
545
-
546
- const result = await executeJs(
547
- 'const text = await agent("hi"); const data = await agent("json", { schema: { type: "object" } }); const node = await agent("handle", { schema: { type: "object" }, handle: true }); return JSON.stringify({ text, data, node });',
548
- { cwd: tempDir.path(), sessionId: sharedJsSessionId, session, sessionFile },
549
- );
550
-
551
- expect(result.exitCode).toBe(0);
552
- const output = JSON.parse(result.output.trim());
553
- expect(output.text).toBe("hello from agent");
554
- expect(output.data).toEqual({ ok: true, n: 3 });
555
- expect(output.node.data).toEqual({ ok: true, n: 3 });
556
- expect(output.node.handle).toBe(`agent://${output.node.id}`);
557
- });
558
-
559
- it("bounds JavaScript parallel() by the task.maxConcurrency setting while preserving order", async () => {
560
- using tempDir = TempDir.createSync("@omp-eval-agent-js-parallel-");
561
- const settings = Settings.isolated({
562
- "async.enabled": false,
563
- "task.isolation.mode": "none",
564
- "task.enableLsp": true,
565
- "task.maxConcurrency": 2,
566
- });
567
- const { session, sessionFile } = makeEvalSession(tempDir, "js-agent-parallel", settings);
568
- mockAgents();
569
- const barrier = spyConcurrencyBarrier(2);
570
-
571
- const result = await executeJs(
572
- 'const values = await parallel(["a", "b", "c", "d"].map(name => () => agent(name))); return JSON.stringify(values);',
573
- { cwd: tempDir.path(), sessionId: sharedJsSessionId, session, sessionFile },
574
- );
575
-
576
- expect(result.exitCode).toBe(0);
577
- expect(JSON.parse(result.output.trim())).toEqual(["a", "b", "c", "d"]);
578
- expect(barrier.maxInFlight()).toBeGreaterThan(1);
579
- expect(barrier.maxInFlight()).toBeLessThanOrEqual(2);
580
- });
581
-
582
- it("propagates JavaScript parallel() rejections", async () => {
583
- using tempDir = TempDir.createSync("@omp-eval-agent-js-reject-");
584
- const { session, sessionFile } = makeEvalSession(tempDir, "js-agent-reject");
585
- mockAgents();
586
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
587
- if (options.assignment === "bad") {
588
- return singleResult(options, { exitCode: 1, output: "", stderr: "boom", error: "boom" });
589
- }
590
- return singleResult(options, { output: options.assignment ?? "" });
591
- });
592
-
593
- const result = await executeJs('await parallel([() => agent("ok"), () => agent("bad")]);', {
594
- cwd: tempDir.path(),
595
- sessionId: sharedJsSessionId,
596
- session,
597
- sessionFile,
598
- });
599
-
600
- expect(result.exitCode).toBe(1);
601
- expect(result.output).toContain("boom");
602
- });
603
-
604
- it("exposes agent() in the Python runtime", async () => {
605
- using tempDir = TempDir.createSync("@omp-eval-agent-py-");
606
- const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent");
607
- mockAgents();
608
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
609
- singleResult(options, {
610
- output: options.outputSchema ? "not JSON" : "hello from python",
611
- ...(options.outputSchema
612
- ? {
613
- structuredOutput: {
614
- source: "caller",
615
- mode: options.outputSchemaMode ?? "permissive",
616
- status: "valid",
617
- data: { ok: true },
618
- } satisfies StructuredSubagentOutput,
619
- }
620
- : {}),
621
- }),
622
- );
623
-
624
- const result = await executePython(
625
- 'import json\nprint(agent("hi"))\nprint(json.dumps(agent("structured", schema={"type": "object"})))\nnode = agent("handle", schema={"type": "object"}, handle=True)\nprint(json.dumps({"data": node["data"], "handle": node["handle"], "id": node["id"]}))',
626
- {
627
- cwd: tempDir.path(),
628
- sessionId,
629
- sessionFile,
630
- kernelMode: "per-call",
631
- toolSession: session,
632
- },
633
- );
634
- if (result.exitCode === undefined && result.cancelled) {
635
- expect(result.output).toBe("");
636
- return; // kernel unavailable in this environment
637
- }
638
-
639
- expect(result.exitCode).toBe(0);
640
- const lines = result.output.trim().split("\n");
641
- expect(lines[0]).toBe("hello from python");
642
- expect(JSON.parse(lines[1] ?? "")).toEqual({ ok: true });
643
- const node = JSON.parse(lines[2] ?? "");
644
- expect(node.data).toEqual({ ok: true });
645
- expect(node.handle).toBe(`agent://${node.id}`);
646
- });
647
-
648
- it("bounds Python parallel() by the task.maxConcurrency setting while preserving order", async () => {
649
- using tempDir = TempDir.createSync("@omp-eval-agent-py-parallel-");
650
- const settings = Settings.isolated({
651
- "async.enabled": false,
652
- "task.isolation.mode": "none",
653
- "task.enableLsp": true,
654
- "task.maxConcurrency": 2,
655
- });
656
- const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-parallel", settings);
657
- mockAgents();
658
- const barrier = spyConcurrencyBarrier(2);
659
-
660
- const result = await executePython(
661
- 'import json\nprint(json.dumps(parallel([lambda n=n: agent(n) for n in ["a", "b", "c", "d"]])))',
662
- { cwd: tempDir.path(), sessionId, sessionFile, kernelMode: "per-call", toolSession: session },
663
- );
664
- if (result.exitCode === undefined && result.cancelled) {
665
- expect(result.output).toBe("");
666
- return; // kernel unavailable in this environment
667
- }
668
-
669
- expect(result.exitCode).toBe(0);
670
- expect(JSON.parse(result.output.trim())).toEqual(["a", "b", "c", "d"]);
671
- expect(barrier.maxInFlight()).toBeGreaterThan(1);
672
- expect(barrier.maxInFlight()).toBeLessThanOrEqual(2);
673
- });
674
-
675
- it("interrupting a Python parallel() fan-out settles the kernel cleanly and preserves session state", async () => {
676
- using tempDir = TempDir.createSync("@omp-eval-agent-py-interrupt-");
677
- const settings = Settings.isolated({
678
- "async.enabled": false,
679
- "task.isolation.mode": "none",
680
- "task.enableLsp": true,
681
- "task.maxConcurrency": 6,
682
- });
683
- const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-interrupt", settings);
684
- mockAgents();
685
- // Each kernel worker thread blocks in a synchronous `urllib` bridge call,
686
- // joined by `parallel()`'s ThreadPoolExecutor exit. The host must keep
687
- // those already-started calls attached until they settle, then interrupt
688
- // the kernel before `parallel()` launches another wave.
689
- let inFlight = 0;
690
- let completed = 0;
691
- let markSaturated: (() => void) | undefined;
692
- const saturated = new Promise<void>(resolve => {
693
- markSaturated = resolve;
694
- });
695
- const releaseAgents = Promise.withResolvers<void>();
696
- const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
697
- // task.maxConcurrency=6 → six bridge calls block at once; signal then.
698
- if (++inFlight >= 6) markSaturated?.();
699
- await releaseAgents.promise;
700
- completed++;
701
- return singleResult(options, { output: options.assignment ?? "" });
702
- });
703
-
704
- // Seed persistent session state and confirm the kernel is reusable.
705
- const seed = await executePython("PREP_MARKER = 4242", {
706
- cwd: tempDir.path(),
707
- sessionId,
708
- sessionFile,
709
- kernelMode: "session",
710
- toolSession: session,
711
- });
712
- if (seed.exitCode === undefined && seed.cancelled) {
713
- expect(seed.output).toBe("");
714
- return; // kernel unavailable in this environment
715
- }
716
- expect(seed.exitCode).toBe(0);
717
-
718
- const ac = new AbortController();
719
- // Abort the instant all six worker threads are confirmed blocked in their
720
- // bridge calls (condition-driven) instead of waiting a fixed wall second.
721
- void saturated.then(() => ac.abort(new Error("external interrupt")));
722
-
723
- const resultPromise = executePython(
724
- "import json\nprint(json.dumps(parallel([lambda n=n: agent(str(n)) for n in range(12)])))",
725
- {
726
- cwd: tempDir.path(),
727
- sessionId,
728
- sessionFile,
729
- kernelMode: "session",
730
- toolSession: session,
731
- idleTimeoutMs: 60_000,
732
- signal: ac.signal,
733
- },
734
- );
735
- await saturated;
736
- await Promise.resolve();
737
- expect(completed).toBe(0);
738
- releaseAgents.resolve();
739
- const result = await resultPromise;
740
-
741
- // Cancelled, but cleanly: no hard-kill, no orphaned bridge calls, and no
742
- // second fan-out wave started after the deferred abort was delivered.
743
- expect(result.cancelled).toBe(true);
744
- expect(result.output).not.toContain("Python kernel shutdown");
745
- expect(completed).toBe(6);
746
- expect(runSpy).toHaveBeenCalledTimes(6);
747
-
748
- // The persistent kernel survived the interrupt: prior state is intact.
749
- const after = await executePython("print(PREP_MARKER)", {
750
- cwd: tempDir.path(),
751
- sessionId,
752
- sessionFile,
753
- kernelMode: "session",
754
- toolSession: session,
755
- });
756
- expect(after.exitCode).toBe(0);
757
- expect(after.output.trim()).toBe("4242");
758
- }, 30_000);
759
-
760
- it("streams enriched agent progress through onStatus before the cell finishes", async () => {
761
- using tempDir = TempDir.createSync("@omp-eval-agent-progress-");
762
- const { session, sessionFile } = makeEvalSession(tempDir, "js-agent-progress");
763
- mockAgents();
764
-
765
- const makeProgress = (options: ExecutorOptions, overrides: Partial<AgentProgress>): AgentProgress => ({
766
- index: options.index,
767
- id: options.id,
768
- agent: options.agent.name,
769
- agentSource: options.agent.source,
770
- status: "running",
771
- task: options.task,
772
- assignment: options.assignment,
773
- description: options.description,
774
- recentTools: [],
775
- recentOutput: [],
776
- toolCount: 0,
777
- tokens: 0,
778
- requests: 0,
779
- cost: 0,
780
- durationMs: 0,
781
- ...overrides,
782
- });
783
-
784
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
785
- options.onProgress?.(
786
- makeProgress(options, {
787
- status: "running",
788
- currentTool: "read",
789
- currentToolArgs: "config.ts",
790
- lastIntent: "Reading config",
791
- toolCount: 4,
792
- contextTokens: 5000,
793
- contextWindow: 200000,
794
- cost: 0.03,
795
- durationMs: 800,
796
- resolvedModel: "p/model",
797
- }),
798
- );
799
- options.onProgress?.(
800
- makeProgress(options, {
801
- status: "completed",
802
- toolCount: 7,
803
- contextTokens: 8000,
804
- contextWindow: 200000,
805
- cost: 0.06,
806
- durationMs: 1500,
807
- resolvedModel: "p/model",
808
- }),
809
- );
810
- return singleResult(options, { output: "done" });
811
- });
812
-
813
- const events: Array<{ op: string; [key: string]: unknown }> = [];
814
- const result = await executeJs('await agent("investigate", { label: "Scout" });', {
815
- cwd: tempDir.path(),
816
- sessionId: sharedJsSessionId,
817
- session,
818
- sessionFile,
819
- onStatus: event => events.push(event),
820
- });
821
-
822
- expect(result.exitCode).toBe(0);
823
-
824
- const agentEvents = events.filter(event => event.op === "agent");
825
- // Both throttled ticks were delivered live (the cell awaited agent() and
826
- // the executor collected them as displayOutputs too).
827
- expect(agentEvents.length).toBe(2);
828
-
829
- const running = agentEvents[0];
830
- expect(running.status).toBe("running");
831
- expect(running.currentTool).toBe("read");
832
- expect(running.lastIntent).toBe("Reading config");
833
- expect(running.contextTokens).toBe(5000);
834
- expect(running.taskPreview).toBe("investigate");
835
- expect(typeof running.id).toBe("string");
836
-
837
- // The final completion event keeps the rich stats — no sparse event
838
- // coalesces over it and drops toolCount/cost.
839
- const completed = agentEvents[1];
840
- expect(completed.status).toBe("completed");
841
- expect(completed.toolCount).toBe(7);
842
- expect(completed.cost).toBeCloseTo(0.06);
843
- expect(completed.id).toBe(running.id);
844
-
845
- // Same events are still present in the executor's returned displayOutputs.
846
- const displayAgentEvents = result.displayOutputs.filter(
847
- (output): output is Extract<typeof output, { type: "status" }> => output.type === "status",
848
- );
849
- expect(displayAgentEvents.length).toBe(2);
850
- });
851
-
852
- it("pauses the idle watchdog while a quiet agent() runs past the budget", async () => {
853
- using tempDir = TempDir.createSync("@omp-eval-agent-timeout-pause-");
854
- const { session } = makeEvalSession(
855
- tempDir,
856
- "js-agent-timeout-pause",
857
- Settings.isolated({ "task.maxRuntimeMs": 1 }),
858
- );
859
- mockAgents();
860
-
861
- // runSubprocess runs far past the eval timeout budget and emits NO progress
862
- // of its own; the bridge pause must make that delegated time invisible to
863
- // the watchdog. Fake timers replace the real wait: the subprocess parks on
864
- // `released` so the test can advance the clock past the budget while the
865
- // bridge call is provably in flight, then release it deterministically.
866
- let release: (() => void) | undefined;
867
- const released = new Promise<void>(resolve => {
868
- release = resolve;
869
- });
870
- let markInFlight: (() => void) | undefined;
871
- const inFlight = new Promise<void>(resolve => {
872
- markInFlight = resolve;
873
- });
874
- let observedMaxRuntimeMs: number | undefined;
875
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
876
- observedMaxRuntimeMs = options.maxRuntimeMs;
877
- markInFlight?.();
878
- await released;
879
- return singleResult(options, { output: "done" });
880
- });
881
-
882
- const ops: string[] = [];
883
- vi.useFakeTimers();
884
- using idle = new IdleTimeout(20);
885
- const resultPromise = runEvalAgent(
886
- { prompt: "investigate" },
887
- {
888
- session,
889
- signal: idle.signal,
890
- emitStatus: event => {
891
- ops.push(event.op);
892
- if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
893
- if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
894
- },
895
- },
896
- );
897
-
898
- // The bridge paused the watchdog; the subprocess is now blocked in flight.
899
- await inFlight;
900
- expect(observedMaxRuntimeMs).toBe(0);
901
- // Burn far more than the 20ms budget while paused: the watchdog stays armed-off.
902
- vi.advanceTimersByTime(1_000);
903
- expect(idle.signal.aborted).toBe(false);
904
-
905
- release?.();
906
- const result = await resultPromise;
907
-
908
- expect(result.text).toBe("done");
909
- expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
910
- expect(idle.signal.aborted).toBe(false);
911
-
912
- // RESUME re-armed a fresh window; once the runtime stays idle past it the
913
- // watchdog finally fires.
914
- vi.advanceTimersByTime(idle.idleMs + 5);
915
- expect(idle.signal.aborted).toBe(true);
916
- });
917
-
918
- it("keeps timeout paused despite agent() progress snapshots", async () => {
919
- using tempDir = TempDir.createSync("@omp-eval-agent-progress-timeout-pause-");
920
- const { session } = makeEvalSession(tempDir, "js-agent-progress-timeout-pause");
921
- mockAgents();
922
-
923
- // Stream frequent progress snapshots (op:"agent") well past the budget.
924
- // They render as status, but timeout accounting is controlled only by the
925
- // bridge pause/resume events — so even a flood of snapshots must not re-arm
926
- // the watchdog. Fake timers make "past the budget" deterministic: the
927
- // subprocess emits its snapshots, parks on `released`, and the test advances
928
- // the clock far past the window before releasing it.
929
- let release: (() => void) | undefined;
930
- const released = new Promise<void>(resolve => {
931
- release = resolve;
932
- });
933
- let markInFlight: (() => void) | undefined;
934
- const inFlight = new Promise<void>(resolve => {
935
- markInFlight = resolve;
936
- });
937
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
938
- for (let i = 0; i < 20; i++) {
939
- options.onProgress?.({
940
- index: options.index,
941
- id: options.id,
942
- agent: options.agent.name,
943
- agentSource: options.agent.source,
944
- status: "running",
945
- task: options.task,
946
- assignment: options.assignment,
947
- description: options.description,
948
- recentTools: [],
949
- recentOutput: [],
950
- toolCount: i,
951
- tokens: 0,
952
- requests: 0,
953
- cost: 0,
954
- durationMs: i * 10,
955
- });
956
- }
957
- markInFlight?.();
958
- await released;
959
- return singleResult(options, { output: "done" });
960
- });
961
-
962
- const ops: string[] = [];
963
- vi.useFakeTimers();
964
- using idle = new IdleTimeout(250);
965
- const resultPromise = runEvalAgent(
966
- { prompt: "investigate" },
967
- {
968
- session,
969
- signal: idle.signal,
970
- emitStatus: event => {
971
- ops.push(event.op);
972
- if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
973
- if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
974
- },
975
- },
976
- );
977
-
978
- // All snapshots have streamed and the subprocess is blocked in flight.
979
- await inFlight;
980
- // Far exceed the 250ms budget while paused: the snapshots already delivered
981
- // must not have re-armed the watchdog.
982
- vi.advanceTimersByTime(10_000);
983
- expect(idle.signal.aborted).toBe(false);
984
-
985
- release?.();
986
- const result = await resultPromise;
987
-
988
- expect(result.text).toBe("done");
989
- expect(ops[0]).toBe(EVAL_TIMEOUT_PAUSE_OP);
990
- expect(ops).toContain("agent");
991
- expect(ops.at(-1)).toBe(EVAL_TIMEOUT_RESUME_OP);
992
- expect(idle.signal.aborted).toBe(false);
993
- });
994
- });
995
-
996
- describe("runEvalAgent isolation", () => {
997
- afterEach(() => {
998
- vi.restoreAllMocks();
999
- });
1000
-
1001
- function isolatedSession(overrides: Partial<Parameters<typeof Settings.isolated>[0]> = {}): ToolSession {
1002
- return makeSession({
1003
- settings: Settings.isolated({
1004
- "async.enabled": false,
1005
- "task.isolation.mode": "auto",
1006
- "task.isolation.merge": "patch",
1007
- ...overrides,
1008
- }),
1009
- });
1010
- }
1011
-
1012
- function mockIsolationContext(): { repoRoot: string } {
1013
- const repoRoot = "/repo-root";
1014
- vi.spyOn(isolationRunner, "prepareIsolationContext").mockResolvedValue({
1015
- repoRoot,
1016
- baseline: {
1017
- root: { repoRoot, headCommit: "HEAD", staged: "", unstaged: "", untracked: [], untrackedPatch: "" },
1018
- nested: [],
1019
- },
1020
- });
1021
- return { repoRoot };
1022
- }
1023
-
1024
- it("rejects isolated=true when task.isolation.mode is 'none'", async () => {
1025
- mockAgents();
1026
- const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
1027
- const prepSpy = vi.spyOn(isolationRunner, "prepareIsolationContext");
1028
-
1029
- const session = makeSession(); // default settings: isolation.mode === "none"
1030
-
1031
- await expect(runEvalAgent({ prompt: "do work", isolated: true }, { session })).rejects.toThrow(
1032
- 'task.isolation.mode to be set; current mode is "none"',
1033
- );
1034
- expect(prepSpy).not.toHaveBeenCalled();
1035
- expect(runSpy).not.toHaveBeenCalled();
1036
- });
1037
-
1038
- it("stays non-isolated by default even when task.isolation.mode is set; isolated=true opts in", async () => {
1039
- mockAgents();
1040
- mockIsolationContext();
1041
- const isolatedSpy = vi
1042
- .spyOn(isolationRunner, "runIsolatedSubprocess")
1043
- .mockImplementation(async opts => singleResult(opts.baseOptions, { output: "isolated-run" }));
1044
- const plainSpy = vi
1045
- .spyOn(taskExecutor, "runSubprocess")
1046
- .mockImplementation(async options => singleResult(options, { output: "plain-run" }));
1047
- const mergeSpy = vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1048
- summary: "",
1049
- changesApplied: true,
1050
- hadAnyChanges: false,
1051
- mergedBranchForNestedPatches: false,
1052
- });
1053
-
1054
- // Default (no isolated arg) — stays non-isolated even when settings allow it.
1055
- const defaultResult = await runEvalAgent({ prompt: "default" }, { session: isolatedSession() });
1056
- expect(plainSpy).toHaveBeenCalledTimes(1);
1057
- expect(isolatedSpy).not.toHaveBeenCalled();
1058
- expect(defaultResult.details.isolated).toBeUndefined();
1059
- expect(defaultResult.details.changesApplied).toBeUndefined();
1060
- expect(mergeSpy).not.toHaveBeenCalled();
1061
-
1062
- // Explicit isolated=true — opt-in turns it on and surfaces merge details.
1063
- const explicitOn = await runEvalAgent({ prompt: "on", isolated: true }, { session: isolatedSession() });
1064
- expect(isolatedSpy).toHaveBeenCalledTimes(1);
1065
- expect(plainSpy).toHaveBeenCalledTimes(1);
1066
- expect(explicitOn.details.isolated).toBe(true);
1067
- expect(mergeSpy).toHaveBeenCalledTimes(1);
1068
- });
1069
-
1070
- it("preserves temp artifacts for non-isolated handle outputs", async () => {
1071
- mockAgents();
1072
- const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
1073
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
1074
-
1075
- await runEvalAgent({ prompt: "plain handle", handle: true }, { session: makeSession() });
1076
-
1077
- const removedArtifactsDir = rmSpy.mock.calls.some(
1078
- ([target]) => typeof target === "string" && target.includes("omp-eval-agent-"),
1079
- );
1080
- expect(removedArtifactsDir).toBe(false);
1081
- });
1082
-
1083
- it("forwards merge=false as patch mode and passes the worktree cwd through baseOptions", async () => {
1084
- mockAgents();
1085
- const { repoRoot } = mockIsolationContext();
1086
- const isolatedSpy = vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1087
- singleResult(opts.baseOptions, {
1088
- output: "isolated-run",
1089
- patchPath: `/artifacts/${opts.agentId}.patch`,
1090
- }),
1091
- );
1092
- vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1093
- summary: "\n\nApplied patches: yes",
1094
- changesApplied: true,
1095
- hadAnyChanges: true,
1096
- mergedBranchForNestedPatches: false,
1097
- });
1098
-
1099
- // Branch is the configured merge mode, but `merge: false` must demote to patch.
1100
- const session = isolatedSession({ "task.isolation.merge": "branch" });
1101
- const result = await runEvalAgent({ prompt: "migration", isolated: true, merge: false }, { session });
1102
-
1103
- expect(isolatedSpy).toHaveBeenCalledTimes(1);
1104
- const isolatedCall = isolatedSpy.mock.calls[0]?.[0];
1105
- if (!isolatedCall) throw new Error("runIsolatedSubprocess was not called");
1106
- expect(isolatedCall.mergeMode).toBe("patch");
1107
- expect(isolatedCall.baseOptions.cwd).toBe(session.cwd);
1108
- expect(isolatedCall.context.repoRoot).toBe(repoRoot);
1109
- expect(result.details.patchPath).toMatch(/\.patch$/);
1110
- expect(result.text).toContain("Applied patches: yes");
1111
- });
1112
-
1113
- it("keeps the timeout paused through isolation merge/apply so the cell can't abort mid-cherry-pick", async () => {
1114
- mockAgents();
1115
- mockIsolationContext();
1116
- const ops: string[] = [];
1117
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts => {
1118
- ops.push("subprocess");
1119
- return singleResult(opts.baseOptions, { output: "done", patchPath: `/artifacts/${opts.agentId}.patch` });
1120
- });
1121
- vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockImplementation(async () => {
1122
- ops.push("merge");
1123
- return {
1124
- summary: "\n\nMerged",
1125
- changesApplied: true,
1126
- hadAnyChanges: true,
1127
- mergedBranchForNestedPatches: false,
1128
- };
1129
- });
1130
-
1131
- await runEvalAgent(
1132
- { prompt: "migration", isolated: true },
1133
- {
1134
- session: isolatedSession(),
1135
- emitStatus: event => {
1136
- if (event.op === EVAL_TIMEOUT_PAUSE_OP || event.op === EVAL_TIMEOUT_RESUME_OP) ops.push(event.op);
1137
- },
1138
- },
1139
- );
1140
-
1141
- const pauseIdx = ops.indexOf(EVAL_TIMEOUT_PAUSE_OP);
1142
- const resumeIdx = ops.lastIndexOf(EVAL_TIMEOUT_RESUME_OP);
1143
- const mergeIdx = ops.indexOf("merge");
1144
- expect(pauseIdx).toBeGreaterThanOrEqual(0);
1145
- expect(resumeIdx).toBeGreaterThan(pauseIdx);
1146
- expect(mergeIdx).toBeGreaterThan(pauseIdx);
1147
- expect(mergeIdx).toBeLessThan(resumeIdx);
1148
- });
1149
-
1150
- it("keeps the timeout paused through isolation baseline capture", async () => {
1151
- mockAgents();
1152
- const ops: string[] = [];
1153
- vi.spyOn(isolationRunner, "prepareIsolationContext").mockImplementation(async () => {
1154
- ops.push("prepare");
1155
- return {
1156
- repoRoot: "/repo-root",
1157
- baseline: {
1158
- root: {
1159
- repoRoot: "/repo-root",
1160
- headCommit: "HEAD",
1161
- staged: "",
1162
- unstaged: "",
1163
- untracked: [],
1164
- untrackedPatch: "",
1165
- },
1166
- nested: [],
1167
- },
1168
- };
1169
- });
1170
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1171
- singleResult(opts.baseOptions, { output: "done", patchPath: `/artifacts/${opts.agentId}.patch` }),
1172
- );
1173
- vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1174
- summary: "\n\nMerged",
1175
- changesApplied: true,
1176
- hadAnyChanges: true,
1177
- mergedBranchForNestedPatches: false,
1178
- });
1179
-
1180
- await runEvalAgent(
1181
- { prompt: "scout", isolated: true },
1182
- {
1183
- session: isolatedSession(),
1184
- emitStatus: event => {
1185
- if (event.op === EVAL_TIMEOUT_PAUSE_OP || event.op === EVAL_TIMEOUT_RESUME_OP) ops.push(event.op);
1186
- },
1187
- },
1188
- );
1189
-
1190
- const pauseIdx = ops.indexOf(EVAL_TIMEOUT_PAUSE_OP);
1191
- const resumeIdx = ops.lastIndexOf(EVAL_TIMEOUT_RESUME_OP);
1192
- const prepareIdx = ops.indexOf("prepare");
1193
- expect(pauseIdx).toBeGreaterThanOrEqual(0);
1194
- expect(prepareIdx).toBeGreaterThan(pauseIdx);
1195
- expect(prepareIdx).toBeLessThan(resumeIdx);
1196
- });
1197
-
1198
- it("keeps schema-backed isolated output parseable by moving merge text into details", async () => {
1199
- mockAgents();
1200
- mockIsolationContext();
1201
- const structuredOutput = JSON.stringify({ status: "ok" });
1202
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1203
- singleResult(opts.baseOptions, {
1204
- output: structuredOutput,
1205
- patchPath: `/artifacts/${opts.agentId}.patch`,
1206
- }),
1207
- );
1208
- vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1209
- summary: "\n\nNo changes to apply.",
1210
- changesApplied: true,
1211
- hadAnyChanges: false,
1212
- mergedBranchForNestedPatches: false,
1213
- });
1214
-
1215
- const result = await runEvalAgent(
1216
- {
1217
- prompt: "structured",
1218
- isolated: true,
1219
- schema: {
1220
- type: "object",
1221
- properties: { status: { type: "string" } },
1222
- required: ["status"],
1223
- },
1224
- },
1225
- { session: isolatedSession() },
1226
- );
1227
-
1228
- expect(JSON.parse(result.text)).toEqual({ status: "ok" });
1229
- expect(result.text).toBe(structuredOutput);
1230
- expect(result.details.isolationSummary).toBe("No changes to apply.");
1231
- });
1232
-
1233
- it("throws when an isolated apply fails so schema callers cannot mistake it for success", async () => {
1234
- mockAgents();
1235
- mockIsolationContext();
1236
- const structuredOutput = JSON.stringify({ status: "ok" });
1237
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1238
- singleResult(opts.baseOptions, {
1239
- output: structuredOutput,
1240
- patchPath: `/artifacts/${opts.agentId}.patch`,
1241
- }),
1242
- );
1243
- vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1244
- summary: "\n\n<system-notification>Patch apply failed: conflict in foo.ts</system-notification>",
1245
- changesApplied: false,
1246
- hadAnyChanges: false,
1247
- mergedBranchForNestedPatches: false,
1248
- });
1249
-
1250
- await expect(
1251
- runEvalAgent(
1252
- {
1253
- prompt: "structured",
1254
- isolated: true,
1255
- schema: {
1256
- type: "object",
1257
- properties: { status: { type: "string" } },
1258
- required: ["status"],
1259
- },
1260
- },
1261
- { session: isolatedSession() },
1262
- ),
1263
- ).rejects.toThrow(/isolated apply failed.*Patch apply failed.*Captured patch preserved at \/artifacts\//s);
1264
- });
1265
-
1266
- it("surfaces the preserved patch path when branch-mode transfer fails before merge runs", async () => {
1267
- mockAgents();
1268
- mockIsolationContext();
1269
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1270
- singleResult(opts.baseOptions, {
1271
- output: "ran",
1272
- patchPath: `/artifacts/${opts.agentId}.patch`,
1273
- error: "Merge failed: remote: garbage at end of loose object '4de7bad'",
1274
- }),
1275
- );
1276
- const mergeSpy = vi.spyOn(isolationRunner, "mergeIsolatedChanges");
1277
-
1278
- const session = isolatedSession({ "task.isolation.merge": "branch" });
1279
- await expect(runEvalAgent({ prompt: "scout", isolated: true }, { session })).rejects.toThrow(
1280
- /Merge failed.*garbage at end of loose object.*Captured patch preserved at \/artifacts\//s,
1281
- );
1282
- expect(mergeSpy).not.toHaveBeenCalled();
1283
- });
1284
-
1285
- it("throws on apply failure for non-schema callers too instead of burying the warning in text", async () => {
1286
- mockAgents();
1287
- mockIsolationContext();
1288
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1289
- singleResult(opts.baseOptions, {
1290
- output: "ran",
1291
- branchName: `omp/task/${opts.agentId}`,
1292
- }),
1293
- );
1294
- vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1295
- summary: "\n\n<system-notification>Branch merge failed: omp/task/x.\nConflict: foo.ts</system-notification>",
1296
- changesApplied: false,
1297
- hadAnyChanges: false,
1298
- mergedBranchForNestedPatches: false,
1299
- });
1300
-
1301
- const session = isolatedSession({ "task.isolation.merge": "branch" });
1302
- await expect(runEvalAgent({ prompt: "scout", isolated: true }, { session })).rejects.toThrow(
1303
- /isolated apply failed.*Branch merge failed.*Captured branch preserved as omp\/task\//s,
1304
- );
1305
- });
1306
-
1307
- it("persists captured nested patches to a recoverable file before throwing on apply failure", async () => {
1308
- mockAgents();
1309
- mockIsolationContext();
1310
- const nestedPatch = "diff --git a/file b/file\n";
1311
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1312
- singleResult(opts.baseOptions, {
1313
- output: "ran",
1314
- patchPath: `/artifacts/${opts.agentId}.patch`,
1315
- nestedPatches: [{ relativePath: "sub/nested", patch: nestedPatch }],
1316
- }),
1317
- );
1318
- vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1319
- summary: "\n\n<system-notification>Patch apply failed: conflict in foo.ts</system-notification>",
1320
- changesApplied: false,
1321
- hadAnyChanges: false,
1322
- mergedBranchForNestedPatches: false,
1323
- });
1324
-
1325
- let caught: Error | undefined;
1326
- try {
1327
- await runEvalAgent({ prompt: "scout", isolated: true }, { session: isolatedSession() });
1328
- } catch (err) {
1329
- caught = err as Error;
1330
- }
1331
- expect(caught).toBeDefined();
1332
- const match = caught?.message.match(/(\/[^\s,]+?\.nested-0-sub_nested\.patch)/);
1333
- expect(match).not.toBeNull();
1334
- const persistedPath = match?.[1];
1335
- expect(persistedPath).toBeDefined();
1336
- const contents = await fs.readFile(persistedPath!, "utf-8");
1337
- expect(contents).toBe(nestedPatch);
1338
- await fs.rm(path.dirname(persistedPath!), { recursive: true, force: true });
1339
- });
1340
-
1341
- it("throws schema calls when nested patch application reports a warning", async () => {
1342
- mockAgents();
1343
- mockIsolationContext();
1344
- const nestedPatch = "diff --git a/file b/file\n";
1345
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1346
- singleResult(opts.baseOptions, {
1347
- output: JSON.stringify({ status: "ok" }),
1348
- patchPath: `/artifacts/${opts.agentId}.patch`,
1349
- nestedPatches: [{ relativePath: "sub/nested", patch: nestedPatch }],
1350
- }),
1351
- );
1352
- vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1353
- summary: "\n\nApplied patches: yes",
1354
- changesApplied: true,
1355
- hadAnyChanges: true,
1356
- mergedBranchForNestedPatches: false,
1357
- });
1358
- vi.spyOn(isolationRunner, "applyEligibleNestedPatches").mockResolvedValue(
1359
- "\n\n<system-notification>Some nested repository patches failed to apply.</system-notification>",
1360
- );
1361
-
1362
- await expect(
1363
- runEvalAgent(
1364
- {
1365
- prompt: "structured",
1366
- isolated: true,
1367
- schema: {
1368
- type: "object",
1369
- properties: { status: { type: "string" } },
1370
- required: ["status"],
1371
- },
1372
- },
1373
- { session: isolatedSession() },
1374
- ),
1375
- ).rejects.toThrow(
1376
- /nested patch apply failed.*Some nested repository patches failed to apply.*nested-0-sub_nested\.patch/s,
1377
- );
1378
- });
1379
-
1380
- it("skips the merge phase when apply=false and surfaces the patch artifact instead", async () => {
1381
- mockAgents();
1382
- mockIsolationContext();
1383
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1384
- singleResult(opts.baseOptions, {
1385
- output: "captured",
1386
- patchPath: "/artifacts/captured.patch",
1387
- }),
1388
- );
1389
- const mergeSpy = vi.spyOn(isolationRunner, "mergeIsolatedChanges");
1390
-
1391
- const result = await runEvalAgent(
1392
- { prompt: "scout", isolated: true, apply: false },
1393
- { session: isolatedSession() },
1394
- );
1395
-
1396
- expect(mergeSpy).not.toHaveBeenCalled();
1397
- expect(result.details.isolated).toBe(true);
1398
- expect(result.details.changesApplied).toBeNull();
1399
- expect(result.details.patchPath).toBe("/artifacts/captured.patch");
1400
- expect(result.text).toContain("/artifacts/captured.patch");
1401
- expect(result.text).toContain("apply=false");
1402
- });
1403
-
1404
- it("surfaces a captured branch name when apply=false and the run used branch mode", async () => {
1405
- mockAgents();
1406
- mockIsolationContext();
1407
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1408
- singleResult(opts.baseOptions, {
1409
- output: "branched",
1410
- branchName: `omp/task/${opts.agentId}`,
1411
- }),
1412
- );
1413
- const mergeSpy = vi.spyOn(isolationRunner, "mergeIsolatedChanges");
1414
-
1415
- const session = isolatedSession({ "task.isolation.merge": "branch" });
1416
- const result = await runEvalAgent({ prompt: "scout", isolated: true, apply: false }, { session });
1417
-
1418
- expect(mergeSpy).not.toHaveBeenCalled();
1419
- expect(result.details.branchName).toMatch(/^omp\/task\//);
1420
- expect(result.text).toContain("omp/task/");
1421
- expect(result.text).toContain("apply=false");
1422
- });
1423
-
1424
- it("surfaces nested patches when apply=false captured branch-mode nested-only changes", async () => {
1425
- mockAgents();
1426
- mockIsolationContext();
1427
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1428
- singleResult(opts.baseOptions, {
1429
- output: "nested-only",
1430
- nestedPatches: [{ relativePath: "nested", patch: "diff --git a/file b/file\n" }],
1431
- }),
1432
- );
1433
- const mergeSpy = vi.spyOn(isolationRunner, "mergeIsolatedChanges");
1434
-
1435
- const session = isolatedSession({ "task.isolation.merge": "branch" });
1436
- const result = await runEvalAgent({ prompt: "scout", isolated: true, apply: false }, { session });
1437
-
1438
- expect(mergeSpy).not.toHaveBeenCalled();
1439
- expect(result.details.branchName).toBeUndefined();
1440
- expect(result.details.patchPath).toBeUndefined();
1441
- expect(result.details.nestedPatches).toEqual([{ relativePath: "nested", patch: "diff --git a/file b/file\n" }]);
1442
- expect(result.text).toContain("nested repository");
1443
- expect(result.text).toContain("apply=false");
1444
- });
1445
-
1446
- it("preserves the temp artifacts dir when apply=false so details.patchPath remains valid", async () => {
1447
- mockAgents();
1448
- mockIsolationContext();
1449
- const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
1450
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1451
- singleResult(opts.baseOptions, { output: "captured", patchPath: `/artifacts/${opts.agentId}.patch` }),
1452
- );
1453
-
1454
- const result = await runEvalAgent(
1455
- { prompt: "scout", isolated: true, apply: false },
1456
- { session: isolatedSession() },
1457
- );
1458
-
1459
- expect(result.details.patchPath).toMatch(/\.patch$/);
1460
- const removedArtifactsDir = rmSpy.mock.calls.some(
1461
- ([target]) => typeof target === "string" && target.includes("omp-eval-agent-"),
1462
- );
1463
- expect(removedArtifactsDir).toBe(false);
1464
- });
1465
-
1466
- it("still cleans the temp artifacts dir when apply succeeds", async () => {
1467
- mockAgents();
1468
- mockIsolationContext();
1469
- const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
1470
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1471
- singleResult(opts.baseOptions, { output: "captured", patchPath: `/artifacts/${opts.agentId}.patch` }),
1472
- );
1473
- vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1474
- summary: "\n\nApplied",
1475
- changesApplied: true,
1476
- hadAnyChanges: true,
1477
- mergedBranchForNestedPatches: false,
1478
- });
1479
-
1480
- await runEvalAgent({ prompt: "scout", isolated: true }, { session: isolatedSession() });
1481
-
1482
- const removedArtifactsDir = rmSpy.mock.calls.some(
1483
- ([target]) => typeof target === "string" && target.includes("omp-eval-agent-"),
1484
- );
1485
- expect(removedArtifactsDir).toBe(true);
1486
- });
1487
-
1488
- it("preserves the temp artifacts dir after a successful apply when handle is requested", async () => {
1489
- mockAgents();
1490
- mockIsolationContext();
1491
- const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
1492
- vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async opts =>
1493
- singleResult(opts.baseOptions, { output: "captured", patchPath: `/artifacts/${opts.agentId}.patch` }),
1494
- );
1495
- vi.spyOn(isolationRunner, "mergeIsolatedChanges").mockResolvedValue({
1496
- summary: "\n\nApplied",
1497
- changesApplied: true,
1498
- hadAnyChanges: true,
1499
- mergedBranchForNestedPatches: false,
1500
- });
1501
-
1502
- await runEvalAgent({ prompt: "scout", isolated: true, handle: true }, { session: isolatedSession() });
1503
-
1504
- const removedArtifactsDir = rmSpy.mock.calls.some(
1505
- ([target]) => typeof target === "string" && target.includes("omp-eval-agent-"),
1506
- );
1507
- expect(removedArtifactsDir).toBe(false);
1508
- });
1509
- });