@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.
- package/CHANGELOG.md +114 -6
- package/dist/types/cli/args.d.ts +1 -1
- package/dist/types/cli/extension-flags.d.ts +36 -0
- package/dist/types/config/config-file.d.ts +4 -0
- package/dist/types/config/file-lock.d.ts +23 -0
- package/dist/types/config/keybindings.d.ts +2 -1
- package/dist/types/config/model-registry.d.ts +6 -0
- package/dist/types/config/settings-schema.d.ts +69 -65
- package/dist/types/edit/hashline/diff.d.ts +3 -3
- package/dist/types/eval/__tests__/agent-bridge.test.d.ts +1 -0
- package/dist/types/eval/__tests__/budget-bridge.test.d.ts +1 -0
- package/dist/types/eval/__tests__/idle-timeout.test.d.ts +1 -0
- package/dist/types/eval/agent-bridge.d.ts +25 -0
- package/dist/types/eval/backend.d.ts +17 -2
- package/dist/types/eval/budget-bridge.d.ts +29 -0
- package/dist/types/eval/idle-timeout.d.ts +28 -0
- package/dist/types/eval/js/executor.d.ts +8 -0
- package/dist/types/eval/js/tool-bridge.d.ts +2 -1
- package/dist/types/eval/py/executor.d.ts +13 -0
- package/dist/types/exec/bash-executor.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
- package/dist/types/extensibility/extensions/runner.d.ts +7 -0
- package/dist/types/extensibility/plugins/git-url.d.ts +11 -1
- package/dist/types/extensibility/plugins/manager.d.ts +12 -1
- package/dist/types/extensibility/shared-events.d.ts +2 -2
- package/dist/types/internal-urls/local-protocol.d.ts +19 -9
- package/dist/types/internal-urls/types.d.ts +14 -0
- package/dist/types/lsp/client.d.ts +3 -0
- package/dist/types/mcp/manager.d.ts +14 -5
- package/dist/types/memory-backend/index.d.ts +1 -1
- package/dist/types/memory-backend/resolve.d.ts +1 -1
- package/dist/types/memory-backend/types.d.ts +3 -3
- package/dist/types/mnemopi/backend.d.ts +4 -0
- package/dist/types/mnemopi/config.d.ts +29 -0
- package/dist/types/mnemopi/state.d.ts +72 -0
- package/dist/types/modes/components/custom-editor.d.ts +2 -2
- package/dist/types/modes/components/omfg-panel.d.ts +19 -0
- package/dist/types/modes/controllers/command-controller.d.ts +6 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -3
- package/dist/types/modes/controllers/omfg-controller.d.ts +10 -0
- package/dist/types/modes/controllers/omfg-rule.d.ts +26 -0
- package/dist/types/modes/gradient-highlight.d.ts +5 -1
- package/dist/types/modes/interactive-mode.d.ts +7 -3
- package/dist/types/modes/magic-keywords.d.ts +14 -0
- package/dist/types/modes/markdown-prose.d.ts +27 -0
- package/dist/types/modes/orchestrate.d.ts +7 -2
- package/dist/types/modes/shared.d.ts +1 -1
- package/dist/types/modes/turn-budget.d.ts +18 -0
- package/dist/types/modes/types.d.ts +7 -3
- package/dist/types/modes/ultrathink.d.ts +7 -2
- package/dist/types/modes/workflow.d.ts +15 -0
- package/dist/types/sdk.d.ts +13 -3
- package/dist/types/session/agent-session.d.ts +36 -17
- package/dist/types/session/session-manager.d.ts +18 -0
- package/dist/types/session/session-storage.d.ts +6 -0
- package/dist/types/session/shake-types.d.ts +24 -0
- package/dist/types/task/executor.d.ts +2 -2
- package/dist/types/task/repair-args.d.ts +52 -0
- package/dist/types/tiny/models.d.ts +1 -1
- package/dist/types/tiny/title-client.d.ts +28 -2
- package/dist/types/tiny/title-protocol.d.ts +8 -5
- package/dist/types/tools/find.d.ts +1 -1
- package/dist/types/tools/index.d.ts +19 -3
- package/dist/types/tools/memory-edit.d.ts +1 -1
- package/dist/types/tools/path-utils.d.ts +7 -0
- package/dist/types/tui/output-block.d.ts +7 -7
- package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
- package/package.json +10 -10
- package/scripts/build-binary.ts +0 -1
- package/src/autoresearch/tools/run-experiment.ts +45 -113
- package/src/cli/args.ts +39 -16
- package/src/cli/extension-flags.ts +48 -0
- package/src/cli/plugin-cli.ts +11 -2
- package/src/cli.ts +59 -0
- package/src/config/config-file.ts +98 -13
- package/src/config/file-lock.ts +60 -17
- package/src/config/keybindings.ts +78 -27
- package/src/config/model-registry.ts +7 -1
- package/src/config/settings-schema.ts +73 -67
- package/src/config/settings.ts +22 -0
- package/src/edit/hashline/diff.ts +81 -24
- package/src/edit/renderer.ts +16 -12
- package/src/eval/__tests__/agent-bridge.test.ts +433 -0
- package/src/eval/__tests__/budget-bridge.test.ts +69 -0
- package/src/eval/__tests__/idle-timeout.test.ts +66 -0
- package/src/eval/__tests__/shared-executors.test.ts +21 -0
- package/src/eval/agent-bridge.ts +295 -0
- package/src/eval/backend.ts +17 -2
- package/src/eval/budget-bridge.ts +48 -0
- package/src/eval/idle-timeout.ts +80 -0
- package/src/eval/js/executor.ts +35 -7
- package/src/eval/js/index.ts +2 -1
- package/src/eval/js/shared/prelude.txt +85 -1
- package/src/eval/js/tool-bridge.ts +9 -0
- package/src/eval/py/executor.ts +41 -14
- package/src/eval/py/index.ts +2 -1
- package/src/eval/py/prelude.py +132 -1
- package/src/exec/bash-executor.ts +2 -3
- package/src/extensibility/custom-tools/types.ts +2 -2
- package/src/extensibility/extensions/runner.ts +12 -2
- package/src/extensibility/plugins/git-url.ts +90 -4
- package/src/extensibility/plugins/manager.ts +103 -7
- package/src/extensibility/shared-events.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +88 -88
- package/src/internal-urls/local-protocol.ts +23 -11
- package/src/internal-urls/types.ts +15 -0
- package/src/lsp/client.ts +28 -5
- package/src/main.ts +44 -55
- package/src/mcp/manager.ts +87 -4
- package/src/memory-backend/index.ts +1 -1
- package/src/memory-backend/resolve.ts +3 -3
- package/src/memory-backend/types.ts +3 -3
- package/src/{mnemosyne → mnemopi}/backend.ts +70 -70
- package/src/{mnemosyne → mnemopi}/config.ts +36 -36
- package/src/{mnemosyne → mnemopi}/state.ts +67 -67
- package/src/modes/components/agent-dashboard.ts +6 -6
- package/src/modes/components/custom-editor.ts +4 -11
- package/src/modes/components/extensions/state-manager.ts +3 -4
- package/src/modes/components/footer.ts +8 -9
- package/src/modes/components/hook-selector.ts +86 -20
- package/src/modes/components/oauth-selector.ts +93 -21
- package/src/modes/components/omfg-panel.ts +141 -0
- package/src/modes/components/settings-defs.ts +2 -2
- package/src/modes/components/tips.txt +2 -1
- package/src/modes/components/tool-execution.ts +38 -19
- package/src/modes/components/tree-selector.ts +4 -3
- package/src/modes/components/user-message-selector.ts +94 -19
- package/src/modes/components/user-message.ts +8 -1
- package/src/modes/controllers/command-controller.ts +25 -0
- package/src/modes/controllers/event-controller.ts +68 -3
- package/src/modes/controllers/input-controller.ts +14 -11
- package/src/modes/controllers/mcp-command-controller.ts +1 -1
- package/src/modes/controllers/omfg-controller.ts +283 -0
- package/src/modes/controllers/omfg-rule.ts +647 -0
- package/src/modes/controllers/selector-controller.ts +1 -0
- package/src/modes/gradient-highlight.ts +23 -6
- package/src/modes/interactive-mode.ts +41 -7
- package/src/modes/magic-keywords.ts +20 -0
- package/src/modes/markdown-prose.ts +247 -0
- package/src/modes/orchestrate.ts +17 -11
- package/src/modes/shared.ts +3 -11
- package/src/modes/turn-budget.ts +31 -0
- package/src/modes/types.ts +7 -1
- package/src/modes/ultrathink.ts +16 -10
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/modes/workflow.ts +42 -0
- package/src/prompts/system/omfg-user.md +51 -0
- package/src/prompts/system/project-prompt.md +3 -2
- package/src/prompts/system/subagent-system-prompt.md +12 -8
- package/src/prompts/system/system-prompt.md +9 -6
- package/src/prompts/system/workflow-notice.md +70 -0
- package/src/prompts/tools/eval.md +13 -1
- package/src/prompts/tools/memory-edit.md +1 -1
- package/src/sdk.ts +63 -33
- package/src/session/agent-session.ts +290 -55
- package/src/session/session-manager.ts +32 -0
- package/src/session/session-storage.ts +68 -8
- package/src/session/shake-types.ts +43 -0
- package/src/slash-commands/builtin-registry.ts +39 -16
- package/src/task/executor.ts +17 -7
- package/src/task/index.ts +9 -8
- package/src/task/repair-args.ts +117 -0
- package/src/tiny/models.ts +2 -2
- package/src/tiny/title-client.ts +133 -43
- package/src/tiny/title-protocol.ts +10 -5
- package/src/tiny/worker.ts +3 -46
- package/src/tools/ast-edit.ts +3 -0
- package/src/tools/ast-grep.ts +3 -0
- package/src/tools/eval.ts +202 -26
- package/src/tools/find.ts +20 -6
- package/src/tools/gh.ts +1 -0
- package/src/tools/grouped-file-output.ts +9 -2
- package/src/tools/index.ts +17 -5
- package/src/tools/memory-edit.ts +4 -4
- package/src/tools/memory-recall.ts +5 -5
- package/src/tools/memory-reflect.ts +5 -5
- package/src/tools/memory-retain.ts +4 -4
- package/src/tools/path-utils.ts +13 -2
- package/src/tools/read.ts +1 -0
- package/src/tools/render-utils.ts +2 -1
- package/src/tools/search.ts +491 -76
- package/src/tui/output-block.ts +37 -75
- package/src/utils/git.ts +9 -3
- package/dist/types/mnemosyne/backend.d.ts +0 -4
- package/dist/types/mnemosyne/config.d.ts +0 -29
- package/dist/types/mnemosyne/state.d.ts +0 -72
- /package/dist/types/{mnemosyne → mnemopi}/index.d.ts +0 -0
- /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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* and
|
|
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
|
-
|
|
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
|
|
52
|
-
return
|
|
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
|
-
|
|
66
|
+
|
|
67
|
+
function createMismatchError(
|
|
55
68
|
section: PatchSection,
|
|
56
69
|
absolutePath: string,
|
|
57
|
-
|
|
70
|
+
normalized: string,
|
|
58
71
|
snapshots: SnapshotStore,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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) {
|
package/src/edit/renderer.ts
CHANGED
|
@@ -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
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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 (
|
|
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
|
-
|
|
255
|
+
remainder.push(`${hiddenLines} more lines`);
|
|
252
256
|
text += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
|
|
253
257
|
}
|
|
254
|
-
text += renderDiffColored(
|
|
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
|
+
});
|