feat(team-mode): add team runtime member resolver with tests
This commit is contained in:
@@ -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(")
|
||||
})
|
||||
})
|
||||
@@ -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<DelegateTaskArgs, "description" | "load_skills" | "prompt" | "run_in_background"> {
|
||||
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<ResolvedMember> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user