@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.
- package/CHANGELOG.md +75 -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 +88 -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/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 +7 -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 +40 -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/tiny/models.d.ts +15 -1
- package/dist/types/tiny/title-protocol.d.ts +4 -0
- package/dist/types/tools/index.d.ts +19 -3
- package/dist/types/tools/memory-edit.d.ts +1 -1
- package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
- package/package.json +10 -10
- 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/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 +94 -67
- package/src/config/settings.ts +12 -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/main.ts +44 -55
- 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 +57 -0
- package/src/modes/controllers/event-controller.ts +60 -2
- package/src/modes/controllers/input-controller.ts +14 -11
- 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/system-prompt.md +1 -0
- 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 +373 -56
- package/src/session/session-manager.ts +32 -0
- package/src/session/session-storage.ts +68 -8
- package/src/session/shake-types.ts +44 -0
- package/src/slash-commands/builtin-registry.ts +41 -16
- package/src/task/executor.ts +3 -3
- package/src/task/index.ts +6 -6
- package/src/tiny/models.ts +30 -2
- package/src/tiny/title-protocol.ts +11 -1
- package/src/tiny/worker.ts +19 -7
- package/src/tools/eval.ts +202 -26
- 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/render-utils.ts +2 -1
- package/src/tools/search.ts +480 -76
- 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
|
@@ -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");
|