From cc91290730b3b91b5f75100bee229b5e59352639 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 13 Apr 2026 20:59:24 +0300 Subject: [PATCH 1/9] test(task-logs): align stream tool call count --- .../team/taskLogs/TaskLogStreamSection.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts index 00ad81c9..732bc396 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts @@ -379,7 +379,7 @@ describe('TaskLogStreamSection integration', () => { expect(text).toContain('Grep'); expect(text).toContain('Edit'); expect(text).toContain('Claude'); - expect(text).toContain('2 tool calls'); + expect(text).toContain('3 tool calls'); expect(text).toContain('Audit complete'); expect(text).not.toContain('[]'); expect(text).not.toContain('lead session'); From 0615badd798bcd188805da7d85dd20cab05fecfd Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 13 Apr 2026 21:13:58 +0300 Subject: [PATCH 2/9] fix(task-logs): preserve activity detail chunks --- src/renderer/components/team/taskLogs/TaskActivitySection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx index 3df66115..ee29d864 100644 --- a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx +++ b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx @@ -89,7 +89,7 @@ function normalizeDetail(detail: BoardTaskActivityDetail): BoardTaskActivityDeta ...detail, logDetail: { ...detail.logDetail, - chunks: asEnhancedChunkArray(detail.logDetail.chunks), + chunks: asEnhancedChunkArray(detail.logDetail.chunks) ?? detail.logDetail.chunks, }, }; } From 3b7dc1f5e2356280bd8ebd35de3ba23128bf99e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Mon, 13 Apr 2026 21:30:30 +0300 Subject: [PATCH 3/9] Replace demo image in README Updated demo image link in README.md. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 871f52b0..2f42dd3c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ 100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions/logins or API keys where supported. Not just coding agents.

