Merge pull request #4073 from code-yeongyu/fix/team-create-permission-inline-spec
fix(team-mode): accept legacy inline team specs
This commit is contained in:
@@ -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" },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<void> {
|
||||
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<typeof setTimeout>[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<typeof setTimeout>) => {
|
||||
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<unknown>()
|
||||
const promptAsyncMock = mock(() => deferredPrompt.promise)
|
||||
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
|
||||
directory: testDir,
|
||||
|
||||
@@ -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<typeof setTimeout>[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<typeof setTimeout>) => {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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-"))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user