Skip to content

Commit 644a6d6

Browse files
committed
feat: support gemini and copilot subagents
1 parent 7fbbbfe commit 644a6d6

4 files changed

Lines changed: 92 additions & 13 deletions

File tree

apps/web/server/routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ import type { Env } from "./types";
5959

6060
const api = new Hono<{ Bindings: Env }>();
6161
const logger = createLogger("api");
62-
const SUBAGENT_RUNTIMES = new Set(["claude", "codex"]);
62+
const SUBAGENT_RUNTIMES = new Set(["claude", "codex", "gemini", "copilot"]);
6363

6464
function assertValidSkillRefs(skills: unknown) {
6565
if (skills === undefined) return;

packages/cli/src/workspace/agents.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { createLogger } from "../logger.js";
66

77
const logger = createLogger("agents");
88

9-
const AGENT_GITIGNORE_ENTRIES = [".claude/agents/", ".codex/agents/"];
9+
const AGENT_GITIGNORE_ENTRIES = [".claude/agents/", ".codex/agents/", ".gemini/agents/"];
1010

1111
export interface SubagentDefinition {
1212
id: string;
@@ -32,16 +32,16 @@ function promptFor(agent: SubagentDefinition): string {
3232
return sections.filter(Boolean).join("\n\n");
3333
}
3434

35-
function claudeModel(agent: SubagentDefinition): string | null {
36-
return agent.runtime === "claude" && agent.model ? agent.model : null;
35+
function markdownModel(runtime: AgentRuntime, agent: SubagentDefinition): string | null {
36+
return runtime === "claude" && agent.runtime === "claude" && agent.model ? agent.model : null;
3737
}
3838

39-
function renderClaudeAgent(agent: SubagentDefinition): string {
39+
function renderMarkdownAgent(runtime: AgentRuntime, agent: SubagentDefinition): string {
4040
const frontmatter: Record<string, string> = {
4141
name: agentName(agent),
4242
description: descriptionFor(agent),
4343
};
44-
const model = claudeModel(agent);
44+
const model = markdownModel(runtime, agent);
4545
if (model) frontmatter.model = model;
4646
return `---\n${stringify(frontmatter).trim()}\n---\n${promptFor(agent)}\n`;
4747
}
@@ -71,7 +71,8 @@ function renderCodexAgent(agent: SubagentDefinition): string {
7171

7272
function renderAgent(runtime: AgentRuntime, agent: SubagentDefinition): { path: string; content: string } {
7373
const name = agentName(agent);
74-
if (runtime === "claude") return { path: `.claude/agents/${name}.md`, content: renderClaudeAgent(agent) };
74+
if (runtime === "claude" || runtime === "copilot") return { path: `.claude/agents/${name}.md`, content: renderMarkdownAgent(runtime, agent) };
75+
if (runtime === "gemini") return { path: `.gemini/agents/${name}.md`, content: renderMarkdownAgent(runtime, agent) };
7576
if (runtime === "codex") return { path: `.codex/agents/${name}.toml`, content: renderCodexAgent(agent) };
7677
throw new Error(`Runtime "${runtime}" does not support task-local subagent installation yet`);
7778
}
@@ -112,6 +113,6 @@ export async function ensureSubagents(worktreeDir: string, runtime: AgentRuntime
112113
}
113114

114115
export const testExports = {
115-
renderClaudeAgent,
116+
renderMarkdownAgent,
116117
renderCodexAgent,
117118
};

packages/cli/tests/workspace-agents.test.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ afterEach(() => {
4242

4343
describe("workspace subagent installer", () => {
4444
it("renders Claude agent definitions with yaml frontmatter", async () => {
45-
const content = testExports.renderClaudeAgent(subagent);
45+
const content = testExports.renderMarkdownAgent("claude", subagent);
4646

4747
expect(content).toContain("---\n");
4848
expect(content).toContain("name: test-writer");
@@ -76,9 +76,10 @@ describe("workspace subagent installer", () => {
7676
const installed = await ensureSubagents(worktree, "claude", [subagent]);
7777

7878
expect(installed).toBe(true);
79-
expect(readFileSync(join(worktree, ".claude/agents/test-writer.md"), "utf-8")).toBe(testExports.renderClaudeAgent(subagent));
79+
expect(readFileSync(join(worktree, ".claude/agents/test-writer.md"), "utf-8")).toBe(testExports.renderMarkdownAgent("claude", subagent));
8080
expect(readFileSync(join(worktree, ".gitignore"), "utf-8")).toContain(".claude/agents/");
8181
expect(readFileSync(join(worktree, ".gitignore"), "utf-8")).toContain(".codex/agents/");
82+
expect(readFileSync(join(worktree, ".gitignore"), "utf-8")).toContain(".gemini/agents/");
8283
});
8384

8485
it("writes Codex subagents and keeps identical files stable", async () => {
@@ -101,9 +102,44 @@ describe("workspace subagent installer", () => {
101102
expect(statSync(filePath).mtimeMs).toBe(firstMtime);
102103
});
103104

105+
it("writes Gemini subagents as project markdown agent definitions", async () => {
106+
const worktree = makeWorktree();
107+
const geminiSubagent = { ...subagent, runtime: "gemini" as const, model: "gemini-3-pro" };
108+
109+
expect(await ensureSubagents(worktree, "gemini", [geminiSubagent])).toBe(true);
110+
const content = readFileSync(join(worktree, ".gemini/agents/test-writer.md"), "utf-8");
111+
112+
expect(content).toBe(testExports.renderMarkdownAgent("gemini", geminiSubagent));
113+
expect(content).toContain("name: test-writer");
114+
expect(content).not.toContain("model:");
115+
});
116+
117+
it("writes Copilot subagents through Claude-compatible project markdown agent definitions", async () => {
118+
const worktree = makeWorktree();
119+
const copilotSubagent = { ...subagent, runtime: "copilot" as const, model: "gpt-5.2" };
120+
121+
expect(await ensureSubagents(worktree, "copilot", [copilotSubagent])).toBe(true);
122+
const content = readFileSync(join(worktree, ".claude/agents/test-writer.md"), "utf-8");
123+
124+
expect(content).toBe(testExports.renderMarkdownAgent("copilot", copilotSubagent));
125+
expect(content).toContain("name: test-writer");
126+
expect(content).not.toContain("model:");
127+
});
128+
129+
it("does not leak Claude model frontmatter into Gemini or Copilot agent files", async () => {
130+
const worktree = makeWorktree();
131+
const claudeWorker = { ...subagent, runtime: "claude" as const, model: "claude-opus-4-6" };
132+
133+
expect(await ensureSubagents(worktree, "gemini", [claudeWorker])).toBe(true);
134+
expect(await ensureSubagents(worktree, "copilot", [claudeWorker])).toBe(true);
135+
136+
expect(readFileSync(join(worktree, ".gemini/agents/test-writer.md"), "utf-8")).not.toContain("model:");
137+
expect(readFileSync(join(worktree, ".claude/agents/test-writer.md"), "utf-8")).not.toContain("model:");
138+
});
139+
104140
it("returns false when the runtime does not support local subagent files", async () => {
105141
const worktree = makeWorktree();
106142

107-
await expect(ensureSubagents(worktree, "gemini", [subagent])).resolves.toBe(false);
143+
await expect(ensureSubagents(worktree, "hermes", [subagent])).resolves.toBe(false);
108144
});
109145
});

tests/routes.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,28 @@ describe("routes", () => {
430430
expect(body.subagents).toEqual([subagent.id]);
431431
});
432432

433+
it.each(["gemini", "copilot"] as const)("POST /api/agents allows %s agents with subagents", async (runtime) => {
434+
const subagent = await createTestAgent(env.DB, userId, {
435+
name: `Create ${runtime} Route Subagent`,
436+
username: `create-${runtime}-route-subagent`,
437+
runtime: "claude",
438+
});
439+
const res = await apiRequest(
440+
"POST",
441+
"/api/agents",
442+
{
443+
name: `${runtime} Subagent Route Agent`,
444+
username: `${runtime}-subagent-route-agent`,
445+
runtime,
446+
subagents: [subagent.id],
447+
},
448+
apiKey,
449+
);
450+
expect(res.status).toBe(201);
451+
const body = (await res.json()) as any;
452+
expect(body.subagents).toEqual([subagent.id]);
453+
});
454+
433455
it("POST /api/agents rejects nonexistent subagent IDs", async () => {
434456
const res = await apiRequest(
435457
"POST",
@@ -480,12 +502,12 @@ describe("routes", () => {
480502
const res = await apiRequest(
481503
"POST",
482504
"/api/agents",
483-
{ name: "Gemini Subagents", username: "gemini-subagents", runtime: "gemini", subagents: [subagent.id] },
505+
{ name: "Hermes Subagents", username: "hermes-subagents", runtime: "hermes", subagents: [subagent.id] },
484506
apiKey,
485507
);
486508
expect(res.status).toBe(400);
487509
const body = (await res.json()) as any;
488-
expect(body.error.message).toContain('Runtime "gemini" does not support subagents yet');
510+
expect(body.error.message).toContain('Runtime "hermes" does not support subagents yet');
489511
});
490512

491513
it("PATCH /api/agents/:id rejects malformed skill refs", async () => {
@@ -528,6 +550,26 @@ describe("routes", () => {
528550
expect(body).not.toHaveProperty("mailbox_token");
529551
});
530552

553+
it.each(["gemini", "copilot"] as const)("PATCH /api/agents/:id allows %s agents with subagents", async (runtime) => {
554+
const jwt = await signLeaderSessionJWT();
555+
const agent = await createTestAgent(env.DB, userId, {
556+
name: `Patch ${runtime} Route Agent`,
557+
username: `patch-${runtime}-route-agent`,
558+
runtime: "claude",
559+
});
560+
const subagent = await createTestAgent(env.DB, userId, {
561+
name: `Patch ${runtime} Route Subagent`,
562+
username: `patch-${runtime}-route-subagent`,
563+
runtime: "claude",
564+
});
565+
const res = await apiRequest("PATCH", `/api/agents/${agent.id}`, { runtime, subagents: [subagent.id] }, jwt);
566+
567+
expect(res.status).toBe(200);
568+
const body = (await res.json()) as any;
569+
expect(body.runtime).toBe(runtime);
570+
expect(body.subagents).toEqual([subagent.id]);
571+
});
572+
531573
it("PATCH /api/agents/:id rejects self-reference as a subagent", async () => {
532574
const jwt = await signLeaderSessionJWT();
533575
const res = await apiRequest("PATCH", `/api/agents/${agentId}`, { subagents: [agentId] }, jwt);

0 commit comments

Comments
 (0)