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

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 (188) hide show
  1. package/CHANGELOG.md +114 -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 +69 -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/internal-urls/local-protocol.d.ts +19 -9
  27. package/dist/types/internal-urls/types.d.ts +14 -0
  28. package/dist/types/lsp/client.d.ts +3 -0
  29. package/dist/types/mcp/manager.d.ts +14 -5
  30. package/dist/types/memory-backend/index.d.ts +1 -1
  31. package/dist/types/memory-backend/resolve.d.ts +1 -1
  32. package/dist/types/memory-backend/types.d.ts +3 -3
  33. package/dist/types/mnemopi/backend.d.ts +4 -0
  34. package/dist/types/mnemopi/config.d.ts +29 -0
  35. package/dist/types/mnemopi/state.d.ts +72 -0
  36. package/dist/types/modes/components/custom-editor.d.ts +2 -2
  37. package/dist/types/modes/components/omfg-panel.d.ts +19 -0
  38. package/dist/types/modes/controllers/command-controller.d.ts +6 -0
  39. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  40. package/dist/types/modes/controllers/omfg-controller.d.ts +10 -0
  41. package/dist/types/modes/controllers/omfg-rule.d.ts +26 -0
  42. package/dist/types/modes/gradient-highlight.d.ts +5 -1
  43. package/dist/types/modes/interactive-mode.d.ts +7 -3
  44. package/dist/types/modes/magic-keywords.d.ts +14 -0
  45. package/dist/types/modes/markdown-prose.d.ts +27 -0
  46. package/dist/types/modes/orchestrate.d.ts +7 -2
  47. package/dist/types/modes/shared.d.ts +1 -1
  48. package/dist/types/modes/turn-budget.d.ts +18 -0
  49. package/dist/types/modes/types.d.ts +7 -3
  50. package/dist/types/modes/ultrathink.d.ts +7 -2
  51. package/dist/types/modes/workflow.d.ts +15 -0
  52. package/dist/types/sdk.d.ts +13 -3
  53. package/dist/types/session/agent-session.d.ts +36 -17
  54. package/dist/types/session/session-manager.d.ts +18 -0
  55. package/dist/types/session/session-storage.d.ts +6 -0
  56. package/dist/types/session/shake-types.d.ts +24 -0
  57. package/dist/types/task/executor.d.ts +2 -2
  58. package/dist/types/task/repair-args.d.ts +52 -0
  59. package/dist/types/tiny/models.d.ts +1 -1
  60. package/dist/types/tiny/title-client.d.ts +28 -2
  61. package/dist/types/tiny/title-protocol.d.ts +8 -5
  62. package/dist/types/tools/find.d.ts +1 -1
  63. package/dist/types/tools/index.d.ts +19 -3
  64. package/dist/types/tools/memory-edit.d.ts +1 -1
  65. package/dist/types/tools/path-utils.d.ts +7 -0
  66. package/dist/types/tui/output-block.d.ts +7 -7
  67. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  68. package/package.json +10 -10
  69. package/scripts/build-binary.ts +0 -1
  70. package/src/autoresearch/tools/run-experiment.ts +45 -113
  71. package/src/cli/args.ts +39 -16
  72. package/src/cli/extension-flags.ts +48 -0
  73. package/src/cli/plugin-cli.ts +11 -2
  74. package/src/cli.ts +59 -0
  75. package/src/config/config-file.ts +98 -13
  76. package/src/config/file-lock.ts +60 -17
  77. package/src/config/keybindings.ts +78 -27
  78. package/src/config/model-registry.ts +7 -1
  79. package/src/config/settings-schema.ts +73 -67
  80. package/src/config/settings.ts +22 -0
  81. package/src/edit/hashline/diff.ts +81 -24
  82. package/src/edit/renderer.ts +16 -12
  83. package/src/eval/__tests__/agent-bridge.test.ts +433 -0
  84. package/src/eval/__tests__/budget-bridge.test.ts +69 -0
  85. package/src/eval/__tests__/idle-timeout.test.ts +66 -0
  86. package/src/eval/__tests__/shared-executors.test.ts +21 -0
  87. package/src/eval/agent-bridge.ts +295 -0
  88. package/src/eval/backend.ts +17 -2
  89. package/src/eval/budget-bridge.ts +48 -0
  90. package/src/eval/idle-timeout.ts +80 -0
  91. package/src/eval/js/executor.ts +35 -7
  92. package/src/eval/js/index.ts +2 -1
  93. package/src/eval/js/shared/prelude.txt +85 -1
  94. package/src/eval/js/tool-bridge.ts +9 -0
  95. package/src/eval/py/executor.ts +41 -14
  96. package/src/eval/py/index.ts +2 -1
  97. package/src/eval/py/prelude.py +132 -1
  98. package/src/exec/bash-executor.ts +2 -3
  99. package/src/extensibility/custom-tools/types.ts +2 -2
  100. package/src/extensibility/extensions/runner.ts +12 -2
  101. package/src/extensibility/plugins/git-url.ts +90 -4
  102. package/src/extensibility/plugins/manager.ts +103 -7
  103. package/src/extensibility/shared-events.ts +2 -2
  104. package/src/internal-urls/docs-index.generated.ts +88 -88
  105. package/src/internal-urls/local-protocol.ts +23 -11
  106. package/src/internal-urls/types.ts +15 -0
  107. package/src/lsp/client.ts +28 -5
  108. package/src/main.ts +44 -55
  109. package/src/mcp/manager.ts +87 -4
  110. package/src/memory-backend/index.ts +1 -1
  111. package/src/memory-backend/resolve.ts +3 -3
  112. package/src/memory-backend/types.ts +3 -3
  113. package/src/{mnemosyne → mnemopi}/backend.ts +70 -70
  114. package/src/{mnemosyne → mnemopi}/config.ts +36 -36
  115. package/src/{mnemosyne → mnemopi}/state.ts +67 -67
  116. package/src/modes/components/agent-dashboard.ts +6 -6
  117. package/src/modes/components/custom-editor.ts +4 -11
  118. package/src/modes/components/extensions/state-manager.ts +3 -4
  119. package/src/modes/components/footer.ts +8 -9
  120. package/src/modes/components/hook-selector.ts +86 -20
  121. package/src/modes/components/oauth-selector.ts +93 -21
  122. package/src/modes/components/omfg-panel.ts +141 -0
  123. package/src/modes/components/settings-defs.ts +2 -2
  124. package/src/modes/components/tips.txt +2 -1
  125. package/src/modes/components/tool-execution.ts +38 -19
  126. package/src/modes/components/tree-selector.ts +4 -3
  127. package/src/modes/components/user-message-selector.ts +94 -19
  128. package/src/modes/components/user-message.ts +8 -1
  129. package/src/modes/controllers/command-controller.ts +25 -0
  130. package/src/modes/controllers/event-controller.ts +68 -3
  131. package/src/modes/controllers/input-controller.ts +14 -11
  132. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  133. package/src/modes/controllers/omfg-controller.ts +283 -0
  134. package/src/modes/controllers/omfg-rule.ts +647 -0
  135. package/src/modes/controllers/selector-controller.ts +1 -0
  136. package/src/modes/gradient-highlight.ts +23 -6
  137. package/src/modes/interactive-mode.ts +41 -7
  138. package/src/modes/magic-keywords.ts +20 -0
  139. package/src/modes/markdown-prose.ts +247 -0
  140. package/src/modes/orchestrate.ts +17 -11
  141. package/src/modes/shared.ts +3 -11
  142. package/src/modes/turn-budget.ts +31 -0
  143. package/src/modes/types.ts +7 -1
  144. package/src/modes/ultrathink.ts +16 -10
  145. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  146. package/src/modes/workflow.ts +42 -0
  147. package/src/prompts/system/omfg-user.md +51 -0
  148. package/src/prompts/system/project-prompt.md +3 -2
  149. package/src/prompts/system/subagent-system-prompt.md +12 -8
  150. package/src/prompts/system/system-prompt.md +9 -6
  151. package/src/prompts/system/workflow-notice.md +70 -0
  152. package/src/prompts/tools/eval.md +13 -1
  153. package/src/prompts/tools/memory-edit.md +1 -1
  154. package/src/sdk.ts +63 -33
  155. package/src/session/agent-session.ts +290 -55
  156. package/src/session/session-manager.ts +32 -0
  157. package/src/session/session-storage.ts +68 -8
  158. package/src/session/shake-types.ts +43 -0
  159. package/src/slash-commands/builtin-registry.ts +39 -16
  160. package/src/task/executor.ts +17 -7
  161. package/src/task/index.ts +9 -8
  162. package/src/task/repair-args.ts +117 -0
  163. package/src/tiny/models.ts +2 -2
  164. package/src/tiny/title-client.ts +133 -43
  165. package/src/tiny/title-protocol.ts +10 -5
  166. package/src/tiny/worker.ts +3 -46
  167. package/src/tools/ast-edit.ts +3 -0
  168. package/src/tools/ast-grep.ts +3 -0
  169. package/src/tools/eval.ts +202 -26
  170. package/src/tools/find.ts +20 -6
  171. package/src/tools/gh.ts +1 -0
  172. package/src/tools/grouped-file-output.ts +9 -2
  173. package/src/tools/index.ts +17 -5
  174. package/src/tools/memory-edit.ts +4 -4
  175. package/src/tools/memory-recall.ts +5 -5
  176. package/src/tools/memory-reflect.ts +5 -5
  177. package/src/tools/memory-retain.ts +4 -4
  178. package/src/tools/path-utils.ts +13 -2
  179. package/src/tools/read.ts +1 -0
  180. package/src/tools/render-utils.ts +2 -1
  181. package/src/tools/search.ts +491 -76
  182. package/src/tui/output-block.ts +37 -75
  183. package/src/utils/git.ts +9 -3
  184. package/dist/types/mnemosyne/backend.d.ts +0 -4
  185. package/dist/types/mnemosyne/config.d.ts +0 -29
  186. package/dist/types/mnemosyne/state.d.ts +0 -72
  187. /package/dist/types/{mnemosyne → mnemopi}/index.d.ts +0 -0
  188. /package/src/{mnemosyne → mnemopi}/index.ts +0 -0
