diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c599260e..f241265e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: uses: pnpm/action-setup@v4 - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm @@ -88,7 +88,7 @@ jobs: uses: pnpm/action-setup@v4 - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/landing.yml b/.github/workflows/landing.yml index c31e3bbe..303564d3 100644 --- a/.github/workflows/landing.yml +++ b/.github/workflows/landing.yml @@ -21,7 +21,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@v6 with: node-version: 22 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b6d1c365..82ac7260 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: uses: pnpm/action-setup@v4 - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm @@ -178,7 +178,7 @@ jobs: uses: pnpm/action-setup@v4 - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm @@ -287,7 +287,7 @@ jobs: uses: pnpm/action-setup@v4 - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm @@ -391,7 +391,7 @@ jobs: uses: pnpm/action-setup@v4 - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm diff --git a/mcp-server/test/stdio.e2e.test.ts b/mcp-server/test/stdio.e2e.test.ts index e90cc252..f3c5d4e8 100644 --- a/mcp-server/test/stdio.e2e.test.ts +++ b/mcp-server/test/stdio.e2e.test.ts @@ -1,14 +1,41 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { fileURLToPath } from 'node:url'; function parseJsonToolResult(result: unknown) { - const text = (result as { content?: Array<{ text?: string }> }).content?.[0]?.text; + const response = result as { + content?: Array<{ text?: string }>; + isError?: boolean; + }; + const text = response.content?.[0]?.text; + if (response.isError) { + throw new Error(text ?? 'Tool returned an unspecified error'); + } return JSON.parse(text ?? 'null'); } +async function writeTeamConfig(claudeDir: string, teamName: string) { + const teamDir = path.join(claudeDir, 'teams', teamName); + await mkdir(teamDir, { recursive: true }); + await writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify( + { + name: teamName, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', agentType: 'teammate', role: 'developer' }, + ], + }, + null, + 2 + ), + 'utf8' + ); +} + class McpStdIoClient { private readonly child: ChildProcessWithoutNullStreams; private stdoutBuffer = ''; @@ -102,6 +129,7 @@ describe('agent-teams-mcp stdio e2e', () => { }); it('boots over stdio, lists task tools, and executes task lifecycle calls', async () => { + await writeTeamConfig(claudeDir, 'e2e-team'); const client = new McpStdIoClient(serverPath, workspaceRoot); try { diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index c8b5f162..8201aa23 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -174,7 +174,8 @@ export class ChangeExtractorService { teamName, taskId, effectiveOptions, - effectiveStateBucket + effectiveStateBucket, + version ); if (options?.forceFresh !== true) { @@ -407,9 +408,10 @@ export class ChangeExtractorService { teamName: string, taskId: string, options: TaskChangeEffectiveOptions, - stateBucket: TaskChangeStateBucket + stateBucket: TaskChangeStateBucket, + version: number ): string { - return `${teamName}:${taskId}:${this.buildTaskSignature(options, stateBucket)}`; + return `${teamName}:${taskId}:v${version}:${this.buildTaskSignature(options, stateBucket)}`; } private normalizeFilePathKey(filePath: string): string { diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 96195389..7143cf53 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -9,14 +9,14 @@ import { import * as fs from 'fs'; import * as path from 'path'; -import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; -import { TeamMembersMetaStore } from './TeamMembersMetaStore'; -import { TeamMetaStore } from './TeamMetaStore'; import { choosePreferredLaunchSnapshot, readBootstrapLaunchSnapshot, } from './TeamBootstrapStateReader'; +import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; +import { TeamMembersMetaStore } from './TeamMembersMetaStore'; +import { TeamMetaStore } from './TeamMetaStore'; import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types'; @@ -48,18 +48,19 @@ interface LaunchStateSummary { async function readLaunchStateSummary(teamDir: string): Promise { const bootstrapSnapshot = await readBootstrapLaunchSnapshot(path.basename(teamDir)); const launchStatePath = path.join(teamDir, TEAM_LAUNCH_STATE_FILE); - let launchSnapshot = null; - try { - const stat = await fs.promises.stat(launchStatePath); - if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) { - launchSnapshot = null; - } else { + const launchSnapshot = await (async () => { + try { + const stat = await fs.promises.stat(launchStatePath); + if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) { + return null; + } + const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS); - launchSnapshot = normalizePersistedLaunchSnapshot(path.basename(teamDir), JSON.parse(raw)); + return normalizePersistedLaunchSnapshot(path.basename(teamDir), JSON.parse(raw)); + } catch { + return null; } - } catch { - launchSnapshot = null; - } + })(); const snapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot); if (!snapshot) { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 1a6af912..307cfa67 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -15,9 +15,9 @@ import { wrapAgentBlock, } from '@shared/constants/agentBlocks'; import { getMemberColorByName } from '@shared/constants/memberColors'; +import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; -import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -31,13 +31,13 @@ import * as path from 'path'; import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; -import { atomicWriteAsync } from './atomicWrite'; import { areLeadSessionFileSignaturesEqual, - LeadSessionParseCache, type LeadSessionFileSignature, + LeadSessionParseCache, type LeadSessionParseCacheKey, } from './cache/LeadSessionParseCache'; +import { atomicWriteAsync } from './atomicWrite'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { TeamConfigReader } from './TeamConfigReader'; @@ -62,9 +62,9 @@ import type { CreateTaskRequest, GlobalTask, InboxMessage, - MessagesPage, KanbanColumnId, KanbanState, + MessagesPage, ResolvedTeamMember, SendMessageRequest, SendMessageResult, @@ -134,11 +134,11 @@ function normalizePassiveUserReplyLinkText(value: string | undefined): string { function extractPassiveUserPeerSummaryBody(text: string): string | null { const classified = classifyIdleNotificationText(text); - if (!classified || classified.primaryKind !== 'heartbeat' || !classified.peerSummary) { + if (classified?.primaryKind !== 'heartbeat' || !classified.peerSummary) { return null; } - const match = classified.peerSummary.match(/^\[to\s+user\]\s*(.*)$/i); + const match = /^\[to\s+user\]\s*(.*)$/i.exec(classified.peerSummary); if (!match) { return null; } @@ -677,7 +677,7 @@ export class TeamDataService { const runWithConcurrencyLimit = (() => { const limit = 2; let active = 0; - const queue: Array<() => void> = []; + const queue: (() => void)[] = []; const releaseNext = (): void => { if (active >= limit) return; const next = queue.shift(); @@ -811,8 +811,8 @@ export class TeamDataService { if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning); if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning); - let tasks: TeamTask[] = tasksStepResult.value; - let inboxNames: string[] = inboxNamesStepResult.value; + const tasks: TeamTask[] = tasksStepResult.value; + const inboxNames: string[] = inboxNamesStepResult.value; let messages: InboxMessage[] = messagesStepResult.value; const leadTexts: InboxMessage[] = leadTextsStepResult.value; const sentMessages: InboxMessage[] = sentMessagesStepResult.value; @@ -2064,8 +2064,37 @@ export class TeamDataService { return true; } - return /^(принято|принял|приняла|ок|ok|okay|на связи|понял|поняла|roger|ack)(?:[ ,.-]+(на связи|остаюсь на связи|жду(?: [^.!?]+)?|ждём(?: [^.!?]+)?|готов(?:а)?(?: [^.!?]+)?|буду ждать(?: [^.!?]+)?))?$/.test( - normalized + const startsWithAckPrefix = Array.from(exactMatches).find((prefix) => { + if (!normalized.startsWith(prefix)) { + return false; + } + const remainder = normalized.slice(prefix.length); + return remainder.length > 0 && /^[ ,.-]+/.test(remainder); + }); + if (!startsWithAckPrefix) { + return false; + } + + const qualifier = normalized + .slice(startsWithAckPrefix.length) + .replace(/^[ ,.-]+/, '') + .trim(); + if (!qualifier) { + return true; + } + + const matchesQualifierWithOptionalDetail = (phrase: string): boolean => + qualifier === phrase || + (qualifier.startsWith(`${phrase} `) && !/[.!?]/.test(qualifier.slice(phrase.length + 1))); + + return ( + qualifier === 'на связи' || + qualifier === 'остаюсь на связи' || + matchesQualifierWithOptionalDetail('жду') || + matchesQualifierWithOptionalDetail('ждём') || + matchesQualifierWithOptionalDetail('готов') || + matchesQualifierWithOptionalDetail('готова') || + matchesQualifierWithOptionalDetail('буду ждать') ); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 35b810a8..afddfdd9 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1450,12 +1450,16 @@ async function writeDeterministicBootstrapSpecFile(spec: RuntimeBootstrapSpec): return filePath; } -async function removeDeterministicBootstrapSpecFile(filePath: string | null): Promise { +async function removeDeterministicBootstrapTempFile(filePath: string | null): Promise { if (!filePath) return; await fs.promises.rm(filePath, { force: true }).catch(() => {}); await fs.promises.rmdir(path.dirname(filePath)).catch(() => {}); } +async function removeDeterministicBootstrapSpecFile(filePath: string | null): Promise { + await removeDeterministicBootstrapTempFile(filePath); +} + async function writeDeterministicBootstrapUserPromptFile(prompt: string): Promise { const tempDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'agent-teams-bootstrap-prompt-') @@ -1469,9 +1473,7 @@ async function writeDeterministicBootstrapUserPromptFile(prompt: string): Promis } async function removeDeterministicBootstrapUserPromptFile(filePath: string | null): Promise { - if (!filePath) return; - await fs.promises.rm(filePath, { force: true }).catch(() => {}); - await fs.promises.rmdir(path.dirname(filePath)).catch(() => {}); + await removeDeterministicBootstrapTempFile(filePath); } function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string { @@ -1577,7 +1579,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string function buildLeadRosterContextBlock( teamName: string, leadName: string, - teammates: Array<{ name: string; role?: string }> + teammates: { name: string; role?: string }[] ): string | null { if (teammates.length === 0) return null; diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 89e63217..31b5510b 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -6,7 +6,6 @@ import { areStringMapsEqual, } from '@renderer/utils/messageRenderEquality'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { createLogger } from '@shared/utils/logger'; import { Layers } from 'lucide-react'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; @@ -81,9 +80,6 @@ const COMPACT_MESSAGES_WIDTH_PX = 400; const EMPTY_TEAM_NAMES: string[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); const DEFAULT_COLLAPSE_MODE = 'default' as const; -const logger = createLogger('Component:ActivityTimeline'); -const ACTIVITY_TIMELINE_SLICE_WARN_MS = 6; -const ACTIVITY_TIMELINE_GROUP_WARN_MS = 8; interface ItemCollapseProps { collapseMode: 'default' | 'managed'; @@ -339,7 +335,6 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ // Pagination counts only significant (non-thought) messages so that lead thoughts // don't consume the page limit — they collapse into a single visual group anyway. const { visibleMessages, hiddenCount } = useMemo(() => { - const startedAt = performance.now(); const total = messages.length; if (total === 0) return { visibleMessages: messages, hiddenCount: 0 }; @@ -359,31 +354,14 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ significantSeen + (cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0); const hidden = Math.max(0, significantTotal - visibleCount); - const result = { + return { visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages, hiddenCount: hidden, }; - const ms = performance.now() - startedAt; - if (ms >= ACTIVITY_TIMELINE_SLICE_WARN_MS) { - logger.warn( - `[perf] paginate team=${teamName} ms=${ms.toFixed(1)} total=${messages.length} visible=${result.visibleMessages.length} hidden=${result.hiddenCount} pageSize=${visibleCount}` - ); - } - return result; }, [messages, visibleCount]); // Group consecutive lead thoughts into collapsible blocks. - const timelineItems = useMemo(() => { - const startedAt = performance.now(); - const result = groupTimelineItems(visibleMessages); - const ms = performance.now() - startedAt; - if (ms >= ACTIVITY_TIMELINE_GROUP_WARN_MS) { - logger.warn( - `[perf] groupTimeline team=${teamName} ms=${ms.toFixed(1)} visibleMessages=${visibleMessages.length} groups=${result.length}` - ); - } - return result; - }, [teamName, visibleMessages]); + const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]); // Zebra striping is anchored from the bottom of the visible list so prepending // new live messages at the top does not recolor every existing card. diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index f9031e5f..b102167b 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -21,6 +21,13 @@ const { mockAddTeamNotification } = vi.hoisted(() => ({ const { mockGetMembersMeta } = vi.hoisted(() => ({ mockGetMembersMeta: vi.fn(), })); +const { mockTeamDataWorkerClient } = vi.hoisted(() => ({ + mockTeamDataWorkerClient: { + isAvailable: vi.fn(), + getTeamData: vi.fn(), + findLogsForTask: vi.fn(), + }, +})); vi.mock('@main/services/infrastructure/NotificationManager', () => ({ NotificationManager: { getInstance: vi.fn().mockReturnValue({ @@ -33,6 +40,9 @@ vi.mock('@main/services/team/TeamMembersMetaStore', () => ({ getMembers: mockGetMembersMeta, })), })); +vi.mock('@main/services/team/TeamDataWorkerClient', () => ({ + getTeamDataWorkerClient: () => mockTeamDataWorkerClient, +})); import { TEAM_ALIVE_LIST, @@ -182,6 +192,9 @@ describe('ipc teams handlers', () => { vi.clearAllMocks(); mockGetMembersMeta.mockReset(); mockGetMembersMeta.mockResolvedValue([]); + mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); + mockTeamDataWorkerClient.getTeamData.mockReset(); + mockTeamDataWorkerClient.findLogsForTask.mockReset(); initializeTeamHandlers(service as never, provisioningService as never); registerTeamHandlers(ipcMain as never); }); diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts index d6e69e20..bb1a537f 100644 --- a/test/main/services/infrastructure/FileWatcher.test.ts +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -247,17 +247,23 @@ describe('FileWatcher', () => { // Simulate having previously processed the file by directly setting tracking state const watcherAny = watcher as unknown as { + isWatching: boolean; lastProcessedLineCount: Map; lastProcessedSize: Map; - activeSessionFiles: Map; + activeSessionFiles: Map< + string, + { projectId: string; sessionId: string; lastObservedAt: number } + >; runCatchUpScan: () => Promise; }; const initialSize = fs.statSync(filePath).size; + watcherAny.isWatching = true; watcherAny.lastProcessedLineCount.set(filePath, 1); watcherAny.lastProcessedSize.set(filePath, initialSize); watcherAny.activeSessionFiles.set(filePath, { projectId: 'test-project', sessionId: 'session-1', + lastObservedAt: Date.now(), }); // Append new data WITHOUT triggering fs.watch (simulating a missed event) @@ -301,17 +307,23 @@ describe('FileWatcher', () => { watcher.setNotificationManager(notificationManager); const watcherAny = watcher as unknown as { + isWatching: boolean; lastProcessedLineCount: Map; lastProcessedSize: Map; - activeSessionFiles: Map; + activeSessionFiles: Map< + string, + { projectId: string; sessionId: string; lastObservedAt: number } + >; runCatchUpScan: () => Promise; }; const currentSize = fs.statSync(filePath).size; + watcherAny.isWatching = true; watcherAny.lastProcessedLineCount.set(filePath, 1); watcherAny.lastProcessedSize.set(filePath, currentSize); watcherAny.activeSessionFiles.set(filePath, { projectId: 'test-project', sessionId: 'session-1', + lastObservedAt: Date.now(), }); vi.mocked(errorDetector.detectErrors).mockClear(); @@ -348,13 +360,19 @@ describe('FileWatcher', () => { watcher.setNotificationManager(notificationManager); const watcherAny = watcher as unknown as { - activeSessionFiles: Map; + isWatching: boolean; + activeSessionFiles: Map< + string, + { projectId: string; sessionId: string; lastObservedAt: number } + >; lastProcessedSize: Map; runCatchUpScan: () => Promise; }; + watcherAny.isWatching = true; watcherAny.activeSessionFiles.set(filePath, { projectId: 'test-project', sessionId: 'old-session', + lastObservedAt: Date.now(), }); watcherAny.lastProcessedSize.set(filePath, 0); @@ -378,14 +396,20 @@ describe('FileWatcher', () => { const filePath = '/tmp/projects/test-project/nonexistent.jsonl'; const watcherAny = watcher as unknown as { - activeSessionFiles: Map; + isWatching: boolean; + activeSessionFiles: Map< + string, + { projectId: string; sessionId: string; lastObservedAt: number } + >; lastProcessedSize: Map; lastProcessedLineCount: Map; runCatchUpScan: () => Promise; }; + watcherAny.isWatching = true; watcherAny.activeSessionFiles.set(filePath, { projectId: 'test-project', sessionId: 'nonexistent', + lastObservedAt: Date.now(), }); watcherAny.lastProcessedSize.set(filePath, 100); watcherAny.lastProcessedLineCount.set(filePath, 5); diff --git a/test/main/services/team/TeamProvisioningServiceIdempotency.test.ts b/test/main/services/team/TeamProvisioningServiceIdempotency.test.ts index 2673341c..55a9f19b 100644 --- a/test/main/services/team/TeamProvisioningServiceIdempotency.test.ts +++ b/test/main/services/team/TeamProvisioningServiceIdempotency.test.ts @@ -5,6 +5,13 @@ import { EventEmitter } from 'events'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const hoisted = vi.hoisted(() => ({ + paths: { + claudeRoot: '', + teamsBase: '', + }, +})); + let tempClaudeRoot = ''; let tempTeamsBase = ''; @@ -12,9 +19,9 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - getAutoDetectedClaudeBasePath: () => tempClaudeRoot, - getClaudeBasePath: () => tempClaudeRoot, - getTeamsBasePath: () => tempTeamsBase, + getAutoDetectedClaudeBasePath: () => hoisted.paths.claudeRoot, + getClaudeBasePath: () => hoisted.paths.claudeRoot, + getTeamsBasePath: () => hoisted.paths.teamsBase, }; }); @@ -25,11 +32,15 @@ describe('TeamProvisioningService idempotent launch guards', () => { vi.clearAllMocks(); tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-launch-')); tempTeamsBase = path.join(tempClaudeRoot, 'teams'); + hoisted.paths.claudeRoot = tempClaudeRoot; + hoisted.paths.teamsBase = tempTeamsBase; fs.mkdirSync(tempTeamsBase, { recursive: true }); }); afterEach(() => { fs.rmSync(tempClaudeRoot, { recursive: true, force: true }); + hoisted.paths.claudeRoot = ''; + hoisted.paths.teamsBase = ''; }); it('reuses the alive run instead of spawning a duplicate launch', async () => { diff --git a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts index ad2bed6b..21024462 100644 --- a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts +++ b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts @@ -5,6 +5,14 @@ import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const hoisted = vi.hoisted(() => ({ + paths: { + claudeRoot: '', + teamsBase: '', + tasksBase: '', + }, +})); + let tempClaudeRoot = ''; let tempTeamsBase = ''; let tempTasksBase = ''; @@ -22,10 +30,10 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - getAutoDetectedClaudeBasePath: () => tempClaudeRoot, - getClaudeBasePath: () => tempClaudeRoot, - getTeamsBasePath: () => tempTeamsBase, - getTasksBasePath: () => tempTasksBase, + getAutoDetectedClaudeBasePath: () => hoisted.paths.claudeRoot, + getClaudeBasePath: () => hoisted.paths.claudeRoot, + getTeamsBasePath: () => hoisted.paths.teamsBase, + getTasksBasePath: () => hoisted.paths.tasksBase, }; }); @@ -112,6 +120,9 @@ describe('TeamProvisioningService post-compact lifecycle', () => { tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-compact-')); tempTeamsBase = path.join(tempClaudeRoot, 'teams'); tempTasksBase = path.join(tempClaudeRoot, 'tasks'); + hoisted.paths.claudeRoot = tempClaudeRoot; + hoisted.paths.teamsBase = tempTeamsBase; + hoisted.paths.tasksBase = tempTasksBase; setAppDataBasePath(tempClaudeRoot); fs.mkdirSync(tempTeamsBase, { recursive: true }); fs.mkdirSync(tempTasksBase, { recursive: true }); @@ -119,6 +130,9 @@ describe('TeamProvisioningService post-compact lifecycle', () => { afterEach(() => { setAppDataBasePath(null); + hoisted.paths.claudeRoot = ''; + hoisted.paths.teamsBase = ''; + hoisted.paths.tasksBase = ''; try { fs.rmSync(tempClaudeRoot, { recursive: true, force: true }); } catch { diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 497e22ba..0c6fe059 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -549,7 +549,7 @@ describe('TeamGraphAdapter particles', () => { 'GPT-5.4 Mini · Medium' ); expect(graph.nodes.find((node) => node.id === 'member:my-team:alice')?.runtimeLabel).toBe( - 'Anthropic · sonnet · High' + 'Anthropic · Sonnet 4.6 · High' ); }); }); diff --git a/test/shared/utils/cliArgsParser.test.ts b/test/shared/utils/cliArgsParser.test.ts index e31236f7..5dc628dc 100644 --- a/test/shared/utils/cliArgsParser.test.ts +++ b/test/shared/utils/cliArgsParser.test.ts @@ -144,9 +144,9 @@ describe('PROTECTED_CLI_FLAGS', () => { expect(PROTECTED_CLI_FLAGS.has('--verbose')).toBe(true); }); - it('does not contain user-facing flags', () => { - expect(PROTECTED_CLI_FLAGS.has('--model')).toBe(false); - expect(PROTECTED_CLI_FLAGS.has('--effort')).toBe(false); + it('contains app-managed launch flags but not unrelated user flags', () => { + expect(PROTECTED_CLI_FLAGS.has('--model')).toBe(true); + expect(PROTECTED_CLI_FLAGS.has('--effort')).toBe(true); expect(PROTECTED_CLI_FLAGS.has('--worktree')).toBe(false); }); });