From f84a456977e5e4f008eb203f70f2cdcafc5b4aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 25 Sep 2026 19:38:58 +0800 Subject: [PATCH] fix: repair team review results and shutdown visibility --- .../agentTeams/AgentTeamsPlanCard.test.tsx | 11 ++++ .../agentTeams/AgentTeamsPlanCard.tsx | 8 ++- desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/jp.ts | 1 + desktop/src/i18n/locales/kr.ts | 1 + desktop/src/i18n/locales/zh-TW.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + src/cli/print.ts | 59 ++++++++----------- src/cli/teamShutdown.test.ts | 24 ++++++++ .../translateCliMessage.teamShutdown.test.ts | 10 ++++ .../sessionService.teamShutdown.test.ts | 9 +++ src/server/services/sessionService.ts | 2 + src/server/services/teamPlanRuntime.test.ts | 7 ++- src/server/services/teamPlanRuntime.ts | 4 +- src/server/ws/handler.ts | 3 +- src/shared/teamPlan.ts | 4 ++ src/tools/TeamCreateTool/prompt.ts | 1 + src/tools/TeamPlanTool/TeamPlanTool.test.ts | 12 ++++ src/tools/TeamPlanTool/TeamPlanTool.ts | 6 +- src/utils/swarm/teamPlanStore.test.ts | 7 +++ src/utils/swarm/teamPlanStore.ts | 5 +- src/utils/swarm/teamShutdownPrompt.ts | 20 +++++++ 22 files changed, 153 insertions(+), 44 deletions(-) create mode 100644 src/cli/teamShutdown.test.ts create mode 100644 src/server/__tests__/translateCliMessage.teamShutdown.test.ts create mode 100644 src/server/services/sessionService.teamShutdown.test.ts create mode 100644 src/utils/swarm/teamShutdownPrompt.ts diff --git a/desktop/src/components/agentTeams/AgentTeamsPlanCard.test.tsx b/desktop/src/components/agentTeams/AgentTeamsPlanCard.test.tsx index 36d84037..9bae920d 100644 --- a/desktop/src/components/agentTeams/AgentTeamsPlanCard.test.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsPlanCard.test.tsx @@ -107,6 +107,17 @@ describe('AgentTeamsPlanCard', () => { expect(screen.getByRole('button', { name: 'Approve and launch' })).toBeEnabled() }) + it('blocks an old review with an unlaunchable member name and points to revision', async () => { + const plan = fixture() + plan.members[0]!.name = 'README Reader' + vi.mocked(teamPlansApi.get).mockResolvedValue({ plan }) + await open() + expect(screen.getByRole('button', { name: 'Approve and launch' })).toBeDisabled() + expect(screen.getByRole('alert')).toHaveTextContent('README Reader') + expect(screen.getByRole('alert')).toHaveTextContent('Request a revision') + expect(teamPlansApi.act).not.toHaveBeenCalled() + }) + it('follows the current session runtime without switching the lead and falls back to the plan snapshot', async () => { await open() fireEvent.click(screen.getByRole('checkbox', { name: 'Select all members' })) diff --git a/desktop/src/components/agentTeams/AgentTeamsPlanCard.tsx b/desktop/src/components/agentTeams/AgentTeamsPlanCard.tsx index f046f126..6835603e 100644 --- a/desktop/src/components/agentTeams/AgentTeamsPlanCard.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsPlanCard.tsx @@ -13,7 +13,7 @@ import { useSessionRuntimeStore } from '@/stores/sessionRuntimeStore' import { useProviderStore } from '@/stores/providerStore' import type { RuntimeSelection } from '@/types/runtime' import { CLAUDE_OFFICIAL_PROVIDER_ID } from '@/constants/openaiOfficialProvider' -import type { TeamPlanRuntime, TeamPlanMember } from '../../../../src/shared/teamPlan' +import { isValidTeamMemberName, type TeamPlanRuntime, type TeamPlanMember } from '../../../../src/shared/teamPlan' function selection(runtime: TeamPlanRuntime): RuntimeSelection { return { @@ -57,6 +57,7 @@ export function AgentTeamsPlanCard({ sessionId }: { sessionId: string }) { const activeMember = members.find(member => member.id === activeMemberId) ?? members[0] const difficultyLabel = (member: TeamPlanMember) => member.difficulty === 'high' ? t('teamPlan.high') : member.difficulty === 'low' ? t('teamPlan.low') : member.difficulty === 'medium' ? t('teamPlan.medium') : t('teamPlan.unspecified') const hasUnassignedTasks = tasks.some(task => !members.some(member => member.id === task.ownerId)) + const invalidNames = members.filter(member => !isValidTeamMemberName(member.name)).map(member => member.name) const presetChanged = members.some(member => plan.members.find(previous => previous.id === member.id)?.agentType !== member.agentType) const canStop = plan.state === 'launching' || plan.state === 'running' || Boolean(plan.parentPlanId) const editable = plan.state === 'review_pending' && !entry.busy && !entry.conflict @@ -101,13 +102,13 @@ export function AgentTeamsPlanCard({ sessionId }: { sessionId: string }) {
{draft?.dirty ? t('teamPlan.unsaved') : t('teamPlan.saved')} - {plan.state === 'review_pending' && } + {plan.state === 'review_pending' && }
{plan.state === 'review_pending' && <> - + } {canStop &&
{plan.launch?.error &&

{plan.launch.error}

} {entry.error &&

{entry.error}

} + {plan.state === 'review_pending' && invalidNames.length > 0 &&

{t('teamPlan.invalidMemberNames', { names: invalidNames.join(', ') })}

} {entry.conflict &&

{t('teamPlan.conflict')}

diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 5a003250..b40da0ac 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -47,6 +47,7 @@ export const en = { "teamPlan.dependencies": "Dependencies", "teamPlan.unassigned": "Unassigned", "teamPlan.assignRequired": "Assign this task to a member before approving.", + "teamPlan.invalidMemberNames": "Cannot launch members named {names}. Names may use only letters, numbers, underscores or hyphens; team-lead is reserved. Request a revision from the lead agent.", "teamPlan.owner": "Assigned member", "teamPlan.feedback": "Revision feedback (required to return)", "teamPlan.state.draft": "Lead agent is revising the plan", diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index ed539fa3..85624a21 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -49,6 +49,7 @@ export const jp: Record = { "teamPlan.dependencies": "依存タスク", "teamPlan.unassigned": "未割り当て", "teamPlan.assignRequired": "承認する前に、このタスクをメンバーに割り当ててください。", + "teamPlan.invalidMemberNames": "{names} は起動できません。名前には文字、数字、アンダースコア、ハイフンのみ使用でき、team-lead は予約されています。リーダーエージェントに修正を依頼してください。", "teamPlan.owner": "担当メンバー", "teamPlan.feedback": "修正依頼の内容(必須)", "teamPlan.state.draft": "リーダーが計画を修正中", diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index f0ec8098..ae4d2db7 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -49,6 +49,7 @@ export const kr: Record = { "teamPlan.dependencies": "선행 작업", "teamPlan.unassigned": "미배정", "teamPlan.assignRequired": "승인하기 전에 이 작업을 멤버에게 배정하세요.", + "teamPlan.invalidMemberNames": "{names} 멤버를 시작할 수 없습니다. 이름에는 문자, 숫자, 밑줄, 하이픈만 사용할 수 있으며 team-lead는 예약되어 있습니다. 리더 에이전트에게 수정을 요청하세요.", "teamPlan.owner": "담당 멤버", "teamPlan.feedback": "수정 의견 (반려 시 필수)", "teamPlan.state.draft": "리더가 계획 수정 중", diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 1c30fed6..ad8b9cd8 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -49,6 +49,7 @@ export const zh: Record = { "teamPlan.dependencies": "前置任務", "teamPlan.unassigned": "未分配", "teamPlan.assignRequired": "請先為此任務分配負責人,再確認啟動。", + "teamPlan.invalidMemberNames": "無法啟動以下成員:{names}。名稱只能使用字母、數字、底線或連字號,且不能使用 team-lead。請向主 Agent 要求修改方案。", "teamPlan.owner": "負責人", "teamPlan.feedback": "調整意見(退回時必填)", "teamPlan.state.draft": "主 Agent 正在調整方案", diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index ded349fc..05373ff9 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -49,6 +49,7 @@ export const zh: Record = { "teamPlan.dependencies": "前置任务", "teamPlan.unassigned": "未分配", "teamPlan.assignRequired": "请先为此任务分配负责人,再确认启动。", + "teamPlan.invalidMemberNames": "无法启动以下成员:{names}。名称只能使用字母、数字、下划线或连字符,且不能使用 team-lead。请向主 Agent 请求修改方案。", "teamPlan.owner": "负责人", "teamPlan.feedback": "调整意见(退回时必填)", "teamPlan.state.draft": "主 Agent 正在调整方案", diff --git a/src/cli/print.ts b/src/cli/print.ts index fa9bbe14..a2afca76 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -354,6 +354,8 @@ import { hasWorkingInProcessTeammates, waitForTeammatesToBecomeIdle, } from '../utils/teammate.js' +import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js' +import { SHUTDOWN_TEAM_PROMPT } from '../utils/swarm/teamShutdownPrompt.js' import { readUnreadMessages, markMessagesAsRead, @@ -402,19 +404,25 @@ const extractMemoriesModule = feature('EXTRACT_MEMORIES') : null /* eslint-enable @typescript-eslint/no-require-imports */ -const SHUTDOWN_TEAM_PROMPT = ` -You are running in non-interactive mode and cannot return a response to the user until your team is shut down. +export function hasTeammatesRequiringShutdown(state: AppState): boolean { + if (hasActiveInProcessTeammates(state)) return true + const team = state.teamContext + if (!team) return false + for (const member of Object.values(team.teammates)) { + if (member.name === TEAM_LEAD_NAME) continue + return true + } + return false +} -You MUST shut down your team before preparing your final response: -1. Use requestShutdown to ask each team member to shut down gracefully -2. Wait for shutdown approvals -3. Use the cleanup operation to clean up the team -4. Only then provide your final response to the user - -The user cannot receive your response until the team is completely shut down. - - -Shut down your team and prepare your final response for the user.` +export function createShutdownTeamPrompt(): QueuedCommand { + return { + mode: 'prompt', + value: SHUTDOWN_TEAM_PROMPT, + uuid: randomUUID(), + isMeta: true, + } +} // Track message UUIDs received during the current session runtime const MAX_RECEIVED_UUIDS = 10_000 @@ -2583,10 +2591,7 @@ function runHeadlessStreaming( while (true) { // Check if teammates are still active const refreshedState = getAppState() - const hasActiveTeammates = - hasActiveInProcessTeammates(refreshedState) || - (refreshedState.teamContext && - Object.keys(refreshedState.teamContext.teammates).length > 0) + const hasActiveTeammates = hasTeammatesRequiringShutdown(refreshedState) if (!hasActiveTeammates) { logForDebugging( @@ -2714,11 +2719,7 @@ function runHeadlessStreaming( logForDebugging( '[print.ts] Input closed with active teammates, injecting shutdown prompt', ) - enqueue({ - mode: 'prompt', - value: SHUTDOWN_TEAM_PROMPT, - uuid: randomUUID(), - }) + enqueue(createShutdownTeamPrompt()) void run() return // run() will come back here after processing } @@ -2740,24 +2741,12 @@ function runHeadlessStreaming( // Re-fetch state after potential wait const refreshedAppState = getAppState() - const refreshedTeamContext = refreshedAppState.teamContext - const hasTeamMembersNotCleanedUp = - refreshedTeamContext && - Object.keys(refreshedTeamContext.teammates).length > 0 - - return ( - hasTeamMembersNotCleanedUp || - hasActiveInProcessTeammates(refreshedAppState) - ) + return hasTeammatesRequiringShutdown(refreshedAppState) })() if (hasActiveSwarm) { // Team members are idle or pane-based - inject prompt to shut down team - enqueue({ - mode: 'prompt', - value: SHUTDOWN_TEAM_PROMPT, - uuid: randomUUID(), - }) + enqueue(createShutdownTeamPrompt()) void run() } else { // Wait for any in-flight push suggestion before closing the output stream. diff --git a/src/cli/teamShutdown.test.ts b/src/cli/teamShutdown.test.ts new file mode 100644 index 00000000..c90faebc --- /dev/null +++ b/src/cli/teamShutdown.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from 'bun:test' +import { getDefaultAppState } from '../state/AppStateStore.js' +import { createShutdownTeamPrompt, hasTeammatesRequiringShutdown } from './print.js' +import { isShutdownTeamPrompt, SHUTDOWN_TEAM_PROMPT } from '../utils/swarm/teamShutdownPrompt.js' + +test('a draft team with only its leader does not trigger shutdown', () => { + const state = getDefaultAppState() + const lead = { name: 'team-lead' } + const teamContext = { teammates: { lead } } as unknown as NonNullable + expect(hasTeammatesRequiringShutdown(state)).toBe(false) + expect(hasTeammatesRequiringShutdown({ ...state, teamContext })).toBe(false) + expect(hasTeammatesRequiringShutdown({ ...state, teamContext: { ...teamContext, teammates: { lead, worker: { ...lead, name: 'worker' } } } as typeof teamContext })).toBe(true) + const activeTask = { type: 'in_process_teammate', status: 'running' } + expect(hasTeammatesRequiringShutdown({ ...state, tasks: { worker: activeTask } as typeof state.tasks })).toBe(true) +}) + +test('shutdown instruction is model-visible but hidden from user history', () => { + const command = createShutdownTeamPrompt() + expect(command.mode).toBe('prompt') + expect(command.isMeta).toBe(true) + expect(command.value).toContain('requestShutdown') + expect(isShutdownTeamPrompt([{ type: 'text', text: SHUTDOWN_TEAM_PROMPT }])).toBe(true) + expect(isShutdownTeamPrompt('User quoted requestShutdown in a question')).toBe(false) +}) diff --git a/src/server/__tests__/translateCliMessage.teamShutdown.test.ts b/src/server/__tests__/translateCliMessage.teamShutdown.test.ts new file mode 100644 index 00000000..44d1c40b --- /dev/null +++ b/src/server/__tests__/translateCliMessage.teamShutdown.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from 'bun:test' +import { SHUTDOWN_TEAM_PROMPT } from '../../utils/swarm/teamShutdownPrompt.js' +import { translateCliMessage } from '../ws/handler.js' + +test('internal shutdown reminders cannot replay as user messages', () => { + const message = { type: 'user', isReplay: true, message: { role: 'user', content: SHUTDOWN_TEAM_PROMPT } } + expect(translateCliMessage(message, 'shutdown-prompt-legacy')).toEqual([]) + expect(translateCliMessage({ ...message, isMeta: true }, 'shutdown-prompt-meta')).toEqual([]) + expect(translateCliMessage({ ...message, message: { role: 'user', content: 'Real user request' } }, 'shutdown-prompt-real')).toContainEqual({ type: 'user_message_replay', content: 'Real user request' }) +}) diff --git a/src/server/services/sessionService.teamShutdown.test.ts b/src/server/services/sessionService.teamShutdown.test.ts new file mode 100644 index 00000000..5c3b5fca --- /dev/null +++ b/src/server/services/sessionService.teamShutdown.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from 'bun:test' +import { SHUTDOWN_TEAM_PROMPT } from '../../utils/swarm/teamShutdownPrompt.js' +import { sessionService } from './sessionService.js' + +test('old shutdown reminders without isMeta stay out of restored chat history', () => { + const entriesToMessages = (sessionService as unknown as { entriesToMessages: (entries: unknown[]) => unknown[] }).entriesToMessages.bind(sessionService) + const entry = (content: string) => ({ type: 'user', uuid: crypto.randomUUID(), message: { role: 'user', content } }) + expect(entriesToMessages([entry('Visible request'), entry(SHUTDOWN_TEAM_PROMPT)])).toHaveLength(1) +}) diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 75d5c922..719af812 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -1,6 +1,7 @@ import { getSideChat, isSideChatId, sideChatSummary } from './sideChatRegistry.js' import { splitSessionReferenceContext } from './sessionReferenceContext.js' import { parseSessionCollaborationEnvelope } from '../../utils/sessionCollaborationEnvelope.js' +import { isShutdownTeamPrompt } from '../../utils/swarm/teamShutdownPrompt.js' import { readHistoryContexts } from './sessionHistoryContext.js' import { recoverBoundedSessionHistory, type SessionHistoryRecovery } from './sessionHistoryRecovery.js' /** @@ -2069,6 +2070,7 @@ export class SessionService { if (role === 'user') { return ( + isShutdownTeamPrompt(content) || shouldHideCommandMetadataContent(content) || this.isSyntheticUserInterruption(content) || this.isTaskNotificationContent(content) diff --git a/src/server/services/teamPlanRuntime.test.ts b/src/server/services/teamPlanRuntime.test.ts index 4adf1c1b..3a7c5994 100644 --- a/src/server/services/teamPlanRuntime.test.ts +++ b/src/server/services/teamPlanRuntime.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'bun:test' -import { startTeamWorkersBarrier } from './teamPlanRuntime.js' +import { startTeamWorkersBarrier, validateTeamPlanRuntime } from './teamPlanRuntime.js' + +test('runtime rejects a legacy review with a teammate name that cannot launch', async () => { + const plan = { workDir: process.cwd(), members: [{ id: 'reader', name: 'README Reader' }] } as never + await expect(validateTeamPlanRuntime(plan)).rejects.toThrow('Invalid teammate name: README Reader') +}) describe('team worker ready barrier', () => { test('never releases a task before all isolated runtimes are ready', async () => { diff --git a/src/server/services/teamPlanRuntime.ts b/src/server/services/teamPlanRuntime.ts index 69eb9e0d..9ca4f607 100644 --- a/src/server/services/teamPlanRuntime.ts +++ b/src/server/services/teamPlanRuntime.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from 'node:crypto' import { stat } from 'node:fs/promises' -import type { TeamPlanRecord, TeamPlanMember } from '../../shared/teamPlan.js' +import { isValidTeamMemberName, type TeamPlanRecord, type TeamPlanMember } from '../../shared/teamPlan.js' import { conversationService } from './conversationService.js' import { ProviderService } from './providerService.js' import { CLAUDE_OFFICIAL_PROVIDER_ID } from '../types/provider.js' @@ -24,7 +24,7 @@ export async function validateTeamPlanRuntime(plan: TeamPlanRecord): Promise existing.name === member.name)) throw new Error(`Member already exists: ${member.name}`) const snapshot = plan.agentCatalog?.[member.agentType] if (!snapshot || !snapshot.systemPrompt.trim()) throw new Error(`Agent preset is unavailable: ${member.agentType}`) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 5de155b9..c06d4fa1 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -10,6 +10,7 @@ import { getSideChat, isSideChatId } from '../services/sideChatRegistry.js' import type { ServerWebSocket } from 'bun' import { sessionMessageUuid } from '../../utils/sessionMessageInbox.js' import { parseSessionCollaborationEnvelope } from '../../utils/sessionCollaborationEnvelope.js' +import { isShutdownTeamPrompt } from '../../utils/swarm/teamShutdownPrompt.js' import { admitSessionUserTurn, emitSessionTurnEvent } from '../services/sessionTurnEvents.js' import { ApiError } from '../middleware/errorHandler.js' import { resolveSessionReferenceContext, splitSessionReferenceContext } from '../services/sessionReferenceContext.js' @@ -3488,7 +3489,7 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa } const replayText = extractReplayUserText(cliMsg) - if (replayText) { + if (replayText && !isShutdownTeamPrompt(cliMsg.message?.content) && (!cliMsg.isMeta || parseSessionCollaborationEnvelope(replayText))) { const collaborationEnvelope = parseSessionCollaborationEnvelope(replayText) messages.push(collaborationEnvelope ? { diff --git a/src/shared/teamPlan.ts b/src/shared/teamPlan.ts index 8b856205..934351d4 100644 --- a/src/shared/teamPlan.ts +++ b/src/shared/teamPlan.ts @@ -1,5 +1,9 @@ import { z } from 'zod/v4' +export function isValidTeamMemberName(name: string): boolean { + return /^[\p{L}\p{N}_-]+$/u.test(name) && name !== 'team-lead' +} + export const teamPlanRuntimeSchema = z.object({ providerId: z.string().min(1), modelId: z.string().min(1), diff --git a/src/tools/TeamCreateTool/prompt.ts b/src/tools/TeamCreateTool/prompt.ts index 666e4bf7..9b7681cc 100644 --- a/src/tools/TeamCreateTool/prompt.ts +++ b/src/tools/TeamCreateTool/prompt.ts @@ -8,6 +8,7 @@ This desktop session requires whole-team review. TeamCreate creates a draft, not Prefer passing a complete plan with members and tasks. Each member has id, name, agentType, prompt, an optional suggested runtime {providerId, modelId, effortLevel}, reason and difficulty. Each task has id, subject, description, ownerId and dependencies (task IDs). +Member names may contain letters, numbers, underscores and hyphens only; team-lead is reserved. Agent calls with name/team_name only register draft members. They do NOT start execution. Finish the entire plan, then call TeamPlan with operation="submit" and expected_revision. After submitting, end this planning turn. The user will review presets, task owners and model diff --git a/src/tools/TeamPlanTool/TeamPlanTool.test.ts b/src/tools/TeamPlanTool/TeamPlanTool.test.ts index f4cac005..224706ba 100644 --- a/src/tools/TeamPlanTool/TeamPlanTool.test.ts +++ b/src/tools/TeamPlanTool/TeamPlanTool.test.ts @@ -54,6 +54,7 @@ afterEach(async () => { describe('whole-team planning tools', () => { test('review instructions describe the actual draft and execution states', () => { expect(getPrompt()).toContain('Human review before execution') + expect(getPrompt()).toContain('Member names may contain letters, numbers, underscores and hyphens only') expect(getPrompt()).toContain('Additional members require a new incremental review') const result = teamPlanToolResult({ teamName: 'review-team', state: 'running', members: [], tasks: [] } as never) expect(result.message).toContain('running') @@ -72,6 +73,9 @@ describe('whole-team planning tools', () => { plan: { members: [{ id: 'researcher', name: 'researcher', agentType: 'Explore', prompt: 'Inspect the source' }], tasks: [{ id: 'audit', subject: 'Audit source', ownerId: 'researcher', dependencies: [] }] }, }, context) expect(submitted.data.state).toBe('review_pending') + expect(TeamPlanTool.mapToolResultToToolResultBlockParam(submitted.data, 'submit-plan')).toMatchObject({ + type: 'tool_result', tool_use_id: 'submit-plan', content: [{ type: 'text', text: expect.stringContaining('review_pending') }], + }) const plan = await readTeamPlan('review-team') expect(plan?.approvedSnapshot).toBeUndefined() expect(plan?.members[0]?.agentSnapshot?.systemPrompt).toContain('READ-ONLY') @@ -91,6 +95,14 @@ describe('whole-team planning tools', () => { expect((await readTeamPlan('review-team'))?.state).toBe('draft') }) + test('rejects a teammate name the launcher cannot use before submitting a plan', async () => { + const created = await TeamCreateTool.call({ team_name: 'review-team' }, context) + await expect(TeamPlanTool.call({ team_name: 'review-team', operation: 'submit', expected_revision: created.data.plan!.revision, + plan: { members: [{ id: 'reader', name: 'README Reader', prompt: 'Read README' }], tasks: [{ id: 'read', subject: 'Read', ownerId: 'reader', dependencies: [] }] }, + }, context)).rejects.toThrow('Use letters, numbers, underscores or hyphens') + expect((await readTeamPlan('review-team'))?.state).toBe('draft') + }) + test('a persisted desktop draft cannot fall through to an execution backend if the environment flag is absent', async () => { await TeamCreateTool.call({ team_name: 'review-team' }, context) delete process.env.CC_HAHA_TEAM_REVIEW_REQUIRED diff --git a/src/tools/TeamPlanTool/TeamPlanTool.ts b/src/tools/TeamPlanTool/TeamPlanTool.ts index 67c5e602..48ead4ed 100644 --- a/src/tools/TeamPlanTool/TeamPlanTool.ts +++ b/src/tools/TeamPlanTool/TeamPlanTool.ts @@ -3,6 +3,7 @@ import { getSessionId } from '../../bootstrap/state.js' import { buildTool } from '../../Tool.js' import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' import { lazySchema } from '../../utils/lazySchema.js' +import { jsonStringify } from '../../utils/slowOperations.js' import { isTeamReviewRequired } from '../../utils/swarm/teamPlanPolicy.js' import { readTeamPlan, replaceTeamPlan, submitTeamPlan } from '../../utils/swarm/teamPlanStore.js' import { isTeammate } from '../../utils/teammate.js' @@ -27,10 +28,13 @@ export const TeamPlanTool = buildTool({ isEnabled() { return isAgentSwarmsEnabled() && isTeamReviewRequired() && !isTeammate() }, async description() { return 'Read, replace or submit a team draft for human review. Never starts members or approves a plan.' }, async prompt() { - return 'After TeamCreate, use TeamPlan to submit the complete roster and tasks together. Give each member a stable id, available agentType, task prompt, suggested runtime and a short reason. Task ownerId refers to a member id; dependencies refer to task ids. Use get to read the current revision. Replace and submit require expected_revision. Submit may include a complete replacement plan. After submit, end the planning turn and wait for human approval. Do not start work, claim tasks, poll the plan or call Agent to bypass review. Only the user can approve in the team panel.' + return 'After TeamCreate, use TeamPlan to submit the complete roster and tasks together. Give each member a stable id, a launchable name using only letters, numbers, underscores or hyphens (team-lead is reserved), available agentType, task prompt, suggested runtime and a short reason. Task ownerId refers to a member id; dependencies refer to task ids. Use get to read the current revision. Replace and submit require expected_revision. Submit may include a complete replacement plan. After submit, end the planning turn and wait for human approval. Do not start work, claim tasks, poll the plan or call Agent to bypass review. Only the user can approve in the team panel.' }, toAutoClassifierInput(input) { return `${input.operation} ${input.team_name}` }, renderToolUseMessage(input) { return `${input.operation} team plan: ${input.team_name}` }, + mapToolResultToToolResultBlockParam(data, toolUseID) { + return { tool_use_id: toolUseID, type: 'tool_result' as const, content: [{ type: 'text' as const, text: jsonStringify(data) }] } + }, async call(input, context) { if (isTeammate()) throw new Error('Only the team leader can submit a team plan.') let plan = await readTeamPlan(input.team_name) diff --git a/src/utils/swarm/teamPlanStore.test.ts b/src/utils/swarm/teamPlanStore.test.ts index c6628191..2ac9baa6 100644 --- a/src/utils/swarm/teamPlanStore.test.ts +++ b/src/utils/swarm/teamPlanStore.test.ts @@ -44,6 +44,13 @@ test('staged edits reject stale revisions and graph corruption without partial w await replaceTeamPlan('review', identity(plan), { feedback: 'new' }) await expect(submitTeamPlan('review', identity(plan))).rejects.toThrow('changed') }) +test('old invalid teammate names cannot be approved or saved from review', async () => { + const plan = await draft() + await expect(replaceTeamPlan('review', identity(plan), { members: [{ ...plan.members[0]!, name: 'README Reader' }] })).rejects.toThrow('Invalid teammate name') + const submitted = await submitTeamPlan('review', identity(plan)) + await expect(approveTeamPlan('review', identity(submitted), 'invalid', { ...submitted, members: [{ ...submitted.members[0]!, name: 'README Reader' }] })).rejects.toThrow('Invalid teammate name') + expect((await readTeamPlan('review'))?.state).toBe('review_pending') +}) test('approval has frozen snapshot, idempotent retries, and concurrent admission once', async () => { const plan = await draft() const submitted = await submitTeamPlan('review', identity(plan)) diff --git a/src/utils/swarm/teamPlanStore.ts b/src/utils/swarm/teamPlanStore.ts index d86ffc86..e89a7a53 100644 --- a/src/utils/swarm/teamPlanStore.ts +++ b/src/utils/swarm/teamPlanStore.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'node:crypto' import { mkdir, readFile, readdir, rename, writeFile, unlink } from 'node:fs/promises' import { join } from 'node:path' -import { teamPlanRecordSchema, type TeamPlanIdentity, type TeamPlanMember, type TeamPlanPatch, type TeamPlanRecord, type TeamPlanRuntime } from '../../shared/teamPlan.js' +import { isValidTeamMemberName, teamPlanRecordSchema, type TeamPlanIdentity, type TeamPlanMember, type TeamPlanPatch, type TeamPlanRecord, type TeamPlanRuntime } from '../../shared/teamPlan.js' import { getTeamsDir } from '../envUtils.js' import { getCanonicalTeamTaskListId, withTaskListLifecycleLock } from '../tasks.js' import { getTeamDir, readTeamFileAsync } from './teamHelpers.js' @@ -41,6 +41,9 @@ async function writePlan(plan: TeamPlanRecord): Promise { } export function validateTeamPlanGraph(plan: Pick, runnable = false): void { + for (const member of plan.members) { + if (!isValidTeamMemberName(member.name)) throw new TeamPlanError(`Invalid teammate name: ${member.name}. Use letters, numbers, underscores or hyphens; "team-lead" is reserved.`, 400) + } const memberIds = new Set(plan.members.map(member => member.id)) const names = new Set(plan.members.map(member => member.name)) const tasks = new Map(plan.tasks.map(task => [task.id, task])) diff --git a/src/utils/swarm/teamShutdownPrompt.ts b/src/utils/swarm/teamShutdownPrompt.ts new file mode 100644 index 00000000..d6861383 --- /dev/null +++ b/src/utils/swarm/teamShutdownPrompt.ts @@ -0,0 +1,20 @@ +export const SHUTDOWN_TEAM_PROMPT = ` +You are running in non-interactive mode and cannot return a response to the user until your team is shut down. + +You MUST shut down your team before preparing your final response: +1. Use requestShutdown to ask each team member to shut down gracefully +2. Wait for shutdown approvals +3. Use the cleanup operation to clean up the team +4. Only then provide your final response to the user + +The user cannot receive your response until the team is completely shut down. + + +Shut down your team and prepare your final response for the user.` + +/** Recognize the exact legacy instruction even when it was persisted without isMeta. */ +export function isShutdownTeamPrompt(content: unknown): boolean { + const text = typeof content === 'string' ? content : + Array.isArray(content) && content.length === 1 && content[0]?.type === 'text' ? content[0].text : null + return text === SHUTDOWN_TEAM_PROMPT +}