From 7eb466228c4eddf3e3e29ca3e36b0a14c9d7b718 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: Tue, 15 Sep 2026 15:34:47 +0800 Subject: [PATCH] feat(settings): restore authoritative Agent Teams control in General --- .../src/__tests__/generalSettings.test.tsx | 47 +++++++ desktop/src/i18n/locales/en.ts | 5 + desktop/src/i18n/locales/jp.ts | 5 + desktop/src/i18n/locales/kr.ts | 5 + desktop/src/i18n/locales/zh-TW.ts | 5 + desktop/src/i18n/locales/zh.ts | 5 + .../src/pages/settings/GeneralSettings.tsx | 31 +++++ desktop/src/stores/settingsStore.test.ts | 69 ++++++++++ desktop/src/stores/settingsStore.ts | 15 +++ desktop/src/types/settings.ts | 1 + .../__tests__/conversation-service.test.ts | 76 +++++++++++ .../__tests__/cron-scheduler-launcher.test.ts | 59 +++++++++ src/server/__tests__/e2e/full-flow.test.ts | 4 +- .../__tests__/persistence-upgrade.test.ts | 24 ++++ src/server/__tests__/settings.test.ts | 40 +++++- src/server/api/settings.ts | 5 +- src/server/services/conversationService.ts | 4 + src/server/services/cronScheduler.ts | 4 + src/server/services/settingsService.ts | 19 +++ src/utils/agentSwarmsEnabled.test.ts | 125 ++++++++++++++++++ src/utils/agentSwarmsEnabled.ts | 25 +++- src/utils/managedEnv.test.ts | 31 ++++- src/utils/managedEnv.ts | 17 ++- src/utils/settings/types.ts | 4 + 24 files changed, 611 insertions(+), 14 deletions(-) create mode 100644 src/utils/agentSwarmsEnabled.test.ts diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index c790d779..61a3c68a 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -249,6 +249,7 @@ describe('Settings > General tab', () => { autoModeOptInAccepted: false, thinkingEnabled: true, workflowKeywordTriggerEnabled: true, + agentTeamsEnabled: true, autoDreamEnabled: false, skipWebFetchPreflight: true, desktopNotificationsEnabled: true, @@ -1270,6 +1271,52 @@ describe('Settings > General tab', () => { } }) + it('saves Agent Teams changes and explains when they take effect', async () => { + const updateUser = vi.spyOn(settingsApi, 'updateUser').mockResolvedValue({ ok: true }) + try { + render() + fireEvent.click(screen.getByText('General')) + const toggle = screen.getByRole('switch', { name: 'Enable Agent Teams' }) + expect(toggle).toBeChecked() + expect(screen.getByText('Applies to new sessions. Restart the app to apply changes to existing sessions.')).toBeVisible() + + await act(async () => { fireEvent.click(toggle) }) + expect(toggle).not.toBeChecked() + expect(updateUser).toHaveBeenLastCalledWith({ agentTeamsEnabled: false }) + await act(async () => { fireEvent.click(toggle) }) + expect(toggle).toBeChecked() + expect(updateUser).toHaveBeenLastCalledWith({ agentTeamsEnabled: true }) + } finally { + updateUser.mockRestore() + } + }) + + it('disables Agent Teams while saving and reports failure after rolling back', async () => { + let rejectSave!: (error: Error) => void + const updateUser = vi.spyOn(settingsApi, 'updateUser').mockImplementation(() => new Promise((_, reject) => { + rejectSave = reject + })) + try { + render() + fireEvent.click(screen.getByText('General')) + const toggle = screen.getByRole('switch', { name: 'Enable Agent Teams' }) + fireEvent.click(toggle) + expect(toggle).toBeDisabled() + expect(toggle).not.toBeChecked() + expect(updateUser).toHaveBeenCalledTimes(1) + + await act(async () => { rejectSave(new Error('disk full')) }) + expect(toggle).toBeEnabled() + expect(toggle).toBeChecked() + expect(useUIStore.getState().toasts.at(-1)).toMatchObject({ + type: 'error', + message: 'Failed to save Agent Teams settings. Please try again.', + }) + } finally { + updateUser.mockRestore() + } + }) + it('lets the user choose a default permission mode for new sessions', async () => { render() diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 30c33090..e9e1623d 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -2188,6 +2188,11 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'settings.general.defaultEditorTitle': 'Default editor', 'settings.general.defaultEditorDescription': 'Which editor the Open With menu offers for a file. Only editors found on this machine are listed.', 'settings.general.defaultEditorAuto': 'Automatic', + 'settings.general.agentTeamsTitle': 'Agent Teams', + 'settings.general.agentTeamsDescription': 'Let multiple agents divide tasks and collaborate as a team.', + 'settings.general.agentTeamsEnabled': 'Enable Agent Teams', + 'settings.general.agentTeamsHint': 'Applies to new sessions. Restart the app to apply changes to existing sessions.', + 'settings.general.agentTeamsSaveFailed': 'Failed to save Agent Teams settings. Please try again.', 'settings.general.workflowKeywordTitle': 'Ultracode', 'settings.general.workflowKeywordDescription': 'Controls whether a standalone Ultracode keyword opts the prompt into dynamic Workflow orchestration.', 'settings.general.workflowKeywordEnabled': 'Enable Ultracode keyword trigger', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 9671590f..330f3f3d 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -2191,6 +2191,11 @@ export const jp: Record = { 'settings.general.defaultEditorTitle': '既定のエディタ', 'settings.general.defaultEditorDescription': 'ファイルの「開く方法」メニューに表示するエディタ。このマシンで検出されたエディタのみ表示されます。', 'settings.general.defaultEditorAuto': '自動', + 'settings.general.agentTeamsTitle': 'Agent Teams', + 'settings.general.agentTeamsDescription': '複数のエージェントがタスクを分担し、チームで協力できるようにします。', + 'settings.general.agentTeamsEnabled': 'Agent Teams を有効にする', + 'settings.general.agentTeamsHint': '新しいセッションに適用されます。既存のセッションに適用するにはアプリを再起動してください。', + 'settings.general.agentTeamsSaveFailed': 'Agent Teams の設定を保存できませんでした。もう一度お試しください。', 'settings.general.workflowKeywordTitle': 'Ultracode', 'settings.general.workflowKeywordDescription': '単独の Ultracode キーワードでプロンプトを動的な Workflow オーケストレーションに切り替えるかを制御します。', 'settings.general.workflowKeywordEnabled': 'Ultracode キーワードトリガーを有効にする', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 20f20ab4..37f1aa8f 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -2191,6 +2191,11 @@ export const kr: Record = { 'settings.general.defaultEditorTitle': '기본 편집기', 'settings.general.defaultEditorDescription': '파일의 연결 프로그램 메뉴에 표시할 편집기입니다. 이 컴퓨터에서 감지된 편집기만 나열됩니다.', 'settings.general.defaultEditorAuto': '자동', + 'settings.general.agentTeamsTitle': 'Agent Teams', + 'settings.general.agentTeamsDescription': '여러 에이전트가 작업을 나누고 팀으로 협력할 수 있습니다.', + 'settings.general.agentTeamsEnabled': 'Agent Teams 사용', + 'settings.general.agentTeamsHint': '새 세션에 적용됩니다. 기존 세션에 적용하려면 앱을 다시 시작하세요.', + 'settings.general.agentTeamsSaveFailed': 'Agent Teams 설정을 저장하지 못했습니다. 다시 시도하세요.', 'settings.general.workflowKeywordTitle': 'Ultracode', 'settings.general.workflowKeywordDescription': '독립된 Ultracode 키워드가 프롬프트를 동적 Workflow 오케스트레이션으로 전환할지 제어합니다.', 'settings.general.workflowKeywordEnabled': 'Ultracode 키워드 트리거 사용', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 541f4320..e9a1e02a 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -2190,6 +2190,11 @@ export const zh: Record = { 'settings.general.defaultEditorTitle': '預設編輯器', 'settings.general.defaultEditorDescription': '檔案的「開啟方式」選單裡提供哪個編輯器。僅列出本機已偵測到的編輯器。', 'settings.general.defaultEditorAuto': '自動選擇', + 'settings.general.agentTeamsTitle': 'Agent Teams 智慧體團隊', + 'settings.general.agentTeamsDescription': '讓多個 Agent 分工協作,共同完成任務。', + 'settings.general.agentTeamsEnabled': '啟用 Agent Teams', + 'settings.general.agentTeamsHint': '對新建工作階段生效,既有工作階段需重新啟動應用程式後生效。', + 'settings.general.agentTeamsSaveFailed': 'Agent Teams 設定儲存失敗,請重試。', 'settings.general.workflowKeywordTitle': 'Ultracode', 'settings.general.workflowKeywordDescription': '控制獨立出現的 Ultracode 關鍵詞是否讓目前提示詞進入動態 Workflow 編排。', 'settings.general.workflowKeywordEnabled': '啟用 Ultracode 關鍵詞觸發', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index c7757a18..940fa0e7 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -2189,6 +2189,11 @@ export const zh: Record = { 'settings.general.defaultEditorTitle': '默认编辑器', 'settings.general.defaultEditorDescription': '文件的「打开方式」菜单里提供哪个编辑器。只列出本机已检测到的编辑器。', 'settings.general.defaultEditorAuto': '自动选择', + 'settings.general.agentTeamsTitle': 'Agent Teams 智能体团队', + 'settings.general.agentTeamsDescription': '让多个 Agent 分工协作,共同完成任务。', + 'settings.general.agentTeamsEnabled': '启用 Agent Teams', + 'settings.general.agentTeamsHint': '对新建会话生效,已有会话重启应用后生效。', + 'settings.general.agentTeamsSaveFailed': 'Agent Teams 设置保存失败,请重试。', 'settings.general.workflowKeywordTitle': 'Ultracode', 'settings.general.workflowKeywordDescription': '控制独立出现的 Ultracode 关键词是否让当前提示词进入动态 Workflow 编排。', 'settings.general.workflowKeywordEnabled': '启用 Ultracode 关键词触发', diff --git a/desktop/src/pages/settings/GeneralSettings.tsx b/desktop/src/pages/settings/GeneralSettings.tsx index 55c9aa88..fd22752e 100644 --- a/desktop/src/pages/settings/GeneralSettings.tsx +++ b/desktop/src/pages/settings/GeneralSettings.tsx @@ -70,6 +70,8 @@ export function GeneralSettings() { setThinkingEnabled, workflowKeywordTriggerEnabled, setWorkflowKeywordTriggerEnabled, + agentTeamsEnabled, + setAgentTeamsEnabled, permissionMode, setPermissionMode, autoDreamEnabled, @@ -129,6 +131,7 @@ export function GeneralSettings() { const [notificationActionRunning, setNotificationActionRunning] = useState(false) const [autoDreamConfirmOpen, setAutoDreamConfirmOpen] = useState(false) const [autoDreamActionRunning, setAutoDreamActionRunning] = useState(false) + const [agentTeamsSaving, setAgentTeamsSaving] = useState(false) const [modeSwitchConfirmOpen, setModeSwitchConfirmOpen] = useState(false) const [pendingMode, setPendingMode] = useState(null) const [pendingPortableDir, setPendingPortableDir] = useState(null) @@ -587,6 +590,18 @@ export function GeneralSettings() { } } + const handleAgentTeamsChange = async (enabled: boolean) => { + if (agentTeamsSaving) return + setAgentTeamsSaving(true) + try { + await setAgentTeamsEnabled(enabled) + } catch { + addToast({ type: 'error', message: t('settings.general.agentTeamsSaveFailed') }) + } finally { + setAgentTeamsSaving(false) + } + } + const handleOutputStyleChange = async (value: string) => { try { await setOutputStyle(value, outputStyleWorkDir) @@ -1109,6 +1124,22 @@ export function GeneralSettings() { + +
+ void handleAgentTeamsChange(enabled)} + disabled={agentTeamsSaving} + label={t('settings.general.agentTeamsEnabled')} + description={t('settings.general.agentTeamsHint')} + /> +
+
+

{t('settings.general.autoDreamTitle')}

{t('settings.general.autoDreamDescription')}

diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 42f77dce..4ae3908a 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -1081,6 +1081,75 @@ describe('settingsStore workflow keyword persistence', () => { }) }) +describe('settingsStore Agent Teams persistence', () => { + const updateUser = vi.fn() + const getUser = vi.fn() + + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + window.localStorage.clear() + updateUser.mockReset().mockResolvedValue({ ok: true }) + getUser.mockReset().mockResolvedValue({}) + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser, + updateUser, + getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn().mockResolvedValue({ models: [] }), + getCurrent: vi.fn().mockResolvedValue({ model: null }), + getEffort: vi.fn().mockResolvedValue({ level: 'medium' }), + }, + })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { get: vi.fn().mockResolvedValue({ settings: {} }) }, + })) + vi.doMock('../api/traces', () => ({ + tracesApi: { getSettings: vi.fn().mockResolvedValue({ enabled: true, storageDir: '/tmp/test-traces' }) }, + })) + }) + + it.each([ + [{}, true], + [{ agentTeamsEnabled: true }, true], + [{ agentTeamsEnabled: false }, false], + ])('loads Agent Teams from user settings %j', async (settings, expected) => { + getUser.mockResolvedValue(settings) + const { useSettingsStore } = await import('./settingsStore') + expect(useSettingsStore.getState().agentTeamsEnabled).toBe(true) + await useSettingsStore.getState().fetchAll() + expect(useSettingsStore.getState().agentTeamsEnabled).toBe(expected) + }) + + it('persists disabling and re-enabling Agent Teams', async () => { + const { useSettingsStore } = await import('./settingsStore') + await useSettingsStore.getState().setAgentTeamsEnabled(false) + expect(useSettingsStore.getState().agentTeamsEnabled).toBe(false) + await useSettingsStore.getState().setAgentTeamsEnabled(true) + expect(updateUser).toHaveBeenNthCalledWith(1, { agentTeamsEnabled: false }) + expect(updateUser).toHaveBeenNthCalledWith(2, { agentTeamsEnabled: true }) + expect(useSettingsStore.getState().agentTeamsEnabled).toBe(true) + }) + + it.each([true, false])('rolls back an optimistic update from %s and rejects on save failure', async (previous) => { + const { useSettingsStore } = await import('./settingsStore') + useSettingsStore.setState({ agentTeamsEnabled: previous }) + const deferred = createDeferred<{ ok: boolean }>() + updateUser.mockReturnValueOnce(deferred.promise) + const saving = useSettingsStore.getState().setAgentTeamsEnabled(!previous) + expect(useSettingsStore.getState().agentTeamsEnabled).toBe(!previous) + const failure = new Error('disk full') + const rejected = expect(saving).rejects.toBe(failure) + deferred.reject(failure) + await rejected + expect(useSettingsStore.getState().agentTeamsEnabled).toBe(previous) + }) +}) + describe('settingsStore Auto-dream persistence', () => { beforeEach(() => { vi.resetModules() diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 501134ca..db90f46e 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -61,6 +61,7 @@ type SettingsStore = { effortLevel: EffortLevel thinkingEnabled: boolean workflowKeywordTriggerEnabled: boolean + agentTeamsEnabled: boolean autoDreamEnabled: boolean autoModeOptInAccepted: boolean availableModels: ModelInfo[] @@ -105,6 +106,7 @@ type SettingsStore = { setEffort: (level: EffortLevel) => Promise setThinkingEnabled: (enabled: boolean) => Promise setWorkflowKeywordTriggerEnabled: (enabled: boolean) => Promise + setAgentTeamsEnabled: (enabled: boolean) => Promise setAutoDreamEnabled: (enabled: boolean) => Promise acceptAutoModeOptIn: () => Promise setLocale: (locale: Locale) => void @@ -197,6 +199,7 @@ export const useSettingsStore = create((set, get) => ({ effortLevel: 'max', thinkingEnabled: true, workflowKeywordTriggerEnabled: true, + agentTeamsEnabled: true, autoDreamEnabled: false, autoModeOptInAccepted: false, availableModels: [], @@ -274,6 +277,7 @@ export const useSettingsStore = create((set, get) => ({ effortLevel: level, thinkingEnabled: userSettings.alwaysThinkingEnabled !== false, workflowKeywordTriggerEnabled: userSettings.workflowKeywordTriggerEnabled !== false, + agentTeamsEnabled: userSettings.agentTeamsEnabled !== false, autoDreamEnabled: userSettings.autoDreamEnabled === true, autoModeOptInAccepted: userSettings.skipAutoPermissionPrompt === true, chatSendBehavior: normalizeChatSendBehavior(userSettings.chatSendBehavior), @@ -358,6 +362,17 @@ export const useSettingsStore = create((set, get) => ({ } }, + setAgentTeamsEnabled: async (enabled) => { + const prev = get().agentTeamsEnabled + set({ agentTeamsEnabled: enabled }) + try { + await settingsApi.updateUser({ agentTeamsEnabled: enabled }) + } catch (error) { + set({ agentTeamsEnabled: prev }) + throw error + } + }, + setAutoDreamEnabled: async (enabled) => { const prev = get().autoDreamEnabled set({ autoDreamEnabled: enabled }) diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 59df36b3..70288c16 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -153,6 +153,7 @@ export type UserSettings = { effort?: EffortLevel alwaysThinkingEnabled?: boolean workflowKeywordTriggerEnabled?: boolean + agentTeamsEnabled?: boolean autoDreamEnabled?: boolean skipAutoPermissionPrompt?: boolean permissionMode?: PermissionMode diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 9c6505c7..383ef742 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -10,6 +10,7 @@ import { import { ProviderService } from '../services/providerService.js' import { updateTraceCaptureSettings } from '../services/traceCaptureService.js' import { resetTerminalShellEnvironmentCacheForTests } from '../../utils/terminalShellEnvironment.js' +import { createSandboxedTestEnvironment } from '../../../scripts/pr/test-environment.js' describe('ConversationService', () => { let tmpDir: string @@ -396,6 +397,81 @@ describe('ConversationService', () => { expect(env.ANTHROPIC_MODEL).toBeUndefined() }) + for (const entrypoint of ['sdk-cli', 'claude-desktop']) { + for (const { settingsFile, setting, preference } of [ + { settingsFile: 'settings.json', setting: undefined, preference: undefined }, + { settingsFile: 'settings.json', setting: '0', preference: undefined }, + { settingsFile: 'cc-haha/settings.json', setting: 'false', preference: undefined }, + { settingsFile: 'cc-haha/settings.json', setting: 'false', preference: true }, + { settingsFile: 'settings.json', setting: '1', preference: false }, + ]) { + test(`desktop team tools survive child startup (${entrypoint}, ${settingsFile}=${setting ?? 'unset'}, preference=${preference ?? 'unset'})`, async () => { + if (setting !== undefined) { + const legacyPath = path.join(tmpDir, settingsFile) + await fs.mkdir(path.dirname(legacyPath), { recursive: true }) + await fs.writeFile(legacyPath, JSON.stringify({ + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: setting }, + })) + } + if (preference !== undefined) { + await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify({ + agentTeamsEnabled: preference, + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: setting }, + })) + } + const service = new ConversationService() as any + service.shouldMarkManagedOAuth = () => entrypoint === 'claude-desktop' + service.buildOfficialOAuthEnv = async () => ({ CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' }) + const childEnv = await service.buildChildEnv(tmpDir, 'ws://127.0.0.1:3456/sdk/test') + const probeHome = path.join(tmpDir, 'team-probe') + const probeEnv = createSandboxedTestEnvironment(probeHome, { + CLAUDE_CODE_ENTRYPOINT: childEnv.CLAUDE_CODE_ENTRYPOINT ?? 'sdk-cli', + ANTHROPIC_API_KEY: 'fake-team-probe-key', + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + DISABLE_TELEMETRY: '1', + }) + // Pass the host's real team defaults across a fresh process boundary, + // without inheriting provider credentials or any user configuration. + for (const key of ['CC_HAHA_AGENT_TEAMS_DEFAULT', 'CC_HAHA_AGENT_TEAMS_ENABLED', 'CLAUDE_CODE_ENABLE_TASKS']) { + if (childEnv[key] !== undefined) probeEnv[key] = childEnv[key] + } + if (setting !== undefined) { + const settingsPath = path.join(probeEnv.CLAUDE_CONFIG_DIR!, settingsFile) + await fs.mkdir(path.dirname(settingsPath), { recursive: true }) + await fs.writeFile(settingsPath, JSON.stringify({ + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: setting }, + })) + } + const repoRoot = path.resolve(import.meta.dir, '../../..') + const probe = Bun.spawn([process.execPath, '--no-env-file', '--preload', path.join(repoRoot, 'preload.ts'), '-e', ` + const { applySafeConfigEnvironmentVariables, applyConfigEnvironmentVariables } = await import(${JSON.stringify(path.join(repoRoot, 'src/utils/managedEnv.ts'))}) + applySafeConfigEnvironmentVariables() + applyConfigEnvironmentVariables() + const { getAllBaseTools } = await import(${JSON.stringify(path.join(repoRoot, 'src/tools.ts'))}) + const { default: teamCommand } = await import(${JSON.stringify(path.join(repoRoot, 'src/commands/team.ts'))}) + const names = getAllBaseTools().filter(tool => tool.isEnabled()).map(tool => tool.name) + console.log('TEAM_PROBE:' + JSON.stringify({ + tools: names.filter(name => ['TeamCreate', 'TeamDelete', 'SendMessage', 'TaskCreate'].includes(name)), + command: teamCommand.isEnabled(), + })) + `], { cwd: probeHome, env: probeEnv, stdout: 'pipe', stderr: 'pipe' }) + const [stdout, stderr, code] = await Promise.all([ + new Response(probe.stdout).text(), + new Response(probe.stderr).text(), + probe.exited, + ]) + expect({ code, stderr }).toEqual({ code: 0, stderr: '' }) + const report = JSON.parse(stdout.split('\n').find(line => line.startsWith('TEAM_PROBE:'))!.slice('TEAM_PROBE:'.length)) + expect(report.tools).toContain('TaskCreate') + const expected = preference ?? (setting === undefined) + expect(report.command).toBe(expected) + for (const name of ['TeamCreate', 'TeamDelete', 'SendMessage']) { + expect(report.tools.includes(name)).toBe(expected) + } + }) + } + } + test('buildChildEnv injects General network timeout and manual proxy for CLI requests', async () => { await fs.writeFile( path.join(tmpDir, 'settings.json'), diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts index 1e8937cf..8c2b8a4e 100644 --- a/src/server/__tests__/cron-scheduler-launcher.test.ts +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -515,6 +515,65 @@ describe('cron scheduler launcher resolution', () => { } }) + unixOnly('executeTask reloads the General team preference for each new scheduled process', async () => { + const appRoot = path.join(tmpDir, 'app-root') + const sidecarPath = path.join(tmpDir, 'claude-sidecar') + const teamEnvPath = path.join(tmpDir, 'team.env') + const settingsPath = path.join(process.env.CLAUDE_CONFIG_DIR!, 'settings.json') + const envKeys = [ + 'CC_HAHA_AGENT_TEAMS_DEFAULT', + 'CC_HAHA_AGENT_TEAMS_ENABLED', + 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', + ] as const + const savedEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]])) + await fs.mkdir(appRoot, { recursive: true }) + await fs.mkdir(path.dirname(settingsPath), { recursive: true }) + await fs.writeFile(sidecarPath, [ + '#!/bin/sh', + `printf '%s\\n' "$CC_HAHA_AGENT_TEAMS_DEFAULT" "\${CC_HAHA_AGENT_TEAMS_ENABLED-unset}" "$CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS" > "${teamEnvPath}"`, + '/bin/cat >/dev/null', + 'printf \'%s\\n\' \'{"type":"result","result":"team env ok"}\'', + '', + ].join('\n')) + await fs.chmod(sidecarPath, 0o755) + process.env.CLAUDE_CLI_PATH = sidecarPath + process.env.CLAUDE_APP_ROOT = appRoot + process.env.HOME = tmpDir + process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = '0' + process.env.CC_HAHA_AGENT_TEAMS_ENABLED = '1' + process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '0' + + try { + const cronService = new CronService() + const scheduler = new CronScheduler(cronService) + const task = await cronService.createTask({ + cron: '* * * * *', + prompt: 'cron team environment test', + recurring: true, + folderPath: tmpDir, + }) + for (const enabled of [undefined, true, false, undefined]) { + await fs.writeFile(settingsPath, JSON.stringify( + enabled === undefined ? {} : { agentTeamsEnabled: enabled }, + )) + const run = await scheduler.executeTask(task) + expect(run.status).toBe('completed') + expect(run.output).toBe('team env ok') + expect((await fs.readFile(teamEnvPath, 'utf-8')).trim().split('\n')).toEqual([ + '0', + enabled === true ? '1' : '0', + '0', + ]) + } + } finally { + for (const key of envKeys) { + const value = savedEnv[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + }) + unixOnly('executeTask launches scheduled tasks with full permissions', async () => { const appRoot = path.join(tmpDir, 'app-root') const sidecarPath = path.join(tmpDir, 'claude-sidecar') diff --git a/src/server/__tests__/e2e/full-flow.test.ts b/src/server/__tests__/e2e/full-flow.test.ts index ddd10a4a..b460fade 100644 --- a/src/server/__tests__/e2e/full-flow.test.ts +++ b/src/server/__tests__/e2e/full-flow.test.ts @@ -185,9 +185,9 @@ describe('E2E: Full Flow', () => { // 3. Settings // ============================================= - it('should get empty settings initially', async () => { + it('should expose the default team preference in initial settings', async () => { const { data } = await api('GET', '/api/settings/user') - expect(data).toEqual({}) + expect(data).toEqual({ agentTeamsEnabled: true }) }) it('should update and read user settings', async () => { diff --git a/src/server/__tests__/persistence-upgrade.test.ts b/src/server/__tests__/persistence-upgrade.test.ts index 32494774..aea3db7a 100644 --- a/src/server/__tests__/persistence-upgrade.test.ts +++ b/src/server/__tests__/persistence-upgrade.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs/promises' import * as os from 'os' import * as path from 'path' import { ProviderService } from '../services/providerService.js' +import { SettingsService } from '../services/settingsService.js' import { CURRENT_PROVIDER_INDEX_SCHEMA_VERSION, ensurePersistentStorageUpgraded, @@ -32,6 +33,29 @@ describe('persistent storage upgrade migrations', () => { await fs.rm(tempDir, { recursive: true, force: true }) }) + test('upgrades legacy team preferences on read and preserves the original settings on save', async () => { + const userPath = path.join(tempDir, 'settings.json') + const managedDir = path.join(tempDir, 'cc-haha') + await fs.mkdir(managedDir, { recursive: true }) + const legacy = { + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', LEGACY_OTHER_ENV: 'preserved' }, + unknownFuturePreference: { keep: true }, + } + const original = JSON.stringify(legacy) + await fs.writeFile(userPath, original) + await fs.writeFile(path.join(managedDir, 'settings.json'), JSON.stringify({ + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '0' }, + })) + const service = new SettingsService() + expect(await service.getAgentTeamsEnabled()).toBe(false) + expect(await fs.readFile(userPath, 'utf-8')).toBe(original) + await service.updateUserSettings({ agentTeamsEnabled: true }) + expect(await new SettingsService().getAgentTeamsEnabled()).toBe(true) + expect(JSON.parse(await fs.readFile(userPath, 'utf-8'))).toEqual({ ...legacy, agentTeamsEnabled: true }) + await service.updateUserSettings({ agentTeamsEnabled: false }) + expect(await new SettingsService().getAgentTeamsEnabled()).toBe(false) + }) + test('migrates legacy providers index and writes a backup before changing it', async () => { const ccHahaDir = path.join(tempDir, 'cc-haha') await fs.mkdir(ccHahaDir, { recursive: true }) diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index 377185e7..4a15eb20 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -44,6 +44,7 @@ import { import { resetSettingsCache } from '../../utils/settings/settingsCache.js' import { clearAllOutputStylesCache } from '../../constants/outputStyles.js' import { clearOutputStyleCaches } from '../../outputStyles/loadOutputStylesDir.js' +import * as terminalShellEnvironment from '../../utils/terminalShellEnvironment.js' // ─── Test helpers ───────────────────────────────────────────────────────────── @@ -240,6 +241,19 @@ describe('SettingsService', () => { expect(settings).toEqual({}) }) + it('reads the same legacy shell opt-out used when spawning desktop sessions', async () => { + const shellEnv = spyOn(terminalShellEnvironment, 'getProcessEnvWithTerminalShellEnvironment') + .mockResolvedValue({ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '0' }) + try { + const service = new SettingsService() + expect(await service.getAgentTeamsEnabled()).toBe(false) + await service.updateUserSettings({ agentTeamsEnabled: true }) + expect(await service.getAgentTeamsEnabled()).toBe(true) + } finally { + shellEnv.mockRestore() + } + }) + it('should recover from malformed user settings after an upgrade', async () => { await fs.writeFile(path.join(tmpDir, 'settings.json'), '{not json', 'utf-8') @@ -488,7 +502,31 @@ describe('Settings API', () => { expect(res.status).toBe(200) const body = await res.json() - expect(body).toEqual({}) + expect(body).toEqual({ agentTeamsEnabled: true }) + }) + + it('persists the General team preference across reads and overrides legacy env', async () => { + await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify({ + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '0', KEEP_ME: 'unchanged' }, + futureSetting: { keep: true }, + })) + for (const enabled of [true, false]) { + const put = makeRequest('PUT', '/api/settings/user', { agentTeamsEnabled: enabled }) + expect((await handleSettingsApi(put.req, put.url, put.segments)).status).toBe(200) + const get = makeRequest('GET', '/api/settings/user') + const response = await handleSettingsApi(get.req, get.url, get.segments) + expect(await response.json()).toMatchObject({ agentTeamsEnabled: enabled, futureSetting: { keep: true } }) + const persisted = JSON.parse(await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8')) + expect(persisted.agentTeamsEnabled).toBe(enabled) + expect(persisted.env.KEEP_ME).toBe('unchanged') + expect(persisted.schemaVersion).toBeUndefined() + } + }) + + it.each(['false', null, 1])('rejects invalid team preference %j without changing settings', async value => { + const put = makeRequest('PUT', '/api/settings/user', { agentTeamsEnabled: value }) + expect((await handleSettingsApi(put.req, put.url, put.segments)).status).toBe(400) + expect(await new SettingsService().getUserSettings()).toEqual({}) }) it('PUT /api/settings/user should update user settings', async () => { diff --git a/src/server/api/settings.ts b/src/server/api/settings.ts index 779c34e3..341b5e92 100644 --- a/src/server/api/settings.ts +++ b/src/server/api/settings.ts @@ -101,7 +101,10 @@ export async function handleSettingsApi( async function handleUserSettings(req: Request): Promise { if (req.method === 'GET') { - return Response.json(await settingsService.getUserSettings()) + return Response.json({ + ...await settingsService.getUserSettings(), + agentTeamsEnabled: await settingsService.getAgentTeamsEnabled(), + }) } if (req.method === 'PUT') { diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 1debc422..63369415 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -10,6 +10,7 @@ import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { ProviderService } from './providerService.js' +import { SettingsService } from './settingsService.js' import { OPENAI_CODEX_OAUTH_FILE_ENV_KEY, OPENAI_OAUTH_PROVIDER_ENV_KEY, @@ -1631,6 +1632,7 @@ export class ConversationService { // earlier); the per-turn hot update below mirrors this for live turns. const streamMaxDurationMs = resolveStreamMaxDurationMs(networkEnv.API_TIMEOUT_MS) const traceCaptureEnabled = (await readTraceCaptureSettings()).enabled + const agentTeamsEnabled = await new SettingsService().getAgentTeamsEnabled() if (explicitProviderEnv && options?.model?.trim()) { explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim() } @@ -1652,6 +1654,8 @@ export class ConversationService { return { ...cleanEnv, CLAUDE_CODE_ENABLE_TASKS: '1', + // Resolve the same preference shown in General before launching the CLI. + CC_HAHA_AGENT_TEAMS_ENABLED: agentTeamsEnabled ? '1' : '0', CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1', // Desktop must fail stuck provider streams instead of leaving the UI running forever. CLAUDE_ENABLE_STREAM_WATCHDOG: cleanEnv.CLAUDE_ENABLE_STREAM_WATCHDOG || '1', diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index b455edde..a80ea718 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -16,6 +16,7 @@ import { CronService, type CronTask } from './cronService.js' import { SessionService } from './sessionService.js' import { sendTaskNotification } from './notificationService.js' import { ProviderService } from './providerService.js' +import { SettingsService } from './settingsService.js' import { isProviderManagedEnvVar } from '../../utils/managedEnvConstants.js' import { buildClaudeCliArgs, @@ -852,6 +853,7 @@ export class CronScheduler { ): Promise> { const cleanEnv = await getProcessEnvWithTerminalShellEnvironment() delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN + delete cleanEnv.CC_HAHA_AGENT_TEAMS_ENABLED if (this.shouldStripInheritedProviderEnv(task.providerId)) { for (const key of Object.keys(cleanEnv)) { @@ -877,10 +879,12 @@ export class CronScheduler { await loadNetworkSettings(), cleanEnv, ) + const agentTeamsEnabled = await new SettingsService().getAgentTeamsEnabled() return { ...cleanEnv, CLAUDE_CODE_ENABLE_TASKS: '1', + CC_HAHA_AGENT_TEAMS_ENABLED: agentTeamsEnabled ? '1' : '0', CLAUDE_CODE_ENTRYPOINT: 'sdk-cli', CALLER_DIR: workDir, PWD: workDir, diff --git a/src/server/services/settingsService.ts b/src/server/services/settingsService.ts index dd357a19..2badc16c 100644 --- a/src/server/services/settingsService.ts +++ b/src/server/services/settingsService.ts @@ -17,6 +17,8 @@ import { normalizeJsonObject, readRecoverableJsonFile } from './recoverableJsonF import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js' import { resetSettingsCache } from '../../utils/settings/settingsCache.js' import { addFileGlobRuleToGitignore } from '../../utils/git/gitignore.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js' export const VALID_PERMISSION_MODES = [ 'default', @@ -101,6 +103,20 @@ export class SettingsService { return this.readJsonFile(this.getUserSettingsPath()) } + /** Read-time upgrade for older settings that only stored the team env flag. + * Keep the original file intact until the user explicitly saves a choice. + */ + async getAgentTeamsEnabled(): Promise { + const user = await this.getUserSettings() + if (typeof user.agentTeamsEnabled === 'boolean') return user.agentTeamsEnabled + const managed = await this.readJsonFile(path.join(this.getConfigDir(), 'cc-haha', 'settings.json')) + const inherited = await getProcessEnvWithTerminalShellEnvironment() + const key = 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS' + const legacyValue = normalizeJsonObject(managed.env)?.[key] ?? + normalizeJsonObject(user.env)?.[key] ?? inherited[key] + return typeof legacyValue === 'string' ? isEnvTruthy(legacyValue) : true + } + /** 获取项目级设置 */ async getProjectSettings(projectRoot?: string): Promise> { return this.readJsonFile(this.getProjectSettingsPath(projectRoot)) @@ -172,6 +188,9 @@ export class SettingsService { /** 更新用户级设置(顶层浅合并,并保留桌面终端的未知子字段) */ async updateUserSettings(settings: Record): Promise { + if (Object.hasOwn(settings, 'agentTeamsEnabled') && typeof settings.agentTeamsEnabled !== 'boolean') { + throw ApiError.badRequest('agentTeamsEnabled must be a boolean') + } const filePath = this.getUserSettingsPath() await this.withWriteLock(filePath, async () => { const current = await this.readJsonFile(filePath) diff --git a/src/utils/agentSwarmsEnabled.test.ts b/src/utils/agentSwarmsEnabled.test.ts new file mode 100644 index 00000000..11fca973 --- /dev/null +++ b/src/utils/agentSwarmsEnabled.test.ts @@ -0,0 +1,125 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const envKeys = [ + 'HOME', + 'CLAUDE_CONFIG_DIR', + 'USER_TYPE', + 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', + 'CC_HAHA_AGENT_TEAMS_DEFAULT', + 'CC_HAHA_AGENT_TEAMS_ENABLED', +] as const + +describe('Agent Teams runtime opt-in', () => { + let originalEnv: Record + let originalArgv: string[] + let fixtureDir: string + let isAgentSwarmsEnabled: typeof import('./agentSwarmsEnabled.js').isAgentSwarmsEnabled + let growthbook: typeof import('../services/analytics/growthbook.js') + let gate: ReturnType + + beforeAll(async () => { + originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]])) + originalArgv = [...process.argv] + fixtureDir = mkdtempSync(join(tmpdir(), 'agent-teams-gate-')) + process.env.HOME = fixtureDir + process.env.CLAUDE_CONFIG_DIR = fixtureDir + growthbook = await import('../services/analytics/growthbook.js') + const runtime = await import('./agentSwarmsEnabled.js') + isAgentSwarmsEnabled = runtime.isAgentSwarmsEnabled + }) + + beforeEach(() => { + delete process.env.USER_TYPE + delete process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS + delete process.env.CC_HAHA_AGENT_TEAMS_DEFAULT + delete process.env.CC_HAHA_AGENT_TEAMS_ENABLED + process.argv = originalArgv.filter(arg => arg !== '--agent-teams') + gate = spyOn(growthbook, 'getFeatureValue_CACHED_MAY_BE_STALE').mockReturnValue(true) + }) + + afterEach(() => { + gate.mockRestore() + }) + + afterAll(() => { + process.argv = originalArgv + for (const key of envKeys) { + const value = originalEnv[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + rmSync(fixtureDir, { recursive: true, force: true }) + }) + + test('enables a cc-haha managed session without an upstream opt-in', () => { + process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = '1' + expect(isAgentSwarmsEnabled()).toBe(true) + expect(gate).toHaveBeenCalledWith('tengu_amber_flint', true) + }) + + test.each(['0', 'false', ''])('preserves the explicit user opt-out %j over the host default', value => { + process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = '1' + process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = value + expect(isAgentSwarmsEnabled()).toBe(false) + expect(gate).not.toHaveBeenCalled() + }) + + test('keeps standalone CLI sessions opt-in', () => { + expect(isAgentSwarmsEnabled()).toBe(false) + process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1' + expect(isAgentSwarmsEnabled()).toBe(true) + }) + + test.each([true, false])('uses the explicit General preference %j over legacy env', enabled => { + process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = '1' + process.env.CC_HAHA_AGENT_TEAMS_ENABLED = enabled ? '1' : '0' + process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = enabled ? '0' : '1' + expect(isAgentSwarmsEnabled()).toBe(enabled) + }) + + test.each([undefined, 'ant'])('honors General opt-out with a forced launch (%j)', userType => { + if (userType) process.env.USER_TYPE = userType + process.env.CC_HAHA_AGENT_TEAMS_ENABLED = '0' + process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1' + process.argv.push('--agent-teams') + expect(isAgentSwarmsEnabled()).toBe(false) + expect(gate).not.toHaveBeenCalled() + }) + + test('retains the explicit CLI flag even when the environment opts out', () => { + process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '0' + process.argv.push('--agent-teams') + expect(isAgentSwarmsEnabled()).toBe(true) + }) + + test.each(['0', 'false', ''])('does not enable an inactive host default %j', value => { + process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = value + expect(isAgentSwarmsEnabled()).toBe(false) + }) + + test('continues to respect the external killswitch with a host default', () => { + process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = '1' + gate.mockReturnValue(false) + expect(isAgentSwarmsEnabled()).toBe(false) + expect(gate).toHaveBeenCalledWith('tengu_amber_flint', true) + }) + + test('retains the Ant build bypass', () => { + process.env.USER_TYPE = 'ant' + process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '0' + gate.mockReturnValue(false) + expect(isAgentSwarmsEnabled()).toBe(true) + expect(gate).not.toHaveBeenCalled() + }) + + test('preserves the persisted team preference through settings parsing', async () => { + const { SettingsSchema } = await import('./settings/types.js') + for (const agentTeamsEnabled of [true, false]) { + expect(SettingsSchema().parse({ agentTeamsEnabled }).agentTeamsEnabled).toBe(agentTeamsEnabled) + } + expect(SettingsSchema().safeParse({ agentTeamsEnabled: 'false' }).success).toBe(false) + }) +}) diff --git a/src/utils/agentSwarmsEnabled.ts b/src/utils/agentSwarmsEnabled.ts index fac5404c..79610e85 100644 --- a/src/utils/agentSwarmsEnabled.ts +++ b/src/utils/agentSwarmsEnabled.ts @@ -16,20 +16,35 @@ function isAgentTeamsFlagSet(): boolean { * This is the single gate that should be checked everywhere teammates * are referenced (prompts, code, tools isEnabled, UI, etc.). * - * Ant builds: always enabled. + * A cc-haha General opt-out takes priority over all opt-ins. + * Ant builds: enabled unless the host explicitly opts out. * External builds require both: - * 1. Opt-in via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS env var OR --agent-teams flag + * 1. Opt-in via the cc-haha host preference, legacy env, or --agent-teams * 2. GrowthBook gate 'tengu_amber_flint' enabled (killswitch) */ export function isAgentSwarmsEnabled(): boolean { - // Ant: always on + // A saved General opt-out is authoritative even for forced/team child launches. + if ( + process.env.CC_HAHA_AGENT_TEAMS_ENABLED !== undefined && + !isEnvTruthy(process.env.CC_HAHA_AGENT_TEAMS_ENABLED) + ) { + return false + } + + // Ant: always on unless the host explicitly disabled teams if (process.env.USER_TYPE === 'ant') { return true } - // External: require opt-in via env var or --agent-teams flag + // The host resolves General and legacy settings before launching. Standalone + // CLI sessions retain their upstream opt-in behavior. + const optIn = process.env.CC_HAHA_AGENT_TEAMS_ENABLED ?? + process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS ?? + process.env.CC_HAHA_AGENT_TEAMS_DEFAULT + + // External: require opt-in via env var, host default, or --agent-teams flag if ( - !isEnvTruthy(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) && + !isEnvTruthy(optIn) && !isAgentTeamsFlagSet() ) { return false diff --git a/src/utils/managedEnv.test.ts b/src/utils/managedEnv.test.ts index 00ceddba..eb12e566 100644 --- a/src/utils/managedEnv.test.ts +++ b/src/utils/managedEnv.test.ts @@ -3,10 +3,13 @@ import * as fs from 'fs/promises' import * as os from 'os' import * as path from 'path' -import { applySafeConfigEnvironmentVariables } from './managedEnv.js' +import { applyConfigEnvironmentVariables, applySafeConfigEnvironmentVariables } from './managedEnv.js' let tmpDir: string const originalEnv = { + CC_HAHA_AGENT_TEAMS_ENABLED: process.env.CC_HAHA_AGENT_TEAMS_ENABLED, + CC_HAHA_AGENT_TEAMS_DEFAULT: process.env.CC_HAHA_AGENT_TEAMS_DEFAULT, + CLAUDE_CODE_ENTRYPOINT: process.env.CLAUDE_CODE_ENTRYPOINT, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST, CC_HAHA_LOCAL_ACCESS_TOKEN: process.env.CC_HAHA_LOCAL_ACCESS_TOKEN, @@ -39,6 +42,9 @@ describe('managedEnv', () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'managed-env-')) process.env.CLAUDE_CONFIG_DIR = tmpDir delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST + delete process.env.CC_HAHA_AGENT_TEAMS_ENABLED + delete process.env.CC_HAHA_AGENT_TEAMS_DEFAULT + process.env.CLAUDE_CODE_ENTRYPOINT = 'sdk-cli' delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN delete process.env.ANTHROPIC_BASE_URL delete process.env.ANTHROPIC_API_KEY @@ -58,6 +64,9 @@ describe('managedEnv', () => { restoreEnv('CLAUDE_CONFIG_DIR') restoreEnv('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST') restoreEnv('CC_HAHA_LOCAL_ACCESS_TOKEN') + restoreEnv('CC_HAHA_AGENT_TEAMS_ENABLED') + restoreEnv('CC_HAHA_AGENT_TEAMS_DEFAULT') + restoreEnv('CLAUDE_CODE_ENTRYPOINT') restoreEnv('ANTHROPIC_BASE_URL') restoreEnv('ANTHROPIC_API_KEY') restoreEnv('ANTHROPIC_AUTH_TOKEN') @@ -68,6 +77,26 @@ describe('managedEnv', () => { restoreEnv('CLAUDE_CODE_PROVIDER_MAX_OUTPUT_TOKENS') }) + test.each(['0', '1', undefined])('protects the General team preference %j through settings application', async (enabled) => { + await writeJson(path.join(tmpDir, 'cc-haha', 'settings.json'), { + env: { + CC_HAHA_AGENT_TEAMS_ENABLED: enabled === '1' ? '0' : '1', + CC_HAHA_AGENT_TEAMS_DEFAULT: '0', + }, + }) + if (enabled !== undefined) process.env.CC_HAHA_AGENT_TEAMS_ENABLED = enabled + process.env.CC_HAHA_AGENT_TEAMS_DEFAULT = '1' + + // OAuth and cron sessions can use sdk-cli without host-owned provider routing. + // Both the pre-trust and post-trust settings paths must preserve the choice. + applySafeConfigEnvironmentVariables() + expect(process.env.CC_HAHA_AGENT_TEAMS_ENABLED).toBe(enabled) + expect(process.env.CC_HAHA_AGENT_TEAMS_DEFAULT).toBe('1') + applyConfigEnvironmentVariables() + expect(process.env.CC_HAHA_AGENT_TEAMS_ENABLED).toBe(enabled) + expect(process.env.CC_HAHA_AGENT_TEAMS_DEFAULT).toBe('1') + }) + test('starts a standalone provider proxy for CLI-only OpenAI-compatible providers', async () => { await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), { activeId: 'agnes-provider', diff --git a/src/utils/managedEnv.ts b/src/utils/managedEnv.ts index eaa82b21..aa3d3df1 100644 --- a/src/utils/managedEnv.ts +++ b/src/utils/managedEnv.ts @@ -66,6 +66,13 @@ function withoutHostManagedProviderVars( return out } +// Internal launch controls are never supplied by settings.env. This also +// covers sdk-cli/OAuth sessions without host-managed provider routing. +const HOST_TEAM_ENV_KEYS = new Set([ + 'CC_HAHA_AGENT_TEAMS_ENABLED', + 'CC_HAHA_AGENT_TEAMS_DEFAULT', +]) + const HOST_OWNED_ENV_KEYS = new Set([ 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', 'CC_HAHA_LOCAL_ACCESS_TOKEN', @@ -74,12 +81,14 @@ const HOST_OWNED_ENV_KEYS = new Set([ function withoutHostOwnedEnvVars( env: Record | undefined, ): Record { - if (!env || !isEnvTruthy(process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST)) { - return env || {} - } + if (!env) return {} + const hostManagedProvider = isEnvTruthy(process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST) return Object.fromEntries( - Object.entries(env).filter(([key]) => !HOST_OWNED_ENV_KEYS.has(key)), + Object.entries(env).filter(([key]) => + !HOST_TEAM_ENV_KEYS.has(key) && + !(hostManagedProvider && HOST_OWNED_ENV_KEYS.has(key)), + ), ) } diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index c8956598..69f2a48e 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -462,6 +462,10 @@ export const SettingsSchema = lazySchema(() => 'Explicitly enable dynamic workflows. Only consulted when they are not ' + 'disabled; `disableWorkflows` and CLAUDE_CODE_DISABLE_WORKFLOWS win.', ), + agentTeamsEnabled: z + .boolean() + .optional() + .describe('Enable Agent Teams for new cc-haha managed sessions. Overrides the legacy team environment setting.'), workflowKeywordTriggerEnabled: z .boolean() .optional()