-demo +demo From 644b45942f7f51ff7f80feab291569b56979cf2c Mon Sep 17 00:00:00 2001 From: Diego Serrano <129707357+diegoserranobst@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:28:32 -0400 Subject: [PATCH 4/9] fix(watcher): ignore transient ENOENT on ephemeral .lock files --- src/main/services/infrastructure/FileWatcher.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 14b888e0..fe90e437 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -485,7 +485,14 @@ export class FileWatcher extends EventEmitter { watcher: fs.FSWatcher, watcherType: 'projects' | 'todos' | 'teams' | 'tasks' ): void { - watcher.on('error', (error) => { + watcher.on('error', (error: NodeJS.ErrnoException) => { + // Ephemeral .lock files cause harmless ENOENT when the recursive watcher + // tries to scandir a path that was already deleted. Log as debug and skip + // the teardown/retry — the watcher is still healthy. + if (error.code === 'ENOENT' && error.path?.endsWith('.lock')) { + logger.debug(`FileWatcher: ${watcherType} ignoring transient ENOENT on lock file`); + return; + } logger.error(`FileWatcher: ${watcherType} watcher error:`, error); if (watcherType === 'projects') { this.projectsWatcher = null; From bda2e160f703409b816e9222b89b65521b260e9b Mon Sep 17 00:00:00 2001 From: Diego Serrano <129707357+diegoserranobst@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:35:59 -0400 Subject: [PATCH 5/9] fix(team): prevent double [1m] suffix on model string during re-launch --- .../team/dialogs/LaunchTeamDialog.tsx | 6 ++-- .../team/dialogs/TeamModelSelector.tsx | 3 +- .../team/dialogs/launchDialogPrefill.ts | 10 +++++- src/renderer/store/slices/teamSlice.ts | 4 +-- src/renderer/utils/teamModelContext.ts | 8 +++++ .../components/team/TeamModelSelector.test.ts | 36 ++++++++++++++++++- .../team/dialogs/launchDialogPrefill.test.ts | 35 ++++++++++++++++++ 7 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 src/renderer/utils/teamModelContext.ts diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index dd0075b5..dcca38bb 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -575,6 +575,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen multimodelEnabled, storedProviderId, storedEffort: storedEffort === null ? 'medium' : storedEffort, + storedLimitContext: localStorage.getItem('team:lastLimitContext') === 'true', getStoredModel: getStoredTeamModel, }); setSavedLaunchProviderId(savedProviderId); @@ -590,10 +591,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setSelectedProviderIdRaw(launchPrefill.providerId); setSelectedModelRaw(launchPrefill.model); setSelectedEffortRaw(launchPrefill.effort); - setLimitContextRaw( - savedRequest?.limitContext === true || - localStorage.getItem('team:lastLimitContext') === 'true' - ); + setLimitContextRaw(launchPrefill.limitContext); setSkipPermissionsRaw( savedRequest?.skipPermissions ?? localStorage.getItem('team:lastSkipPermissions') !== 'false' diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index c1aa208a..2afa54b6 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -16,6 +16,7 @@ import { GEMINI_UI_DISABLED_REASON, isGeminiUiFrozen, } from '@renderer/utils/geminiUiFreeze'; +import { stripTrailingOneMillionSuffixes } from '@renderer/utils/teamModelContext'; import { doesTeamModelCarryProviderBrand, getProviderScopedTeamModelLabel, @@ -99,7 +100,7 @@ export function computeEffectiveTeamModel( limitContext: boolean, providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic' ): string | undefined { - const base = selectedModel || undefined; + const base = stripTrailingOneMillionSuffixes(selectedModel); if (providerId !== 'anthropic') return base; if (limitContext) return base; if (base === 'haiku') return base; diff --git a/src/renderer/components/team/dialogs/launchDialogPrefill.ts b/src/renderer/components/team/dialogs/launchDialogPrefill.ts index 874a3164..c359b14b 100644 --- a/src/renderer/components/team/dialogs/launchDialogPrefill.ts +++ b/src/renderer/components/team/dialogs/launchDialogPrefill.ts @@ -1,4 +1,5 @@ import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; +import { stripTrailingOneMillionSuffixes } from '@renderer/utils/teamModelContext'; import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; @@ -9,6 +10,7 @@ interface PreviousLaunchParamsLike { providerId?: TeamProviderId; model?: string; effort?: string; + limitContext?: boolean; } interface LaunchDialogPrefillInput { @@ -18,6 +20,7 @@ interface LaunchDialogPrefillInput { multimodelEnabled: boolean; storedProviderId: TeamProviderId; storedEffort: string; + storedLimitContext: boolean; getStoredModel: (providerId: TeamProviderId) => string; } @@ -25,6 +28,7 @@ interface LaunchDialogPrefillResult { providerId: TeamProviderId; model: string; effort: string; + limitContext: boolean; } function normalizeModelCandidate(model: string | undefined): string { @@ -32,7 +36,7 @@ function normalizeModelCandidate(model: string | undefined): string { if (!trimmed || trimmed === 'default' || trimmed === '__default__') { return ''; } - return trimmed; + return stripTrailingOneMillionSuffixes(trimmed) ?? ''; } function canReuseModelForSelectedProvider( @@ -52,6 +56,7 @@ export function resolveLaunchDialogPrefill({ multimodelEnabled, storedProviderId, storedEffort, + storedLimitContext, getStoredModel, }: LaunchDialogPrefillInput): LaunchDialogPrefillResult { const currentLead = members.find((member) => isLeadMember(member)); @@ -88,6 +93,8 @@ export function resolveLaunchDialogPrefill({ const effort = currentLead?.effort ?? savedRequest?.effort ?? previousLaunchParams?.effort ?? storedEffort; + const limitContext = + previousLaunchParams?.limitContext ?? savedRequest?.limitContext ?? storedLimitContext; return { providerId, @@ -95,5 +102,6 @@ export function resolveLaunchDialogPrefill({ ? normalizeTeamModelForUi(providerId, matchingModel) : getStoredModel(providerId), effort, + limitContext, }; } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index d52244ea..a0b8f8b2 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -6,6 +6,7 @@ import { canDisplayTaskChangesForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; +import { stripTrailingOneMillionSuffixes } from '@renderer/utils/teamModelContext'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { createLogger } from '@shared/utils/logger'; @@ -1220,8 +1221,7 @@ function saveLaunchParams(teamName: string, params: TeamLaunchParams): void { * E.g. 'opus[1m]' → 'opus', 'sonnet' → 'sonnet', undefined → undefined. */ function extractBaseModel(raw?: string): string | undefined { - if (!raw) return undefined; - return raw.replace(/\[1m\]$/, '') || undefined; + return stripTrailingOneMillionSuffixes(raw); } const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:'; diff --git a/src/renderer/utils/teamModelContext.ts b/src/renderer/utils/teamModelContext.ts new file mode 100644 index 00000000..7229cff0 --- /dev/null +++ b/src/renderer/utils/teamModelContext.ts @@ -0,0 +1,8 @@ +export function stripTrailingOneMillionSuffixes(model: string | undefined): string | undefined { + const trimmed = model?.trim(); + if (!trimmed) { + return undefined; + } + + return trimmed.replace(/(?:\[1m\])+$/, '') || undefined; +} diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index 87ae7485..5e7f2a47 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; +import { + computeEffectiveTeamModel, + formatTeamModelSummary, +} from '@renderer/components/team/dialogs/TeamModelSelector'; import { GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, @@ -36,3 +39,34 @@ describe('formatTeamModelSummary', () => { expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini'); }); }); + +describe('computeEffectiveTeamModel', () => { + it('appends [1m] for anthropic models', () => { + expect(computeEffectiveTeamModel('opus', false, 'anthropic')).toBe('opus[1m]'); + expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet[1m]'); + }); + + it('does not double-append [1m] when input already has it', () => { + expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]'); + expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[1m]'); + expect(computeEffectiveTeamModel('opus[1m][1m]', false, 'anthropic')).toBe('opus[1m]'); + }); + + it('defaults to opus[1m] when no model selected', () => { + expect(computeEffectiveTeamModel('', false, 'anthropic')).toBe('opus[1m]'); + }); + + it('returns base model without [1m] when limitContext is true', () => { + expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus'); + expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus'); + expect(computeEffectiveTeamModel('opus[1m][1m]', true, 'anthropic')).toBe('opus'); + }); + + it('returns haiku as-is', () => { + expect(computeEffectiveTeamModel('haiku', false, 'anthropic')).toBe('haiku'); + }); + + it('returns non-anthropic models as-is', () => { + expect(computeEffectiveTeamModel('gpt-5.4', false, 'codex')).toBe('gpt-5.4'); + }); +}); diff --git a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts index 2b1d4d58..b91c03f1 100644 --- a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts +++ b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts @@ -38,6 +38,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', codex: 'gpt-5.4', @@ -48,6 +49,7 @@ describe('resolveLaunchDialogPrefill', () => { providerId: 'codex', model: 'gpt-5.4', effort: 'medium', + limitContext: false, }); }); @@ -78,6 +80,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', codex: 'gpt-5.4', @@ -88,6 +91,7 @@ describe('resolveLaunchDialogPrefill', () => { providerId: 'codex', model: 'gpt-5.4', effort: 'medium', + limitContext: false, }); }); @@ -103,6 +107,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', codex: 'gpt-5.4', @@ -113,6 +118,7 @@ describe('resolveLaunchDialogPrefill', () => { providerId: 'codex', model: 'gpt-5.3-codex', effort: 'high', + limitContext: false, }); }); @@ -134,6 +140,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', codex: 'gpt-5.4', @@ -144,6 +151,34 @@ describe('resolveLaunchDialogPrefill', () => { providerId: 'anthropic', model: 'haiku', effort: 'medium', + limitContext: false, + }); + }); + + it('prefers per-team launch params for limitContext over stale global storage', () => { + const result = resolveLaunchDialogPrefill({ + members: [], + savedRequest: null, + previousLaunchParams: { + providerId: 'anthropic', + model: 'opus[1m][1m]', + effort: 'high', + limitContext: true, + }, + multimodelEnabled: true, + storedProviderId: 'anthropic', + storedEffort: 'medium', + storedLimitContext: false, + getStoredModel: createStoredModelGetter({ + anthropic: 'haiku', + }), + }); + + expect(result).toEqual({ + providerId: 'anthropic', + model: 'opus', + effort: 'high', + limitContext: true, }); }); }); From 7d98956dadd4ea9ec0d22e7a2791620ffdd05f30 Mon Sep 17 00:00:00 2001 From: Diego Serrano <129707357+diegoserranobst@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:37:04 -0400 Subject: [PATCH 6/9] fix(extensions): resolve project path from both projects and repositoryGroups --- .../extensions/ExtensionStoreView.tsx | 15 +- src/renderer/store/slices/extensionsSlice.ts | 18 +- src/renderer/utils/projectLookup.ts | 27 +++ test/renderer/store/extensionsSlice.test.ts | 36 ++++ test/renderer/utils/projectLookup.test.ts | 162 +++++++++++++++++- 5 files changed, 247 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index cec304fb..ec905ee0 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -18,6 +18,7 @@ import { import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useStore } from '@renderer/store'; +import { resolveProjectPathById } from '@renderer/utils/projectLookup'; import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -45,6 +46,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { openDashboard, sessions, projects, + repositoryGroups, } = useStore( useShallow((s) => ({ fetchPluginCatalog: s.fetchPluginCatalog, @@ -61,6 +63,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { openDashboard: s.openDashboard, sessions: s.sessions, projects: s.projects, + repositoryGroups: s.repositoryGroups, })) ); const cliInstalled = cliStatus?.installed ?? true; @@ -74,14 +77,12 @@ export const ExtensionStoreView = (): React.JSX.Element => { const tabState = useExtensionsTabState(); const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false); - const projectPath = useMemo( - () => projects.find((project) => project.id === extensionsTabProjectId)?.path ?? null, - [extensionsTabProjectId, projects] - ); - const projectLabel = useMemo( - () => projects.find((project) => project.id === extensionsTabProjectId)?.name ?? null, - [extensionsTabProjectId, projects] + const resolvedProject = useMemo( + () => resolveProjectPathById(extensionsTabProjectId, projects, repositoryGroups), + [extensionsTabProjectId, projects, repositoryGroups] ); + const projectPath = resolvedProject?.path ?? null; + const projectLabel = resolvedProject?.name ?? null; const subTabs = useMemo( () => [ { diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index eb44adae..58063575 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -7,6 +7,7 @@ import { api } from '@renderer/api'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import type { AppState } from '../types'; +import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; import type { ApiKeyEntry, ApiKeySaveRequest, @@ -889,9 +890,24 @@ export const createExtensionsSlice: StateCreator { const state = get(); + const currentProjectId = state.selectedProjectId ?? state.activeProjectId ?? undefined; const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId); const existingTab = focusedPane?.tabs.find((tab) => tab.type === 'extensions'); if (existingTab) { + // Update projectId to reflect the currently selected project + if (existingTab.projectId !== currentProjectId) { + const pane = findPaneByTabId(state.paneLayout, existingTab.id); + if (pane) { + set({ + paneLayout: updatePane(state.paneLayout, { + ...pane, + tabs: pane.tabs.map((t) => + t.id === existingTab.id ? { ...t, projectId: currentProjectId } : t + ), + }), + }); + } + } state.setActiveTab(existingTab.id); return; } @@ -899,7 +915,7 @@ export const createExtensionsSlice: StateCreator[], + repositoryGroups: readonly Pick[] +): { path: string; name: string } | null { + if (!projectId) return null; + + const fromProjects = projects.find((p) => p.id === projectId); + if (fromProjects) return { path: fromProjects.path, name: fromProjects.name }; + + for (const group of repositoryGroups) { + const worktree = group.worktrees.find((w) => w.id === projectId); + if (worktree) return { path: worktree.path, name: worktree.name }; + } + + return null; +} diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 623a7b06..a69507fa 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -258,6 +258,42 @@ describe('extensionsSlice', () => { expect(count1).toBe(1); expect(count2).toBe(1); // no duplicate }); + + it('updates projectId on existing tab when selected project changes', () => { + // Open Extensions with project-A + store.setState({ selectedProjectId: 'project-A', activeProjectId: null }); + store.getState().openExtensionsTab(); + + const tabsBefore = store.getState().paneLayout.panes.flatMap((p) => p.tabs); + const extTabBefore = tabsBefore.find((t) => t.type === 'extensions'); + expect(extTabBefore?.projectId).toBe('project-A'); + + // Switch to project-B and reopen Extensions + store.setState({ selectedProjectId: 'project-B' }); + store.getState().openExtensionsTab(); + + const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs); + const extTabAfter = tabsAfter.find((t) => t.type === 'extensions'); + expect(extTabAfter?.projectId).toBe('project-B'); + // Still only one extensions tab + expect(tabsAfter.filter((t) => t.type === 'extensions')).toHaveLength(1); + }); + + it('does not update projectId when it already matches', () => { + store.setState({ selectedProjectId: 'project-A', activeProjectId: null }); + store.getState().openExtensionsTab(); + + const layoutBefore = store.getState().paneLayout; + + // Reopen with same project — layout should be referentially stable (no set() call) + store.getState().openExtensionsTab(); + + const tabsBefore = layoutBefore.panes.flatMap((p) => p.tabs); + const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs); + const extBefore = tabsBefore.find((t) => t.type === 'extensions'); + const extAfter = tabsAfter.find((t) => t.type === 'extensions'); + expect(extAfter?.projectId).toBe(extBefore?.projectId); + }); }); describe('installPlugin', () => { diff --git a/test/renderer/utils/projectLookup.test.ts b/test/renderer/utils/projectLookup.test.ts index e8685d45..c90b2626 100644 --- a/test/renderer/utils/projectLookup.test.ts +++ b/test/renderer/utils/projectLookup.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; +import { resolveProjectIdByPath, resolveProjectPathById } from '@renderer/utils/projectLookup'; import type { Project, RepositoryGroup } from '@renderer/types/data'; @@ -9,16 +9,19 @@ import type { Project, RepositoryGroup } from '@renderer/types/data'; // --------------------------------------------------------------------------- type ProjectLike = Pick; +type ProjectWithName = Pick; type RepoGroupLike = Pick; -const CRYPTO_PROJECT: ProjectLike = { +const CRYPTO_PROJECT: ProjectWithName = { id: '-Users-belief-dev-projects-crypto-research', path: '/Users/belief/dev/projects/crypto_research', + name: 'crypto_research', }; -const CLAUDE_PROJECT: ProjectLike = { +const CLAUDE_PROJECT: ProjectWithName = { id: '-Users-belief-dev-projects-claude-claude-team', path: '/Users/belief/dev/projects/claude/claude_team', + name: 'claude_team', }; function makeRepoGroup(worktrees: { id: string; path: string }[]): RepoGroupLike { @@ -290,3 +293,156 @@ describe('resolveProjectIdByPath', () => { }); }); }); + +// =========================================================================== +// resolveProjectPathById — inverse lookup (ID → path + name) +// =========================================================================== + +describe('resolveProjectPathById', () => { + // ----------------------------------------------------------------------- + // Null / undefined / empty input + // ----------------------------------------------------------------------- + describe('null/undefined/empty projectId', () => { + it('returns null for undefined projectId', () => { + expect(resolveProjectPathById(undefined, [CRYPTO_PROJECT], [])).toBeNull(); + }); + + it('returns null for null projectId', () => { + expect(resolveProjectPathById(null, [CRYPTO_PROJECT], [])).toBeNull(); + }); + + it('returns null for empty string projectId', () => { + expect(resolveProjectPathById('', [CRYPTO_PROJECT], [])).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // Lookup from projects (flat view mode) + // ----------------------------------------------------------------------- + describe('lookup from projects (flat mode)', () => { + it('finds project by exact id match', () => { + const result = resolveProjectPathById( + '-Users-belief-dev-projects-crypto-research', + [CRYPTO_PROJECT, CLAUDE_PROJECT], + [] + ); + expect(result).toEqual({ + path: '/Users/belief/dev/projects/crypto_research', + name: 'crypto_research', + }); + }); + + it('returns null when id not in projects', () => { + expect( + resolveProjectPathById('-Users-belief-dev-projects-unknown', [CRYPTO_PROJECT], []) + ).toBeNull(); + }); + + it('returns null when projects list is empty', () => { + expect( + resolveProjectPathById('-Users-belief-dev-projects-crypto-research', [], []) + ).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // Lookup from repositoryGroups (grouped view mode) + // ----------------------------------------------------------------------- + describe('lookup from repositoryGroups (grouped mode)', () => { + it('finds project in worktrees when projects is empty', () => { + const result = resolveProjectPathById( + '-Users-belief-dev-projects-crypto-research', + [], + [CRYPTO_REPO_GROUP] + ); + expect(result).toEqual({ + path: '/Users/belief/dev/projects/crypto_research', + name: '-Users-belief-dev-projects-crypto-research', + }); + }); + + it('finds project across multiple repo groups', () => { + const result = resolveProjectPathById( + '-Users-belief-dev-projects-claude-claude-team', + [], + [CRYPTO_REPO_GROUP, CLAUDE_REPO_GROUP] + ); + expect(result).toEqual({ + path: '/Users/belief/dev/projects/claude/claude_team', + name: '-Users-belief-dev-projects-claude-claude-team', + }); + }); + + it('finds correct worktree in multi-worktree group', () => { + const result = resolveProjectPathById( + '-Users-belief-dev-projects-app-wt-feature', + [], + [MULTI_WORKTREE_GROUP] + ); + expect(result).toEqual({ + path: '/Users/belief/dev/projects/app-wt-feature', + name: '-Users-belief-dev-projects-app-wt-feature', + }); + }); + + it('returns null when id not in any worktree', () => { + expect( + resolveProjectPathById('-Users-belief-dev-projects-unknown', [], [CRYPTO_REPO_GROUP]) + ).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // Priority: projects takes precedence over repositoryGroups + // ----------------------------------------------------------------------- + describe('priority order', () => { + it('prefers projects match over repositoryGroups match', () => { + const projectEntry: ProjectWithName = { + id: 'shared-id', + path: '/from/projects', + name: 'from-projects', + }; + + const repoGroupEntry = makeRepoGroup([ + { id: 'shared-id', path: '/from/repo-group' }, + ]); + + const result = resolveProjectPathById('shared-id', [projectEntry], [repoGroupEntry]); + expect(result).toEqual({ path: '/from/projects', name: 'from-projects' }); + }); + + it('falls back to repositoryGroups when projects has no match', () => { + const result = resolveProjectPathById( + '-Users-belief-dev-projects-crypto-research', + [CLAUDE_PROJECT], + [CRYPTO_REPO_GROUP] + ); + expect(result).toEqual({ + path: '/Users/belief/dev/projects/crypto_research', + name: '-Users-belief-dev-projects-crypto-research', + }); + }); + }); + + // ----------------------------------------------------------------------- + // Regression: Extensions tab with grouped view mode + // ----------------------------------------------------------------------- + describe('regression: Extensions tab skills in grouped view mode', () => { + it('resolves projectPath from id when only repositoryGroups is populated', () => { + // This is the exact scenario that caused skills not to show: + // viewMode=grouped → projects=[] but repositoryGroups has the data + // ExtensionStoreView used projects.find(p => p.id === tabProjectId) + // which returned null, so projectPath was null and no project skills loaded + const emptyProjects: ProjectWithName[] = []; + const populatedGroups: RepoGroupLike[] = [CRYPTO_REPO_GROUP, CLAUDE_REPO_GROUP]; + + const result = resolveProjectPathById( + '-Users-belief-dev-projects-crypto-research', + emptyProjects, + populatedGroups + ); + expect(result).not.toBeNull(); + expect(result!.path).toBe('/Users/belief/dev/projects/crypto_research'); + }); + }); +}); From 5375eea19f016b72ab33ece1d6ced5bfe80f68a3 Mon Sep 17 00:00:00 2001 From: Diego Serrano <129707357+diegoserranobst@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:37:27 -0400 Subject: [PATCH 7/9] feat(chat): show project and user skills as slash command suggestions --- .../team/messages/MessageComposer.tsx | 25 ++++--- .../components/ui/MentionSuggestionList.tsx | 19 ++++- src/renderer/types/mention.ts | 6 +- src/renderer/utils/mentionSuggestions.ts | 4 +- src/renderer/utils/skillCommandSuggestions.ts | 48 +++++++++++++ src/shared/utils/slashCommands.ts | 5 ++ .../utils/skillCommandSuggestions.test.ts | 72 +++++++++++++++++++ test/shared/utils/slashCommands.test.ts | 8 +++ 8 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 src/renderer/utils/skillCommandSuggestions.ts create mode 100644 test/renderer/utils/skillCommandSuggestions.test.ts diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 7f79114c..7f5003ea 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -19,6 +19,7 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; +import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions'; import { extractTaskRefsFromText, stripEncodedTaskReferenceMetadata, @@ -208,17 +209,21 @@ export const MessageComposer = ({ const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); + // Project skills as slash command suggestions + const projectSkills = useStore( + useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : [])) + ); + const userSkills = useStore(useShallow((s) => s.skillsUserCatalog)); + const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); + + // Fetch skills catalog for the team's project on mount / project change + useEffect(() => { + void fetchSkillsCatalog(projectPath ?? undefined); + }, [fetchSkillsCatalog, projectPath]); + const slashCommandSuggestions = useMemo( - () => - KNOWN_SLASH_COMMANDS.map((command) => ({ - id: `command:${command.name}`, - name: command.name, - command: command.command, - description: command.description, - subtitle: command.description, - type: 'command', - })), - [] + () => buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, projectSkills, userSkills), + [projectSkills, userSkills] ); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim(); diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx index 141c9460..e64d6341 100644 --- a/src/renderer/components/ui/MentionSuggestionList.tsx +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -83,11 +83,12 @@ export const MentionSuggestionList = ({ } // Categorize suggestions (folders are grouped with files) - type Section = 'member' | 'team' | 'task' | 'file' | 'command'; + type Section = 'member' | 'team' | 'task' | 'file' | 'command' | 'skill'; const getSuggestionSection = (s: MentionSuggestion): Section => { if (s.type === 'file' || s.type === 'folder') return 'file'; if (s.type === 'task') return 'task'; if (s.type === 'command') return 'command'; + if (s.type === 'skill') return 'skill'; if (s.type === 'team') return 'team'; return 'member'; }; @@ -98,6 +99,7 @@ export const MentionSuggestionList = ({ task: 'Tasks', file: 'Files', command: 'Commands', + skill: 'Skills', }; // Determine which sections are present @@ -117,6 +119,7 @@ export const MentionSuggestionList = ({ const isTeam = section === 'team'; const isTask = section === 'task'; const isCommand = section === 'command'; + const isSkill = section === 'skill'; const taskTeamColorSet = isTask && s.color ? getTeamColorSet(s.color) @@ -165,6 +168,8 @@ export const MentionSuggestionList = ({ ) : isCommand ? ( + ) : isSkill ? ( + ) : isTeam ? ( @@ -218,7 +231,7 @@ export const MentionSuggestionList = ({ {isTask && s.subtitle ? (
{s.subtitle}
) : null} - {isCommand && s.description ? ( + {(isCommand || isSkill) && s.description ? (
{s.description}
diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts index a708abc8..f5150e30 100644 --- a/src/renderer/types/mention.ts +++ b/src/renderer/types/mention.ts @@ -9,8 +9,8 @@ export interface MentionSuggestion { description?: string; /** Color name from TeamColorSet palette */ color?: string; - /** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', or 'command' */ - type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command'; + /** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', 'command', or 'skill' */ + type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command' | 'skill'; /** Whether the team is currently online (team suggestions only) */ isOnline?: boolean; /** Absolute file/folder path (file/folder suggestions only) */ @@ -21,7 +21,7 @@ export interface MentionSuggestion { insertText?: string; /** Optional extra searchable text (subject, team name, path, etc.) */ searchText?: string; - /** Optional slash command string including leading slash (command suggestions only) */ + /** Optional slash command string including leading slash (command and skill suggestions only) */ command?: `/${string}`; /** Canonical task id (task suggestions only) */ taskId?: string; diff --git a/src/renderer/utils/mentionSuggestions.ts b/src/renderer/utils/mentionSuggestions.ts index a83e1bed..cc979085 100644 --- a/src/renderer/utils/mentionSuggestions.ts +++ b/src/renderer/utils/mentionSuggestions.ts @@ -2,12 +2,12 @@ import type { MentionSuggestion } from '@renderer/types/mention'; export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' | '/' { if (suggestion.type === 'task') return '#'; - if (suggestion.type === 'command') return '/'; + if (suggestion.type === 'command' || suggestion.type === 'skill') return '/'; return '@'; } export function getSuggestionInsertionText(suggestion: MentionSuggestion): string { - if (suggestion.type === 'command') { + if (suggestion.type === 'command' || suggestion.type === 'skill') { return suggestion.command?.slice(1) ?? suggestion.insertText ?? suggestion.name; } return suggestion.insertText ?? suggestion.name; diff --git a/src/renderer/utils/skillCommandSuggestions.ts b/src/renderer/utils/skillCommandSuggestions.ts new file mode 100644 index 00000000..201cffea --- /dev/null +++ b/src/renderer/utils/skillCommandSuggestions.ts @@ -0,0 +1,48 @@ +import { getKnownSlashCommand, isSupportedSlashCommandName } from '@shared/utils/slashCommands'; + +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { SkillCatalogItem } from '@shared/types/extensions'; +import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands'; + +export function buildSlashCommandSuggestions( + builtIns: readonly KnownSlashCommandDefinition[], + projectSkills: readonly SkillCatalogItem[], + userSkills: readonly SkillCatalogItem[] +): MentionSuggestion[] { + const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({ + id: `command:${command.name}`, + name: command.name, + command: command.command, + description: command.description, + subtitle: command.description, + type: 'command', + })); + + const seenSkillNames = new Set(); + const skillSuggestions: MentionSuggestion[] = []; + for (const skill of [...projectSkills, ...userSkills]) { + const normalizedFolderName = skill.folderName.trim().toLowerCase(); + if ( + !skill.isValid || + !normalizedFolderName || + !isSupportedSlashCommandName(normalizedFolderName) || + getKnownSlashCommand(normalizedFolderName) !== null || + seenSkillNames.has(normalizedFolderName) + ) { + continue; + } + + seenSkillNames.add(normalizedFolderName); + skillSuggestions.push({ + id: `skill:${skill.id}`, + name: skill.folderName, + command: `/${normalizedFolderName}`, + description: skill.description, + subtitle: skill.scope === 'project' ? 'Project skill' : 'Personal skill', + searchText: `${skill.name} ${skill.folderName}`, + type: 'skill', + }); + } + + return [...builtInSuggestions, ...skillSuggestions]; +} diff --git a/src/shared/utils/slashCommands.ts b/src/shared/utils/slashCommands.ts index 35e4f19b..e06dd174 100644 --- a/src/shared/utils/slashCommands.ts +++ b/src/shared/utils/slashCommands.ts @@ -15,6 +15,7 @@ export interface ParsedStandaloneSlashCommand { endIndex: number; } +const SLASH_COMMAND_NAME_PATTERN = /^[a-z][a-z0-9:-]{0,63}$/i; const STANDALONE_SLASH_COMMAND_PATTERN = /^\/([a-z][a-z0-9:-]{0,63})(?:\s+([\s\S]*\S))?$/i; export const KNOWN_SLASH_COMMANDS: readonly KnownSlashCommandDefinition[] = [ @@ -78,6 +79,10 @@ export function getKnownSlashCommand(name: string): KnownSlashCommandDefinition return KNOWN_SLASH_COMMANDS_BY_NAME.get(name.trim().toLowerCase()) ?? null; } +export function isSupportedSlashCommandName(name: string): boolean { + return SLASH_COMMAND_NAME_PATTERN.test(name.trim()); +} + export function buildSlashCommandMeta( name: string, args?: string, diff --git a/test/renderer/utils/skillCommandSuggestions.test.ts b/test/renderer/utils/skillCommandSuggestions.test.ts new file mode 100644 index 00000000..e7e2a1d2 --- /dev/null +++ b/test/renderer/utils/skillCommandSuggestions.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions'; +import { KNOWN_SLASH_COMMANDS } from '@shared/utils/slashCommands'; + +import type { SkillCatalogItem } from '@shared/types/extensions'; + +function createSkill(overrides: Partial): SkillCatalogItem { + return { + id: overrides.id ?? 'skill-id', + sourceType: 'filesystem', + name: overrides.name ?? 'Skill Name', + description: overrides.description ?? 'Skill description', + folderName: overrides.folderName ?? 'skill-name', + scope: overrides.scope ?? 'project', + rootKind: overrides.rootKind ?? 'claude', + projectRoot: overrides.projectRoot ?? '/tmp/project', + discoveryRoot: overrides.discoveryRoot ?? '/tmp/project/.claude/skills', + skillDir: overrides.skillDir ?? '/tmp/project/.claude/skills/skill-name', + skillFile: overrides.skillFile ?? '/tmp/project/.claude/skills/skill-name/SKILL.md', + metadata: overrides.metadata ?? {}, + invocationMode: overrides.invocationMode ?? 'manual-only', + flags: overrides.flags ?? { hasScripts: false, hasReferences: false, hasAssets: false }, + isValid: overrides.isValid ?? true, + issues: overrides.issues ?? [], + modifiedAt: overrides.modifiedAt ?? 0, + }; +} + +describe('buildSlashCommandSuggestions', () => { + it('keeps built-ins and adds valid skills in a separate suggestion type', () => { + const suggestions = buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, [ + createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' }), + ], []); + + expect(suggestions[0]?.type).toBe('command'); + expect(suggestions.some((suggestion) => suggestion.type === 'skill')).toBe(true); + expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject({ + name: 'review-skill', + command: '/review-skill', + subtitle: 'Project skill', + type: 'skill', + }); + }); + + it('filters slash-unsafe names and built-in collisions', () => { + const suggestions = buildSlashCommandSuggestions( + KNOWN_SLASH_COMMANDS, + [ + createSkill({ id: 'unsafe', folderName: 'bad skill' }), + createSkill({ id: 'collision', folderName: 'plan' }), + ], + [] + ); + + expect(suggestions.find((suggestion) => suggestion.id === 'skill:unsafe')).toBeUndefined(); + expect(suggestions.find((suggestion) => suggestion.id === 'skill:collision')).toBeUndefined(); + }); + + it('prefers project skills when user and project skills share the same slash name', () => { + const suggestions = buildSlashCommandSuggestions( + KNOWN_SLASH_COMMANDS, + [createSkill({ id: 'project', folderName: 'shared-skill', scope: 'project' })], + [createSkill({ id: 'user', folderName: 'shared-skill', scope: 'user' })] + ); + + expect(suggestions.filter((suggestion) => suggestion.command === '/shared-skill')).toHaveLength(1); + expect(suggestions.find((suggestion) => suggestion.command === '/shared-skill')?.id).toBe( + 'skill:project' + ); + }); +}); diff --git a/test/shared/utils/slashCommands.test.ts b/test/shared/utils/slashCommands.test.ts index 29a48857..e30492a9 100644 --- a/test/shared/utils/slashCommands.test.ts +++ b/test/shared/utils/slashCommands.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { getKnownSlashCommand, + isSupportedSlashCommandName, KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand, } from '@shared/utils/slashCommands'; @@ -53,4 +54,11 @@ describe('slashCommands', () => { expect(getKnownSlashCommand('MODEL')?.description).toContain('Claude model'); expect(getKnownSlashCommand('foo')).toBeNull(); }); + + it('validates slash-compatible command names', () => { + expect(isSupportedSlashCommandName('review')).toBe(true); + expect(isSupportedSlashCommandName('skill:name')).toBe(true); + expect(isSupportedSlashCommandName('my_skill')).toBe(false); + expect(isSupportedSlashCommandName('my skill')).toBe(false); + }); }); From f819dd0c27c48434537b2c0aaca52713fb4b2c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Tue, 14 Apr 2026 14:56:29 +0300 Subject: [PATCH 8/9] fix(team): preserve provider model ids and codex slash suggestions --- .../team/dialogs/TeamModelSelector.tsx | 9 +- .../team/dialogs/launchDialogPrefill.ts | 15 ++- .../team/messages/MessageComposer.tsx | 22 +++- src/renderer/store/slices/teamSlice.ts | 81 ++++++------ src/renderer/utils/providerSlashCommands.ts | 122 ++++++++++++++++++ src/renderer/utils/skillCommandSuggestions.ts | 5 +- src/renderer/utils/teamModelContext.ts | 24 ++++ .../components/team/TeamModelSelector.test.ts | 1 + .../team/dialogs/launchDialogPrefill.test.ts | 29 ++++- .../utils/providerSlashCommands.test.ts | 27 ++++ .../utils/skillCommandSuggestions.test.ts | 50 +++++-- 11 files changed, 315 insertions(+), 70 deletions(-) create mode 100644 src/renderer/utils/providerSlashCommands.ts create mode 100644 test/renderer/utils/providerSlashCommands.test.ts diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 2afa54b6..c0175dbf 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -16,7 +16,6 @@ import { GEMINI_UI_DISABLED_REASON, isGeminiUiFrozen, } from '@renderer/utils/geminiUiFreeze'; -import { stripTrailingOneMillionSuffixes } from '@renderer/utils/teamModelContext'; import { doesTeamModelCarryProviderBrand, getProviderScopedTeamModelLabel, @@ -27,6 +26,7 @@ import { normalizeTeamModelForUi, TEAM_MODEL_UI_DISABLED_BADGE_LABEL, } from '@renderer/utils/teamModelCatalog'; +import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { Info } from 'lucide-react'; export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; @@ -100,8 +100,11 @@ export function computeEffectiveTeamModel( limitContext: boolean, providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic' ): string | undefined { - const base = stripTrailingOneMillionSuffixes(selectedModel); - if (providerId !== 'anthropic') return base; + if (providerId !== 'anthropic') { + return selectedModel.trim() || undefined; + } + + const base = extractProviderScopedBaseModel(selectedModel, providerId); if (limitContext) return base; if (base === 'haiku') return base; return base ? `${base}[1m]` : 'opus[1m]'; diff --git a/src/renderer/components/team/dialogs/launchDialogPrefill.ts b/src/renderer/components/team/dialogs/launchDialogPrefill.ts index c359b14b..5651a207 100644 --- a/src/renderer/components/team/dialogs/launchDialogPrefill.ts +++ b/src/renderer/components/team/dialogs/launchDialogPrefill.ts @@ -1,6 +1,6 @@ import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; -import { stripTrailingOneMillionSuffixes } from '@renderer/utils/teamModelContext'; import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; +import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; @@ -31,12 +31,15 @@ interface LaunchDialogPrefillResult { limitContext: boolean; } -function normalizeModelCandidate(model: string | undefined): string { +function normalizeModelCandidate( + model: string | undefined, + providerId: TeamProviderId | undefined +): string { const trimmed = model?.trim() ?? ''; if (!trimmed || trimmed === 'default' || trimmed === '__default__') { return ''; } - return stripTrailingOneMillionSuffixes(trimmed) ?? ''; + return extractProviderScopedBaseModel(trimmed, providerId) ?? ''; } function canReuseModelForSelectedProvider( @@ -74,15 +77,15 @@ export function resolveLaunchDialogPrefill({ const modelCandidates = [ { providerId: currentLeadProviderId, - model: normalizeModelCandidate(currentLead?.model), + model: normalizeModelCandidate(currentLead?.model, currentLeadProviderId), }, { providerId: savedRequestProviderId, - model: normalizeModelCandidate(savedRequest?.model), + model: normalizeModelCandidate(savedRequest?.model, savedRequestProviderId), }, { providerId: previousLaunchProviderId, - model: normalizeModelCandidate(previousLaunchParams?.model), + model: normalizeModelCandidate(previousLaunchParams?.model, previousLaunchProviderId), }, ]; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 7f5003ea..eff3ceea 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -19,6 +19,7 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; +import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands'; import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions'; import { extractTaskRefsFromText, @@ -26,7 +27,11 @@ import { } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands'; +import { parseStandaloneSlashCommand } from '@shared/utils/slashCommands'; +import { + inferTeamProviderIdFromModel, + normalizeOptionalTeamProviderId, +} from '@shared/utils/teamProvider'; import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -206,6 +211,12 @@ export const MessageComposer = ({ })), [members, colorMap] ); + const leadProviderId = useMemo(() => { + const lead = members.find((member) => isLeadMember(member)); + return ( + normalizeOptionalTeamProviderId(lead?.providerId) ?? inferTeamProviderIdFromModel(lead?.model) + ); + }, [members]); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); @@ -222,8 +233,13 @@ export const MessageComposer = ({ }, [fetchSkillsCatalog, projectPath]); const slashCommandSuggestions = useMemo( - () => buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, projectSkills, userSkills), - [projectSkills, userSkills] + () => + buildSlashCommandSuggestions( + getSuggestedSlashCommandsForProvider(leadProviderId), + projectSkills, + userSkills + ), + [leadProviderId, projectSkills, userSkills] ); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim(); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index a0b8f8b2..91dc5d1e 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -6,16 +6,51 @@ import { canDisplayTaskChangesForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; -import { stripTrailingOneMillionSuffixes } from '@renderer/utils/teamModelContext'; +import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { createLogger } from '@shared/utils/logger'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; +import type { AppState } from '../types'; +import type { AppConfig } from '@renderer/types/data'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; +import type { + ActiveToolCall, + AddMemberRequest, + AddTaskCommentRequest, + CreateTaskRequest, + CrossTeamSendRequest, + EffortLevel, + GlobalTask, + InboxMessage, + KanbanColumnId, + LeadActivityState, + LeadContextUsage, + MemberSpawnStatusEntry, + MemberSpawnStatusesSnapshot, + PersistedTeamLaunchSummary, + SendMessageRequest, + SendMessageResult, + TaskChangePresenceState, + TaskComment, + TeamCreateRequest, + TeamData, + TeamLaunchRequest, + TeamProviderId, + TeamProvisioningProgress, + TeamSummary, + TeamTask, + TeamTaskStatus, + ToolApprovalRequest, + ToolApprovalSettings, + UpdateKanbanPatch, +} from '@shared/types'; +import type { StateCreator } from 'zustand'; const logger = createLogger('teamSlice'); @@ -484,42 +519,6 @@ async function pollProvisioningStatus( } } -import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; - -import type { AppState } from '../types'; -import type { AppConfig } from '@renderer/types/data'; -import type { - ActiveToolCall, - AddMemberRequest, - AddTaskCommentRequest, - CreateTaskRequest, - CrossTeamSendRequest, - EffortLevel, - GlobalTask, - InboxMessage, - KanbanColumnId, - LeadActivityState, - LeadContextUsage, - MemberSpawnStatusEntry, - MemberSpawnStatusesSnapshot, - PersistedTeamLaunchSummary, - SendMessageRequest, - SendMessageResult, - TaskChangePresenceState, - TaskComment, - TeamCreateRequest, - TeamData, - TeamLaunchRequest, - TeamProvisioningProgress, - TeamSummary, - TeamTask, - TeamTaskStatus, - ToolApprovalRequest, - ToolApprovalSettings, - UpdateKanbanPatch, -} from '@shared/types'; -import type { StateCreator } from 'zustand'; - // --- Clarification notification tracking --- // Native OS notifications for new inbox messages are handled in main process // (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only @@ -1220,8 +1219,8 @@ function saveLaunchParams(teamName: string, params: TeamLaunchParams): void { * Extract the base model name from the raw model string sent to CLI. * E.g. 'opus[1m]' → 'opus', 'sonnet' → 'sonnet', undefined → undefined. */ -function extractBaseModel(raw?: string): string | undefined { - return stripTrailingOneMillionSuffixes(raw); +function extractBaseModel(raw?: string, providerId?: TeamProviderId): string | undefined { + return extractProviderScopedBaseModel(raw, providerId); } const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:'; @@ -2587,7 +2586,7 @@ export const createTeamSlice: StateCreator = (set, const response = await unwrapIpc('team:create', () => api.teams.createTeam(request)); // Persist per-team launch params (model, effort, limit context) - const baseModel = extractBaseModel(request.model); + const baseModel = extractBaseModel(request.model, request.providerId); const params: TeamLaunchParams = { providerId: request.providerId ?? 'anthropic', model: baseModel || 'default', @@ -2767,7 +2766,7 @@ export const createTeamSlice: StateCreator = (set, const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request)); // Persist per-team launch params (model, effort, limit context) - const baseModel = extractBaseModel(request.model); + const baseModel = extractBaseModel(request.model, request.providerId); const params: TeamLaunchParams = { providerId: request.providerId ?? 'anthropic', model: baseModel || 'default', diff --git a/src/renderer/utils/providerSlashCommands.ts b/src/renderer/utils/providerSlashCommands.ts new file mode 100644 index 00000000..d7cc66af --- /dev/null +++ b/src/renderer/utils/providerSlashCommands.ts @@ -0,0 +1,122 @@ +import { KNOWN_SLASH_COMMANDS } from '@shared/utils/slashCommands'; + +import type { TeamProviderId } from '@shared/types'; +import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands'; + +const CODEX_SLASH_COMMAND_SUGGESTIONS: readonly KnownSlashCommandDefinition[] = [ + { + name: 'model', + command: '/model', + description: 'Choose the active model for this session.', + }, + { + name: 'fast', + command: '/fast', + description: 'Toggle Fast mode on or off.', + }, + { + name: 'permissions', + command: '/permissions', + description: 'Adjust approval requirements for tools and commands.', + }, + { + name: 'plan', + command: '/plan', + description: 'Switch to plan mode with an optional prompt.', + }, + { + name: 'review', + command: '/review', + description: 'Ask Codex to review the current working tree.', + }, + { + name: 'diff', + command: '/diff', + description: 'Show the current Git diff, including untracked files.', + }, + { + name: 'status', + command: '/status', + description: 'Show session configuration and token usage.', + }, + { + name: 'mcp', + command: '/mcp', + description: 'List configured MCP tools for this session.', + }, + { + name: 'mention', + command: '/mention', + description: 'Attach a file or folder to the conversation.', + }, + { + name: 'apps', + command: '/apps', + description: 'Browse available apps and connectors.', + }, + { + name: 'plugins', + command: '/plugins', + description: 'Browse and manage installed plugins.', + }, + { + name: 'agent', + command: '/agent', + description: 'Switch to another agent thread.', + }, + { + name: 'personality', + command: '/personality', + description: 'Change Codex response style for the current thread.', + }, + { + name: 'compact', + command: '/compact', + description: 'Summarize the conversation to free tokens.', + }, + { + name: 'clear', + command: '/clear', + description: 'Clear the terminal and start a fresh chat.', + }, + { + name: 'new', + command: '/new', + description: 'Start a new conversation in the current session.', + }, + { + name: 'copy', + command: '/copy', + description: 'Copy the latest completed Codex output.', + }, + { + name: 'fork', + command: '/fork', + description: 'Fork the current conversation into a new thread.', + }, + { + name: 'resume', + command: '/resume', + description: 'Resume a previous conversation.', + }, + { + name: 'quit', + command: '/quit', + description: 'Exit the CLI.', + }, + { + name: 'exit', + command: '/exit', + description: 'Exit the CLI.', + }, +] as const; + +export function getSuggestedSlashCommandsForProvider( + providerId?: TeamProviderId +): readonly KnownSlashCommandDefinition[] { + if (providerId === 'codex') { + return CODEX_SLASH_COMMAND_SUGGESTIONS; + } + + return KNOWN_SLASH_COMMANDS; +} diff --git a/src/renderer/utils/skillCommandSuggestions.ts b/src/renderer/utils/skillCommandSuggestions.ts index 201cffea..370df4e9 100644 --- a/src/renderer/utils/skillCommandSuggestions.ts +++ b/src/renderer/utils/skillCommandSuggestions.ts @@ -1,4 +1,4 @@ -import { getKnownSlashCommand, isSupportedSlashCommandName } from '@shared/utils/slashCommands'; +import { isSupportedSlashCommandName } from '@shared/utils/slashCommands'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { SkillCatalogItem } from '@shared/types/extensions'; @@ -9,6 +9,7 @@ export function buildSlashCommandSuggestions( projectSkills: readonly SkillCatalogItem[], userSkills: readonly SkillCatalogItem[] ): MentionSuggestion[] { + const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase())); const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({ id: `command:${command.name}`, name: command.name, @@ -26,7 +27,7 @@ export function buildSlashCommandSuggestions( !skill.isValid || !normalizedFolderName || !isSupportedSlashCommandName(normalizedFolderName) || - getKnownSlashCommand(normalizedFolderName) !== null || + builtInNames.has(normalizedFolderName) || seenSkillNames.has(normalizedFolderName) ) { continue; diff --git a/src/renderer/utils/teamModelContext.ts b/src/renderer/utils/teamModelContext.ts index 7229cff0..706dc691 100644 --- a/src/renderer/utils/teamModelContext.ts +++ b/src/renderer/utils/teamModelContext.ts @@ -1,3 +1,7 @@ +import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; + +import type { TeamProviderId } from '@shared/types'; + export function stripTrailingOneMillionSuffixes(model: string | undefined): string | undefined { const trimmed = model?.trim(); if (!trimmed) { @@ -6,3 +10,23 @@ export function stripTrailingOneMillionSuffixes(model: string | undefined): stri return trimmed.replace(/(?:\[1m\])+$/, '') || undefined; } + +export function extractProviderScopedBaseModel( + model: string | undefined, + providerId?: TeamProviderId +): string | undefined { + const trimmed = model?.trim(); + if (!trimmed) { + return undefined; + } + + const effectiveProviderId = + providerId ?? + inferTeamProviderIdFromModel(trimmed) ?? + inferTeamProviderIdFromModel(stripTrailingOneMillionSuffixes(trimmed)); + if (effectiveProviderId !== 'anthropic') { + return trimmed; + } + + return stripTrailingOneMillionSuffixes(trimmed); +} diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index 5e7f2a47..f441903a 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -68,5 +68,6 @@ describe('computeEffectiveTeamModel', () => { it('returns non-anthropic models as-is', () => { expect(computeEffectiveTeamModel('gpt-5.4', false, 'codex')).toBe('gpt-5.4'); + expect(computeEffectiveTeamModel('custom-model[1m]', false, 'codex')).toBe('custom-model[1m]'); }); }); diff --git a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts index b91c03f1..35ca0b67 100644 --- a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts +++ b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts @@ -66,7 +66,7 @@ describe('resolveLaunchDialogPrefill', () => { const savedRequest = { teamName: 'vector-room-2', - cwd: '/tmp/project', + cwd: '/Users/test/project', providerId: 'anthropic', model: 'haiku', effort: 'low', @@ -181,4 +181,31 @@ describe('resolveLaunchDialogPrefill', () => { limitContext: true, }); }); + + it('preserves literal [1m] suffixes for non-anthropic providers', () => { + const result = resolveLaunchDialogPrefill({ + members: [], + savedRequest: null, + previousLaunchParams: { + providerId: 'codex', + model: 'custom-model[1m]', + effort: 'medium', + }, + multimodelEnabled: true, + storedProviderId: 'anthropic', + storedEffort: 'medium', + storedLimitContext: false, + getStoredModel: createStoredModelGetter({ + anthropic: 'haiku', + codex: 'gpt-5.4', + }), + }); + + expect(result).toEqual({ + providerId: 'codex', + model: 'custom-model[1m]', + effort: 'medium', + limitContext: false, + }); + }); }); diff --git a/test/renderer/utils/providerSlashCommands.test.ts b/test/renderer/utils/providerSlashCommands.test.ts new file mode 100644 index 00000000..0934de33 --- /dev/null +++ b/test/renderer/utils/providerSlashCommands.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands'; + +describe('getSuggestedSlashCommandsForProvider', () => { + it('returns Codex-specific command suggestions without Anthropic-only entries', () => { + const commands = getSuggestedSlashCommandsForProvider('codex').map( + (command) => command.command + ); + + expect(commands).toContain('/permissions'); + expect(commands).toContain('/agent'); + expect(commands).toContain('/review'); + expect(commands).not.toContain('/effort'); + expect(commands).not.toContain('/usage'); + }); + + it('falls back to the default curated list for Anthropic-like providers', () => { + const commands = getSuggestedSlashCommandsForProvider('anthropic').map( + (command) => command.command + ); + + expect(commands).toContain('/effort'); + expect(commands).toContain('/usage'); + expect(commands).not.toContain('/permissions'); + }); +}); diff --git a/test/renderer/utils/skillCommandSuggestions.test.ts b/test/renderer/utils/skillCommandSuggestions.test.ts index e7e2a1d2..528a2f2d 100644 --- a/test/renderer/utils/skillCommandSuggestions.test.ts +++ b/test/renderer/utils/skillCommandSuggestions.test.ts @@ -14,10 +14,10 @@ function createSkill(overrides: Partial): SkillCatalogItem { folderName: overrides.folderName ?? 'skill-name', scope: overrides.scope ?? 'project', rootKind: overrides.rootKind ?? 'claude', - projectRoot: overrides.projectRoot ?? '/tmp/project', - discoveryRoot: overrides.discoveryRoot ?? '/tmp/project/.claude/skills', - skillDir: overrides.skillDir ?? '/tmp/project/.claude/skills/skill-name', - skillFile: overrides.skillFile ?? '/tmp/project/.claude/skills/skill-name/SKILL.md', + projectRoot: overrides.projectRoot ?? '/Users/test/project', + discoveryRoot: overrides.discoveryRoot ?? '/Users/test/project/.claude/skills', + skillDir: overrides.skillDir ?? '/Users/test/project/.claude/skills/skill-name', + skillFile: overrides.skillFile ?? '/Users/test/project/.claude/skills/skill-name/SKILL.md', metadata: overrides.metadata ?? {}, invocationMode: overrides.invocationMode ?? 'manual-only', flags: overrides.flags ?? { hasScripts: false, hasReferences: false, hasAssets: false }, @@ -29,18 +29,22 @@ function createSkill(overrides: Partial): SkillCatalogItem { describe('buildSlashCommandSuggestions', () => { it('keeps built-ins and adds valid skills in a separate suggestion type', () => { - const suggestions = buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, [ - createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' }), - ], []); + const suggestions = buildSlashCommandSuggestions( + KNOWN_SLASH_COMMANDS, + [createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' })], + [] + ); expect(suggestions[0]?.type).toBe('command'); expect(suggestions.some((suggestion) => suggestion.type === 'skill')).toBe(true); - expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject({ - name: 'review-skill', - command: '/review-skill', - subtitle: 'Project skill', - type: 'skill', - }); + expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject( + { + name: 'review-skill', + command: '/review-skill', + subtitle: 'Project skill', + type: 'skill', + } + ); }); it('filters slash-unsafe names and built-in collisions', () => { @@ -64,9 +68,27 @@ describe('buildSlashCommandSuggestions', () => { [createSkill({ id: 'user', folderName: 'shared-skill', scope: 'user' })] ); - expect(suggestions.filter((suggestion) => suggestion.command === '/shared-skill')).toHaveLength(1); + expect(suggestions.filter((suggestion) => suggestion.command === '/shared-skill')).toHaveLength( + 1 + ); expect(suggestions.find((suggestion) => suggestion.command === '/shared-skill')?.id).toBe( 'skill:project' ); }); + + it('uses the provided built-in set when filtering skill collisions', () => { + const suggestions = buildSlashCommandSuggestions( + [ + { + name: 'custom-cmd', + command: '/custom-cmd', + description: 'Custom command', + }, + ], + [createSkill({ id: 'collision', folderName: 'custom-cmd' })], + [] + ); + + expect(suggestions.find((suggestion) => suggestion.id === 'skill:collision')).toBeUndefined(); + }); }); From 080e0af55a228bba4f6194c0458266c4d8421de4 Mon Sep 17 00:00:00 2001 From: Diego Serrano <129707357+diegoserranobst@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:31:09 -0400 Subject: [PATCH 9/9] fix(team): resolve stuck "reconciling" state and skip resume when teammates never spawned (#55) * fix(team): resolve stuck "reconciling" state and skip resume when teammates never spawned Addresses #54. When a team launch fails to bootstrap teammates, the team gets stuck showing "Last launch is still reconciling" indefinitely, and retrying with --resume reconnects the lead but does not re-spawn the dead teammates. The only workaround was enabling "Clear context (fresh session)", which loses the lead's prior conversation context. Two root causes addressed: 1. createPersistedLaunchSnapshot counted members still in 'starting' state (agentToolAccepted=false) as 'pending' regardless of launchPhase. When launchPhase was 'finished' with never-spawned members, the aggregate state stayed as 'partial_pending' forever, rendered as "still reconciling". Fix: when launchPhase != 'active', promote such members to 'failed_to_start' so the aggregate becomes 'partial_failure' ("Launch failed partway"), which correctly signals a terminal state. 2. TeamProvisioningService._launchTeamInner always used --resume when a previous leadSessionId existed, even if the previous launch had no teammates successfully spawned. The CLI's deterministic reconnect path restores lead context but does not re-spawn dead teammates, so the team stays broken across relaunches. Fix: before adding --resume, read the persisted launch state. If every expected teammate is 'starting' (never spawned) or 'failed_to_start', skip --resume so the CLI performs a full fresh bootstrap that spawns all teammates. Verified manually on Linux: a team stuck in "still reconciling" correctly transitions to "failed partway" after the first fix, and the next Launch (without "Clear context") fully bootstraps and brings teammates online. * fix(team): narrow skip resume to never-spawned teammates --------- Co-authored-by: 777genius --- .../services/team/TeamLaunchStateEvaluator.ts | 28 ++- .../services/team/TeamProvisioningService.ts | 48 +++++ .../team/TeamProvisioningService.test.ts | 191 ++++++++++++++++-- 3 files changed, 249 insertions(+), 18 deletions(-) diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index 14599520..c3ca6bb5 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -213,13 +213,39 @@ export function createPersistedLaunchSnapshot(params: { ) ); const members = params.members ?? {}; + const launchPhase = params.launchPhase ?? 'active'; + + // When the launch is over (finished/reconciled), members still in 'starting' state + // (never spawned — agentToolAccepted is false) are unreachable and should be marked + // as failed. Without this, they stay as 'pending' forever, causing the UI to show + // "Last launch is still reconciling" indefinitely after a crash or incomplete launch. + if (launchPhase !== 'active') { + for (const name of expectedMembers) { + const member = members[name]; + if ( + member && + member.launchState === 'starting' && + !member.agentToolAccepted && + !member.runtimeAlive && + !member.bootstrapConfirmed && + !member.hardFailure + ) { + member.hardFailure = true; + member.hardFailureReason = + member.hardFailureReason ?? 'Teammate was never spawned during launch.'; + member.launchState = deriveMemberLaunchState(member); + member.diagnostics = buildDiagnostics(member); + } + } + } + const summary = summarizePersistedLaunchMembers(expectedMembers, members); return { version: 2, teamName: params.teamName, updatedAt, ...(params.leadSessionId ? { leadSessionId: params.leadSessionId } : {}), - launchPhase: params.launchPhase ?? 'active', + launchPhase, expectedMembers, members, summary, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1dc1a7ba..81072cb9 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4860,11 +4860,59 @@ export class TeamProvisioningService { // so the lead retains full context of prior work. // When clearContext is true, skip resume entirely to start a fresh session. let previousSessionId: string | undefined; + let skipResume = false; if (request.clearContext) { + skipResume = true; logger.info( `[${request.teamName}] clearContext requested — skipping session resume, starting fresh` ); } else { + // Check persisted launch state: if the previous launch ended with no teammates + // ever spawned (all in 'starting' state), resuming would reconnect the lead but + // the CLI's deterministic bootstrap won't re-spawn dead teammates in reconnect + // mode. Skip resume so the CLI creates a fresh session that fully bootstraps. + const persistedLaunchState = await this.launchStateStore.read(request.teamName); + if (persistedLaunchState) { + const { + expectedMembers: prevExpected, + members: prevMembers, + launchPhase, + } = persistedLaunchState; + const teammateWasNeverSpawned = ( + member: + | { + agentToolAccepted?: boolean; + firstSpawnAcceptedAt?: string; + runtimeAlive?: boolean; + bootstrapConfirmed?: boolean; + } + | undefined + ): boolean => { + if (!member) return true; + const hasAcceptedSpawn = + member.agentToolAccepted === true || + (typeof member.firstSpawnAcceptedAt === 'string' && + member.firstSpawnAcceptedAt.trim().length > 0); + return ( + !hasAcceptedSpawn && + member.runtimeAlive !== true && + member.bootstrapConfirmed !== true + ); + }; + const allTeammatesNeverSpawned = + launchPhase !== 'active' && + prevExpected.length > 0 && + prevExpected.every((name) => teammateWasNeverSpawned(prevMembers[name])); + if (allTeammatesNeverSpawned) { + skipResume = true; + logger.info( + `[${request.teamName}] Previous launch had no teammates successfully spawned — ` + + `skipping session resume to allow full bootstrap` + ); + } + } + } + if (!skipResume) { try { const configParsed = JSON.parse(configRaw) as Record; const resumeGuard = shouldSkipResumeForProviderRuntimeChange(request, configParsed); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 834437f9..95ff14cd 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -51,6 +51,8 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { }); import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; +import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; +import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { spawnCli } from '@main/utils/childProcess'; import { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES } from 'agent-teams-controller'; @@ -84,6 +86,63 @@ function createRunningChild() { }); } +function writeLaunchConfig( + teamName: string, + projectPath: string, + leadSessionId: string, + members: string[] +): void { + const teamDir = path.join(tempTeamsBase, teamName); + fs.mkdirSync(teamDir, { recursive: true }); + fs.writeFileSync( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: teamName, + projectPath, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + ...members.map((name) => ({ name })), + ], + }), + 'utf8' + ); +} + +function writeLaunchState( + teamName: string, + leadSessionId: string, + members: Record> +): void { + const snapshot = createPersistedLaunchSnapshot({ + teamName, + leadSessionId, + launchPhase: 'finished', + expectedMembers: Object.keys(members), + members: Object.fromEntries( + Object.entries(members).map(([name, member]) => [ + name, + { + name, + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate was never spawned during launch.', + lastEvaluatedAt: new Date().toISOString(), + ...member, + }, + ]) + ) as any, + }); + fs.writeFileSync( + getTeamLaunchStatePath(teamName), + `${JSON.stringify(snapshot, null, 2)}\n`, + 'utf8' + ); +} + describe('TeamProvisioningService', () => { beforeEach(() => { vi.clearAllMocks(); @@ -100,7 +159,6 @@ describe('TeamProvisioningService', () => { fs.mkdirSync(tempProjectsBase, { recursive: true }); }); - afterEach(() => { vi.useRealTimers(); try { @@ -389,14 +447,12 @@ describe('TeamProvisioningService', () => { it('expands teammate permission suggestions to the operational tool set only', async () => { allowConsoleLogs(); - const svc = new TeamProvisioningService( - { - getConfig: vi.fn(async () => ({ - projectPath: tempClaudeRoot, - members: [{ cwd: tempClaudeRoot }], - })), - } as any - ); + const svc = new TeamProvisioningService({ + getConfig: vi.fn(async () => ({ + projectPath: tempClaudeRoot, + members: [{ cwd: tempClaudeRoot }], + })), + } as any); await (svc as any).respondToTeammatePermission( { teamName: 'ops-team' }, @@ -427,14 +483,12 @@ describe('TeamProvisioningService', () => { it('does not broaden admin/runtime teammate permission suggestions', async () => { allowConsoleLogs(); - const svc = new TeamProvisioningService( - { - getConfig: vi.fn(async () => ({ - projectPath: tempClaudeRoot, - members: [{ cwd: tempClaudeRoot }], - })), - } as any - ); + const svc = new TeamProvisioningService({ + getConfig: vi.fn(async () => ({ + projectPath: tempClaudeRoot, + members: [{ cwd: tempClaudeRoot }], + })), + } as any); await (svc as any).respondToTeammatePermission( { teamName: 'ops-team' }, @@ -516,4 +570,107 @@ describe('TeamProvisioningService', () => { }) ).toBe('Questions (2): First question with extra spacing.'); }); + + it('skips --resume when the persisted launch state shows no teammate ever spawned', async () => { + allowConsoleLogs(); + const teamName = 'resume-skip-team'; + const leadSessionId = 'lead-session-skip'; + writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice', 'bob']); + writeLaunchState(teamName, leadSessionId, { + alice: { + launchState: 'failed_to_start', + }, + bob: { + launchState: 'starting', + hardFailure: false, + }, + }); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockImplementation(() => { + throw new Error('launch spawn EINVAL'); + }); + + const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'), + removeConfigFile: vi.fn(async () => {}), + } as any); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { ANTHROPIC_API_KEY: 'test' }, + authSource: 'anthropic_api_key', + })); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice' }, { name: 'bob' }], + source: 'members-meta', + warning: undefined, + })); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async (targetPath: string) => + targetPath.endsWith(`${leadSessionId}.jsonl`) + ); + + await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow( + 'launch spawn EINVAL' + ); + + const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; + expect(launchArgs).toBeTruthy(); + expect(launchArgs).not.toContain('--resume'); + expect(launchArgs).not.toContain(leadSessionId); + }); + + it('keeps --resume when a teammate had an accepted spawn before failing bootstrap', async () => { + allowConsoleLogs(); + const teamName = 'resume-keep-team'; + const leadSessionId = 'lead-session-keep'; + const acceptedAt = '2026-04-14T12:00:00.000Z'; + writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']); + writeLaunchState(teamName, leadSessionId, { + alice: { + launchState: 'failed_to_start', + agentToolAccepted: true, + firstSpawnAcceptedAt: acceptedAt, + hardFailureReason: 'Teammate did not join within the launch grace window.', + }, + }); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockImplementation(() => { + throw new Error('launch spawn EINVAL'); + }); + + const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'), + removeConfigFile: vi.fn(async () => {}), + } as any); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { ANTHROPIC_API_KEY: 'test' }, + authSource: 'anthropic_api_key', + })); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice' }], + source: 'members-meta', + warning: undefined, + })); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async (targetPath: string) => + targetPath.endsWith(`${leadSessionId}.jsonl`) + ); + + await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow( + 'launch spawn EINVAL' + ); + + const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; + expect(launchArgs).toContain('--resume'); + expect(launchArgs).toContain(leadSessionId); + }); });