diff --git a/src/features/team-mode/team-registry/team-spec-input-normalizer.test.ts b/src/features/team-mode/team-registry/team-spec-input-normalizer.test.ts index d14e266d1..ddc2c7baa 100644 --- a/src/features/team-mode/team-registry/team-spec-input-normalizer.test.ts +++ b/src/features/team-mode/team-registry/team-spec-input-normalizer.test.ts @@ -89,6 +89,25 @@ describe("normalizeTeamSpecInput", () => { expect(result).toThrow("Caller agent explore is not eligible as team lead; specify leadAgentId explicitly") }) + test("still requires an eligible caller or explicit lead for 8 inline members", () => { + // given + const rawSpec = { + name: "eight-member-team", + members: Array.from({ length: 8 }, () => ({ + category: "quick", + prompt: "Complete one validation task.", + })), + } + + // when + const result = () => normalizeTeamSpecInput(rawSpec, { + callerTeamLead: resolveCallerTeamLead("explore"), + }) + + // then + expect(result).toThrow("Caller agent explore is not eligible as team lead; specify leadAgentId explicitly") + }) + test("normalizes natural inline names to schema-safe names", () => { // given const rawSpec = { @@ -141,4 +160,35 @@ describe("normalizeTeamSpecInput", () => { ], }) }) + + test("uses the first generated member as lead when 8 inline members leave no room for implicit lead injection", () => { + // given + const rawSpec = { + name: "eight-member-team", + members: Array.from({ length: 8 }, () => ({ + category: "quick", + prompt: "Complete one validation task.", + })), + } + + // when + const normalizedSpec = normalizeTeamSpecInput(rawSpec, { + callerTeamLead: resolveCallerTeamLead("Sisyphus - Ultraworker"), + }) + + // then + expect(normalizedSpec).toMatchObject({ + leadAgentId: "quick-1", + members: [ + { name: "quick-1", kind: "category" }, + { name: "quick-2", kind: "category" }, + { name: "quick-3", kind: "category" }, + { name: "quick-4", kind: "category" }, + { name: "quick-5", kind: "category" }, + { name: "quick-6", kind: "category" }, + { name: "quick-7", kind: "category" }, + { name: "quick-8", kind: "category" }, + ], + }) + }) }) diff --git a/src/features/team-mode/team-registry/team-spec-input-normalizer.ts b/src/features/team-mode/team-registry/team-spec-input-normalizer.ts index 3856c7ead..bdda8a730 100644 --- a/src/features/team-mode/team-registry/team-spec-input-normalizer.ts +++ b/src/features/team-mode/team-registry/team-spec-input-normalizer.ts @@ -133,6 +133,7 @@ function normalizeInlineMember(member: JsonRecord, options?: NormalizeTeamSpecIn description: _description, loadSkills: _loadSkills, load_skills: _loadSkillsSnakeCase, + permission: _permission, responsibilities: _responsibilities, role: _role, systemPrompt: _systemPrompt, @@ -191,6 +192,10 @@ export function normalizeTeamSpecInput(raw: unknown, options?: NormalizeTeamSpec if (Array.isArray(rawMembers)) { let normalizedMembers = rawMembers.map((member) => isJsonRecord(member) ? normalizeInlineMember(member, options) : member) + const callerTeamLead = options?.callerTeamLead + const shouldUseFirstMemberAsLead = !hasExplicitLead + && normalizedMembers.length >= 8 + && callerTeamLead?.isEligibleForTeamLead === true if (isJsonRecord(rawLead)) { const leadMember = normalizeInlineMember(rawLead, options) @@ -209,8 +214,9 @@ export function normalizeTeamSpecInput(raw: unknown, options?: NormalizeTeamSpec } } - if (!hasExplicitLead) { - const callerTeamLead = options?.callerTeamLead + if (shouldUseFirstMemberAsLead) { + leadAgentId = getMemberName(normalizedMembers[0]) + } else if (!hasExplicitLead) { if (callerTeamLead?.isEligibleForTeamLead && callerTeamLead.agentTypeId !== undefined) { normalizedMembers = [createCallerLeadMember(callerTeamLead.agentTypeId), ...normalizedMembers] leadAgentId = "lead" @@ -221,6 +227,10 @@ export function normalizeTeamSpecInput(raw: unknown, options?: NormalizeTeamSpec normalizedMembers = assignGeneratedMemberNames(normalizedMembers) + if (leadAgentId === undefined && shouldUseFirstMemberAsLead) { + leadAgentId = getMemberName(normalizedMembers[0]) + } + normalizedMembers = normalizedMembers.map((member) => { const memberName = getMemberName(member) const isLead = isJsonRecord(member) && member.isLead === true diff --git a/src/features/team-mode/team-registry/validator.test.ts b/src/features/team-mode/team-registry/validator.test.ts index dce57dc52..97f646653 100644 --- a/src/features/team-mode/team-registry/validator.test.ts +++ b/src/features/team-mode/team-registry/validator.test.ts @@ -157,6 +157,21 @@ describe("team-registry validator", () => { expect(act).toThrow("Team 'validator-team' exceeds max 8 members.") }) + test("accepts teams with exactly 8 members", () => { + // given + const teamSpec = { + ...createBaseTeamSpec(), + members: Array.from({ length: 8 }, (_, index) => createCategoryMember(`member-${index}`)), + leadAgentId: "member-0", + } + + // when + const act = () => validateSpec(teamSpec) + + // then + expect(act).not.toThrow() + }) + test("rejects hyperplan teams that omit required adversarial categories", () => { // given const teamSpec: TeamSpec = { diff --git a/src/features/team-mode/tools/lifecycle-inline-spec.test.ts b/src/features/team-mode/tools/lifecycle-inline-spec.test.ts index 3999fb427..efff79410 100644 --- a/src/features/team-mode/tools/lifecycle-inline-spec.test.ts +++ b/src/features/team-mode/tools/lifecycle-inline-spec.test.ts @@ -271,6 +271,79 @@ describe("createTeamCreateTool inline_spec normalization", () => { }) }) + test("accepts legacy member permission fields in inline_spec", async () => { + // given + const createTeamCreateTool = await loadCreateTeamCreateTool() + const config = createConfig() + const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config) + const inlineSpec = { + name: "permission-compat-team", + members: [ + { + name: "docs-validator", + category: "quick", + prompt: "Check docs against code and report mismatches.", + permission: "read", + }, + { + name: "code-validator", + subagent_type: "atlas", + permission: { write: false }, + }, + ], + } + + // when + await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session", "Sisyphus")) + const spec = createTeamRunMock.mock.calls[0]?.[0] + + // then + expect(spec).toMatchObject({ + name: "permission-compat-team", + members: [ + { name: "lead", kind: "subagent_type" }, + { name: "docs-validator", kind: "category", category: "quick" }, + { name: "code-validator", kind: "subagent_type", subagent_type: "atlas" }, + ], + }) + expect(JSON.stringify(spec?.members)).not.toContain("permission") + }) + + test("accepts exactly 8 inline members when no explicit lead is provided", async () => { + // given + const createTeamCreateTool = await loadCreateTeamCreateTool() + const config = createConfig() + const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config) + const inlineSpec = { + name: "eight-member-team", + members: Array.from({ length: 8 }, (_, index) => ({ + name: `member-${index + 1}`, + category: "quick", + prompt: `Complete validation scenario ${index + 1}.`, + })), + } + + // when + await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session", "Sisyphus")) + const spec = createTeamRunMock.mock.calls[0]?.[0] + + // then + expect(spec?.members).toHaveLength(8) + expect(spec).toMatchObject({ + leadAgentId: "member-1", + members: [ + { name: "member-1", kind: "category", category: "quick" }, + { name: "member-2", kind: "category", category: "quick" }, + { name: "member-3", kind: "category", category: "quick" }, + { name: "member-4", kind: "category", category: "quick" }, + { name: "member-5", kind: "category", category: "quick" }, + { name: "member-6", kind: "category", category: "quick" }, + { name: "member-7", kind: "category", category: "quick" }, + { name: "member-8", kind: "category", category: "quick" }, + ], + }) + }) + test("accepts role and capabilities style members with the configured fallback category", async () => { // given const createTeamCreateTool = await loadCreateTeamCreateTool() diff --git a/src/hooks/atlas/background-task-retry.test.ts b/src/hooks/atlas/background-task-retry.test.ts index 8cab40ba2..3df7194a0 100644 --- a/src/hooks/atlas/background-task-retry.test.ts +++ b/src/hooks/atlas/background-task-retry.test.ts @@ -7,6 +7,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { createAtlasHook } from "./atlas-hook" import { clearBoulderState, writeBoulderState } from "../../features/boulder-state" import { _resetForTesting, clearSessionAgent, registerAgentName, setSessionAgent } from "../../features/claude-code-session-state" +import { DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS } from "../../shared/prompt-async-gate" import { unsafeTestValue } from "../../../test-support/unsafe-test-value" // Force process isolation in CI runner (globalThis.setTimeout override conflicts with other atlas tests) @@ -24,6 +25,8 @@ describe("atlas background task retry", () => { let nextFakeTimerId = 1000 const originalSetTimeout = globalThis.setTimeout const originalClearTimeout = globalThis.clearTimeout + const originalDateNow = Date.now + let fakeNow = 0 async function flushMicrotasks(): Promise { await Promise.resolve() @@ -52,6 +55,7 @@ describe("atlas background task retry", () => { } capturedTimers.delete(id) + fakeNow += 6000 await entry.callback() } await flushMicrotasks() @@ -67,6 +71,8 @@ describe("atlas background task retry", () => { capturedTimers.clear() nextFakeTimerId = 1000 + fakeNow = 10_000 + Date.now = () => fakeNow globalThis.setTimeout = ((callback: Parameters[0], delay?: number, ...args: unknown[]) => { const normalizedDelay = typeof delay === "number" ? delay : 0 @@ -74,7 +80,7 @@ describe("atlas background task retry", () => { return originalSetTimeout(callback, delay, ...args) } - if (normalizedDelay >= 5000) { + if (normalizedDelay >= 5000 && normalizedDelay !== DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS) { const id = nextFakeTimerId++ capturedTimers.set(id, { callback: () => (callback as LongTimerCallback)(...args), @@ -87,8 +93,9 @@ describe("atlas background task retry", () => { }) as typeof setTimeout globalThis.clearTimeout = ((id?: number | ReturnType) => { - if (typeof id === "number" && capturedTimers.has(id)) { - capturedTimers.get(id)!.cleared = true + const timerEntry = typeof id === "number" ? capturedTimers.get(id) : undefined + if (timerEntry) { + timerEntry.cleared = true capturedTimers.delete(id) return } @@ -100,6 +107,7 @@ describe("atlas background task retry", () => { afterEach(() => { globalThis.setTimeout = originalSetTimeout globalThis.clearTimeout = originalClearTimeout + Date.now = originalDateNow _resetForTesting() clearBoulderState(testDir) if (existsSync(testDir)) { @@ -423,7 +431,7 @@ describe("atlas background task retry", () => { agent: "atlas", }) - const deferredPrompt = createDeferred<{}>() + const deferredPrompt = createDeferred() const promptAsyncMock = mock(() => deferredPrompt.promise) const hook = createAtlasHook(unsafeTestValue({ directory: testDir, diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 019468224..58dffc638 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -11,6 +11,7 @@ import { } from "../../features/boulder-state" import type { BoulderState } from "../../features/boulder-state" import { _resetForTesting, registerAgentName, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state" +import { DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS } from "../../shared/prompt-async-gate" import type { AtlasHookOptions, PendingTaskRef } from "./types" import { createAtlasHook } from "./index" import { createToolExecuteAfterHandler } from "./tool-execute-after" @@ -1702,7 +1703,7 @@ session_id: ses_untrusted_999 // then - stale idle is consumed, not converted into another scheduled continuation expect(mockInput._promptMock).toHaveBeenCalledTimes(1) - expect(scheduledDelays.filter((delay) => delay >= 5_000)).toHaveLength(0) + expect(scheduledDelays.filter((delay) => delay >= 5_000 && delay !== DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS)).toHaveLength(0) } finally { globalThis.setTimeout = originalSetTimeout } @@ -2498,7 +2499,7 @@ session_id: ses_untrusted_999 globalThis.setTimeout = ((callback: Parameters[0], delay?: number, ...args: unknown[]) => { const normalized = typeof delay === "number" ? delay : 0 - if (normalized >= 5000) { + if (normalized >= 5000 && normalized !== DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS) { const timerID = originalSetTimeout(() => undefined, 0) const capturedCallback = typeof callback === "function" ? () => callback(...args) @@ -2512,8 +2513,9 @@ session_id: ses_untrusted_999 }) as typeof setTimeout globalThis.clearTimeout = ((id?: ReturnType) => { - if (id && capturedTimers.has(id)) { - capturedTimers.get(id)!.cleared = true + const timerEntry = id ? capturedTimers.get(id) : undefined + if (timerEntry) { + timerEntry.cleared = true capturedTimers.delete(id) return } diff --git a/src/hooks/team-tool-gating/hook.test.ts b/src/hooks/team-tool-gating/hook.test.ts index efbba8e3a..63396d104 100644 --- a/src/hooks/team-tool-gating/hook.test.ts +++ b/src/hooks/team-tool-gating/hook.test.ts @@ -255,6 +255,19 @@ describe("createTeamToolGating", () => { await expect(result).rejects.toThrow("team-mode tool team_send_message denied: not a participant of team 11111111-1111-4111-8111-111111111111") }) + test("rejects team_status when the session is not in the registry and not in runtime state", async () => { + // given + const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-")) + temporaryDirectories.push(baseDir) + await seedTeams(baseDir, createRuntimeState()) + + // when + const result = runHook("team_status", "unknown-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir) + + // then + await expect(result).rejects.toThrow("team-mode tool team_status denied: not a participant of team 11111111-1111-4111-8111-111111111111") + }) + test("rejects team_send_message when the registry only has the caller for a different team than the requested teamRunId", async () => { // given const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-")) diff --git a/src/index.compaction-model-agnostic.static.test.ts b/src/index.compaction-model-agnostic.static.test.ts index 91b427ba6..036c0052b 100644 --- a/src/index.compaction-model-agnostic.static.test.ts +++ b/src/index.compaction-model-agnostic.static.test.ts @@ -4,9 +4,9 @@ import { readFileSync } from "node:fs" describe("experimental.session.compacting", () => { test("does not hardcode a model and uses output.context", () => { //#given - const indexUrl = new URL("./index.ts", import.meta.url) + const moduleUrl = new URL("./testing/create-plugin-module.ts", import.meta.url) const compactionUrl = new URL("./plugin/session-compacting.ts", import.meta.url) - const content = readFileSync(indexUrl, "utf-8") + const content = readFileSync(moduleUrl, "utf-8") const compactionContent = readFileSync(compactionUrl, "utf-8") //#when @@ -22,9 +22,9 @@ describe("experimental.session.compacting", () => { test("registers autocontinue restores before OpenCode synthetic continue", () => { //#given - const indexUrl = new URL("./index.ts", import.meta.url) + const moduleUrl = new URL("./testing/create-plugin-module.ts", import.meta.url) const compactionUrl = new URL("./plugin/session-compacting.ts", import.meta.url) - const content = readFileSync(indexUrl, "utf-8") + const content = readFileSync(moduleUrl, "utf-8") const compactionContent = readFileSync(compactionUrl, "utf-8") //#when