diff --git a/src/features/team-mode/team-runtime/resolve-member.test.ts b/src/features/team-mode/team-runtime/resolve-member.test.ts new file mode 100644 index 000000000..09daeda61 --- /dev/null +++ b/src/features/team-mode/team-runtime/resolve-member.test.ts @@ -0,0 +1,198 @@ +import { readFileSync } from "node:fs" +declare const require: (name: string) => any +const { describe, expect, mock, test, beforeEach } = require("bun:test") +import type { ExecutorContext } from "../../../tools/delegate-task/executor-types" +import type { Member } from "../types" + +const resolveCategoryExecutionMock = mock() +const resolveSubagentExecutionMock = mock() +const buildSystemContentMock = mock(() => "resolved-system-content") + +mock.module("./resolve-member-dependencies", () => ({ + resolveCategoryExecution: resolveCategoryExecutionMock, + resolveSubagentExecution: resolveSubagentExecutionMock, + buildSystemContent: buildSystemContentMock, +})) + +const { resolveMember, TeamMemberResolutionError } = await import("./resolve-member") + +function createExecutorContext(): ExecutorContext { + return { + client: {} as ExecutorContext["client"], + manager: {} as ExecutorContext["manager"], + directory: "/tmp/team-mode-test", + } +} + +describe("resolveMember", () => { + beforeEach(() => { + mock.restore() + resolveCategoryExecutionMock.mockReset() + resolveSubagentExecutionMock.mockReset() + buildSystemContentMock.mockReset() + buildSystemContentMock.mockImplementation(() => "resolved-system-content") + }) + + test("routes category members through resolveCategoryExecution", async () => { + // given + const member = { + backendType: "in-process", + isActive: true, + kind: "category", + name: "m1", + category: "deep", + prompt: "impl X", + } satisfies Member + + resolveCategoryExecutionMock.mockResolvedValue({ + agentToUse: "sisyphus-junior", + categoryModel: { providerID: "openai", modelID: "gpt-5.4" }, + categoryPromptAppend: "appendix", + maxPromptTokens: 512, + fallbackChain: [{ providers: ["openai"], model: "gpt-5.4-mini" }], + }) + + // when + const result = await resolveMember(member, createExecutorContext(), "deep, quick") + + // then + expect(resolveCategoryExecutionMock).toHaveBeenCalledTimes(1) + expect(resolveCategoryExecutionMock).toHaveBeenCalledWith( + { + category: "deep", + description: "Resolve team member", + load_skills: [], + prompt: "impl X", + run_in_background: false, + subagent_type: "sisyphus-junior", + }, + createExecutorContext(), + undefined, + undefined, + ) + expect(resolveSubagentExecutionMock).not.toHaveBeenCalled() + expect(result.agentToUse).toBe("sisyphus-junior") + expect(result.systemContent).toBe("resolved-system-content") + }) + + test("routes subagent members through resolveSubagentExecution", async () => { + // given + const member = { + backendType: "in-process", + isActive: true, + kind: "subagent_type", + name: "m2", + subagent_type: "atlas", + prompt: "addendum", + } satisfies Member + + resolveSubagentExecutionMock.mockResolvedValue({ + agentToUse: "atlas", + categoryModel: { providerID: "openai", modelID: "gpt-5.4-mini" }, + fallbackChain: [{ providers: ["openai"], model: "gpt-5.4-nano" }], + }) + + // when + const result = await resolveMember(member, createExecutorContext(), "deep, quick", "sisyphus") + + // then + expect(resolveSubagentExecutionMock).toHaveBeenCalledTimes(1) + expect(resolveSubagentExecutionMock).toHaveBeenCalledWith( + { + description: "Resolve team member", + load_skills: [], + prompt: "addendum", + run_in_background: false, + subagent_type: "atlas", + }, + createExecutorContext(), + "sisyphus", + "deep, quick", + { + allowSisyphusJuniorDirect: true, + allowPrimaryAgentDelegation: true, + }, + ) + expect(resolveCategoryExecutionMock).not.toHaveBeenCalled() + expect(result.agentToUse).toBe("atlas") + expect(result.systemContent).toBe("resolved-system-content") + }) + + test("throws TeamMemberResolutionError without category fallback when subagent resolution fails", async () => { + // given + const member = { + backendType: "in-process", + isActive: true, + kind: "subagent_type", + name: "unknown", + subagent_type: "unknown-agent", + } satisfies Member + + resolveSubagentExecutionMock.mockRejectedValue(new Error("unknown agent")) + + // when + const result = resolveMember(member, createExecutorContext(), "deep, quick") + + // then + await expect(result).rejects.toBeInstanceOf(TeamMemberResolutionError) + await expect(result).rejects.toThrow("Failed to resolve member 'unknown': unknown agent") + expect(resolveCategoryExecutionMock).not.toHaveBeenCalled() + }) + + test("reuses buildSystemContent for both resolution kinds without custom prompt concatenation", async () => { + // given + const categoryMember = { + backendType: "in-process", + isActive: true, + kind: "category", + name: "m1", + category: "deep", + prompt: "impl X", + } satisfies Member + const subagentMember = { + backendType: "in-process", + isActive: true, + kind: "subagent_type", + name: "m2", + subagent_type: "atlas", + prompt: "addendum", + } satisfies Member + + resolveCategoryExecutionMock.mockResolvedValue({ + agentToUse: "sisyphus-junior", + categoryModel: { providerID: "openai", modelID: "gpt-5.4" }, + categoryPromptAppend: "appendix", + maxPromptTokens: 128, + fallbackChain: [], + }) + resolveSubagentExecutionMock.mockResolvedValue({ + agentToUse: "atlas", + categoryModel: { providerID: "openai", modelID: "gpt-5.4-mini" }, + fallbackChain: [], + }) + const source = readFileSync(new URL("./resolve-member.ts", import.meta.url), "utf8") + + // when + await resolveMember(categoryMember, createExecutorContext(), "deep, quick") + await resolveMember(subagentMember, createExecutorContext(), "deep, quick") + + // then + expect(buildSystemContentMock).toHaveBeenCalledTimes(2) + expect(buildSystemContentMock).toHaveBeenNthCalledWith(1, { + agentName: "sisyphus-junior", + categoryPromptAppend: "appendix", + maxPromptTokens: 128, + model: { providerID: "openai", modelID: "gpt-5.4" }, + }) + expect(buildSystemContentMock).toHaveBeenNthCalledWith(2, { + agentName: "atlas", + categoryPromptAppend: undefined, + maxPromptTokens: undefined, + model: { providerID: "openai", modelID: "gpt-5.4-mini" }, + }) + expect(source).toContain("buildSystemContent({") + expect(source).not.toContain("member.prompt +") + expect(source).not.toContain("+ member.prompt") + expect(source).not.toContain(".join(") + }) +}) diff --git a/src/features/team-mode/team-runtime/resolve-member.ts b/src/features/team-mode/team-runtime/resolve-member.ts new file mode 100644 index 000000000..dde6e1a2c --- /dev/null +++ b/src/features/team-mode/team-runtime/resolve-member.ts @@ -0,0 +1,122 @@ +import type { FallbackEntry } from "../../../shared/model-requirements" +import type { DelegatedModelConfig } from "../../../shared/model-resolution-types" +import type { ExecutorContext } from "../../../tools/delegate-task/executor-types" +import type { DelegateTaskArgs } from "../../../tools/delegate-task/types" +import type { Member } from "../types" +import { + buildSystemContent, + resolveCategoryExecution, + resolveSubagentExecution, +} from "./resolve-member-dependencies" + +export class TeamMemberResolutionError extends Error { + constructor(public readonly memberName: string, public readonly cause: Error) { + super(`Failed to resolve member '${memberName}': ${cause.message}`) + this.name = "TeamMemberResolutionError" + } +} + +export interface ResolvedMember { + memberName: string + agentToUse: string + model: DelegatedModelConfig | undefined + fallbackChain: FallbackEntry[] | undefined + systemContent: string +} + +function createBaseDelegateTaskArgs(prompt: string): Pick { + return { + description: "Resolve team member", + load_skills: [], + prompt, + run_in_background: false, + } +} + +function normalizeResolutionError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +function resolveSystemContent(input: { + agentToUse: string + categoryPromptAppend?: string + maxPromptTokens?: number + model: DelegatedModelConfig | undefined +}): string { + return buildSystemContent({ + agentName: input.agentToUse, + categoryPromptAppend: input.categoryPromptAppend, + maxPromptTokens: input.maxPromptTokens, + model: input.model, + }) ?? "" +} + +export async function resolveMember( + member: Member, + ctx: ExecutorContext, + categoryExamples: string, + parentAgent?: string, +): Promise { + try { + if (member.kind === "category") { + const execution = await resolveCategoryExecution( + { + ...createBaseDelegateTaskArgs(member.prompt), + category: member.category, + subagent_type: "sisyphus-junior", + }, + ctx, + undefined, + undefined, + ) + + if (execution.error) { + throw new Error(execution.error) + } + + return { + memberName: member.name, + agentToUse: execution.agentToUse, + model: execution.categoryModel, + fallbackChain: execution.fallbackChain, + systemContent: resolveSystemContent({ + agentToUse: execution.agentToUse, + categoryPromptAppend: execution.categoryPromptAppend, + maxPromptTokens: execution.maxPromptTokens, + model: execution.categoryModel, + }), + } + } + + const execution = await resolveSubagentExecution( + { + ...createBaseDelegateTaskArgs(member.prompt ?? ""), + subagent_type: member.subagent_type, + }, + ctx, + parentAgent, + categoryExamples, + { + allowSisyphusJuniorDirect: true, + allowPrimaryAgentDelegation: true, + }, + ) + + if (execution.error) { + throw new Error(execution.error) + } + + return { + memberName: member.name, + agentToUse: execution.agentToUse, + model: execution.categoryModel, + fallbackChain: execution.fallbackChain, + systemContent: resolveSystemContent({ + agentToUse: execution.agentToUse, + model: execution.categoryModel, + }), + } + } catch (error) { + throw new TeamMemberResolutionError(member.name, normalizeResolutionError(error)) + } +}