@oh-my-pi/pi-coding-agent 15.7.2 → 15.7.3

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 (160) hide show
  1. package/CHANGELOG.md +75 -6
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/extension-flags.d.ts +36 -0
  4. package/dist/types/config/config-file.d.ts +4 -0
  5. package/dist/types/config/file-lock.d.ts +23 -0
  6. package/dist/types/config/keybindings.d.ts +2 -1
  7. package/dist/types/config/model-registry.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +88 -65
  9. package/dist/types/edit/hashline/diff.d.ts +3 -3
  10. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +1 -0
  11. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +1 -0
  12. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +1 -0
  13. package/dist/types/eval/agent-bridge.d.ts +25 -0
  14. package/dist/types/eval/backend.d.ts +17 -2
  15. package/dist/types/eval/budget-bridge.d.ts +29 -0
  16. package/dist/types/eval/idle-timeout.d.ts +28 -0
  17. package/dist/types/eval/js/executor.d.ts +8 -0
  18. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  19. package/dist/types/eval/py/executor.d.ts +13 -0
  20. package/dist/types/exec/bash-executor.d.ts +1 -0
  21. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  22. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  23. package/dist/types/extensibility/plugins/git-url.d.ts +11 -1
  24. package/dist/types/extensibility/plugins/manager.d.ts +12 -1
  25. package/dist/types/extensibility/shared-events.d.ts +2 -2
  26. package/dist/types/memory-backend/index.d.ts +1 -1
  27. package/dist/types/memory-backend/resolve.d.ts +1 -1
  28. package/dist/types/memory-backend/types.d.ts +3 -3
  29. package/dist/types/mnemopi/backend.d.ts +4 -0
  30. package/dist/types/mnemopi/config.d.ts +29 -0
  31. package/dist/types/mnemopi/state.d.ts +72 -0
  32. package/dist/types/modes/components/custom-editor.d.ts +2 -2
  33. package/dist/types/modes/components/omfg-panel.d.ts +19 -0
  34. package/dist/types/modes/controllers/command-controller.d.ts +7 -0
  35. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  36. package/dist/types/modes/controllers/omfg-controller.d.ts +10 -0
  37. package/dist/types/modes/controllers/omfg-rule.d.ts +26 -0
  38. package/dist/types/modes/gradient-highlight.d.ts +5 -1
  39. package/dist/types/modes/interactive-mode.d.ts +7 -3
  40. package/dist/types/modes/magic-keywords.d.ts +14 -0
  41. package/dist/types/modes/markdown-prose.d.ts +27 -0
  42. package/dist/types/modes/orchestrate.d.ts +7 -2
  43. package/dist/types/modes/shared.d.ts +1 -1
  44. package/dist/types/modes/turn-budget.d.ts +18 -0
  45. package/dist/types/modes/types.d.ts +7 -3
  46. package/dist/types/modes/ultrathink.d.ts +7 -2
  47. package/dist/types/modes/workflow.d.ts +15 -0
  48. package/dist/types/sdk.d.ts +13 -3
  49. package/dist/types/session/agent-session.d.ts +40 -17
  50. package/dist/types/session/session-manager.d.ts +18 -0
  51. package/dist/types/session/session-storage.d.ts +6 -0
  52. package/dist/types/session/shake-types.d.ts +24 -0
  53. package/dist/types/task/executor.d.ts +2 -2
  54. package/dist/types/tiny/models.d.ts +15 -1
  55. package/dist/types/tiny/title-protocol.d.ts +4 -0
  56. package/dist/types/tools/index.d.ts +19 -3
  57. package/dist/types/tools/memory-edit.d.ts +1 -1
  58. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  59. package/package.json +10 -10
  60. package/src/autoresearch/tools/run-experiment.ts +45 -113
  61. package/src/cli/args.ts +39 -16
  62. package/src/cli/extension-flags.ts +48 -0
  63. package/src/cli/plugin-cli.ts +11 -2
  64. package/src/config/config-file.ts +98 -13
  65. package/src/config/file-lock.ts +60 -17
  66. package/src/config/keybindings.ts +78 -27
  67. package/src/config/model-registry.ts +7 -1
  68. package/src/config/settings-schema.ts +94 -67
  69. package/src/config/settings.ts +12 -0
  70. package/src/edit/hashline/diff.ts +81 -24
  71. package/src/edit/renderer.ts +16 -12
  72. package/src/eval/__tests__/agent-bridge.test.ts +433 -0
  73. package/src/eval/__tests__/budget-bridge.test.ts +69 -0
  74. package/src/eval/__tests__/idle-timeout.test.ts +66 -0
  75. package/src/eval/__tests__/shared-executors.test.ts +21 -0
  76. package/src/eval/agent-bridge.ts +295 -0
  77. package/src/eval/backend.ts +17 -2
  78. package/src/eval/budget-bridge.ts +48 -0
  79. package/src/eval/idle-timeout.ts +80 -0
  80. package/src/eval/js/executor.ts +35 -7
  81. package/src/eval/js/index.ts +2 -1
  82. package/src/eval/js/shared/prelude.txt +85 -1
  83. package/src/eval/js/tool-bridge.ts +9 -0
  84. package/src/eval/py/executor.ts +41 -14
  85. package/src/eval/py/index.ts +2 -1
  86. package/src/eval/py/prelude.py +132 -1
  87. package/src/exec/bash-executor.ts +2 -3
  88. package/src/extensibility/custom-tools/types.ts +2 -2
  89. package/src/extensibility/extensions/runner.ts +12 -2
  90. package/src/extensibility/plugins/git-url.ts +90 -4
  91. package/src/extensibility/plugins/manager.ts +103 -7
  92. package/src/extensibility/shared-events.ts +2 -2
  93. package/src/internal-urls/docs-index.generated.ts +88 -88
  94. package/src/main.ts +44 -55
  95. package/src/memory-backend/index.ts +1 -1
  96. package/src/memory-backend/resolve.ts +3 -3
  97. package/src/memory-backend/types.ts +3 -3
  98. package/src/{mnemosyne → mnemopi}/backend.ts +70 -70
  99. package/src/{mnemosyne → mnemopi}/config.ts +36 -36
  100. package/src/{mnemosyne → mnemopi}/state.ts +67 -67
  101. package/src/modes/components/agent-dashboard.ts +6 -6
  102. package/src/modes/components/custom-editor.ts +4 -11
  103. package/src/modes/components/extensions/state-manager.ts +3 -4
  104. package/src/modes/components/footer.ts +8 -9
  105. package/src/modes/components/hook-selector.ts +86 -20
  106. package/src/modes/components/oauth-selector.ts +93 -21
  107. package/src/modes/components/omfg-panel.ts +141 -0
  108. package/src/modes/components/settings-defs.ts +2 -2
  109. package/src/modes/components/tips.txt +2 -1
  110. package/src/modes/components/tool-execution.ts +38 -19
  111. package/src/modes/components/tree-selector.ts +4 -3
  112. package/src/modes/components/user-message-selector.ts +94 -19
  113. package/src/modes/components/user-message.ts +8 -1
  114. package/src/modes/controllers/command-controller.ts +57 -0
  115. package/src/modes/controllers/event-controller.ts +60 -2
  116. package/src/modes/controllers/input-controller.ts +14 -11
  117. package/src/modes/controllers/omfg-controller.ts +283 -0
  118. package/src/modes/controllers/omfg-rule.ts +647 -0
  119. package/src/modes/controllers/selector-controller.ts +1 -0
  120. package/src/modes/gradient-highlight.ts +23 -6
  121. package/src/modes/interactive-mode.ts +41 -7
  122. package/src/modes/magic-keywords.ts +20 -0
  123. package/src/modes/markdown-prose.ts +247 -0
  124. package/src/modes/orchestrate.ts +17 -11
  125. package/src/modes/shared.ts +3 -11
  126. package/src/modes/turn-budget.ts +31 -0
  127. package/src/modes/types.ts +7 -1
  128. package/src/modes/ultrathink.ts +16 -10
  129. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  130. package/src/modes/workflow.ts +42 -0
  131. package/src/prompts/system/omfg-user.md +51 -0
  132. package/src/prompts/system/system-prompt.md +1 -0
  133. package/src/prompts/system/workflow-notice.md +70 -0
  134. package/src/prompts/tools/eval.md +13 -1
  135. package/src/prompts/tools/memory-edit.md +1 -1
  136. package/src/sdk.ts +63 -33
  137. package/src/session/agent-session.ts +373 -56
  138. package/src/session/session-manager.ts +32 -0
  139. package/src/session/session-storage.ts +68 -8
  140. package/src/session/shake-types.ts +44 -0
  141. package/src/slash-commands/builtin-registry.ts +41 -16
  142. package/src/task/executor.ts +3 -3
  143. package/src/task/index.ts +6 -6
  144. package/src/tiny/models.ts +30 -2
  145. package/src/tiny/title-protocol.ts +11 -1
  146. package/src/tiny/worker.ts +19 -7
  147. package/src/tools/eval.ts +202 -26
  148. package/src/tools/grouped-file-output.ts +9 -2
  149. package/src/tools/index.ts +17 -5
  150. package/src/tools/memory-edit.ts +4 -4
  151. package/src/tools/memory-recall.ts +5 -5
  152. package/src/tools/memory-reflect.ts +5 -5
  153. package/src/tools/memory-retain.ts +4 -4
  154. package/src/tools/render-utils.ts +2 -1
  155. package/src/tools/search.ts +480 -76
  156. package/dist/types/mnemosyne/backend.d.ts +0 -4
  157. package/dist/types/mnemosyne/config.d.ts +0 -29
  158. package/dist/types/mnemosyne/state.d.ts +0 -72
  159. /package/dist/types/{mnemosyne → mnemopi}/index.d.ts +0 -0
  160. /package/src/{mnemosyne → mnemopi}/index.ts +0 -0