@@ -5,17 +5,25 @@
5
5
  * pair to {@link generateDiffString} so the renderer can show the diff
6
6
  * while the tool call is still streaming.
7
7
  *
8
- * Validation is intentionally light: only the section snapshot tag is checked
9
- * (so the preview goes red when anchors are stale), no plan-mode guards
10
- * and no auto-generated-file refusal those belong on the write path.
8
+ * Uses the same snapshot-tag semantics as the apply path: a live content-hash
9
+ * match is accepted even when the tag was minted by a source that did not keep
10
+ * history, and stale tags recover through the session snapshot store when possible.
11
11
  */
12
12
  import {
13
+ type ApplyResult,
14
+ applyEdits,
15
+ computeFileHash,
16
+ type Edit,
13
17
  Patch as HashlinePatch,
18
+ hasBlockEdit,
19
+ MismatchError,
14
20
  missingSnapshotTagMessage,
15
21
  normalizeToLF,
16
22
  type Patch,
17
23
  type PatchSection,
18
- type Snapshot,
24
+ parsePatchStreaming,
25
+ Recovery,
26
+ resolveBlockEdits,
19
27
  type SnapshotStore,
20
28
  stripBom,
21
29
  } from "@oh-my-pi/hashline";
@@ -48,24 +56,79 @@ async function readSectionText(absolutePath: string, sectionPath: string): Promi
48
56
  }
