@agentplate/cli 1.0.0
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 +54 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/agents/architect.md +108 -0
- package/agents/builder.md +97 -0
- package/agents/coordinator.md +113 -0
- package/agents/deployer.md +117 -0
- package/agents/devops.md +114 -0
- package/agents/lead.md +107 -0
- package/agents/merger.md +103 -0
- package/agents/reviewer.md +90 -0
- package/agents/scout.md +95 -0
- package/agents/verifier.md +106 -0
- package/package.json +64 -0
- package/src/agents/guard-rules.ts +55 -0
- package/src/agents/identity.test.ts +161 -0
- package/src/agents/identity.ts +229 -0
- package/src/agents/manifest.test.ts +260 -0
- package/src/agents/manifest.ts +286 -0
- package/src/agents/overlay.test.ts +190 -0
- package/src/agents/overlay.ts +212 -0
- package/src/agents/system-prompt.test.ts +53 -0
- package/src/agents/system-prompt.ts +95 -0
- package/src/agents/turn-runner.ts +79 -0
- package/src/commands/coordinator.test.ts +75 -0
- package/src/commands/coordinator.ts +259 -0
- package/src/commands/deploy.test.ts +504 -0
- package/src/commands/deploy.ts +874 -0
- package/src/commands/doctor.test.ts +106 -0
- package/src/commands/doctor.ts +208 -0
- package/src/commands/init.ts +71 -0
- package/src/commands/log.ts +51 -0
- package/src/commands/mail.ts +197 -0
- package/src/commands/merge.ts +127 -0
- package/src/commands/model.ts +58 -0
- package/src/commands/prime.ts +61 -0
- package/src/commands/reap.ts +87 -0
- package/src/commands/serve.ts +61 -0
- package/src/commands/setup.ts +48 -0
- package/src/commands/ship.test.ts +106 -0
- package/src/commands/ship.ts +202 -0
- package/src/commands/skill.test.ts +458 -0
- package/src/commands/skill.ts +730 -0
- package/src/commands/sling.ts +365 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/stop.ts +56 -0
- package/src/commands/tui.ts +199 -0
- package/src/commands/worktree.ts +77 -0
- package/src/config.test.ts +92 -0
- package/src/config.ts +202 -0
- package/src/db/sqlite.test.ts +77 -0
- package/src/db/sqlite.ts +102 -0
- package/src/deploy/audit.test.ts +233 -0
- package/src/deploy/audit.ts +245 -0
- package/src/deploy/context.test.ts +243 -0
- package/src/deploy/context.ts +72 -0
- package/src/deploy/registry.test.ts +101 -0
- package/src/deploy/registry.ts +86 -0
- package/src/deploy/secrets.test.ts +129 -0
- package/src/deploy/secrets.ts +69 -0
- package/src/deploy/targets/docker-gha.test.ts +323 -0
- package/src/deploy/targets/docker-gha.ts +841 -0
- package/src/deploy/types.ts +153 -0
- package/src/errors.test.ts +42 -0
- package/src/errors.ts +69 -0
- package/src/events/store.test.ts +183 -0
- package/src/events/store.ts +201 -0
- package/src/index.ts +137 -0
- package/src/insights/quality-gates.ts +73 -0
- package/src/json.test.ts +28 -0
- package/src/json.ts +50 -0
- package/src/logging/color.ts +62 -0
- package/src/logging/logger.ts +60 -0
- package/src/logging/sanitizer.test.ts +36 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/client.test.ts +192 -0
- package/src/mail/client.ts +188 -0
- package/src/mail/store.test.ts +279 -0
- package/src/mail/store.ts +311 -0
- package/src/merge/lock.test.ts +88 -0
- package/src/merge/lock.ts +84 -0
- package/src/merge/queue.test.ts +136 -0
- package/src/merge/queue.ts +177 -0
- package/src/merge/resolver.test.ts +219 -0
- package/src/merge/resolver.ts +274 -0
- package/src/paths.ts +36 -0
- package/src/providers/apply.test.ts +90 -0
- package/src/providers/apply.ts +66 -0
- package/src/providers/registry.test.ts +74 -0
- package/src/providers/registry.ts +254 -0
- package/src/runtimes/claude.ts +313 -0
- package/src/runtimes/codex.ts +280 -0
- package/src/runtimes/cursor.ts +247 -0
- package/src/runtimes/gemini.ts +173 -0
- package/src/runtimes/mock.ts +71 -0
- package/src/runtimes/opencode.ts +259 -0
- package/src/runtimes/registry.test.ts +924 -0
- package/src/runtimes/registry.ts +63 -0
- package/src/runtimes/resolve.ts +45 -0
- package/src/runtimes/types.ts +97 -0
- package/src/scaffold.ts +68 -0
- package/src/secrets.test.ts +51 -0
- package/src/secrets.ts +78 -0
- package/src/serve/api.ts +667 -0
- package/src/serve/server.test.ts +433 -0
- package/src/serve/server.ts +271 -0
- package/src/serve/system.ts +90 -0
- package/src/serve/weather.ts +140 -0
- package/src/sessions/reaper.test.ts +162 -0
- package/src/sessions/reaper.ts +149 -0
- package/src/sessions/store.test.ts +351 -0
- package/src/sessions/store.ts +350 -0
- package/src/skills/distiller.test.ts +498 -0
- package/src/skills/distiller.ts +426 -0
- package/src/skills/feedback.test.ts +300 -0
- package/src/skills/feedback.ts +168 -0
- package/src/skills/lifecycle.ts +169 -0
- package/src/skills/retrieval.test.ts +421 -0
- package/src/skills/retrieval.ts +365 -0
- package/src/skills/safety.test.ts +335 -0
- package/src/skills/safety.ts +216 -0
- package/src/skills/store.test.ts +425 -0
- package/src/skills/store.ts +684 -0
- package/src/skills/types.ts +107 -0
- package/src/types.ts +442 -0
- package/src/utils/detect.test.ts +35 -0
- package/src/utils/detect.ts +82 -0
- package/src/version.test.ts +19 -0
- package/src/version.ts +7 -0
- package/src/wizard/setup.ts +254 -0
- package/src/worktree/manager.test.ts +181 -0
- package/src/worktree/manager.ts +229 -0
- package/templates/overlay.md.tmpl +102 -0
- package/ui/dist/assets/index-C7rXIMER.css +1 -0
- package/ui/dist/assets/index-W4kbr4by.js +4526 -0
- package/ui/dist/favicon.svg +21 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/logo-clay.svg +21 -0
- package/ui/dist/logo.svg +18 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { ValidationError } from "../errors.ts";
|
|
3
|
+
import type { ResolvedModel } from "../types.ts";
|
|
4
|
+
import { ClaudeRuntime } from "./claude.ts";
|
|
5
|
+
import { CodexRuntime } from "./codex.ts";
|
|
6
|
+
import { CursorRuntime } from "./cursor.ts";
|
|
7
|
+
import { GeminiRuntime } from "./gemini.ts";
|
|
8
|
+
import { MockRuntime } from "./mock.ts";
|
|
9
|
+
import { OpenCodeRuntime, opencodeModel } from "./opencode.ts";
|
|
10
|
+
import { getRuntime, getRuntimeNames } from "./registry.ts";
|
|
11
|
+
import type { AgentEvent, DirectSpawnOpts } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
// Minimal DirectSpawnOpts builder for argv-shape assertions. `cwd` and
|
|
14
|
+
// `instructionPath` are required by the type but irrelevant to argv here.
|
|
15
|
+
function spawnOpts(overrides: Partial<DirectSpawnOpts> = {}): DirectSpawnOpts {
|
|
16
|
+
return {
|
|
17
|
+
cwd: "/tmp/wt",
|
|
18
|
+
model: "claude-sonnet-4-6",
|
|
19
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Build a ReadableStream of UTF-8 bytes from string chunks, to feed parseEvents
|
|
25
|
+
// the same shape Bun.spawn's stdout produces (no real subprocess needed).
|
|
26
|
+
function streamFromChunks(chunks: string[]): ReadableStream<Uint8Array> {
|
|
27
|
+
const encoder = new TextEncoder();
|
|
28
|
+
return new ReadableStream<Uint8Array>({
|
|
29
|
+
start(controller) {
|
|
30
|
+
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
|
|
31
|
+
controller.close();
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("ClaudeRuntime", () => {
|
|
37
|
+
const runtime = new ClaudeRuntime();
|
|
38
|
+
|
|
39
|
+
test("static metadata", () => {
|
|
40
|
+
expect(runtime.id).toBe("claude");
|
|
41
|
+
expect(runtime.stability).toBe("stable");
|
|
42
|
+
expect(runtime.instructionPath).toBe(".claude/CLAUDE.md");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("buildDirectSpawn omits --resume on the first turn", () => {
|
|
46
|
+
const argv = runtime.buildDirectSpawn(spawnOpts({ prompt: "do the thing" }));
|
|
47
|
+
expect(argv).toEqual([
|
|
48
|
+
"claude",
|
|
49
|
+
"-p",
|
|
50
|
+
"do the thing",
|
|
51
|
+
"--output-format",
|
|
52
|
+
"stream-json",
|
|
53
|
+
"--verbose",
|
|
54
|
+
"--model",
|
|
55
|
+
"claude-sonnet-4-6",
|
|
56
|
+
"--disallowedTools",
|
|
57
|
+
"Task",
|
|
58
|
+
"Agent",
|
|
59
|
+
"Workflow",
|
|
60
|
+
"--permission-mode",
|
|
61
|
+
"bypassPermissions",
|
|
62
|
+
]);
|
|
63
|
+
expect(argv).not.toContain("--resume");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("blocks Claude Code's native spawn tools so sling is the only spawn path", () => {
|
|
67
|
+
// Headless turns (workers/leads): Task/Agent/Workflow disallowed — but workers
|
|
68
|
+
// MUST still be able to edit, so file-mutation tools stay available.
|
|
69
|
+
const direct = runtime.buildDirectSpawn(spawnOpts({ prompt: "go" }));
|
|
70
|
+
const di = direct.indexOf("--disallowedTools");
|
|
71
|
+
expect(di).toBeGreaterThan(-1);
|
|
72
|
+
expect(direct.slice(di + 1, di + 4)).toEqual(["Task", "Agent", "Workflow"]);
|
|
73
|
+
expect(direct).not.toContain("Edit"); // workers can edit
|
|
74
|
+
expect(direct).not.toContain("Write");
|
|
75
|
+
|
|
76
|
+
// Interactive coordinator: spawn block, and the variadic must NOT swallow the
|
|
77
|
+
// trailing seed message.
|
|
78
|
+
const interactive = runtime.buildInteractiveSpawn?.({
|
|
79
|
+
model: "m",
|
|
80
|
+
initialMessage: "build a todo app",
|
|
81
|
+
});
|
|
82
|
+
expect(interactive).toContain("--disallowedTools");
|
|
83
|
+
expect(interactive).toContain("Task");
|
|
84
|
+
expect(interactive?.[interactive.length - 1]).toBe("build a todo app");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("interactive coordinator is dispatch-only: file-mutation tools are blocked", () => {
|
|
88
|
+
const argv = runtime.buildInteractiveSpawn?.({ model: "m" }) ?? [];
|
|
89
|
+
const di = argv.indexOf("--disallowedTools");
|
|
90
|
+
const blocked = argv.slice(di + 1, argv.indexOf("--permission-mode"));
|
|
91
|
+
// Coordinator hires agents; it must not implement. Bash/Read stay (so sling +
|
|
92
|
+
// surveying work); edit tools and native sub-agent tools are blocked.
|
|
93
|
+
expect(blocked).toEqual([
|
|
94
|
+
"Task",
|
|
95
|
+
"Agent",
|
|
96
|
+
"Workflow",
|
|
97
|
+
"Edit",
|
|
98
|
+
"Write",
|
|
99
|
+
"MultiEdit",
|
|
100
|
+
"NotebookEdit",
|
|
101
|
+
]);
|
|
102
|
+
expect(argv).not.toContain("Bash");
|
|
103
|
+
expect(argv).not.toContain("Read");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("buildDirectSpawn includes --resume <id> on a follow-up turn", () => {
|
|
107
|
+
const argv = runtime.buildDirectSpawn(
|
|
108
|
+
spawnOpts({ prompt: "continue", resumeSessionId: "sess-123" }),
|
|
109
|
+
);
|
|
110
|
+
const idx = argv.indexOf("--resume");
|
|
111
|
+
expect(idx).toBeGreaterThan(-1);
|
|
112
|
+
expect(argv[idx + 1]).toBe("sess-123");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("buildDirectSpawn treats an empty resumeSessionId as no resume", () => {
|
|
116
|
+
const argv = runtime.buildDirectSpawn(spawnOpts({ resumeSessionId: "" }));
|
|
117
|
+
expect(argv).not.toContain("--resume");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("buildDirectSpawn defaults a missing prompt to empty string", () => {
|
|
121
|
+
const argv = runtime.buildDirectSpawn(spawnOpts());
|
|
122
|
+
// `-p` must always be followed by a string argument (here "").
|
|
123
|
+
expect(argv[1]).toBe("-p");
|
|
124
|
+
expect(argv[2]).toBe("");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("buildInteractiveSpawn builds an attended session (no -p, default perms)", () => {
|
|
128
|
+
const argv = runtime.buildInteractiveSpawn?.({
|
|
129
|
+
model: "claude-opus-4-8",
|
|
130
|
+
systemPrompt: "You are the coordinator.",
|
|
131
|
+
permissionMode: "default",
|
|
132
|
+
});
|
|
133
|
+
expect(argv).toBeDefined();
|
|
134
|
+
expect(argv?.[0]).toBe("claude");
|
|
135
|
+
expect(argv).not.toContain("-p"); // interactive, not headless
|
|
136
|
+
expect(argv).toContain("--model");
|
|
137
|
+
expect(argv).toContain("claude-opus-4-8");
|
|
138
|
+
expect(argv).toContain("--permission-mode");
|
|
139
|
+
expect(argv).toContain("default");
|
|
140
|
+
expect(argv).toContain("--append-system-prompt");
|
|
141
|
+
expect(argv).toContain("You are the coordinator.");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("buildInteractiveSpawn appends a seed message as the trailing arg", () => {
|
|
145
|
+
const argv = runtime.buildInteractiveSpawn?.({
|
|
146
|
+
model: "m",
|
|
147
|
+
initialMessage: "build a todo app",
|
|
148
|
+
});
|
|
149
|
+
expect(argv?.[argv.length - 1]).toBe("build a todo app");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("buildInteractiveSpawn maps bypass → bypassPermissions (auto mode)", () => {
|
|
153
|
+
const argv = runtime.buildInteractiveSpawn?.({ model: "m", permissionMode: "bypass" });
|
|
154
|
+
expect(argv).toContain("--permission-mode");
|
|
155
|
+
expect(argv).toContain("bypassPermissions");
|
|
156
|
+
expect(argv).not.toContain("default");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("buildInteractiveSpawn omits the system prompt flag when none given", () => {
|
|
160
|
+
const argv = runtime.buildInteractiveSpawn?.({ model: "m" });
|
|
161
|
+
expect(argv).not.toContain("--append-system-prompt");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("buildPrintCommand emits text output and appends model only when given", () => {
|
|
165
|
+
expect(runtime.buildPrintCommand("hi")).toEqual([
|
|
166
|
+
"claude",
|
|
167
|
+
"-p",
|
|
168
|
+
"hi",
|
|
169
|
+
"--output-format",
|
|
170
|
+
"text",
|
|
171
|
+
]);
|
|
172
|
+
expect(runtime.buildPrintCommand("hi", "opus")).toEqual([
|
|
173
|
+
"claude",
|
|
174
|
+
"-p",
|
|
175
|
+
"hi",
|
|
176
|
+
"--output-format",
|
|
177
|
+
"text",
|
|
178
|
+
"--model",
|
|
179
|
+
"opus",
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("buildEnv copies provider env and is not the same reference", () => {
|
|
184
|
+
const model: ResolvedModel = { model: "m", env: { ANTHROPIC_API_KEY: "sk-test" } };
|
|
185
|
+
const env = runtime.buildEnv(model);
|
|
186
|
+
expect(env).toEqual({ ANTHROPIC_API_KEY: "sk-test" });
|
|
187
|
+
// A fresh object — mutating the result must not corrupt the source.
|
|
188
|
+
env.EXTRA = "x";
|
|
189
|
+
expect(model.env).toEqual({ ANTHROPIC_API_KEY: "sk-test" });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("buildEnv returns an empty object when the model has no env", () => {
|
|
193
|
+
expect(runtime.buildEnv({ model: "m" })).toEqual({});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("parseEvents yields one event per JSON line and captures sessionId", async () => {
|
|
197
|
+
const stream = streamFromChunks([
|
|
198
|
+
`${JSON.stringify({ type: "system", subtype: "init", session_id: "abc-123" })}\n`,
|
|
199
|
+
`${JSON.stringify({ type: "result", is_error: false })}\n`,
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
const events: AgentEvent[] = [];
|
|
203
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
204
|
+
|
|
205
|
+
expect(events).toHaveLength(2);
|
|
206
|
+
expect(events[0]?.type).toBe("system");
|
|
207
|
+
expect(events[0]?.sessionId).toBe("abc-123");
|
|
208
|
+
// Only the init/system event carries a session id.
|
|
209
|
+
expect(events[1]?.type).toBe("result");
|
|
210
|
+
expect(events[1]?.sessionId).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("parseEvents reassembles a JSON object split across chunk boundaries", async () => {
|
|
214
|
+
const full = JSON.stringify({ type: "system", session_id: "split-1" });
|
|
215
|
+
const mid = Math.floor(full.length / 2);
|
|
216
|
+
// Newline withheld until the final chunk — exercises cross-read buffering.
|
|
217
|
+
const stream = streamFromChunks([full.slice(0, mid), `${full.slice(mid)}\n`]);
|
|
218
|
+
|
|
219
|
+
const events: AgentEvent[] = [];
|
|
220
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
221
|
+
|
|
222
|
+
expect(events).toHaveLength(1);
|
|
223
|
+
expect(events[0]?.sessionId).toBe("split-1");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("parseEvents lifts a tool_use name into the tool field", async () => {
|
|
227
|
+
const line = JSON.stringify({
|
|
228
|
+
type: "assistant",
|
|
229
|
+
message: { content: [{ type: "tool_use", name: "Edit", input: {} }] },
|
|
230
|
+
});
|
|
231
|
+
const stream = streamFromChunks([`${line}\n`]);
|
|
232
|
+
|
|
233
|
+
const events: AgentEvent[] = [];
|
|
234
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
235
|
+
|
|
236
|
+
expect(events).toHaveLength(1);
|
|
237
|
+
expect(events[0]?.type).toBe("assistant");
|
|
238
|
+
expect(events[0]?.tool).toBe("Edit");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("parseEvents extracts token usage + cost from a result event", async () => {
|
|
242
|
+
const line = JSON.stringify({
|
|
243
|
+
type: "result",
|
|
244
|
+
total_cost_usd: 0.0123,
|
|
245
|
+
usage: {
|
|
246
|
+
input_tokens: 100,
|
|
247
|
+
output_tokens: 50,
|
|
248
|
+
cache_creation_input_tokens: 10,
|
|
249
|
+
cache_read_input_tokens: 5,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
const stream = streamFromChunks([`${line}\n`]);
|
|
253
|
+
|
|
254
|
+
const events: AgentEvent[] = [];
|
|
255
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
256
|
+
|
|
257
|
+
expect(events).toHaveLength(1);
|
|
258
|
+
expect(events[0]?.usage).toEqual({ tokens: 165, costUsd: 0.0123 });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("parseEvents leaves usage undefined for non-result events / zero spend", async () => {
|
|
262
|
+
const stream = streamFromChunks([
|
|
263
|
+
`${JSON.stringify({ type: "assistant", message: { content: [] } })}\n`,
|
|
264
|
+
`${JSON.stringify({ type: "result", total_cost_usd: 0, usage: {} })}\n`,
|
|
265
|
+
]);
|
|
266
|
+
const events: AgentEvent[] = [];
|
|
267
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
268
|
+
expect(events[0]?.usage).toBeUndefined();
|
|
269
|
+
expect(events[1]?.usage).toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("parseEvents surfaces an error message from an is_error result", async () => {
|
|
273
|
+
const stream = streamFromChunks([
|
|
274
|
+
`${JSON.stringify({ type: "result", is_error: true, result: "rate limit exceeded" })}\n`,
|
|
275
|
+
]);
|
|
276
|
+
const events: AgentEvent[] = [];
|
|
277
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
278
|
+
expect(events[0]?.error).toBe("rate limit exceeded");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("parseEvents skips blank lines and malformed JSON without throwing", async () => {
|
|
282
|
+
const valid = JSON.stringify({ type: "result" });
|
|
283
|
+
const stream = streamFromChunks([
|
|
284
|
+
"\n",
|
|
285
|
+
"not json at all\n",
|
|
286
|
+
"{ partial: \n", // unparseable fragment terminated by newline
|
|
287
|
+
`${valid}\n`,
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
const events: AgentEvent[] = [];
|
|
291
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
292
|
+
|
|
293
|
+
expect(events).toHaveLength(1);
|
|
294
|
+
expect(events[0]?.type).toBe("result");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("parseEvents emits a final line that lacks a trailing newline", async () => {
|
|
298
|
+
const line = JSON.stringify({ type: "result", session_id: "tail-1" });
|
|
299
|
+
const stream = streamFromChunks([line]); // no "\n"
|
|
300
|
+
|
|
301
|
+
const events: AgentEvent[] = [];
|
|
302
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
303
|
+
|
|
304
|
+
expect(events).toHaveLength(1);
|
|
305
|
+
expect(events[0]?.sessionId).toBe("tail-1");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("MockRuntime", () => {
|
|
310
|
+
const runtime = new MockRuntime();
|
|
311
|
+
// Snapshot and restore the env vars the mock reads so tests stay isolated.
|
|
312
|
+
let savedCmd: string | undefined;
|
|
313
|
+
let savedPrint: string | undefined;
|
|
314
|
+
|
|
315
|
+
beforeEach(() => {
|
|
316
|
+
savedCmd = process.env.AGENTPLATE_MOCK_CMD;
|
|
317
|
+
savedPrint = process.env.AGENTPLATE_MOCK_PRINT;
|
|
318
|
+
delete process.env.AGENTPLATE_MOCK_CMD;
|
|
319
|
+
delete process.env.AGENTPLATE_MOCK_PRINT;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
afterEach(() => {
|
|
323
|
+
if (savedCmd === undefined) delete process.env.AGENTPLATE_MOCK_CMD;
|
|
324
|
+
else process.env.AGENTPLATE_MOCK_CMD = savedCmd;
|
|
325
|
+
if (savedPrint === undefined) delete process.env.AGENTPLATE_MOCK_PRINT;
|
|
326
|
+
else process.env.AGENTPLATE_MOCK_PRINT = savedPrint;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("static metadata", () => {
|
|
330
|
+
expect(runtime.id).toBe("mock");
|
|
331
|
+
expect(runtime.stability).toBe("experimental");
|
|
332
|
+
expect(runtime.instructionPath).toBe("CLAUDE.md");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("buildDirectSpawn defaults to a no-op `true` command", () => {
|
|
336
|
+
expect(runtime.buildDirectSpawn(spawnOpts())).toEqual(["bash", "-lc", "true"]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("buildDirectSpawn uses AGENTPLATE_MOCK_CMD when set", () => {
|
|
340
|
+
process.env.AGENTPLATE_MOCK_CMD = "echo hi > out.txt && git add -A";
|
|
341
|
+
expect(runtime.buildDirectSpawn(spawnOpts())).toEqual([
|
|
342
|
+
"bash",
|
|
343
|
+
"-lc",
|
|
344
|
+
"echo hi > out.txt && git add -A",
|
|
345
|
+
]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("buildPrintCommand defaults to `echo mock` and honors AGENTPLATE_MOCK_PRINT", () => {
|
|
349
|
+
expect(runtime.buildPrintCommand("ignored")).toEqual(["bash", "-lc", "echo mock"]);
|
|
350
|
+
process.env.AGENTPLATE_MOCK_PRINT = "echo custom";
|
|
351
|
+
expect(runtime.buildPrintCommand("ignored")).toEqual(["bash", "-lc", "echo custom"]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("buildInteractiveSpawn is a scripted bash command (default `true`)", () => {
|
|
355
|
+
const saved = process.env.AGENTPLATE_MOCK_INTERACTIVE;
|
|
356
|
+
delete process.env.AGENTPLATE_MOCK_INTERACTIVE;
|
|
357
|
+
expect(runtime.buildInteractiveSpawn?.({ model: "m" })).toEqual(["bash", "-lc", "true"]);
|
|
358
|
+
if (saved === undefined) delete process.env.AGENTPLATE_MOCK_INTERACTIVE;
|
|
359
|
+
else process.env.AGENTPLATE_MOCK_INTERACTIVE = saved;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("the scripted command actually runs in a real subprocess", async () => {
|
|
363
|
+
// Real subprocess (no mocking): the argv from buildDirectSpawn must be
|
|
364
|
+
// directly executable by Bun.spawn and produce the scripted output.
|
|
365
|
+
process.env.AGENTPLATE_MOCK_CMD = "printf agentplate-mock-ran";
|
|
366
|
+
const argv = runtime.buildDirectSpawn(spawnOpts());
|
|
367
|
+
const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" });
|
|
368
|
+
const stdout = await new Response(proc.stdout).text();
|
|
369
|
+
const exitCode = await proc.exited;
|
|
370
|
+
expect(exitCode).toBe(0);
|
|
371
|
+
expect(stdout).toBe("agentplate-mock-ran");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("buildEnv copies provider env", () => {
|
|
375
|
+
expect(runtime.buildEnv({ model: "m", env: { FOO: "bar" } })).toEqual({ FOO: "bar" });
|
|
376
|
+
expect(runtime.buildEnv({ model: "m" })).toEqual({});
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("OpenCodeRuntime", () => {
|
|
381
|
+
const runtime = new OpenCodeRuntime();
|
|
382
|
+
|
|
383
|
+
test("metadata", () => {
|
|
384
|
+
expect(runtime.id).toBe("opencode");
|
|
385
|
+
expect(runtime.instructionPath).toBe("AGENTS.md");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("metadata stability is beta", () => {
|
|
389
|
+
expect(runtime.stability).toBe("beta");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("buildDirectSpawn: positional message + json; permissions handled via env, not a flag", () => {
|
|
393
|
+
const argv = runtime.buildDirectSpawn(
|
|
394
|
+
spawnOpts({ prompt: "do it", model: "openrouter/gpt-4o" }),
|
|
395
|
+
);
|
|
396
|
+
expect(argv.slice(0, 2)).toEqual(["opencode", "run"]);
|
|
397
|
+
// The `run` subcommand takes the message as a positional (no --prompt flag).
|
|
398
|
+
expect(argv).not.toContain("--prompt");
|
|
399
|
+
expect(argv[argv.length - 1]).toBe("do it");
|
|
400
|
+
expect(argv).toContain("--model");
|
|
401
|
+
expect(argv).toContain("openrouter/gpt-4o"); // provider-qualified, passes through
|
|
402
|
+
expect(argv).toContain("--format");
|
|
403
|
+
expect(argv).toContain("json");
|
|
404
|
+
// Auto-approve is via OPENCODE_PERMISSION (buildEnv), not the fragile run-only flag.
|
|
405
|
+
expect(argv).not.toContain("--dangerously-skip-permissions");
|
|
406
|
+
expect(argv).not.toContain("--session"); // no resume on first turn
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("buildEnv injects OPENCODE_PERMISSION (allow + guardrails) alongside provider env", () => {
|
|
410
|
+
const env = runtime.buildEnv({ model: "m", env: { OPENROUTER_API_KEY: "k" } });
|
|
411
|
+
expect(env.OPENROUTER_API_KEY).toBe("k"); // provider env preserved
|
|
412
|
+
const policy = JSON.parse(env.OPENCODE_PERMISSION ?? "{}");
|
|
413
|
+
expect(policy.edit).toBe("allow");
|
|
414
|
+
expect(policy.external_directory).toBe("allow"); // the headless gotcha
|
|
415
|
+
expect(policy.bash["*"]).toBe("allow");
|
|
416
|
+
expect(policy.bash["rm -rf *"]).toBe("deny"); // destructive guardrail (deny, not ask)
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("opencodeModel prefixes bare ids with opencode/ and leaves provider-qualified ids", () => {
|
|
420
|
+
expect(opencodeModel("minimax-m3-free")).toBe("opencode/minimax-m3-free");
|
|
421
|
+
expect(opencodeModel("opencode/minimax-m3-free")).toBe("opencode/minimax-m3-free");
|
|
422
|
+
expect(opencodeModel("openrouter/google/gemini-2.5-flash-lite")).toBe(
|
|
423
|
+
"openrouter/google/gemini-2.5-flash-lite",
|
|
424
|
+
);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("buildDirectSpawn normalizes a bare model to opencode/<model>", () => {
|
|
428
|
+
const argv = runtime.buildDirectSpawn(spawnOpts({ prompt: "x", model: "minimax-m3-free" }));
|
|
429
|
+
const i = argv.indexOf("--model");
|
|
430
|
+
expect(argv[i + 1]).toBe("opencode/minimax-m3-free");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("buildDirectSpawn adds --session on resume and keeps the message last", () => {
|
|
434
|
+
const argv = runtime.buildDirectSpawn(
|
|
435
|
+
spawnOpts({ prompt: "go on", resumeSessionId: "sess-1" }),
|
|
436
|
+
);
|
|
437
|
+
const idx = argv.indexOf("--session");
|
|
438
|
+
expect(idx).toBeGreaterThan(-1);
|
|
439
|
+
expect(argv[idx + 1]).toBe("sess-1");
|
|
440
|
+
expect(argv[argv.length - 1]).toBe("go on");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("buildInteractiveSpawn seeds via top-level --prompt and never adds the run-only perm flag", () => {
|
|
444
|
+
const argv = runtime.buildInteractiveSpawn?.({
|
|
445
|
+
model: "openrouter/gpt-4o",
|
|
446
|
+
permissionMode: "bypass",
|
|
447
|
+
initialMessage: "build x",
|
|
448
|
+
});
|
|
449
|
+
expect(argv?.slice(0, 3)).toEqual(["opencode", "--model", "openrouter/gpt-4o"]);
|
|
450
|
+
expect(argv).not.toContain("run");
|
|
451
|
+
// --dangerously-skip-permissions is run-only; on the interactive entrypoint it
|
|
452
|
+
// makes opencode exit 1, so it must NOT be passed here (TUI approves instead).
|
|
453
|
+
expect(argv).not.toContain("--dangerously-skip-permissions");
|
|
454
|
+
expect(argv).toContain("--prompt");
|
|
455
|
+
expect(argv?.[argv.length - 1]).toBe("build x");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("buildInteractiveSpawn without a seed is a bare attended session", () => {
|
|
459
|
+
const argv = runtime.buildInteractiveSpawn?.({
|
|
460
|
+
model: "openrouter/m",
|
|
461
|
+
permissionMode: "default",
|
|
462
|
+
});
|
|
463
|
+
expect(argv).toEqual(["opencode", "--model", "openrouter/m"]);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("buildPrintCommand emits text format with the prompt as a positional", () => {
|
|
467
|
+
expect(runtime.buildPrintCommand("hi", "openrouter/gpt-4o")).toEqual([
|
|
468
|
+
"opencode",
|
|
469
|
+
"run",
|
|
470
|
+
"--format",
|
|
471
|
+
"text",
|
|
472
|
+
"--model",
|
|
473
|
+
"openrouter/gpt-4o",
|
|
474
|
+
"hi",
|
|
475
|
+
]);
|
|
476
|
+
expect(runtime.buildPrintCommand("hi")).toEqual(["opencode", "run", "--format", "text", "hi"]);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("parseEvents captures sessionID and passes event types through", async () => {
|
|
480
|
+
const stream = streamFromChunks([
|
|
481
|
+
`${JSON.stringify({ type: "step.start", sessionID: "ses_1", timestamp: 1 })}\n`,
|
|
482
|
+
`${JSON.stringify({ type: "error", sessionID: "ses_1", error: { name: "X" } })}\n`,
|
|
483
|
+
]);
|
|
484
|
+
const events: AgentEvent[] = [];
|
|
485
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
486
|
+
|
|
487
|
+
expect(events).toHaveLength(2);
|
|
488
|
+
expect(events[0]?.type).toBe("step.start");
|
|
489
|
+
expect(events[0]?.sessionId).toBe("ses_1");
|
|
490
|
+
expect(events[1]?.type).toBe("error");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("parseEvents lifts a tool name from a part of type tool, skips junk", async () => {
|
|
494
|
+
const toolLine = JSON.stringify({
|
|
495
|
+
type: "message.part.updated",
|
|
496
|
+
sessionID: "ses_2",
|
|
497
|
+
part: { type: "tool", tool: "bash" },
|
|
498
|
+
});
|
|
499
|
+
const stream = streamFromChunks(["\n", "not json\n", `${toolLine}\n`]);
|
|
500
|
+
const events: AgentEvent[] = [];
|
|
501
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
502
|
+
|
|
503
|
+
expect(events).toHaveLength(1);
|
|
504
|
+
expect(events[0]?.tool).toBe("bash");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("parseEvents surfaces the error message (real opencode 'Model not found' shape)", async () => {
|
|
508
|
+
const line = JSON.stringify({
|
|
509
|
+
type: "error",
|
|
510
|
+
sessionID: "ses_3",
|
|
511
|
+
error: { name: "UnknownError", data: { message: "Model not found: openrouter/x" } },
|
|
512
|
+
});
|
|
513
|
+
const stream = streamFromChunks([`${line}\n`]);
|
|
514
|
+
const events: AgentEvent[] = [];
|
|
515
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
516
|
+
|
|
517
|
+
expect(events[0]?.type).toBe("error");
|
|
518
|
+
expect(events[0]?.error).toBe("Model not found: openrouter/x");
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
describe("CodexRuntime", () => {
|
|
523
|
+
const runtime = new CodexRuntime();
|
|
524
|
+
|
|
525
|
+
test("metadata", () => {
|
|
526
|
+
expect(runtime.id).toBe("codex");
|
|
527
|
+
expect(runtime.stability).toBe("beta");
|
|
528
|
+
expect(runtime.instructionPath).toBe("AGENTS.md");
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("buildDirectSpawn uses `codex exec --json --model …` with the bypass flag", () => {
|
|
532
|
+
const argv = runtime.buildDirectSpawn(spawnOpts({ prompt: "do it", model: "gpt-5-codex" }));
|
|
533
|
+
expect(argv.slice(0, 3)).toEqual(["codex", "exec", "--json"]);
|
|
534
|
+
expect(argv).toContain("--model");
|
|
535
|
+
expect(argv).toContain("gpt-5-codex");
|
|
536
|
+
expect(argv).toContain("--dangerously-bypass-approvals-and-sandbox");
|
|
537
|
+
expect(argv[argv.length - 1]).toBe("do it"); // prompt is the trailing positional
|
|
538
|
+
expect(argv).not.toContain("resume"); // no resume on the first turn
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("buildDirectSpawn uses the `resume <id>` subcommand on a follow-up turn", () => {
|
|
542
|
+
const argv = runtime.buildDirectSpawn(
|
|
543
|
+
spawnOpts({ prompt: "go on", resumeSessionId: "uuid-1" }),
|
|
544
|
+
);
|
|
545
|
+
expect(argv.slice(0, 4)).toEqual(["codex", "exec", "resume", "uuid-1"]);
|
|
546
|
+
expect(argv).toContain("--json");
|
|
547
|
+
expect(argv[argv.length - 1]).toBe("go on");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("buildDirectSpawn treats an empty resumeSessionId as no resume", () => {
|
|
551
|
+
const argv = runtime.buildDirectSpawn(spawnOpts({ resumeSessionId: "" }));
|
|
552
|
+
expect(argv).not.toContain("resume");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("buildInteractiveSpawn is an attended `codex --model` (no exec)", () => {
|
|
556
|
+
const argv = runtime.buildInteractiveSpawn?.({ model: "gpt-5-codex" });
|
|
557
|
+
expect(argv).toEqual(["codex", "--model", "gpt-5-codex"]);
|
|
558
|
+
expect(argv).not.toContain("exec");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("buildInteractiveSpawn appends a seed message as the trailing arg", () => {
|
|
562
|
+
const argv = runtime.buildInteractiveSpawn?.({ model: "m", initialMessage: "build x" });
|
|
563
|
+
expect(argv?.[argv.length - 1]).toBe("build x");
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("buildPrintCommand stays non-interactive and appends model only when given", () => {
|
|
567
|
+
expect(runtime.buildPrintCommand("hi")).toEqual([
|
|
568
|
+
"codex",
|
|
569
|
+
"exec",
|
|
570
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
571
|
+
"hi",
|
|
572
|
+
]);
|
|
573
|
+
const withModel = runtime.buildPrintCommand("hi", "gpt-5-codex");
|
|
574
|
+
expect(withModel).toContain("--model");
|
|
575
|
+
expect(withModel).toContain("gpt-5-codex");
|
|
576
|
+
expect(withModel[withModel.length - 1]).toBe("hi");
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("buildEnv copies provider env and is a fresh object", () => {
|
|
580
|
+
const model: ResolvedModel = { model: "m", env: { OPENAI_API_KEY: "sk-test" } };
|
|
581
|
+
const env = runtime.buildEnv(model);
|
|
582
|
+
expect(env).toEqual({ OPENAI_API_KEY: "sk-test" });
|
|
583
|
+
env.EXTRA = "x";
|
|
584
|
+
expect(model.env).toEqual({ OPENAI_API_KEY: "sk-test" });
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("buildEnv returns empty for subscription/OAuth login (no key injected)", () => {
|
|
588
|
+
expect(runtime.buildEnv({ model: "m" })).toEqual({});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("parseEvents captures the session id from a session_configured event", async () => {
|
|
592
|
+
const stream = streamFromChunks([
|
|
593
|
+
`${JSON.stringify({ id: "0", msg: { type: "session_configured", session_id: "sess-9" } })}\n`,
|
|
594
|
+
`${JSON.stringify({ id: "1", msg: { type: "task_complete" } })}\n`,
|
|
595
|
+
]);
|
|
596
|
+
|
|
597
|
+
const events: AgentEvent[] = [];
|
|
598
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
599
|
+
|
|
600
|
+
expect(events).toHaveLength(2);
|
|
601
|
+
expect(events[0]?.type).toBe("session_configured");
|
|
602
|
+
expect(events[0]?.sessionId).toBe("sess-9");
|
|
603
|
+
expect(events[1]?.type).toBe("task_complete");
|
|
604
|
+
expect(events[1]?.sessionId).toBeUndefined();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("parseEvents lifts a tool name from exec_command_begin / mcp_tool_call_begin", async () => {
|
|
608
|
+
const stream = streamFromChunks([
|
|
609
|
+
`${JSON.stringify({ msg: { type: "exec_command_begin", command: ["ls"] } })}\n`,
|
|
610
|
+
`${JSON.stringify({ msg: { type: "mcp_tool_call_begin", tool: "search" } })}\n`,
|
|
611
|
+
]);
|
|
612
|
+
|
|
613
|
+
const events: AgentEvent[] = [];
|
|
614
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
615
|
+
|
|
616
|
+
expect(events[0]?.tool).toBe("shell");
|
|
617
|
+
expect(events[1]?.tool).toBe("search");
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("parseEvents tolerates the flatter thread/item shape and skips junk", async () => {
|
|
621
|
+
const stream = streamFromChunks([
|
|
622
|
+
"\n",
|
|
623
|
+
"not json\n",
|
|
624
|
+
`${JSON.stringify({ type: "thread.started", thread_id: "th-1" })}\n`,
|
|
625
|
+
]);
|
|
626
|
+
|
|
627
|
+
const events: AgentEvent[] = [];
|
|
628
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
629
|
+
|
|
630
|
+
expect(events).toHaveLength(1);
|
|
631
|
+
expect(events[0]?.type).toBe("thread.started");
|
|
632
|
+
expect(events[0]?.sessionId).toBe("th-1");
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("parseEvents handles the real 0.128 thread/item stream (tool + usage)", async () => {
|
|
636
|
+
const stream = streamFromChunks([
|
|
637
|
+
`${JSON.stringify({ type: "thread.started", thread_id: "th-9" })}\n`,
|
|
638
|
+
`${JSON.stringify({ type: "item.completed", item: { id: "i0", type: "agent_message", text: "Hi." } })}\n`,
|
|
639
|
+
`${JSON.stringify({ type: "item.completed", item: { id: "i1", type: "command_execution", command: "ls" } })}\n`,
|
|
640
|
+
`${JSON.stringify({ type: "turn.completed", usage: { input_tokens: 100, cached_input_tokens: 30, output_tokens: 20, reasoning_output_tokens: 5 } })}\n`,
|
|
641
|
+
]);
|
|
642
|
+
const events: AgentEvent[] = [];
|
|
643
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
644
|
+
|
|
645
|
+
expect(events).toHaveLength(4);
|
|
646
|
+
expect(events[0]?.sessionId).toBe("th-9");
|
|
647
|
+
expect(events[1]?.tool).toBeUndefined(); // agent_message is not a tool
|
|
648
|
+
expect(events[2]?.tool).toBe("shell"); // command_execution → shell
|
|
649
|
+
// input + output only (cached/reasoning are subsets), cost unknown → 0.
|
|
650
|
+
expect(events[3]?.usage).toEqual({ tokens: 120, costUsd: 0 });
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
describe("GeminiRuntime", () => {
|
|
655
|
+
const runtime = new GeminiRuntime();
|
|
656
|
+
|
|
657
|
+
test("metadata", () => {
|
|
658
|
+
expect(runtime.id).toBe("gemini");
|
|
659
|
+
expect(runtime.stability).toBe("beta");
|
|
660
|
+
expect(runtime.instructionPath).toBe("GEMINI.md");
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("buildDirectSpawn uses stream-json + --skip-trust + --yolo (headless)", () => {
|
|
664
|
+
const argv = runtime.buildDirectSpawn(spawnOpts({ prompt: "do it", model: "gemini-2.5-pro" }));
|
|
665
|
+
expect(argv[0]).toBe("gemini");
|
|
666
|
+
expect(argv).toContain("--skip-trust"); // untrusted-worktree gotcha
|
|
667
|
+
expect(argv).toContain("--yolo");
|
|
668
|
+
expect(argv).toContain("--model");
|
|
669
|
+
expect(argv).toContain("gemini-2.5-pro");
|
|
670
|
+
expect(argv).toContain("--output-format");
|
|
671
|
+
expect(argv).toContain("stream-json");
|
|
672
|
+
expect(argv).toContain("--prompt");
|
|
673
|
+
expect(argv[argv.length - 1]).toBe("do it");
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("buildDirectSpawn ignores resumeSessionId (Gemini resume is index-based)", () => {
|
|
677
|
+
const argv = runtime.buildDirectSpawn(spawnOpts({ resumeSessionId: "sess-1" }));
|
|
678
|
+
expect(argv).not.toContain("sess-1");
|
|
679
|
+
expect(argv).not.toContain("--resume");
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("buildInteractiveSpawn: --skip-trust always; --yolo only in bypass; seed via -i", () => {
|
|
683
|
+
const bypass = runtime.buildInteractiveSpawn?.({
|
|
684
|
+
model: "m",
|
|
685
|
+
permissionMode: "bypass",
|
|
686
|
+
initialMessage: "build x",
|
|
687
|
+
});
|
|
688
|
+
expect(bypass?.[0]).toBe("gemini");
|
|
689
|
+
expect(bypass).toContain("--skip-trust");
|
|
690
|
+
expect(bypass).toContain("--yolo");
|
|
691
|
+
expect(bypass).toContain("--prompt-interactive");
|
|
692
|
+
expect(bypass?.[bypass.length - 1]).toBe("build x");
|
|
693
|
+
|
|
694
|
+
const safe = runtime.buildInteractiveSpawn?.({ model: "m", permissionMode: "default" });
|
|
695
|
+
expect(safe).toContain("--skip-trust");
|
|
696
|
+
expect(safe).not.toContain("--yolo"); // --safe keeps in-TUI approval
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("buildPrintCommand emits a text --prompt call with --skip-trust", () => {
|
|
700
|
+
expect(runtime.buildPrintCommand("hi")).toEqual([
|
|
701
|
+
"gemini",
|
|
702
|
+
"--skip-trust",
|
|
703
|
+
"--output-format",
|
|
704
|
+
"text",
|
|
705
|
+
"--prompt",
|
|
706
|
+
"hi",
|
|
707
|
+
]);
|
|
708
|
+
const withModel = runtime.buildPrintCommand("hi", "gemini-2.5-flash");
|
|
709
|
+
expect(withModel).toContain("--model");
|
|
710
|
+
expect(withModel).toContain("gemini-2.5-flash");
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("buildEnv copies provider env and returns empty for subscription/OAuth login", () => {
|
|
714
|
+
expect(runtime.buildEnv({ model: "m", env: { GEMINI_API_KEY: "k" } })).toEqual({
|
|
715
|
+
GEMINI_API_KEY: "k",
|
|
716
|
+
});
|
|
717
|
+
expect(runtime.buildEnv({ model: "m" })).toEqual({});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("parseEvents handles the real stream-json (init session_id, result usage)", async () => {
|
|
721
|
+
const stream = streamFromChunks([
|
|
722
|
+
`${JSON.stringify({ type: "init", session_id: "g-1", model: "auto" })}\n`,
|
|
723
|
+
`${JSON.stringify({ type: "message", role: "assistant", content: "hi" })}\n`,
|
|
724
|
+
`${JSON.stringify({ type: "result", status: "success", stats: { total_tokens: 14341 } })}\n`,
|
|
725
|
+
]);
|
|
726
|
+
const events: AgentEvent[] = [];
|
|
727
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
728
|
+
|
|
729
|
+
expect(events).toHaveLength(3);
|
|
730
|
+
expect(events[0]?.sessionId).toBe("g-1");
|
|
731
|
+
expect(events[2]?.usage).toEqual({ tokens: 14341, costUsd: 0 });
|
|
732
|
+
expect(events[2]?.error).toBeUndefined();
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("parseEvents surfaces a failed result status as an error", async () => {
|
|
736
|
+
const stream = streamFromChunks([
|
|
737
|
+
`${JSON.stringify({ type: "result", status: "error", error: "quota exceeded" })}\n`,
|
|
738
|
+
]);
|
|
739
|
+
const events: AgentEvent[] = [];
|
|
740
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
741
|
+
expect(events[0]?.error).toContain("quota exceeded");
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("parseEvents skips blank/malformed lines", async () => {
|
|
745
|
+
const events: AgentEvent[] = [];
|
|
746
|
+
for await (const ev of runtime.parseEvents(streamFromChunks(["\n", "not json\n"]))) {
|
|
747
|
+
events.push(ev);
|
|
748
|
+
}
|
|
749
|
+
expect(events).toHaveLength(0);
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
describe("CursorRuntime", () => {
|
|
754
|
+
const runtime = new CursorRuntime();
|
|
755
|
+
|
|
756
|
+
test("metadata", () => {
|
|
757
|
+
expect(runtime.id).toBe("cursor");
|
|
758
|
+
expect(runtime.stability).toBe("beta");
|
|
759
|
+
expect(runtime.instructionPath).toBe("AGENTS.md");
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test("buildDirectSpawn invokes the `cursor-agent` binary with stream-json + --force", () => {
|
|
763
|
+
const argv = runtime.buildDirectSpawn(spawnOpts({ prompt: "do it", model: "gpt-5" }));
|
|
764
|
+
expect(argv.slice(0, 3)).toEqual(["cursor-agent", "-p", "do it"]);
|
|
765
|
+
expect(argv).toContain("--output-format");
|
|
766
|
+
expect(argv).toContain("stream-json");
|
|
767
|
+
expect(argv).toContain("--model");
|
|
768
|
+
expect(argv).toContain("gpt-5");
|
|
769
|
+
expect(argv).toContain("--force");
|
|
770
|
+
expect(argv).not.toContain("--resume"); // no resume on the first turn
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("buildDirectSpawn adds --resume <id> on a follow-up turn", () => {
|
|
774
|
+
const argv = runtime.buildDirectSpawn(spawnOpts({ resumeSessionId: "chat-1" }));
|
|
775
|
+
const idx = argv.indexOf("--resume");
|
|
776
|
+
expect(idx).toBeGreaterThan(-1);
|
|
777
|
+
expect(argv[idx + 1]).toBe("chat-1");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("buildDirectSpawn treats an empty resumeSessionId as no resume", () => {
|
|
781
|
+
const argv = runtime.buildDirectSpawn(spawnOpts({ resumeSessionId: "" }));
|
|
782
|
+
expect(argv).not.toContain("--resume");
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("buildInteractiveSpawn is an attended `cursor-agent --model` (no -p)", () => {
|
|
786
|
+
const argv = runtime.buildInteractiveSpawn?.({ model: "gpt-5" });
|
|
787
|
+
expect(argv).toEqual(["cursor-agent", "--model", "gpt-5"]);
|
|
788
|
+
expect(argv).not.toContain("-p");
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test("buildInteractiveSpawn appends a seed message as the trailing arg", () => {
|
|
792
|
+
const argv = runtime.buildInteractiveSpawn?.({ model: "m", initialMessage: "build x" });
|
|
793
|
+
expect(argv?.[argv.length - 1]).toBe("build x");
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("buildPrintCommand emits text output and appends model only when given", () => {
|
|
797
|
+
expect(runtime.buildPrintCommand("hi")).toEqual([
|
|
798
|
+
"cursor-agent",
|
|
799
|
+
"-p",
|
|
800
|
+
"hi",
|
|
801
|
+
"--output-format",
|
|
802
|
+
"text",
|
|
803
|
+
]);
|
|
804
|
+
const withModel = runtime.buildPrintCommand("hi", "gpt-5");
|
|
805
|
+
expect(withModel).toContain("--model");
|
|
806
|
+
expect(withModel).toContain("gpt-5");
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
test("buildEnv copies provider env and returns empty for subscription/OAuth login", () => {
|
|
810
|
+
expect(runtime.buildEnv({ model: "m", env: { CURSOR_API_KEY: "k" } })).toEqual({
|
|
811
|
+
CURSOR_API_KEY: "k",
|
|
812
|
+
});
|
|
813
|
+
expect(runtime.buildEnv({ model: "m" })).toEqual({});
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test("parseEvents captures the chat id under any common key spelling", async () => {
|
|
817
|
+
const stream = streamFromChunks([
|
|
818
|
+
`${JSON.stringify({ type: "system", chat_id: "chat-9" })}\n`,
|
|
819
|
+
`${JSON.stringify({ type: "result" })}\n`,
|
|
820
|
+
]);
|
|
821
|
+
|
|
822
|
+
const events: AgentEvent[] = [];
|
|
823
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
824
|
+
|
|
825
|
+
expect(events).toHaveLength(2);
|
|
826
|
+
expect(events[0]?.sessionId).toBe("chat-9");
|
|
827
|
+
expect(events[1]?.sessionId).toBeUndefined();
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test("parseEvents lifts a tool_use name and skips malformed lines", async () => {
|
|
831
|
+
const toolLine = JSON.stringify({
|
|
832
|
+
type: "assistant",
|
|
833
|
+
message: { content: [{ type: "tool_use", name: "Edit" }] },
|
|
834
|
+
});
|
|
835
|
+
const stream = streamFromChunks(["\n", "not json\n", `${toolLine}\n`]);
|
|
836
|
+
|
|
837
|
+
const events: AgentEvent[] = [];
|
|
838
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
839
|
+
|
|
840
|
+
expect(events).toHaveLength(1);
|
|
841
|
+
expect(events[0]?.tool).toBe("Edit");
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test("parseEvents handles the real stream-json: tool_call name, usage, is_error", async () => {
|
|
845
|
+
const stream = streamFromChunks([
|
|
846
|
+
`${JSON.stringify({ type: "system", subtype: "init", session_id: "s-1" })}\n`,
|
|
847
|
+
`${JSON.stringify({ type: "tool_call", subtype: "started", tool_call: { shellToolCall: { args: { command: "ls" } } } })}\n`,
|
|
848
|
+
`${JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "done", usage: { inputTokens: 6, outputTokens: 90, cacheReadTokens: 100881 } })}\n`,
|
|
849
|
+
]);
|
|
850
|
+
const events: AgentEvent[] = [];
|
|
851
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
852
|
+
|
|
853
|
+
expect(events[0]?.sessionId).toBe("s-1");
|
|
854
|
+
expect(events[1]?.tool).toBe("shell"); // shellToolCall → shell
|
|
855
|
+
expect(events[2]?.usage).toEqual({ tokens: 96, costUsd: 0 }); // input + output
|
|
856
|
+
expect(events[2]?.error).toBeUndefined();
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test("parseEvents surfaces an is_error result as an error", async () => {
|
|
860
|
+
const stream = streamFromChunks([
|
|
861
|
+
`${JSON.stringify({ type: "result", is_error: true, result: "Authentication required" })}\n`,
|
|
862
|
+
]);
|
|
863
|
+
const events: AgentEvent[] = [];
|
|
864
|
+
for await (const ev of runtime.parseEvents(stream)) events.push(ev);
|
|
865
|
+
expect(events[0]?.error).toBe("Authentication required");
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
describe("getRuntime / getRuntimeNames", () => {
|
|
870
|
+
test("lists exactly the registered runtimes in order", () => {
|
|
871
|
+
expect(getRuntimeNames()).toEqual(["claude", "codex", "gemini", "cursor", "opencode", "mock"]);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test("resolves claude by name", () => {
|
|
875
|
+
expect(getRuntime("claude")).toBeInstanceOf(ClaudeRuntime);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("resolves opencode by name", () => {
|
|
879
|
+
expect(getRuntime("opencode")).toBeInstanceOf(OpenCodeRuntime);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
test("resolves codex + gemini + cursor by name (subscription/OAuth runtimes)", () => {
|
|
883
|
+
expect(getRuntime("codex")).toBeInstanceOf(CodexRuntime);
|
|
884
|
+
expect(getRuntime("gemini")).toBeInstanceOf(GeminiRuntime);
|
|
885
|
+
expect(getRuntime("cursor")).toBeInstanceOf(CursorRuntime);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("resolves mock by name", () => {
|
|
889
|
+
expect(getRuntime("mock")).toBeInstanceOf(MockRuntime);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("returns a fresh instance on each call", () => {
|
|
893
|
+
expect(getRuntime("claude")).not.toBe(getRuntime("claude"));
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
test("falls back to claude when no name is given", () => {
|
|
897
|
+
expect(getRuntime()).toBeInstanceOf(ClaudeRuntime);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("honors the fallback argument when name is omitted", () => {
|
|
901
|
+
expect(getRuntime(undefined, "mock")).toBeInstanceOf(MockRuntime);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("an explicit name takes precedence over the fallback", () => {
|
|
905
|
+
expect(getRuntime("claude", "mock")).toBeInstanceOf(ClaudeRuntime);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("throws ValidationError listing valid names on an unknown runtime", () => {
|
|
909
|
+
expect(() => getRuntime("nope")).toThrow(ValidationError);
|
|
910
|
+
try {
|
|
911
|
+
getRuntime("nope");
|
|
912
|
+
throw new Error("expected getRuntime to throw");
|
|
913
|
+
} catch (error) {
|
|
914
|
+
expect(error).toBeInstanceOf(ValidationError);
|
|
915
|
+
expect((error as ValidationError).message).toContain("nope");
|
|
916
|
+
expect((error as ValidationError).message).toContain("claude");
|
|
917
|
+
expect((error as ValidationError).message).toContain("mock");
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test("an unknown fallback also throws ValidationError", () => {
|
|
922
|
+
expect(() => getRuntime(undefined, "bogus")).toThrow(ValidationError);
|
|
923
|
+
});
|
|
924
|
+
});
|