@@ -0,0 +1,433 @@
1
+ import { afterAll, afterEach, describe, expect, it, vi } from "bun:test";
2
+ import * as path from "node:path";
3
+ import { TempDir } from "@oh-my-pi/pi-utils";
4
+ import { Settings } from "../../config/settings";
5
+ import type { PlanModeState } from "../../plan-mode/state";
6
+ import * as taskDiscovery from "../../task/discovery";
7
+ import type { ExecutorOptions } from "../../task/executor";
8
+ import * as taskExecutor from "../../task/executor";
9
+ import { AgentOutputManager } from "../../task/output-manager";
10
+ import type { AgentDefinition, AgentProgress, SingleResult } from "../../task/types";
11
+ import type { ToolSession } from "../../tools";
12
+ import { EVAL_AGENT_MAX_DEPTH, runEvalAgent } from "../agent-bridge";
13
+ import { disposeAllVmContexts } from "../js/context-manager";
14
+ import { executeJs } from "../js/executor";
15
+ import { disposeAllKernelSessions, executePython } from "../py/executor";
16
+
17
+ const taskAgent = {
18
+ name: "task",
19
+ description: "Task agent",
20
+ systemPrompt: "Run the task.",
21
+ source: "bundled",
22
+ spawns: "*",
23
+ model: ["pi/task"],
24
+ } satisfies AgentDefinition;
25
+
26
+ const reviewerAgent = {
27
+ name: "reviewer",
28
+ description: "Reviewer agent",
29
+ systemPrompt: "Review the task.",
30
+ source: "bundled",
31
+ model: ["pi/smol"],
32
+ } satisfies AgentDefinition;
33
+
34
+ interface SessionOptions {
35
+ cwd?: string;
36
+ sessionFile?: string | null;
37
+ artifactsDir?: string | null;
38
+ spawns?: string | null;
39
+ depth?: number;
40
+ activeModel?: string;
41
+ modelString?: string;
42
+ enableLsp?: boolean;
43
+ settings?: Settings;
44
+ outputManager?: AgentOutputManager;
45
+ planMode?: boolean;
46
+ }
47
+
48
+ function makeSession(options: SessionOptions = {}): ToolSession {
49
+ const settings =
50
+ options.settings ??
51
+ Settings.isolated({
52
+ "async.enabled": false,
53
+ "task.isolation.mode": "none",
54
+ "task.enableLsp": true,
55
+ });
56
+ const artifactsDir = options.artifactsDir ?? null;
57
+ return {
58
+ cwd: options.cwd ?? process.cwd(),
59
+ hasUI: false,
60
+ settings,
61
+ taskDepth: options.depth ?? 0,
62
+ enableLsp: options.enableLsp ?? true,
63
+ agentOutputManager: options.outputManager,
64
+ getSessionFile: () => options.sessionFile ?? null,
65
+ getSessionSpawns: () => options.spawns ?? "*",
66
+ getActiveModelString: () => options.activeModel ?? "p/active",
67
+ getModelString: () => options.modelString ?? "p/fallback",
68
+ getArtifactsDir: () => artifactsDir,
69
+ getSessionId: () => "test-session",
70
+ getEvalSessionId: () => "test-eval-session",
71
+ getPlanModeState: options.planMode
72
+ ? () =>
73
+ ({
74
+ enabled: true,
75
+ planFilePath: path.join(options.cwd ?? process.cwd(), "plan.md"),
76
+ }) satisfies PlanModeState
77
+ : undefined,
78
+ };
79
+ }
80
+
81
+ function mockAgents(agents: AgentDefinition[] = [taskAgent, reviewerAgent]): void {
82
+ vi.spyOn(taskDiscovery, "discoverAgents").mockResolvedValue({ agents, projectAgentsDir: null });
83
+ }
84
+
85
+ function singleResult(options: ExecutorOptions, overrides: Partial<SingleResult> = {}): SingleResult {
86
+ return {
87
+ index: options.index,
88
+ id: options.id,
89
+ agent: options.agent.name,
90
+ agentSource: options.agent.source,
91
+ task: options.task,
92
+ assignment: options.assignment,
93
+ description: options.description,
94
+ exitCode: 0,
95
+ output: "ok",
96
+ stderr: "",
97
+ truncated: false,
98
+ durationMs: 1,
99
+ tokens: 0,
100
+ ...overrides,
101
+ };
102
+ }
103
+
104
+ function makeEvalSession(
105
+ tempDir: TempDir,
106
+ prefix: string,
107
+ ): { session: ToolSession; sessionFile: string; sessionId: string } {
108
+ const sessionFile = path.join(tempDir.path(), "session.jsonl");
109
+ const artifactsDir = sessionFile.slice(0, -6);
110
+ const session = makeSession({
111
+ cwd: tempDir.path(),
112
+ sessionFile,
113
+ artifactsDir,
114
+ outputManager: new AgentOutputManager(() => artifactsDir),
115
+ });
116
+ return { session, sessionFile, sessionId: `${prefix}:${crypto.randomUUID()}` };
117
+ }
118
+
119
+ describe("runEvalAgent", () => {
120
+ afterEach(() => {
121
+ vi.restoreAllMocks();
122
+ });
123
+
124
+ it("resolves the default task agent and agentType overrides", async () => {
125
+ mockAgents();
126
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
127
+ singleResult(options, {
128
+ output: options.agent.name,
129
+ }),
130
+ );
131
+ const session = makeSession();
132
+
133
+ const defaultResult = await runEvalAgent({ prompt: "hello" }, { session });
134
+ const overrideResult = await runEvalAgent({ prompt: "hello", agentType: "reviewer" }, { session });
135
+
136
+ expect(defaultResult.text).toBe("task");
137
+ expect(overrideResult.text).toBe("reviewer");
138
+ expect(runSpy.mock.calls[0]?.[0].agent.name).toBe("task");
139
+ expect(runSpy.mock.calls[1]?.[0].agent.name).toBe("reviewer");
140
+ });
141
+
142
+ it("throws for an unknown agent", async () => {
143
+ mockAgents([taskAgent]);
144
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
145
+
146
+ await expect(runEvalAgent({ prompt: "hello", agentType: "missing" }, { session: makeSession() })).rejects.toThrow(
147
+ 'Unknown agent "missing"',
148
+ );
149
+ });
150
+
151
+ it("enforces spawn restrictions and the eval recursion cap", async () => {
152
+ mockAgents();
153
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
154
+
155
+ await expect(runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "" }) })).rejects.toThrow(
156
+ "spawns disabled",
157
+ );
158
+ await expect(runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "reviewer" }) })).rejects.toThrow(
159
+ "Allowed: reviewer",
160
+ );
161
+ await expect(
162
+ runEvalAgent({ prompt: "hello" }, { session: makeSession({ depth: EVAL_AGENT_MAX_DEPTH }) }),
163
+ ).rejects.toThrow("maximum depth");
164
+ expect(runSpy).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it("throws instead of spawning from plan mode", async () => {
168
+ mockAgents();
169
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
170
+
171
+ await expect(runEvalAgent({ prompt: "hello" }, { session: makeSession({ planMode: true }) })).rejects.toThrow(
172
+ "unavailable in plan mode",
173
+ );
174
+ expect(runSpy).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it("passes the parent execution context and only sets outputSchema when schema is supplied", async () => {
178
+ mockAgents();
179
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
180
+ const abortController = new AbortController();
181
+ const schema = { type: "object", properties: { ok: { type: "boolean" } } };
182
+ const session = makeSession({ depth: 2, activeModel: "p/current", modelString: "p/fallback" });
183
+
184
+ await runEvalAgent(
185
+ { prompt: " hello ", context: " context ", label: "My Agent", model: "p/override", schema },
186
+ { session, signal: abortController.signal },
187
+ );
188
+ await runEvalAgent({ prompt: "plain" }, { session });
189
+
190
+ const firstOptions = runSpy.mock.calls[0]?.[0];
191
+ const secondOptions = runSpy.mock.calls[1]?.[0];
192
+ if (!firstOptions || !secondOptions) throw new Error("runSubprocess was not called");
193
+ expect(firstOptions.taskDepth).toBe(2);
194
+ expect(firstOptions.signal).toBe(abortController.signal);
195
+ expect(firstOptions.parentActiveModelPattern).toBe("p/current");
196
+ expect(firstOptions.outputSchema).toBe(schema);
197
+ expect(firstOptions.assignment).toBe("hello");
198
+ expect(firstOptions.context).toBe("context");
199
+ expect(firstOptions.description).toBe("My Agent");
200
+ expect(firstOptions.modelOverride).toEqual(["p/override"]);
201
+ expect(secondOptions.outputSchema).toBeUndefined();
202
+ });
203
+
204
+ it("maps successful and failed subagent results", async () => {
205
+ mockAgents();
206
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess");
207
+ runSpy.mockImplementationOnce(async options =>
208
+ singleResult(options, {
209
+ id: "0-EvalAgent",
210
+ output: "done",
211
+ resolvedModel: "p/model",
212
+ }),
213
+ );
214
+ runSpy.mockImplementationOnce(async options =>
215
+ singleResult(options, {
216
+ exitCode: 1,
217
+ output: "",
218
+ stderr: "stderr",
219
+ error: "boom",
220
+ }),
221
+ );
222
+
223
+ const result = await runEvalAgent({ prompt: "hello" }, { session: makeSession() });
224
+ expect(result).toEqual({
225
+ text: "done",
226
+ details: { agent: "task", id: "0-EvalAgent", model: "p/model", structured: false },
227
+ });
228
+ await expect(runEvalAgent({ prompt: "fail" }, { session: makeSession() })).rejects.toThrow("boom");
229
+ });
230
+ });
231
+
232
+ describe("agent() through eval runtimes", () => {
233
+ afterEach(() => {
234
+ vi.restoreAllMocks();
235
+ });
236
+
237
+ afterAll(async () => {
238
+ await disposeAllVmContexts();
239
+ await disposeAllKernelSessions();
240
+ });
241
+
242
+ it("exposes agent() in JavaScript and parses structured output", async () => {
243
+ using tempDir = TempDir.createSync("@omp-eval-agent-js-");
244
+ const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent");
245
+ mockAgents();
246
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
247
+ singleResult(options, {
248
+ output: options.outputSchema ? '{"ok":true,"n":3}' : "hello from agent",
249
+ }),
250
+ );
251
+
252
+ const result = await executeJs(
253
+ 'const text = await agent("hi"); const data = await agent("json", { schema: { type: "object" } }); return JSON.stringify([text, data]);',
254
+ { cwd: tempDir.path(), sessionId, session, sessionFile },
255
+ );
256
+
257
+ expect(result.exitCode).toBe(0);
258
+ expect(JSON.parse(result.output.trim())).toEqual(["hello from agent", { ok: true, n: 3 }]);
259
+ });
260
+
261
+ it("runs JavaScript parallel() with bounded concurrency while preserving order", async () => {
262
+ using tempDir = TempDir.createSync("@omp-eval-agent-js-parallel-");
263
+ const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-parallel");
264
+ mockAgents();
265
+ let inFlight = 0;
266
+ let maxInFlight = 0;
267
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
268
+ inFlight++;
269
+ maxInFlight = Math.max(maxInFlight, inFlight);
270
+ try {
271
+ await Bun.sleep(options.assignment === "a" ? 30 : 10);
272
+ return singleResult(options, { output: options.assignment ?? "" });
273
+ } finally {
274
+ inFlight--;
275
+ }
276
+ });
277
+
278
+ const result = await executeJs(
279
+ 'const values = await parallel(["a", "b", "c", "d"].map(name => () => agent(name)), { concurrency: 2 }); return JSON.stringify(values);',
280
+ { cwd: tempDir.path(), sessionId, session, sessionFile },
281
+ );
282
+
283
+ expect(result.exitCode).toBe(0);
284
+ expect(JSON.parse(result.output.trim())).toEqual(["a", "b", "c", "d"]);
285
+ expect(maxInFlight).toBeGreaterThan(1);
286
+ expect(maxInFlight).toBeLessThanOrEqual(2);
287
+ });
288
+
289
+ it("propagates JavaScript parallel() rejections", async () => {
290
+ using tempDir = TempDir.createSync("@omp-eval-agent-js-reject-");
291
+ const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-reject");
292
+ mockAgents();
293
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
294
+ if (options.assignment === "bad") {
295
+ return singleResult(options, { exitCode: 1, output: "", stderr: "boom", error: "boom" });
296
+ }
297
+ return singleResult(options, { output: options.assignment ?? "" });
298
+ });
299
+
300
+ const result = await executeJs('await parallel([() => agent("ok"), () => agent("bad")], { concurrency: 2 });', {
301
+ cwd: tempDir.path(),
302
+ sessionId,
303
+ session,
304
+ sessionFile,
305
+ });
306
+
307
+ expect(result.exitCode).toBe(1);
308
+ expect(result.output).toContain("boom");
309
+ });
310
+
311
+ it("exposes agent() in the Python runtime", async () => {
312
+ using tempDir = TempDir.createSync("@omp-eval-agent-py-");
313
+ const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent");
314
+ mockAgents();
315
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
316
+ singleResult(options, { output: "hello from python" }),
317
+ );
318
+
319
+ const probe = await executePython('print("probe")', {
320
+ cwd: tempDir.path(),
321
+ sessionId: `${sessionId}:probe`,
322
+ sessionFile,
323
+ kernelMode: "per-call",
324
+ });
325
+ if (probe.exitCode === undefined && probe.cancelled) {
326
+ expect(probe.output).toBe("");
327
+ return;
328
+ }
329
+ expect(probe.exitCode).toBe(0);
330
+
331
+ const result = await executePython('print(agent("hi"))', {
332
+ cwd: tempDir.path(),
333
+ sessionId,
334
+ sessionFile,
335
+ kernelMode: "per-call",
336
+ toolSession: session,
337
+ });
338
+
339
+ expect(result.exitCode).toBe(0);
340
+ expect(result.output.trim()).toBe("hello from python");
341
+ });
342
+
343
+ it("streams enriched agent progress through onStatus before the cell finishes", async () => {
344
+ using tempDir = TempDir.createSync("@omp-eval-agent-progress-");
345
+ const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-progress");
346
+ mockAgents();
347
+
348
+ const makeProgress = (options: ExecutorOptions, overrides: Partial<AgentProgress>): AgentProgress => ({
349
+ index: options.index,
350
+ id: options.id,
351
+ agent: options.agent.name,
352
+ agentSource: options.agent.source,
353
+ status: "running",
354
+ task: options.task,
355
+ assignment: options.assignment,
356
+ description: options.description,
357
+ recentTools: [],
358
+ recentOutput: [],
359
+ toolCount: 0,
360
+ tokens: 0,
361
+ cost: 0,
362
+ durationMs: 0,
363
+ ...overrides,
364
+ });
365
+
366
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
367
+ options.onProgress?.(
368
+ makeProgress(options, {
369
+ status: "running",
370
+ currentTool: "read",
371
+ currentToolArgs: "config.ts",
372
+ lastIntent: "Reading config",
373
+ toolCount: 4,
374
+ contextTokens: 5000,
375
+ contextWindow: 200000,
376
+ cost: 0.03,
377
+ durationMs: 800,
378
+ resolvedModel: "p/model",
379
+ }),
380
+ );
381
+ options.onProgress?.(
382
+ makeProgress(options, {
383
+ status: "completed",
384
+ toolCount: 7,
385
+ contextTokens: 8000,
386
+ contextWindow: 200000,
387
+ cost: 0.06,
388
+ durationMs: 1500,
389
+ resolvedModel: "p/model",
390
+ }),
391
+ );
392
+ return singleResult(options, { output: "done" });
393
+ });
394
+
395
+ const events: Array<{ op: string; [key: string]: unknown }> = [];
396
+ const result = await executeJs('await agent("investigate", { label: "Scout" });', {
397
+ cwd: tempDir.path(),
398
+ sessionId,
399
+ session,
400
+ sessionFile,
401
+ onStatus: event => events.push(event),
402
+ });
403
+
404
+ expect(result.exitCode).toBe(0);
405
+
406
+ const agentEvents = events.filter(event => event.op === "agent");
407
+ // Both throttled ticks were delivered live (the cell awaited agent() and
408
+ // the executor collected them as displayOutputs too).
409
+ expect(agentEvents.length).toBe(2);
410
+
411
+ const running = agentEvents[0];
412
+ expect(running.status).toBe("running");
413
+ expect(running.currentTool).toBe("read");
414
+ expect(running.lastIntent).toBe("Reading config");
415
+ expect(running.contextTokens).toBe(5000);
416
+ expect(running.taskPreview).toBe("investigate");
417
+ expect(typeof running.id).toBe("string");
418
+
419
+ // The final completion event keeps the rich stats — no sparse event
420
+ // coalesces over it and drops toolCount/cost.
421
+ const completed = agentEvents[1];
422
+ expect(completed.status).toBe("completed");
423
+ expect(completed.toolCount).toBe(7);
424
+ expect(completed.cost).toBeCloseTo(0.06);
425
+ expect(completed.id).toBe(running.id);
426
+
427
+ // Same events are still present in the executor's returned displayOutputs.
428
+ const displayAgentEvents = result.displayOutputs.filter(
429
+ (output): output is Extract<typeof output, { type: "status" }> => output.type === "status",
430
+ );
431
+ expect(displayAgentEvents.length).toBe(2);
432
+ });
433
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { GoalModeState } from "../../goals/state";
3
+ import type { UsageStatistics } from "../../session/session-manager";
4
+ import type { ToolSession } from "../../tools";
5
+ import { runEvalBudget } from "../budget-bridge";
6
+
7
+ type TurnBudget = { total: number | null; spent: number; hard: boolean };
8
+
9
+ function makeSession(parts: { turn?: TurnBudget; goal?: GoalModeState; usage?: UsageStatistics }): ToolSession {
10
+ return {
11
+ getTurnBudget: parts.turn ? () => parts.turn as TurnBudget : undefined,
12
+ getGoalModeState: parts.goal ? () => parts.goal : undefined,
13
+ getUsageStatistics: parts.usage ? () => parts.usage as UsageStatistics : undefined,
14
+ } as unknown as ToolSession;
15
+ }
16
+
17
+ function goalState(extra: Partial<GoalModeState["goal"]>): GoalModeState {
18
+ return {
19
+ enabled: true,
20
+ mode: "active",
21
+ goal: { id: "g1", status: "active", tokensUsed: 0, timeUsedSeconds: 0, ...extra },
22
+ } as GoalModeState;
23
+ }
24
+
25
+ function usage(output: number): UsageStatistics {
26
+ return { input: 0, output, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
27
+ }
28
+
29
+ describe("runEvalBudget", () => {
30
+ it("prefers an active +Nk turn directive over Goal Mode", async () => {
31
+ const session = makeSession({
32
+ turn: { total: 200_000, spent: 5_000, hard: true },
33
+ goal: goalState({ tokenBudget: 100_000, tokensUsed: 4_200 }),
34
+ });
35
+ expect(await runEvalBudget({}, { session })).toEqual({ total: 200_000, spent: 5_000, hard: true });
36
+ });
37
+
38
+ it("reports an advisory turn budget as hard:false", async () => {
39
+ const session = makeSession({ turn: { total: 50_000, spent: 1_000, hard: false } });
40
+ expect(await runEvalBudget({}, { session })).toEqual({ total: 50_000, spent: 1_000, hard: false });
41
+ });
42
+
43
+ it("falls through to Goal Mode when no turn directive set a ceiling", async () => {
44
+ const session = makeSession({
45
+ turn: { total: null, spent: 7_777, hard: false },
46
+ goal: goalState({ tokenBudget: 100_000, tokensUsed: 4_200 }),
47
+ });
48
+ expect(await runEvalBudget({}, { session })).toEqual({ total: 100_000, spent: 4_200, hard: true });
49
+ });
50
+
51
+ it("treats a Goal Mode budget as hard, and a budgetless goal as no ceiling", async () => {
52
+ const withBudget = makeSession({ goal: goalState({ tokenBudget: 80_000, tokensUsed: 9_000 }) });
53
+ expect(await runEvalBudget({}, { session: withBudget })).toEqual({ total: 80_000, spent: 9_000, hard: true });
54
+
55
+ const noBudget = makeSession({ goal: goalState({ tokenBudget: undefined, tokensUsed: 1_234 }) });
56
+ expect(await runEvalBudget({}, { session: noBudget })).toEqual({ total: null, spent: 1_234, hard: false });
57
+ });
58
+
59
+ it("reports no ceiling but still surfaces spend", async () => {
60
+ const fromTurn = makeSession({ turn: { total: null, spent: 333, hard: false } });
61
+ expect(await runEvalBudget({}, { session: fromTurn })).toEqual({ total: null, spent: 333, hard: false });
62
+
63
+ const fromUsage = makeSession({ usage: usage(777) });
64
+ expect(await runEvalBudget({}, { session: fromUsage })).toEqual({ total: null, spent: 777, hard: false });
65
+
66
+ const empty = makeSession({});
67
+ expect(await runEvalBudget({}, { session: empty })).toEqual({ total: null, spent: 0, hard: false });
68
+ });
69
+ });
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { IdleTimeout } from "../idle-timeout";
3
+
4
+ /** Resolve true if `signal` aborts within `ms`, false if the window elapses first. */
5
+ function abortedWithin(signal: AbortSignal, ms: number): Promise<boolean> {
6
+ if (signal.aborted) return Promise.resolve(true);
7
+ const { promise, resolve } = Promise.withResolvers<boolean>();
8
+ const timer = setTimeout(() => resolve(false), ms);
9
+ signal.addEventListener(
10
+ "abort",
11
+ () => {
12
+ clearTimeout(timer);
13
+ resolve(true);
14
+ },
15
+ { once: true },
16
+ );
17
+ return promise;
18
+ }
19
+
20
+ describe("IdleTimeout", () => {
21
+ it("aborts with a TimeoutError reason once the idle window elapses with no activity", async () => {
22
+ using idle = new IdleTimeout(40);
23
+ expect(idle.signal.aborted).toBe(false);
24
+
25
+ const fired = await abortedWithin(idle.signal, 500);
26
+ expect(fired).toBe(true);
27
+ expect(idle.signal.aborted).toBe(true);
28
+ // The reason must be a TimeoutError so downstream timeout detection
29
+ // (kernel `isTimeoutReason`, executor `isTimedOutCancellation`) classifies
30
+ // the cancellation as a timeout rather than a plain abort.
31
+ expect(idle.signal.reason).toBeInstanceOf(DOMException);
32
+ expect((idle.signal.reason as DOMException).name).toBe("TimeoutError");
33
+ });
34
+
35
+ it("re-arms on every bump and only fires after activity stops", async () => {
36
+ using idle = new IdleTimeout(150);
37
+ // Bump well past a single window; each bump must push the deadline forward
38
+ // so the watchdog never trips while activity continues.
39
+ for (let i = 0; i < 6; i++) {
40
+ await Bun.sleep(40);
41
+ idle.bump();
42
+ }
43
+ expect(idle.signal.aborted).toBe(false);
44
+
45
+ // Activity stopped — the watchdog should now fire within roughly one window.
46
+ const fired = await abortedWithin(idle.signal, 800);
47
+ expect(fired).toBe(true);
48
+ });
49
+
50
+ it("never fires after dispose()", async () => {
51
+ const idle = new IdleTimeout(30);
52
+ idle.dispose();
53
+ const fired = await abortedWithin(idle.signal, 150);
54
+ expect(fired).toBe(false);
55
+ expect(idle.signal.aborted).toBe(false);
56
+ });
57
+
58
+ it("ignores bump() after the watchdog has already fired", async () => {
59
+ using idle = new IdleTimeout(30);
60
+ await abortedWithin(idle.signal, 500);
61
+ expect(idle.signal.aborted).toBe(true);
62
+ // Late activity must not un-abort or rearm a settled watchdog.
63
+ idle.bump();
64
+ expect(idle.signal.aborted).toBe(true);
65
+ });
66
+ });
@@ -154,6 +154,27 @@ describe("shared eval executors", () => {
154
154
  expect(result.output.trim()).toBe("42");
155
155
  });
156
156
 
157
+ it("treats idleTimeoutMs as an inactivity budget, not a fixed timer", async () => {
158
+ using tempDir = TempDir.createSync("@omp-eval-js-idle-budget-");
159
+ const sessionFile = path.join(tempDir.path(), "session.jsonl");
160
+ const sessionId = `js-idle-budget:${crypto.randomUUID()}`;
161
+ const session = createToolSession(tempDir.path(), sessionFile);
162
+
163
+ // With no wall-clock deadlineMs/timeoutMs and no aborting signal, a cell that
164
+ // runs well past idleTimeoutMs must still complete: the backend must never
165
+ // derive a competing fixed timer from the inactivity budget.
166
+ const result = await executeJs("await Bun.sleep(120); return 'done';", {
167
+ sessionId,
168
+ session,
169
+ sessionFile,
170
+ idleTimeoutMs: 30,
171
+ });
172
+
173
+ expect(result.cancelled).toBe(false);
174
+ expect(result.exitCode).toBe(0);
175
+ expect(result.output.trim()).toBe("done");
176
+ });
177
+
157
178
  it("shares Python state across executePython calls with one session id", async () => {
158
179
  using tempDir = TempDir.createSync("@omp-eval-py-shared-");
159
180
  const sessionFile = path.join(tempDir.path(), "session.jsonl");