Skip to content

Commit abd8726

Browse files
committed
fix: make gemini smoke subagents runnable
1 parent 644a6d6 commit abd8726

4 files changed

Lines changed: 109 additions & 31 deletions

File tree

packages/cli/src/providers/gemini.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ function readSystemPrompt(filePath?: string): string {
2727
}
2828
}
2929

30+
function buildPrompt(opts: ExecuteOpts): string {
31+
return [readSystemPrompt(opts.systemPromptFile), opts.taskContext].filter(Boolean).join("\n\n");
32+
}
33+
3034
export function parseEvent(raw: string): AgentEvent | null {
3135
let event: any;
3236
try {
@@ -63,17 +67,16 @@ export function parseEvent(raw: string): AgentEvent | null {
6367
}
6468

6569
export function buildArgs(opts: ExecuteOpts): string[] {
66-
const systemPrompt = readSystemPrompt(opts.systemPromptFile);
67-
const args = ["--prompt", systemPrompt, "--output-format", "stream-json", "--yolo"];
70+
const args = ["--prompt", buildPrompt(opts), "--output-format", "stream-json", "--yolo"];
6871
if (opts.model) {
6972
args.push("--model", opts.model);
7073
}
7174
return args;
7275
}
7376

74-
export function buildResumeArgs(model?: string): string[] {
77+
export function buildResumeArgs(model?: string, prompt = ""): string[] {
7578
logger.warn("Gemini CLI does not support resume by session ID — resuming latest session");
76-
const args = ["--resume", "latest", "--prompt", "", "--output-format", "stream-json", "--yolo"];
79+
const args = ["--resume", "latest", "--prompt", prompt, "--output-format", "stream-json", "--yolo"];
7780
if (model) {
7881
args.push("--model", model);
7982
}
@@ -91,14 +94,13 @@ export const geminiProvider: AgentProvider = {
9194
},
9295

9396
execute(opts: ExecuteOpts): Promise<AgentHandle> {
94-
const args = opts.resume ? buildResumeArgs(opts.model) : buildArgs(opts);
97+
const args = opts.resume ? buildResumeArgs(opts.model, opts.taskContext) : buildArgs(opts);
9598
return Promise.resolve(
9699
spawnAgent({
97100
command: "gemini",
98101
args,
99102
cwd: opts.cwd,
100103
env: opts.env,
101-
input: opts.taskContext,
102104
parseEvent,
103105
}),
104106
);

packages/cli/tests/providers.test.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,12 +1056,21 @@ describe("geminiProvider.buildArgs", () => {
10561056
expect(args[promptIdx + 1]).toBe("You are a helpful assistant.");
10571057
});
10581058

1059-
it("uses empty string as prompt when systemPromptFile cannot be read", () => {
1059+
it("combines system prompt content and task context in --prompt", async () => {
1060+
const fsModule = await import("node:fs");
1061+
vi.mocked(fsModule.readFileSync).mockReturnValueOnce("You are a helpful assistant." as any);
1062+
const args = geminiBuildArgs({ sessionId: "s1", cwd: "/", env: {}, taskContext: "Do the task.", systemPromptFile: "/tmp/system.txt" });
1063+
const promptIdx = args.indexOf("--prompt");
1064+
1065+
expect(args[promptIdx + 1]).toBe("You are a helpful assistant.\n\nDo the task.");
1066+
});
1067+
1068+
it("uses task context as prompt when systemPromptFile cannot be read", () => {
10601069
// readFileSync is already mocked to throw by default
1061-
const args = geminiBuildArgs({ sessionId: "s1", cwd: "/", env: {}, taskContext: "", systemPromptFile: "/nonexistent/file.txt" });
1070+
const args = geminiBuildArgs({ sessionId: "s1", cwd: "/", env: {}, taskContext: "Do the task.", systemPromptFile: "/nonexistent/file.txt" });
10621071
const promptIdx = args.indexOf("--prompt");
10631072
expect(promptIdx).toBeGreaterThan(-1);
1064-
expect(args[promptIdx + 1]).toBe("");
1073+
expect(args[promptIdx + 1]).toBe("Do the task.");
10651074
});
10661075
});
10671076

@@ -1093,6 +1102,13 @@ describe("geminiProvider.buildResumeArgs", () => {
10931102
expect(args[idx + 1]).toBe("latest");
10941103
});
10951104

1105+
it("uses task context as resume prompt", () => {
1106+
const args = geminiBuildResumeArgs(undefined, "Address review feedback.");
1107+
const idx = args.indexOf("--prompt");
1108+
1109+
expect(args[idx + 1]).toBe("Address review feedback.");
1110+
});
1111+
10961112
it("includes --model when model is provided", () => {
10971113
const args = geminiBuildResumeArgs("gemini-2.5-pro");
10981114
const idx = args.indexOf("--model");
@@ -1298,13 +1314,11 @@ describe("geminiProvider.parseEvent — error variants", () => {
12981314
});
12991315

13001316
// ---------------------------------------------------------------------------
1301-
// geminiProvider.buildInput — Gemini passes context directly as input
1317+
// geminiProvider.buildInput — Gemini passes context through --prompt
13021318
// ---------------------------------------------------------------------------
13031319

13041320
describe("geminiProvider.buildInput", () => {
1305-
it("execute passes task context directly as input (not wrapped)", () => {
1306-
// Gemini's input is the raw taskContext string — verified by the buildArgs/execute design.
1307-
// We verify this by confirming buildArgs does not add a JSON wrapper flag.
1321+
it("does not add stdin input flags", () => {
13081322
const args = geminiBuildArgs({ sessionId: "s1", cwd: "/", env: {}, taskContext: "do the task" });
13091323
expect(args).not.toContain("--input-format");
13101324
});
@@ -1339,11 +1353,12 @@ describe("geminiProvider.execute — arg selection", () => {
13391353
expect(spawnAgent).toHaveBeenCalled();
13401354
});
13411355

1342-
it("passes taskContext as input to spawnAgent", async () => {
1356+
it("passes taskContext through --prompt instead of stdin", async () => {
13431357
const { spawnAgent } = await import("../src/providers/spawnHelper.js");
13441358
vi.mocked(spawnAgent).mockClear();
13451359
await geminiProvider.execute({ sessionId: "s1", cwd: "/tmp", env: {}, taskContext: "my task context" });
1346-
expect(vi.mocked(spawnAgent)).toHaveBeenCalledWith(expect.objectContaining({ input: "my task context" }));
1360+
expect(vi.mocked(spawnAgent)).toHaveBeenCalledWith(expect.not.objectContaining({ input: expect.any(String) }));
1361+
expect(vi.mocked(spawnAgent).mock.calls[0][0].args).toContain("my task context");
13471362
});
13481363

13491364
it("uses buildResumeArgs when resume is true", async () => {

scripts/daemon-smoke-test.sh

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ create_repo() {
9797
}
9898

9999
discover_agent() {
100-
ak get agent -o json | json_query "data.find((a) => a.builtin !== 1 && a.username === 'codex-smoke-nomodel' && a.runtime_available && a.active_task_count === 0)?.id || data.find((a) => a.builtin !== 1 && a.username === 'codex-smoke-nomodel' && a.runtime_available)?.id || data.find((a) => a.builtin !== 1 && (a.runtime === 'codex' || a.runtime === 'claude') && a.runtime_available && a.active_task_count === 0)?.id || data.find((a) => a.builtin !== 1 && (a.runtime === 'codex' || a.runtime === 'claude') && a.runtime_available)?.id"
100+
ak get agent -o json | json_query "data.find((a) => a.builtin !== 1 && a.username === 'codex-smoke-nomodel' && a.runtime_available && a.active_task_count === 0)?.id || data.find((a) => a.builtin !== 1 && a.username === 'codex-smoke-nomodel' && a.runtime_available)?.id || data.find((a) => a.builtin !== 1 && ['codex', 'claude', 'gemini', 'copilot'].includes(a.runtime) && a.runtime_available && a.active_task_count === 0)?.id || data.find((a) => a.builtin !== 1 && ['codex', 'claude', 'gemini', 'copilot'].includes(a.runtime) && a.runtime_available)?.id"
101101
}
102102

103103
discover_runtime() {
@@ -111,21 +111,46 @@ discover_runtime() {
111111
echo "claude"
112112
return 0
113113
fi
114+
if echo "$status" | grep -q "gemini"; then
115+
echo "gemini"
116+
return 0
117+
fi
118+
if echo "$status" | grep -q "copilot"; then
119+
echo "copilot"
120+
return 0
121+
fi
114122
return 1
115123
}
116124

117125
create_agent() {
118126
local runtime="$1"
119127
local name username bio
120-
if [ "$runtime" = "codex" ]; then
121-
name="Codex Smoke NoModel"
122-
username="codex-smoke-nomodel"
123-
bio="Codex worker for daemon smoke tests"
124-
else
125-
name="Claude Smoke"
126-
username="claude-smoke"
127-
bio="Claude worker for daemon smoke tests"
128-
fi
128+
case "$runtime" in
129+
codex)
130+
name="Codex Smoke NoModel"
131+
username="codex-smoke-nomodel"
132+
bio="Codex worker for daemon smoke tests"
133+
;;
134+
claude)
135+
name="Claude Smoke"
136+
username="claude-smoke"
137+
bio="Claude worker for daemon smoke tests"
138+
;;
139+
gemini)
140+
name="Gemini Smoke"
141+
username="gemini-smoke"
142+
bio="Gemini worker for daemon smoke tests"
143+
;;
144+
copilot)
145+
name="Copilot Smoke"
146+
username="copilot-smoke"
147+
bio="Copilot worker for daemon smoke tests"
148+
;;
149+
*)
150+
fail "unsupported smoke runtime for agent creation: $runtime"
151+
return 1
152+
;;
153+
esac
129154
ak create agent \
130155
--name "$name" \
131156
--username "$username" \
@@ -172,7 +197,8 @@ wait_subagent_file() {
172197
local expected
173198
case "$AGENT_RUNTIME" in
174199
codex) expected=".codex/agents/$SUBAGENT_USERNAME.toml" ;;
175-
claude) expected=".claude/agents/$SUBAGENT_USERNAME.md" ;;
200+
claude | copilot) expected=".claude/agents/$SUBAGENT_USERNAME.md" ;;
201+
gemini) expected=".gemini/agents/$SUBAGENT_USERNAME.md" ;;
176202
*) fail "unsupported smoke runtime for subagent file check: $AGENT_RUNTIME"; return 1 ;;
177203
esac
178204

@@ -223,7 +249,7 @@ if [ -z "$AGENT_ID" ]; then AGENT_ID="$(discover_agent 2>/dev/null || true)"; fi
223249
if [ -z "$AGENT_ID" ]; then
224250
RUNTIME_TO_CREATE="$(discover_runtime 2>/dev/null || true)"
225251
if [ -z "$RUNTIME_TO_CREATE" ]; then
226-
echo "FATAL: no available subagent-capable runtime found (codex or claude). Start a daemon with one of those providers ready."
252+
echo "FATAL: no available subagent-capable runtime found (codex, claude, gemini, or copilot). Start a daemon with one of those providers ready."
227253
exit 1
228254
fi
229255
AGENT_ID="$(create_agent "$RUNTIME_TO_CREATE")"
@@ -237,8 +263,8 @@ if [ -z "$BOARD_ID" ] || [ -z "$REPO_ID" ] || [ -z "$AGENT_ID" ]; then
237263
fi
238264

239265
AGENT_RUNTIME="$(agent_field "$AGENT_ID" runtime)"
240-
if [ "$AGENT_RUNTIME" != "codex" ] && [ "$AGENT_RUNTIME" != "claude" ]; then
241-
echo "FATAL: smoke agent runtime must support subagents (codex or claude), got: $AGENT_RUNTIME"
266+
if [ "$AGENT_RUNTIME" != "codex" ] && [ "$AGENT_RUNTIME" != "claude" ] && [ "$AGENT_RUNTIME" != "gemini" ] && [ "$AGENT_RUNTIME" != "copilot" ]; then
267+
echo "FATAL: smoke agent runtime must support subagents (codex, claude, gemini, or copilot), got: $AGENT_RUNTIME"
242268
exit 1
243269
fi
244270
ensure_smoke_subagent "$AGENT_RUNTIME"

tests/daemon-smoke-script.test.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,46 @@
11
// @vitest-environment node
22

33
import { execFileSync } from "node:child_process";
4+
import { readFileSync } from "node:fs";
45
import { join } from "node:path";
5-
import { describe, it } from "vitest";
6+
import { describe, expect, it } from "vitest";
7+
8+
const scriptPath = join(__dirname, "../scripts/daemon-smoke-test.sh");
9+
10+
function readScript() {
11+
return readFileSync(scriptPath, "utf8");
12+
}
613

714
describe("daemon smoke script", () => {
815
it("has valid bash syntax", () => {
9-
execFileSync("bash", ["-n", join(__dirname, "../scripts/daemon-smoke-test.sh")], { stdio: "pipe" });
16+
execFileSync("bash", ["-n", scriptPath], { stdio: "pipe" });
17+
});
18+
19+
it("accepts all subagent-capable runtimes during agent discovery", () => {
20+
const script = readScript();
21+
22+
expect(script).toContain("['codex', 'claude', 'gemini', 'copilot'].includes(a.runtime)");
23+
expect(script).toContain("codex, claude, gemini, or copilot");
24+
});
25+
26+
it("creates smoke agents for each supported runtime", () => {
27+
const script = readScript();
28+
29+
expect(script).toContain("codex)");
30+
expect(script).toContain('username="codex-smoke-nomodel"');
31+
expect(script).toContain("claude)");
32+
expect(script).toContain('username="claude-smoke"');
33+
expect(script).toContain("gemini)");
34+
expect(script).toContain('username="gemini-smoke"');
35+
expect(script).toContain("copilot)");
36+
expect(script).toContain('username="copilot-smoke"');
37+
});
38+
39+
it("checks runtime-specific subagent definition paths", () => {
40+
const script = readScript();
41+
42+
expect(script).toContain('codex) expected=".codex/agents/$SUBAGENT_USERNAME.toml"');
43+
expect(script).toContain('claude | copilot) expected=".claude/agents/$SUBAGENT_USERNAME.md"');
44+
expect(script).toContain('gemini) expected=".gemini/agents/$SUBAGENT_USERNAME.md"');
1045
});
1146
});

0 commit comments

Comments
 (0)