49
57
  }
50
58
 
51
- function snapshotMatchesCurrent(snapshot: Snapshot, currentText: string): boolean {
52
- return snapshot.text === currentText;
59
+ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
60
+ return edits.some(edit => {
61
+ if (edit.kind === "delete") return true;
62
+ if (edit.kind === "block") return true;
63
+ return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
64
+ });
53
65
  }
54
- function validateSectionHash(
66
+
67
+ function createMismatchError(
55
68
  section: PatchSection,
56
69
  absolutePath: string,
57
- text: string,
70
+ normalized: string,
58
71
  snapshots: SnapshotStore,
59
- ): string | null {
60
- if (section.fileHash === undefined) {
61
- // The snapshot tag is mandatory on every section — head/tail inserts
62
- // included — to keep this preview path in lockstep with the apply path
63
- // (`Patcher.prepare`), which rejects tagless sections unconditionally.
64
- return missingSnapshotTagMessage(section.path);
72
+ expected: string,
73
+ ): MismatchError {
74
+ return new MismatchError({
75
+ path: section.path,
76
+ expectedFileHash: expected,
77
+ actualFileHash: computeFileHash(normalized),
78
+ fileLines: normalized.split("\n"),
79
+ anchorLines: section.collectAnchorLines(),
80
+ hashRecognized: snapshots.byHash(absolutePath, expected) !== null,
81
+ });
82
+ }
83
+
84
+ function parsePreviewEdits(section: PatchSection, streaming: boolean | undefined): readonly Edit[] {
85
+ return streaming ? parsePatchStreaming(section.diff).edits : section.edits;
86
+ }
87
+
88
+ function resolvePreviewEdits(args: {
89
+ section: PatchSection;
90
+ absolutePath: string;
91
+ normalized: string;
92
+ snapshots: SnapshotStore;
93
+ expected: string | undefined;
94
+ liveMatches: boolean;
95
+ edits: readonly Edit[];
96
+ }): readonly Edit[] {
97
+ const { section, absolutePath, normalized, snapshots, expected, liveMatches, edits } = args;
98
+ if (!hasBlockEdit(edits)) return edits;
99
+ const baseText = expected === undefined || liveMatches ? normalized : snapshots.byHash(absolutePath, expected)?.text;
100
+ if (baseText === undefined) {
101
+ throw createMismatchError(section, absolutePath, normalized, snapshots, expected ?? "");
65
102
  }
66
- const snapshot = snapshots.byHash(absolutePath, section.fileHash);
67
- if (snapshot && snapshotMatchesCurrent(snapshot, text)) return null;
68
- return `Hashline snapshot tag mismatch for ${section.path}: section is bound to #${section.fileHash}, but current file does not match that snapshot; re-read and try again.`;
103
+ return resolveBlockEdits(edits, baseText, section.path, nativeBlockResolver, { onUnresolved: "throw" });
104
+ }
105
+
106
+ function applyPreviewEdits(args: {
107
+ section: PatchSection;
108
+ absolutePath: string;
109
+ normalized: string;
110
+ snapshots: SnapshotStore;
111
+ options: HashlineDiffOptions;
112
+ }): ApplyResult {
113
+ const { section, absolutePath, normalized, snapshots, options } = args;
114
+ const expected = section.fileHash;
115
+ if (!options.skipHashValidation && expected === undefined) {
116
+ throw new Error(missingSnapshotTagMessage(section.path));
117
+ }
118
+ const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
119
+ const edits = parsePreviewEdits(section, options.streaming);
120
+ const resolved = resolvePreviewEdits({ section, absolutePath, normalized, snapshots, expected, liveMatches, edits });
121
+ if (options.skipHashValidation || expected === undefined || liveMatches) return applyEdits(normalized, resolved);
122
+ if (!hasAnchorScopedEdit(resolved)) return applyEdits(normalized, resolved);
123
+
124
+ const recovered = new Recovery(snapshots).tryRecover({
125
+ path: absolutePath,
126
+ currentText: normalized,
127
+ fileHash: expected,
128
+ edits: resolved,
129
+ });
130
+ if (recovered) return recovered;
131
+ throw createMismatchError(section, absolutePath, normalized, snapshots, expected);
69
132
  }
70
133
 
71
134
  export async function computeHashlineSectionDiff(
@@ -79,13 +142,7 @@ export async function computeHashlineSectionDiff(
79
142
  const rawContent = await readSectionText(absolutePath, section.path);
80
143
  const { text: content } = stripBom(rawContent);
81
144
  const normalized = normalizeToLF(content);
82
- if (!options.skipHashValidation) {
83
- const hashError = validateSectionHash(section, absolutePath, normalized, snapshots);
84
- if (hashError) return { error: hashError };
85
- }
86
- const result = options.streaming
87
- ? section.applyPartialTo(normalized, nativeBlockResolver)
88
- : section.applyTo(normalized, nativeBlockResolver);
145
+ const result = applyPreviewEdits({ section, absolutePath, normalized, snapshots, options });
89
146
  if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
90
147
  return generateDiffString(normalized, result.text);
91
148
  } catch (err) {
@@ -235,23 +235,27 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string)
235
235
 
236
236
  function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string {
237
237
  if (!diff) return "";
238
- // Hunk-aware truncation keeps the change rows themselves visible. Tail-mode
239
- // pins the visible window to the bottom of the diff so newly streamed
240
- // hunks stay on screen as more arrives, instead of leaving the user stuck
241
- // staring at the head of the file while the tail scrolls offscreen.
242
- const {
243
- text: truncatedDiff,
244
- hiddenHunks,
245
- hiddenLines,
246
- } = truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, EDIT_STREAMING_PREVIEW_LINES, { fromTail: true });
238
+ // "Cursor" tail window: pin the last EDIT_STREAMING_PREVIEW_LINES rows to the
239
+ // bottom of the diff so freshly streamed changes stay on screen, and accept
240
+ // the trailing rows "from the back" once the diff outgrows the window. The
241
+ // whole-file diff is recomputed on every streamed chunk and its Myers
242
+ // alignment is not monotonic in payload length, so a hunk-aware window that
243
+ // kept whole change segments gained and lost rows tick to tick — the box
244
+ // stuttered, and the earlier high-water fix traded that for a half-empty
245
+ // rectangle. A strict fixed-height window keeps the box steady and always
246
+ // full of real diff context instead of blank padding.
247
+ const allLines = diff.replace(/\n+$/u, "").split("\n");
248
+ const hiddenLines = Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES);
249
+ const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines;
247
250
  let text = "\n\n";
248
- if (hiddenHunks > 0 || hiddenLines > 0) {
251
+ if (hiddenLines > 0) {
252
+ const hiddenHunks = getDiffStats(allLines.slice(0, hiddenLines).join("\n")).hunks;
249
253
  const remainder: string[] = [];
250
254
  if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
251
- if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
255
+ remainder.push(`${hiddenLines} more lines`);
252
256
  text += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
253
257
  }
254
- text += renderDiffColored(truncatedDiff, { filePath: rawPath });
258
+ text += renderDiffColored(visible.join("\n"), { filePath: rawPath });
255
259
  text += uiTheme.fg("dim", `\n(${label})`);
256
260
  return text;
257
261
  }
@@ -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
+ });