From a8e7f1ccd5f95ab8e6812f08423a08466345b1df Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 21 May 2026 23:47:55 +0300 Subject: [PATCH 01/41] refactor(team): extract message notification scanner --- src/main/ipc/teams.ts | 286 ++++-------------- .../teams/teamMessageNotificationScanner.ts | 240 +++++++++++++++ src/main/services/team/TeamDataService.ts | 20 +- .../services/team/actionModeInstructions.ts | 4 +- .../teamMessageNotificationScanner.test.ts | 171 +++++++++++ 5 files changed, 483 insertions(+), 238 deletions(-) create mode 100644 src/main/ipc/teams/teamMessageNotificationScanner.ts create mode 100644 test/main/ipc/teams/teamMessageNotificationScanner.test.ts diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 65daa752..de7e0a6b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -94,10 +94,9 @@ import { TEAM_VALIDATE_CLI_ARGS, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design } from '@preload/constants/ipcChannels'; -import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, wrapAgentBlock } from '@shared/constants/agentBlocks'; +import { wrapAgentBlock } from '@shared/constants/agentBlocks'; import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits'; -import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { extractFlagsFromHelp, extractUserFlags, @@ -111,7 +110,6 @@ import { getErrorMessage } from '@shared/utils/errorHandling'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend'; -import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { buildStandaloneSlashCommandMeta, parseStandaloneSlashCommand, @@ -133,7 +131,6 @@ import { import { getAutoResumeService, initializeAutoResumeService, - planRateLimitAutoResume, } from '../services/team/AutoResumeService'; import { cloneLaunchIoGovernorPayload, @@ -156,6 +153,7 @@ import { import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService'; +import { teamMessageNotificationScanner } from './teams/teamMessageNotificationScanner'; import { validateFromField, validateMemberName, @@ -301,14 +299,6 @@ function validateTeamGetDataOptions( }; } -/** - * In-memory set of rate-limit message keys already processed. - * Independent of NotificationManager storage — survives notification deletion/pruning. - * Without this, deleted rate-limit notifications would re-appear on next getData() scan. - */ -const seenRateLimitKeys = new Set(); -const SEEN_RATE_LIMIT_KEYS_MAX = 500; - async function withTimeoutValue( promise: Promise, timeoutMs: number, @@ -442,178 +432,6 @@ function buildLeadDirectDelegateAckBlock(actionMode?: AgentActionMode): string | ); } -/** - * In-memory set of API error message keys already processed. - * Independent of NotificationManager storage — survives notification deletion/pruning. - */ -const seenApiErrorKeys = new Set(); -const SEEN_API_ERROR_KEYS_MAX = 500; - -function formatNotificationClockTime(date: Date): string { - return new Intl.DateTimeFormat(undefined, { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).format(date); -} - -function buildRateLimitNotificationBody(plan: ReturnType): string { - if (plan.kind === 'scheduled') { - return `Auto-resume scheduled at ${formatNotificationClockTime(new Date(plan.fireAtMs))}`; - } - return 'Manual restart needed'; -} - -/** - * Check messages for rate limit indicators and fire notifications for new ones. - * Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion) - * and NotificationManager dedupeKey (to prevent storage duplicates). - */ -function checkRateLimitMessages( - messages: readonly { - messageId?: string; - from: string; - text: string; - timestamp: string; - to?: string; - source?: string; - leadSessionId?: string; - }[], - teamName: string, - teamDisplayName: string, - projectPath?: string, - teamIsAlive = true, - currentLeadSessionId: string | null = null -): void { - const observedAt = new Date(); - const autoResumeEnabled = - ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit; - - for (const msg of messages) { - if (msg.from === 'user') continue; - if (!isRateLimitMessage(msg.text)) continue; - - const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; - const dedupeKey = `rate-limit:${teamName}:${rawKey}`; - const isLeadAutoResumeCandidate = - !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session'); - const autoResumeSessionMatches = - msg.source !== 'lead_session' || - (Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId); - const autoResumePlan = planRateLimitAutoResume({ - enabled: autoResumeEnabled, - canAutoResume: teamIsAlive && isLeadAutoResumeCandidate && autoResumeSessionMatches, - messageText: msg.text, - observedAt, - messageTimestamp: new Date(msg.timestamp), - }); - - // In-memory guard: prevents resurrection after user deletes the notification. - if (!seenRateLimitKeys.has(dedupeKey)) { - seenRateLimitKeys.add(dedupeKey); - - // Evict oldest entries to prevent unbounded growth - if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) { - const first = seenRateLimitKeys.values().next().value; - if (first) seenRateLimitKeys.delete(first); - } - - void NotificationManager.getInstance() - .addTeamNotification({ - teamEventType: 'rate_limit', - teamName, - teamDisplayName, - from: msg.from, - summary: 'Rate limit', - body: buildRateLimitNotificationBody(autoResumePlan), - dedupeKey, - target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' }, - projectPath, - }) - .catch(() => undefined); - } - - // Only schedule auto-resume while a live team run currently exists. - // Persisted history for an offline/stopped team may still contain the old - // rate-limit message, but arming a new timer from that stale history would - // resurrect the nudge into a later manual restart. - if (autoResumePlan.kind === 'scheduled') { - // Only let persisted lead_session history rebuild auto-resume when it - // clearly belongs to the currently running lead session. Otherwise an old - // rate-limit from a previous manual run can resurrect into a newer restart. - // Pass the original message timestamp so relative reset windows survive restarts - // and old history does not rebuild a fresh auto-resume timer from "now". - getAutoResumeService().handleRateLimitMessage( - teamName, - msg.text, - observedAt, - new Date(msg.timestamp) - ); - } - } -} - -/** - * Check messages for API errors (e.g. "API Error: 429 ...") and fire OS notifications. - * Mirrors the rate-limit approach: in-memory dedup + NotificationManager dedupeKey. - * Skips rate-limit messages (they have their own notification path). - */ -function checkApiErrorMessages( - messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], - teamName: string, - teamDisplayName: string, - projectPath?: string -): void { - for (const msg of messages) { - if (msg.from === 'user') continue; - if (!isApiErrorMessage(msg.text)) continue; - // Don't double-notify if it's also a rate limit message - if (isRateLimitMessage(msg.text)) continue; - - const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; - const dedupeKey = `api-error:${teamName}:${rawKey}`; - - if (seenApiErrorKeys.has(dedupeKey)) continue; - seenApiErrorKeys.add(dedupeKey); - - if (seenApiErrorKeys.size > SEEN_API_ERROR_KEYS_MAX) { - const first = seenApiErrorKeys.values().next().value; - if (first) seenApiErrorKeys.delete(first); - } - - // Extract status code for summary - const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text); - const statusCode = statusMatch?.[1] ?? '???'; - - void NotificationManager.getInstance() - .addTeamNotification({ - teamEventType: 'api_error', - teamName, - teamDisplayName, - from: msg.from, - summary: `API Error ${statusCode}`, - body: 'Manual restart needed', - dedupeKey, - target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' }, - projectPath, - }) - .catch(() => undefined); - } -} - -function scanTeamMessageNotifications( - messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], - teamName: string, - teamDisplayName: string, - projectPath?: string -): void { - if (messages.length === 0) { - return; - } - checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath); - checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath); -} - let teamDataService: TeamDataService | null = null; let teamProvisioningService: TeamProvisioningService | null = null; let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; @@ -1145,17 +963,24 @@ async function handleGetData( if (live.length === 0) { if (durableMessages.length > 0) { - checkRateLimitMessages( - durableMessages, - tn, - displayName, + teamMessageNotificationScanner.checkRateLimitMessages(durableMessages, { + teamName: tn, + teamDisplayName: displayName, projectPath, - isAlive, - currentLeadSessionId - ); - checkApiErrorMessages(durableMessages, tn, displayName, projectPath); + teamIsAlive: isAlive, + currentLeadSessionId, + }); + teamMessageNotificationScanner.checkApiErrorMessages(durableMessages, { + teamName: tn, + teamDisplayName: displayName, + projectPath, + }); } else { - scanTeamMessageNotifications(live, tn, displayName, projectPath); + teamMessageNotificationScanner.scan(live, { + teamName: tn, + teamDisplayName: displayName, + projectPath, + }); } return { success: true, data: { ...data, isAlive } }; } @@ -1177,8 +1002,18 @@ async function handleGetData( } } - checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId); - checkApiErrorMessages(merged, tn, displayName, projectPath); + teamMessageNotificationScanner.checkRateLimitMessages(merged, { + teamName: tn, + teamDisplayName: displayName, + projectPath, + teamIsAlive: isAlive, + currentLeadSessionId, + }); + teamMessageNotificationScanner.checkApiErrorMessages(merged, { + teamName: tn, + teamDisplayName: displayName, + projectPath, + }); return { success: true, data: { ...data, isAlive } }; } @@ -2786,27 +2621,27 @@ function buildMessageDeliveryText( 'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.', ]; hiddenBlocks.push( - [ - AGENT_BLOCK_OPEN, - `You received a direct message from ${senderDescriptor} via the UI.`, - ...replyInstructionLines, - `Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`, - 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").', - ...(canUseAgentTeamsMessageSend - ? [ - 'If neither Agent Teams MCP message_send tool name is available before any visible-message tool attempt, write exactly the concise reply text as normal assistant text so the runtime can relay it.', - ] - : []), - ...(isUserReplyRecipient - ? [ - 'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").', - 'Only after that first acknowledgement may you message the lead or another teammate.', - 'After you get the needed information, send the final answer back to "user".', - 'Do NOT stay silent while you go ask someone else.', - ] - : []), - AGENT_BLOCK_CLOSE, - ].join('\n') + wrapAgentBlock( + [ + `You received a direct message from ${senderDescriptor} via the UI.`, + ...replyInstructionLines, + `Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`, + 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").', + ...(canUseAgentTeamsMessageSend + ? [ + 'If neither Agent Teams MCP message_send tool name is available before any visible-message tool attempt, write exactly the concise reply text as normal assistant text so the runtime can relay it.', + ] + : []), + ...(isUserReplyRecipient + ? [ + 'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").', + 'Only after that first acknowledgement may you message the lead or another teammate.', + 'After you get the needed information, send the final answer back to "user".', + 'Do NOT stay silent while you go ask someone else.', + ] + : []), + ].join('\n') + ) ); } @@ -2844,12 +2679,11 @@ async function handleGetMessagesPage( .catch(() => ({ displayName: teamName })); void notificationContextPromise .then((notificationContext) => { - scanTeamMessageNotifications( - messagesPage.messages, + teamMessageNotificationScanner.scan(messagesPage.messages, { teamName, - notificationContext.displayName, - notificationContext.projectPath - ); + teamDisplayName: notificationContext.displayName, + projectPath: notificationContext.projectPath, + }); }) .catch((error: unknown) => { logger.debug( @@ -3062,10 +2896,12 @@ async function handleSendMessage( `IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`, ...(rosterContextBlock ? [rosterContextBlock] : []), ...(delegateAckBlock ? [delegateAckBlock] : []), - AGENT_BLOCK_OPEN, - `MessageId: ${preGeneratedMessageId}`, - `When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`, - AGENT_BLOCK_CLOSE, + wrapAgentBlock( + [ + `MessageId: ${preGeneratedMessageId}`, + `When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`, + ].join('\n') + ), ``, `Message from user:`, buildMessageDeliveryText(payload.text!, { diff --git a/src/main/ipc/teams/teamMessageNotificationScanner.ts b/src/main/ipc/teams/teamMessageNotificationScanner.ts new file mode 100644 index 00000000..e974c0fe --- /dev/null +++ b/src/main/ipc/teams/teamMessageNotificationScanner.ts @@ -0,0 +1,240 @@ +import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; +import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; +import { + getAutoResumeService, + planRateLimitAutoResume, + type RateLimitAutoResumePlan, +} from '@main/services/team/AutoResumeService'; +import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; +import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; + +import type { TeamNotificationPayload } from '@main/utils/teamNotificationBuilder'; + +export interface TeamNotificationMessage { + messageId?: string; + from: string; + text: string; + timestamp: string; + to?: string; + source?: string; + leadSessionId?: string; +} + +interface TeamNotificationSink { + addTeamNotification(payload: TeamNotificationPayload): Promise; +} + +interface AutoResumeSink { + handleRateLimitMessage( + teamName: string, + messageText: string, + observedAt: Date, + messageTimestamp: Date + ): void; +} + +interface ConfigReader { + getConfig(): { + notifications: { + autoResumeOnRateLimit: boolean; + }; + }; +} + +export interface TeamMessageNotificationScannerDeps { + configReader?: ConfigReader; + notificationSink?: TeamNotificationSink; + autoResumeSink?: AutoResumeSink; + planAutoResume?: typeof planRateLimitAutoResume; + isRateLimit?: (text: string) => boolean; + isApiError?: (text: string) => boolean; + now?: () => Date; + formatClockTime?: (date: Date) => string; +} + +export interface TeamMessageNotificationContext { + teamName: string; + teamDisplayName: string; + projectPath?: string; + teamIsAlive?: boolean; + currentLeadSessionId?: string | null; +} + +const SEEN_RATE_LIMIT_KEYS_MAX = 500; +const SEEN_API_ERROR_KEYS_MAX = 500; + +function formatNotificationClockTime(date: Date): string { + return new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); +} + +function buildRateLimitNotificationBody( + plan: RateLimitAutoResumePlan, + formatClockTime: (date: Date) => string +): string { + if (plan.kind === 'scheduled') { + return `Auto-resume scheduled at ${formatClockTime(new Date(plan.fireAtMs))}`; + } + return 'Manual restart needed'; +} + +function evictOldestIfNeeded(keys: Set, maxSize: number): void { + if (keys.size <= maxSize) { + return; + } + + const first = keys.values().next().value; + if (first) { + keys.delete(first); + } +} + +function createDefaultNotificationSink(): TeamNotificationSink { + return { + addTeamNotification: (payload) => NotificationManager.getInstance().addTeamNotification(payload), + }; +} + +export class TeamMessageNotificationScanner { + readonly #seenRateLimitKeys = new Set(); + readonly #seenApiErrorKeys = new Set(); + readonly #configReader: ConfigReader; + readonly #notificationSink: TeamNotificationSink; + readonly #planAutoResume: typeof planRateLimitAutoResume; + readonly #isRateLimit: (text: string) => boolean; + readonly #isApiError: (text: string) => boolean; + readonly #now: () => Date; + readonly #formatClockTime: (date: Date) => string; + readonly #autoResumeSink: AutoResumeSink | null; + + constructor(deps: TeamMessageNotificationScannerDeps = {}) { + this.#configReader = deps.configReader ?? ConfigManager.getInstance(); + this.#notificationSink = deps.notificationSink ?? createDefaultNotificationSink(); + this.#planAutoResume = deps.planAutoResume ?? planRateLimitAutoResume; + this.#isRateLimit = deps.isRateLimit ?? isRateLimitMessage; + this.#isApiError = deps.isApiError ?? isApiErrorMessage; + this.#now = deps.now ?? (() => new Date()); + this.#formatClockTime = deps.formatClockTime ?? formatNotificationClockTime; + this.#autoResumeSink = deps.autoResumeSink ?? null; + } + + checkRateLimitMessages( + messages: readonly TeamNotificationMessage[], + context: TeamMessageNotificationContext + ): void { + const observedAt = this.#now(); + const autoResumeEnabled = this.#configReader.getConfig().notifications.autoResumeOnRateLimit; + + for (const msg of messages) { + if (msg.from === 'user') continue; + if (!this.#isRateLimit(msg.text)) continue; + + const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; + const dedupeKey = `rate-limit:${context.teamName}:${rawKey}`; + const isLeadAutoResumeCandidate = + !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session'); + const currentLeadSessionId = context.currentLeadSessionId ?? null; + const autoResumeSessionMatches = + msg.source !== 'lead_session' || + (Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId); + const autoResumePlan = this.#planAutoResume({ + enabled: autoResumeEnabled, + canAutoResume: + (context.teamIsAlive ?? true) && + isLeadAutoResumeCandidate && + autoResumeSessionMatches, + messageText: msg.text, + observedAt, + messageTimestamp: new Date(msg.timestamp), + }); + + if (!this.#seenRateLimitKeys.has(dedupeKey)) { + this.#seenRateLimitKeys.add(dedupeKey); + evictOldestIfNeeded(this.#seenRateLimitKeys, SEEN_RATE_LIMIT_KEYS_MAX); + + void this.#notificationSink + .addTeamNotification({ + teamEventType: 'rate_limit', + teamName: context.teamName, + teamDisplayName: context.teamDisplayName, + from: msg.from, + summary: 'Rate limit', + body: buildRateLimitNotificationBody(autoResumePlan, this.#formatClockTime), + dedupeKey, + target: { + kind: 'member', + teamName: context.teamName, + memberName: msg.from, + focus: 'logs', + }, + projectPath: context.projectPath, + }) + .catch(() => undefined); + } + + if (autoResumePlan.kind === 'scheduled') { + const autoResumeSink = this.#autoResumeSink ?? getAutoResumeService(); + autoResumeSink.handleRateLimitMessage( + context.teamName, + msg.text, + observedAt, + new Date(msg.timestamp) + ); + } + } + } + + checkApiErrorMessages( + messages: readonly TeamNotificationMessage[], + context: TeamMessageNotificationContext + ): void { + for (const msg of messages) { + if (msg.from === 'user') continue; + if (!this.#isApiError(msg.text)) continue; + if (this.#isRateLimit(msg.text)) continue; + + const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; + const dedupeKey = `api-error:${context.teamName}:${rawKey}`; + + if (this.#seenApiErrorKeys.has(dedupeKey)) continue; + this.#seenApiErrorKeys.add(dedupeKey); + evictOldestIfNeeded(this.#seenApiErrorKeys, SEEN_API_ERROR_KEYS_MAX); + + const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text); + const statusCode = statusMatch?.[1] ?? '???'; + + void this.#notificationSink + .addTeamNotification({ + teamEventType: 'api_error', + teamName: context.teamName, + teamDisplayName: context.teamDisplayName, + from: msg.from, + summary: `API Error ${statusCode}`, + body: 'Manual restart needed', + dedupeKey, + target: { + kind: 'member', + teamName: context.teamName, + memberName: msg.from, + focus: 'logs', + }, + projectPath: context.projectPath, + }) + .catch(() => undefined); + } + } + + scan(messages: readonly TeamNotificationMessage[], context: TeamMessageNotificationContext): void { + if (messages.length === 0) { + return; + } + + this.checkRateLimitMessages(messages, context); + this.checkApiErrorMessages(messages, context); + } +} + +export const teamMessageNotificationScanner = new TeamMessageNotificationScanner(); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index f3c445aa..27e3954c 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -2,12 +2,7 @@ import { fromProvisioningMembers, isMixedOpenCodeSideLanePlan } from '@features/ import { yieldToEventLoop } from '@main/utils/asyncYield'; import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { killProcessByPid } from '@main/utils/processKill'; -import { - AGENT_BLOCK_CLOSE, - AGENT_BLOCK_OPEN, - stripAgentBlocks, - wrapAgentBlock, -} from '@shared/constants/agentBlocks'; +import { stripAgentBlocks, wrapAgentBlock } from '@shared/constants/agentBlocks'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; @@ -2053,11 +2048,14 @@ export class TeamDataService { parts.push(`\nDetails:\n${task.description.trim()}`); } parts.push( - `\n${AGENT_BLOCK_OPEN}`, - `Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`, - `Update task status using the board MCP tools:`, - `task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, - AGENT_BLOCK_CLOSE + '', + wrapAgentBlock( + [ + `Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`, + `Update task status using the board MCP tools:`, + `task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, + ].join('\n') + ) ); await this.sendMessage(teamName, { member: task.owner, diff --git a/src/main/services/team/actionModeInstructions.ts b/src/main/services/team/actionModeInstructions.ts index 589c8ca1..9eac1db2 100644 --- a/src/main/services/team/actionModeInstructions.ts +++ b/src/main/services/team/actionModeInstructions.ts @@ -1,4 +1,4 @@ -import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; +import { wrapAgentBlock } from '@shared/constants/agentBlocks'; import * as agentTeamsControllerModule from 'agent-teams-controller'; import type { AgentActionMode } from '@shared/types'; @@ -46,7 +46,7 @@ export function buildActionModeAgentBlock(mode: AgentActionMode | undefined): st } const lines = ACTION_MODE_BLOCKS[mode]; - return `${AGENT_BLOCK_OPEN}\n${lines.join('\n')}\n${AGENT_BLOCK_CLOSE}`; + return wrapAgentBlock(lines.join('\n')); } export function isAgentActionMode(value: unknown): value is AgentActionMode { diff --git a/test/main/ipc/teams/teamMessageNotificationScanner.test.ts b/test/main/ipc/teams/teamMessageNotificationScanner.test.ts new file mode 100644 index 00000000..cc8339c9 --- /dev/null +++ b/test/main/ipc/teams/teamMessageNotificationScanner.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + TeamMessageNotificationScanner, + type TeamNotificationMessage, +} from '../../../../src/main/ipc/teams/teamMessageNotificationScanner'; + +import type { RateLimitAutoResumePlan } from '../../../../src/main/services/team/AutoResumeService'; +import type { TeamNotificationPayload } from '../../../../src/main/utils/teamNotificationBuilder'; + +function createMessage(overrides: Partial = {}): TeamNotificationMessage { + return { + from: 'team-lead', + text: "You've hit your limit. Resets in 5 minutes.", + timestamp: '2026-04-17T12:00:00.000Z', + messageId: 'msg-1', + source: 'lead_session', + leadSessionId: 'sess-live', + ...overrides, + }; +} + +describe('TeamMessageNotificationScanner', () => { + const notificationSink = { + addTeamNotification: vi.fn<() => Promise>(), + }; + const autoResumeSink = { + handleRateLimitMessage: vi.fn(), + }; + let autoResumeEnabled = true; + + beforeEach(() => { + notificationSink.addTeamNotification.mockReset(); + notificationSink.addTeamNotification.mockResolvedValue(null); + autoResumeSink.handleRateLimitMessage.mockReset(); + autoResumeEnabled = true; + }); + + function createScanner(options?: { + isRateLimit?: (text: string) => boolean; + isApiError?: (text: string) => boolean; + planAutoResume?: (input: { + enabled: boolean; + canAutoResume: boolean; + messageText: string; + observedAt: Date; + messageTimestamp?: Date; + }) => RateLimitAutoResumePlan; + }): TeamMessageNotificationScanner { + return new TeamMessageNotificationScanner({ + configReader: { + getConfig: () => ({ notifications: { autoResumeOnRateLimit: autoResumeEnabled } }), + }, + notificationSink, + autoResumeSink, + now: () => new Date('2026-04-17T12:02:00.000Z'), + formatClockTime: () => '12:05', + isRateLimit: options?.isRateLimit ?? ((text) => text.includes('limit')), + isApiError: options?.isApiError ?? ((text) => text.startsWith('API Error:')), + planAutoResume: + options?.planAutoResume ?? + ((input) => + input.enabled && input.canAutoResume + ? { + kind: 'scheduled', + resetTime: new Date('2026-04-17T12:05:00.000Z'), + delayMs: 180_000, + fireAtMs: Date.parse('2026-04-17T12:05:30.000Z'), + rawDelayMs: 180_000, + } + : { kind: 'manual', reason: 'disabled' }), + }); + } + + it('notifies and schedules auto-resume for a live lead rate-limit message', () => { + const scanner = createScanner(); + + scanner.checkRateLimitMessages([createMessage()], { + teamName: 'my-team', + teamDisplayName: 'My Team', + projectPath: '/tmp/project', + teamIsAlive: true, + currentLeadSessionId: 'sess-live', + }); + + expect(notificationSink.addTeamNotification).toHaveBeenCalledWith( + expect.objectContaining({ + teamEventType: 'rate_limit', + teamName: 'my-team', + teamDisplayName: 'My Team', + from: 'team-lead', + summary: 'Rate limit', + body: 'Auto-resume scheduled at 12:05', + dedupeKey: 'rate-limit:my-team:msg-1', + target: { kind: 'member', teamName: 'my-team', memberName: 'team-lead', focus: 'logs' }, + projectPath: '/tmp/project', + }) + ); + expect(autoResumeSink.handleRateLimitMessage).toHaveBeenCalledWith( + 'my-team', + "You've hit your limit. Resets in 5 minutes.", + new Date('2026-04-17T12:02:00.000Z'), + new Date('2026-04-17T12:00:00.000Z') + ); + }); + + it('dedupes notification storage but still re-evaluates auto-resume later', () => { + const scanner = createScanner(); + const context = { + teamName: 'my-team', + teamDisplayName: 'My Team', + teamIsAlive: true, + currentLeadSessionId: 'sess-live', + }; + + autoResumeEnabled = false; + scanner.checkRateLimitMessages([createMessage()], context); + expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1); + expect(autoResumeSink.handleRateLimitMessage).not.toHaveBeenCalled(); + + autoResumeEnabled = true; + scanner.checkRateLimitMessages([createMessage()], context); + + expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1); + expect(autoResumeSink.handleRateLimitMessage).toHaveBeenCalledTimes(1); + }); + + it('does not schedule auto-resume from an older lead session', () => { + const scanner = createScanner(); + + scanner.checkRateLimitMessages( + [createMessage({ leadSessionId: 'sess-old', messageId: 'old-session' })], + { + teamName: 'my-team', + teamDisplayName: 'My Team', + teamIsAlive: true, + currentLeadSessionId: 'sess-live', + } + ); + + expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1); + expect(autoResumeSink.handleRateLimitMessage).not.toHaveBeenCalled(); + }); + + it('sends API-error notifications while leaving rate limits to the rate-limit path', () => { + const scanner = createScanner({ + isRateLimit: (text) => text.includes('429'), + isApiError: (text) => text.startsWith('API Error:'), + }); + + scanner.checkApiErrorMessages( + [ + createMessage({ text: 'API Error: 429 rate limited', messageId: 'rate-limit-api' }), + createMessage({ text: 'API Error: 500 server failed', messageId: 'api-500' }), + ], + { + teamName: 'my-team', + teamDisplayName: 'My Team', + } + ); + + expect(notificationSink.addTeamNotification).toHaveBeenCalledTimes(1); + expect(notificationSink.addTeamNotification).toHaveBeenCalledWith( + expect.objectContaining({ + teamEventType: 'api_error', + summary: 'API Error 500', + dedupeKey: 'api-error:my-team:api-500', + }) + ); + }); +}); From 8589391ccfe508d8713f9b83d5a03ea7b43ea9ab Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 00:01:53 +0300 Subject: [PATCH 02/41] refactor(team): extract message cache layer --- src/renderer/store/slices/teamSlice.ts | 301 ++---------------- src/renderer/store/team/teamMessagesCache.ts | 291 +++++++++++++++++ test/renderer/store/teamMessagesCache.test.ts | 186 +++++++++++ 3 files changed, 503 insertions(+), 275 deletions(-) create mode 100644 src/renderer/store/team/teamMessagesCache.ts create mode 100644 test/renderer/store/teamMessagesCache.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 12537c80..4fc94234 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -11,7 +11,6 @@ import { canDisplayTaskChangesForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; -import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; @@ -30,9 +29,24 @@ import { isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; +import { + areInboxMessageArraysEquivalent, + clearTeamMessageSelectorCaches, + clearTeamMessageSelectorCachesForTeam, + extractRetainedCanonicalOlderTail, + getCanonicalHeadSlice, + getTeamMessagesCacheEntry, + getTeamMessageSelectorCacheSnapshotForTeam, + pruneOptimisticMessages, + upsertOptimisticTeamMessage, +} from '../team/teamMessagesCache'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; +import type { + RefreshTeamMessagesHeadResult, + TeamMessagesCacheEntry, +} from '../team/teamMessagesCache'; import type { AppState } from '../types'; import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; import type { AppConfig } from '@renderer/types/data'; @@ -81,6 +95,12 @@ import type { } from '@shared/types'; import type { StateCreator } from 'zustand'; +export type { + RefreshTeamMessagesHeadResult, + TeamMessagesCacheEntry, +} from '../team/teamMessagesCache'; +export { selectMemberMessagesForTeamMember, selectTeamMessages } from '../team/teamMessagesCache'; + const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true; const logger = createLogger('teamSlice'); @@ -240,13 +260,12 @@ export function __resetTeamSliceModuleStateForTests(): void { memberSpawnUiEqualLastWarnAtByTeam.clear(); resolvedMembersSelectorCache.clear(); resolvedMemberSelectorCache.clear(); - mergedMessagesSelectorCache.clear(); - memberMessagesSelectorCache.clear(); + clearTeamMessageSelectorCaches(); } function clearTeamScopedSelectorCaches(teamName: string): void { resolvedMembersSelectorCache.delete(teamName); - mergedMessagesSelectorCache.delete(teamName); + clearTeamMessageSelectorCachesForTeam(teamName); const teamScopedPrefix = `${teamName}:`; for (const key of resolvedMemberSelectorCache.keys()) { @@ -254,11 +273,6 @@ function clearTeamScopedSelectorCaches(teamName: string): void { resolvedMemberSelectorCache.delete(key); } } - for (const key of memberMessagesSelectorCache.keys()) { - if (key.startsWith(teamScopedPrefix)) { - memberMessagesSelectorCache.delete(key); - } - } } function clearTeamScopedTransientState(teamName: string): void { @@ -649,25 +663,20 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { hasMemberSpawnUiEqualLastWarn: boolean; } { const teamScopedPrefix = `${teamName}:`; + const messageSelectorCache = getTeamMessageSelectorCacheSnapshotForTeam(teamName); let resolvedMemberSelectorCount = 0; - let memberMessagesSelectorCount = 0; for (const key of resolvedMemberSelectorCache.keys()) { if (key.startsWith(teamScopedPrefix)) { resolvedMemberSelectorCount += 1; } } - for (const key of memberMessagesSelectorCache.keys()) { - if (key.startsWith(teamScopedPrefix)) { - memberMessagesSelectorCount += 1; - } - } return { hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName), resolvedMemberSelectorCount, - hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName), - memberMessagesSelectorCount, + hasMergedMessagesSelector: messageSelectorCache.hasMergedMessagesSelector, + memberMessagesSelectorCount: messageSelectorCache.memberMessagesSelectorCount, hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName), hasQueuedFullTeamDataRefreshAfterThin: queuedFullTeamDataRefreshesAfterThin.has(teamName), hasPostPaintTeamEnrichmentTimer: postPaintTeamEnrichmentTimers.has(teamName), @@ -1082,153 +1091,6 @@ function areTeamAgentRuntimeSnapshotsEqual( return true; } -function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number { - const aTime = Date.parse(a.timestamp); - const bTime = Date.parse(b.timestamp); - const aValid = Number.isFinite(aTime); - const bValid = Number.isFinite(bTime); - if (aValid && bValid && aTime !== bTime) { - return aTime - bTime; - } - if (aValid !== bValid) { - return aValid ? -1 : 1; - } - const aId = typeof a.messageId === 'string' ? a.messageId : ''; - const bId = typeof b.messageId === 'string' ? b.messageId : ''; - return aId.localeCompare(bId); -} - -export interface TeamMessagesCacheEntry { - canonicalMessages: InboxMessage[]; - optimisticMessages: InboxMessage[]; - feedRevision: string | null; - nextCursor: string | null; - hasMore: boolean; - lastFetchedAt: number | null; - loadingHead: boolean; - loadingOlder: boolean; - headHydrated: boolean; -} - -export interface RefreshTeamMessagesHeadResult { - feedChanged: boolean; - headChanged: boolean; - feedRevision: string | null; -} - -const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = { - canonicalMessages: [], - optimisticMessages: [], - feedRevision: null, - nextCursor: null, - hasMore: false, - lastFetchedAt: null, - loadingHead: false, - loadingOlder: false, - headHydrated: false, -}; - -function createEmptyTeamMessagesCacheEntry(): TeamMessagesCacheEntry { - return { - canonicalMessages: [], - optimisticMessages: [], - feedRevision: null, - nextCursor: null, - hasMore: false, - lastFetchedAt: null, - loadingHead: false, - loadingOlder: false, - headHydrated: false, - }; -} - -function getTeamMessagesCacheEntry( - state: Pick, - teamName: string -): TeamMessagesCacheEntry { - return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY; -} - -function upsertOptimisticTeamMessage( - entry: TeamMessagesCacheEntry, - message: InboxMessage -): TeamMessagesCacheEntry { - const nextOptimistic = [...entry.optimisticMessages]; - const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; - if (messageId.length > 0) { - const existingIndex = nextOptimistic.findIndex( - (candidate) => - typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId - ); - if (existingIndex >= 0) { - nextOptimistic[existingIndex] = { - ...nextOptimistic[existingIndex], - ...message, - }; - } else { - nextOptimistic.push(message); - } - } else { - nextOptimistic.push(message); - } - nextOptimistic.sort(compareInboxMessagesByTimestamp); - return { - ...entry, - optimisticMessages: nextOptimistic, - }; -} - -function areInboxMessageArraysEquivalent( - left: readonly InboxMessage[], - right: readonly InboxMessage[] -): boolean { - if (left === right) return true; - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - const leftItem = left[index]; - const rightItem = right[index]; - if ( - leftItem.messageId !== rightItem.messageId || - leftItem.timestamp !== rightItem.timestamp || - leftItem.from !== rightItem.from || - leftItem.to !== rightItem.to || - leftItem.text !== rightItem.text || - leftItem.summary !== rightItem.summary || - leftItem.read !== rightItem.read || - leftItem.actionMode !== rightItem.actionMode || - leftItem.commentId !== rightItem.commentId || - leftItem.relayOfMessageId !== rightItem.relayOfMessageId || - leftItem.source !== rightItem.source || - leftItem.leadSessionId !== rightItem.leadSessionId || - leftItem.messageKind !== rightItem.messageKind || - JSON.stringify(leftItem.taskRefs ?? null) !== JSON.stringify(rightItem.taskRefs ?? null) - ) { - return false; - } - } - return true; -} - -function pruneOptimisticMessages( - optimistic: readonly InboxMessage[], - canonical: readonly InboxMessage[] -): InboxMessage[] { - if (optimistic.length === 0) { - return []; - } - - const canonicalIds = new Set( - canonical - .map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : '')) - .filter((messageId) => messageId.length > 0) - ); - - return optimistic.filter((message) => { - const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; - return !messageId || !canonicalIds.has(messageId); - }); -} - function clearPendingReplyRefreshTimer(teamName: string): void { const existingTimer = pendingTeamPendingReplyRefreshTimers.get(teamName); if (existingTimer == null) { @@ -1266,50 +1128,6 @@ function setPendingReplyRefreshEnabled( return true; } -function getCanonicalHeadSlice( - canonicalMessages: readonly InboxMessage[], - headLength: number -): readonly InboxMessage[] { - if (headLength <= 0) { - return []; - } - return canonicalMessages.slice(0, headLength); -} - -function extractRetainedCanonicalOlderTail( - canonicalMessages: readonly InboxMessage[], - freshHeadMessages: readonly InboxMessage[] -): InboxMessage[] | null { - if (canonicalMessages.length === 0) { - return []; - } - if (freshHeadMessages.length === 0) { - return null; - } - - const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message))); - let hasMessagesOutsideFreshHead = false; - for (const message of canonicalMessages) { - if (!freshHeadKeys.has(toMessageKey(message))) { - hasMessagesOutsideFreshHead = true; - break; - } - } - if (!hasMessagesOutsideFreshHead) { - return []; - } - - const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]); - const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey); - if (anchorIndex < 0) { - return null; - } - - return canonicalMessages - .slice(anchorIndex + 1) - .filter((message) => !freshHeadKeys.has(toMessageKey(message))); -} - async function refreshTaskChangePresenceForUpdatedTask( getState: () => AppState, teamName: string, @@ -1945,23 +1763,8 @@ const resolvedMemberSelectorCache = new Map< result: ResolvedTeamMember | null; } >(); -const mergedMessagesSelectorCache = new Map< - string, - { - canonicalRef: InboxMessage[]; - optimisticRef: InboxMessage[]; - result: InboxMessage[]; - } ->(); const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamMemberSnapshot[] = []; const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = []; -const memberMessagesSelectorCache = new Map< - string, - { - messagesRef: InboxMessage[]; - result: InboxMessage[]; - } ->(); function resolveMemberStatus( snapshot: TeamMemberSnapshot, @@ -2534,58 +2337,6 @@ export function selectTeamIsAliveForName( return selectTeamDataForName(state, teamName)?.isAlive; } -export function selectTeamMessages( - state: Pick, - teamName: string | null | undefined -): InboxMessage[] { - if (!teamName) { - return []; - } - - const entry = getTeamMessagesCacheEntry(state, teamName); - const cached = mergedMessagesSelectorCache.get(teamName); - if ( - cached?.canonicalRef === entry.canonicalMessages && - cached.optimisticRef === entry.optimisticMessages - ) { - return cached.result; - } - - const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages); - mergedMessagesSelectorCache.set(teamName, { - canonicalRef: entry.canonicalMessages, - optimisticRef: entry.optimisticMessages, - result, - }); - return result; -} - -export function selectMemberMessagesForTeamMember( - state: Pick, - teamName: string | null | undefined, - memberName: string | null | undefined -): InboxMessage[] { - if (!teamName || !memberName) { - return []; - } - - const messages = selectTeamMessages(state, teamName); - const cacheKey = `${teamName}:${memberName}`; - const cached = memberMessagesSelectorCache.get(cacheKey); - if (cached?.messagesRef === messages) { - return cached.result; - } - - const result = messages.filter( - (message) => message.from === memberName || message.to === memberName - ); - memberMessagesSelectorCache.set(cacheKey, { - messagesRef: messages, - result, - }); - return result; -} - function isMemberActivityMetaStale( state: Pick, teamName: string diff --git a/src/renderer/store/team/teamMessagesCache.ts b/src/renderer/store/team/teamMessagesCache.ts new file mode 100644 index 00000000..b5900d82 --- /dev/null +++ b/src/renderer/store/team/teamMessagesCache.ts @@ -0,0 +1,291 @@ +import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; + +import type { InboxMessage } from '@shared/types'; + +export interface TeamMessagesCacheEntry { + canonicalMessages: InboxMessage[]; + optimisticMessages: InboxMessage[]; + feedRevision: string | null; + nextCursor: string | null; + hasMore: boolean; + lastFetchedAt: number | null; + loadingHead: boolean; + loadingOlder: boolean; + headHydrated: boolean; +} + +export interface RefreshTeamMessagesHeadResult { + feedChanged: boolean; + headChanged: boolean; + feedRevision: string | null; +} + +export interface TeamMessagesCacheState { + teamMessagesByName: Record; +} + +export interface TeamMessageSelectorCacheSnapshot { + hasMergedMessagesSelector: boolean; + memberMessagesSelectorCount: number; +} + +export const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: null, + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: false, +}; + +const mergedMessagesSelectorCache = new Map< + string, + { + canonicalRef: readonly InboxMessage[]; + optimisticRef: readonly InboxMessage[]; + result: InboxMessage[]; + } +>(); +const memberMessagesSelectorCache = new Map< + string, + { + messagesRef: readonly InboxMessage[]; + result: InboxMessage[]; + } +>(); + +export function clearTeamMessageSelectorCaches(): void { + mergedMessagesSelectorCache.clear(); + memberMessagesSelectorCache.clear(); +} + +export function clearTeamMessageSelectorCachesForTeam(teamName: string): void { + mergedMessagesSelectorCache.delete(teamName); + + const teamScopedPrefix = `${teamName}:`; + for (const key of memberMessagesSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + memberMessagesSelectorCache.delete(key); + } + } +} + +export function getTeamMessageSelectorCacheSnapshotForTeam( + teamName: string +): TeamMessageSelectorCacheSnapshot { + const teamScopedPrefix = `${teamName}:`; + let memberMessagesSelectorCount = 0; + for (const key of memberMessagesSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + memberMessagesSelectorCount += 1; + } + } + + return { + hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName), + memberMessagesSelectorCount, + }; +} + +export function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number { + const aTime = Date.parse(a.timestamp); + const bTime = Date.parse(b.timestamp); + const aValid = Number.isFinite(aTime); + const bValid = Number.isFinite(bTime); + if (aValid && bValid && aTime !== bTime) { + return aTime - bTime; + } + if (aValid !== bValid) { + return aValid ? -1 : 1; + } + const aId = typeof a.messageId === 'string' ? a.messageId : ''; + const bId = typeof b.messageId === 'string' ? b.messageId : ''; + return aId.localeCompare(bId); +} + +export function getTeamMessagesCacheEntry( + state: TeamMessagesCacheState, + teamName: string +): TeamMessagesCacheEntry { + return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY; +} + +export function upsertOptimisticTeamMessage( + entry: TeamMessagesCacheEntry, + message: InboxMessage +): TeamMessagesCacheEntry { + const nextOptimistic = [...entry.optimisticMessages]; + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId.length > 0) { + const existingIndex = nextOptimistic.findIndex( + (candidate) => + typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId + ); + if (existingIndex >= 0) { + nextOptimistic[existingIndex] = { + ...nextOptimistic[existingIndex], + ...message, + }; + } else { + nextOptimistic.push(message); + } + } else { + nextOptimistic.push(message); + } + nextOptimistic.sort(compareInboxMessagesByTimestamp); + return { + ...entry, + optimisticMessages: nextOptimistic, + }; +} + +export function areInboxMessageArraysEquivalent( + left: readonly InboxMessage[], + right: readonly InboxMessage[] +): boolean { + if (left === right) return true; + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftItem = left[index]; + const rightItem = right[index]; + if ( + leftItem.messageId !== rightItem.messageId || + leftItem.timestamp !== rightItem.timestamp || + leftItem.from !== rightItem.from || + leftItem.to !== rightItem.to || + leftItem.text !== rightItem.text || + leftItem.summary !== rightItem.summary || + leftItem.read !== rightItem.read || + leftItem.actionMode !== rightItem.actionMode || + leftItem.commentId !== rightItem.commentId || + leftItem.relayOfMessageId !== rightItem.relayOfMessageId || + leftItem.source !== rightItem.source || + leftItem.leadSessionId !== rightItem.leadSessionId || + leftItem.messageKind !== rightItem.messageKind || + JSON.stringify(leftItem.taskRefs ?? null) !== JSON.stringify(rightItem.taskRefs ?? null) + ) { + return false; + } + } + return true; +} + +export function pruneOptimisticMessages( + optimistic: readonly InboxMessage[], + canonical: readonly InboxMessage[] +): InboxMessage[] { + if (optimistic.length === 0) { + return []; + } + + const canonicalIds = new Set( + canonical + .map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : '')) + .filter((messageId) => messageId.length > 0) + ); + + return optimistic.filter((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + return !messageId || !canonicalIds.has(messageId); + }); +} + +export function getCanonicalHeadSlice( + canonicalMessages: readonly InboxMessage[], + headLength: number +): readonly InboxMessage[] { + if (headLength <= 0) { + return []; + } + return canonicalMessages.slice(0, headLength); +} + +export function extractRetainedCanonicalOlderTail( + canonicalMessages: readonly InboxMessage[], + freshHeadMessages: readonly InboxMessage[] +): InboxMessage[] | null { + if (canonicalMessages.length === 0) { + return []; + } + if (freshHeadMessages.length === 0) { + return null; + } + + const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message))); + let hasMessagesOutsideFreshHead = false; + for (const message of canonicalMessages) { + if (!freshHeadKeys.has(toMessageKey(message))) { + hasMessagesOutsideFreshHead = true; + break; + } + } + if (!hasMessagesOutsideFreshHead) { + return []; + } + + const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]); + const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey); + if (anchorIndex < 0) { + return null; + } + + return canonicalMessages + .slice(anchorIndex + 1) + .filter((message) => !freshHeadKeys.has(toMessageKey(message))); +} + +export function selectTeamMessages( + state: TeamMessagesCacheState, + teamName: string | null | undefined +): InboxMessage[] { + if (!teamName) { + return []; + } + + const entry = getTeamMessagesCacheEntry(state, teamName); + const cached = mergedMessagesSelectorCache.get(teamName); + if ( + cached?.canonicalRef === entry.canonicalMessages && + cached.optimisticRef === entry.optimisticMessages + ) { + return cached.result; + } + + const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages); + mergedMessagesSelectorCache.set(teamName, { + canonicalRef: entry.canonicalMessages, + optimisticRef: entry.optimisticMessages, + result, + }); + return result; +} + +export function selectMemberMessagesForTeamMember( + state: TeamMessagesCacheState, + teamName: string | null | undefined, + memberName: string | null | undefined +): InboxMessage[] { + if (!teamName || !memberName) { + return []; + } + + const messages = selectTeamMessages(state, teamName); + const cacheKey = `${teamName}:${memberName}`; + const cached = memberMessagesSelectorCache.get(cacheKey); + if (cached?.messagesRef === messages) { + return cached.result; + } + + const result = messages.filter( + (message) => message.from === memberName || message.to === memberName + ); + memberMessagesSelectorCache.set(cacheKey, { + messagesRef: messages, + result, + }); + return result; +} diff --git a/test/renderer/store/teamMessagesCache.test.ts b/test/renderer/store/teamMessagesCache.test.ts new file mode 100644 index 00000000..74f11d82 --- /dev/null +++ b/test/renderer/store/teamMessagesCache.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + areInboxMessageArraysEquivalent, + clearTeamMessageSelectorCaches, + clearTeamMessageSelectorCachesForTeam, + EMPTY_TEAM_MESSAGES_CACHE_ENTRY, + extractRetainedCanonicalOlderTail, + getCanonicalHeadSlice, + getTeamMessagesCacheEntry, + getTeamMessageSelectorCacheSnapshotForTeam, + pruneOptimisticMessages, + selectMemberMessagesForTeamMember, + selectTeamMessages, + type TeamMessagesCacheEntry, + type TeamMessagesCacheState, + upsertOptimisticTeamMessage, +} from '../../../src/renderer/store/team/teamMessagesCache'; + +import type { InboxMessage } from '../../../src/shared/types'; + +afterEach(() => { + clearTeamMessageSelectorCaches(); +}); + +function createMessage(overrides: Partial & { messageId: string }): InboxMessage { + return { + from: 'lead', + to: 'alice', + text: overrides.messageId, + timestamp: '2026-03-12T10:00:00.000Z', + read: false, + ...overrides, + }; +} + +function createEntry(overrides: Partial = {}): TeamMessagesCacheEntry { + return { + ...EMPTY_TEAM_MESSAGES_CACHE_ENTRY, + ...overrides, + }; +} + +describe('teamMessagesCache', () => { + it('returns the immutable empty entry when a team has no cached messages', () => { + const state: TeamMessagesCacheState = { teamMessagesByName: {} }; + + expect(getTeamMessagesCacheEntry(state, 'missing-team')).toBe(EMPTY_TEAM_MESSAGES_CACHE_ENTRY); + }); + + it('upserts optimistic messages by durable id and keeps deterministic timestamp order', () => { + const first = upsertOptimisticTeamMessage( + createEntry(), + createMessage({ + messageId: 'msg-new', + timestamp: '2026-03-12T10:00:03.000Z', + text: 'draft', + }) + ); + const second = upsertOptimisticTeamMessage( + first, + createMessage({ + messageId: 'msg-old', + timestamp: '2026-03-12T10:00:01.000Z', + }) + ); + const replaced = upsertOptimisticTeamMessage( + second, + createMessage({ + messageId: 'msg-new', + timestamp: '2026-03-12T10:00:03.000Z', + text: 'sent', + }) + ); + + expect(replaced.optimisticMessages.map((message) => message.messageId)).toEqual([ + 'msg-old', + 'msg-new', + ]); + expect(replaced.optimisticMessages[1].text).toBe('sent'); + }); + + it('compares semantic message arrays and prunes optimistic rows confirmed by canonical data', () => { + const canonical = [ + createMessage({ messageId: 'msg-1', text: 'confirmed' }), + createMessage({ messageId: 'msg-2' }), + ]; + const equivalentCanonical = [ + createMessage({ messageId: 'msg-1', text: 'confirmed' }), + createMessage({ messageId: 'msg-2' }), + ]; + const optimistic = [ + createMessage({ messageId: 'msg-1', text: 'draft that arrived' }), + createMessage({ messageId: 'msg-local', text: 'still local' }), + ]; + + expect(areInboxMessageArraysEquivalent(canonical, equivalentCanonical)).toBe(true); + expect( + areInboxMessageArraysEquivalent(canonical, [ + createMessage({ messageId: 'msg-1', text: 'changed' }), + createMessage({ messageId: 'msg-2' }), + ]) + ).toBe(false); + expect(pruneOptimisticMessages(optimistic, canonical).map((message) => message.messageId)).toEqual( + ['msg-local'] + ); + }); + + it('retains already-loaded older tail only when the fresh head anchors into canonical data', () => { + const canonical = [ + createMessage({ messageId: 'msg-4', timestamp: '2026-03-12T10:00:04.000Z' }), + createMessage({ messageId: 'msg-3', timestamp: '2026-03-12T10:00:03.000Z' }), + createMessage({ messageId: 'msg-2', timestamp: '2026-03-12T10:00:02.000Z' }), + createMessage({ messageId: 'msg-1', timestamp: '2026-03-12T10:00:01.000Z' }), + ]; + const freshHead = [ + createMessage({ messageId: 'msg-5', timestamp: '2026-03-12T10:00:05.000Z' }), + createMessage({ messageId: 'msg-3', timestamp: '2026-03-12T10:00:03.000Z' }), + ]; + + expect(getCanonicalHeadSlice(canonical, 2).map((message) => message.messageId)).toEqual([ + 'msg-4', + 'msg-3', + ]); + expect( + extractRetainedCanonicalOlderTail(canonical, freshHead)?.map((message) => message.messageId) + ).toEqual(['msg-2', 'msg-1']); + expect( + extractRetainedCanonicalOlderTail(canonical, [createMessage({ messageId: 'disjoint' })]) + ).toBeNull(); + }); + + it('memoizes merged and member-scoped selectors and clears team-scoped caches', () => { + const state: TeamMessagesCacheState = { + teamMessagesByName: { + 'my-team': createEntry({ + canonicalMessages: [ + createMessage({ + messageId: 'msg-1', + to: 'alice', + timestamp: '2026-03-12T10:00:01.000Z', + }), + createMessage({ + messageId: 'msg-2', + to: 'bob', + timestamp: '2026-03-12T10:00:02.000Z', + }), + ], + optimisticMessages: [ + createMessage({ + messageId: 'msg-3', + from: 'alice', + to: 'lead', + timestamp: '2026-03-12T10:00:03.000Z', + }), + ], + }), + }, + }; + + const firstTeamMessages = selectTeamMessages(state, 'my-team'); + const secondTeamMessages = selectTeamMessages(state, 'my-team'); + const firstAliceMessages = selectMemberMessagesForTeamMember(state, 'my-team', 'alice'); + const secondAliceMessages = selectMemberMessagesForTeamMember(state, 'my-team', 'alice'); + + expect(firstTeamMessages).toBe(secondTeamMessages); + expect(firstAliceMessages).toBe(secondAliceMessages); + expect(firstTeamMessages.map((message) => message.messageId)).toEqual([ + 'msg-3', + 'msg-2', + 'msg-1', + ]); + expect(firstAliceMessages.map((message) => message.messageId)).toEqual(['msg-3', 'msg-1']); + expect(getTeamMessageSelectorCacheSnapshotForTeam('my-team')).toEqual({ + hasMergedMessagesSelector: true, + memberMessagesSelectorCount: 1, + }); + + clearTeamMessageSelectorCachesForTeam('my-team'); + + expect(getTeamMessageSelectorCacheSnapshotForTeam('my-team')).toEqual({ + hasMergedMessagesSelector: false, + memberMessagesSelectorCount: 0, + }); + }); +}); From 1d2f61ad86183b38d78174591ea4e10fc9416b7d Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 00:06:28 +0300 Subject: [PATCH 03/41] refactor(team): extract team data selectors --- src/renderer/store/slices/teamSlice.ts | 52 ++-------- src/renderer/store/team/teamDataSelectors.ts | 47 +++++++++ test/renderer/store/teamDataSelectors.test.ts | 95 +++++++++++++++++++ 3 files changed, 149 insertions(+), 45 deletions(-) create mode 100644 src/renderer/store/team/teamDataSelectors.ts create mode 100644 test/renderer/store/teamDataSelectors.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 4fc94234..58151dee 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -29,6 +29,7 @@ import { isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; +import { selectTeamDataForName } from '../team/teamDataSelectors'; import { areInboxMessageArraysEquivalent, clearTeamMessageSelectorCaches, @@ -95,6 +96,12 @@ import type { } from '@shared/types'; import type { StateCreator } from 'zustand'; +export { + selectTeamDataForName, + selectTeamIsAliveForName, + selectTeamMemberSnapshotsForName, + selectTeamTasksForName, +} from '../team/teamDataSelectors'; export type { RefreshTeamMessagesHeadResult, TeamMessagesCacheEntry, @@ -1763,9 +1770,6 @@ const resolvedMemberSelectorCache = new Map< result: ResolvedTeamMember | null; } >(); -const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamMemberSnapshot[] = []; -const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = []; - function resolveMemberStatus( snapshot: TeamMemberSnapshot, activity: MemberActivityMetaEntry | undefined @@ -2187,27 +2191,6 @@ function structurallyShareMemberActivityFacts( return changed ? shared : previous; } -type TeamDataSelectorState = Pick< - TeamSlice, - 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' ->; - -export function selectTeamDataForName( - state: TeamDataSelectorState, - teamName: string | null | undefined -): TeamViewSnapshot | null { - if (!teamName) { - return null; - } - if (state.selectedTeamName === teamName && state.selectedTeamData) { - return state.selectedTeamData; - } - return ( - state.teamDataCacheByName[teamName] ?? - (state.selectedTeamName === teamName ? state.selectedTeamData : null) - ); -} - type ResolvedMemberSelectorState = Pick< TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' @@ -2316,27 +2299,6 @@ export function selectResolvedMemberForTeamName( return result; } -export function selectTeamMemberSnapshotsForName( - state: TeamDataSelectorState, - teamName: string | null | undefined -): TeamViewSnapshot['members'] { - return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS; -} - -export function selectTeamTasksForName( - state: TeamDataSelectorState, - teamName: string | null | undefined -): TeamViewSnapshot['tasks'] { - return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS; -} - -export function selectTeamIsAliveForName( - state: TeamDataSelectorState, - teamName: string | null | undefined -): boolean | undefined { - return selectTeamDataForName(state, teamName)?.isAlive; -} - function isMemberActivityMetaStale( state: Pick, teamName: string diff --git a/src/renderer/store/team/teamDataSelectors.ts b/src/renderer/store/team/teamDataSelectors.ts new file mode 100644 index 00000000..aaa23433 --- /dev/null +++ b/src/renderer/store/team/teamDataSelectors.ts @@ -0,0 +1,47 @@ +import type { TeamViewSnapshot } from '@shared/types'; + +export interface TeamDataSelectorState { + teamDataCacheByName: Record; + selectedTeamName: string | null; + selectedTeamData: TeamViewSnapshot | null; +} + +const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamViewSnapshot['members'] = []; +const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = []; + +export function selectTeamDataForName( + state: TeamDataSelectorState, + teamName: string | null | undefined +): TeamViewSnapshot | null { + if (!teamName) { + return null; + } + if (state.selectedTeamName === teamName && state.selectedTeamData) { + return state.selectedTeamData; + } + return ( + state.teamDataCacheByName[teamName] ?? + (state.selectedTeamName === teamName ? state.selectedTeamData : null) + ); +} + +export function selectTeamMemberSnapshotsForName( + state: TeamDataSelectorState, + teamName: string | null | undefined +): TeamViewSnapshot['members'] { + return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS; +} + +export function selectTeamTasksForName( + state: TeamDataSelectorState, + teamName: string | null | undefined +): TeamViewSnapshot['tasks'] { + return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS; +} + +export function selectTeamIsAliveForName( + state: TeamDataSelectorState, + teamName: string | null | undefined +): boolean | undefined { + return selectTeamDataForName(state, teamName)?.isAlive; +} diff --git a/test/renderer/store/teamDataSelectors.test.ts b/test/renderer/store/teamDataSelectors.test.ts new file mode 100644 index 00000000..049081cf --- /dev/null +++ b/test/renderer/store/teamDataSelectors.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { + selectTeamDataForName, + selectTeamIsAliveForName, + selectTeamMemberSnapshotsForName, + selectTeamTasksForName, + type TeamDataSelectorState, +} from '../../../src/renderer/store/team/teamDataSelectors'; + +import type { TeamViewSnapshot } from '../../../src/shared/types'; + +function createSnapshot(overrides: Partial = {}): TeamViewSnapshot { + return { + teamName: 'my-team', + config: { name: 'My Team' }, + members: [], + tasks: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + ...overrides, + }; +} + +function createState(overrides: Partial = {}): TeamDataSelectorState { + return { + teamDataCacheByName: {}, + selectedTeamName: null, + selectedTeamData: null, + ...overrides, + }; +} + +describe('teamDataSelectors', () => { + it('returns null when no team name is selected or cached', () => { + const state = createState(); + + expect(selectTeamDataForName(state, null)).toBeNull(); + expect(selectTeamDataForName(state, undefined)).toBeNull(); + expect(selectTeamDataForName(state, 'missing-team')).toBeNull(); + }); + + it('prefers selected team data over cached data for the active team', () => { + const cachedSnapshot = createSnapshot({ teamName: 'my-team', isAlive: false }); + const selectedSnapshot = createSnapshot({ teamName: 'my-team', isAlive: true }); + const state = createState({ + selectedTeamName: 'my-team', + selectedTeamData: selectedSnapshot, + teamDataCacheByName: { + 'my-team': cachedSnapshot, + }, + }); + + expect(selectTeamDataForName(state, 'my-team')).toBe(selectedSnapshot); + }); + + it('falls back to cached team data outside the selected snapshot', () => { + const cachedSnapshot = createSnapshot({ teamName: 'cached-team', isAlive: true }); + const state = createState({ + selectedTeamName: 'other-team', + selectedTeamData: createSnapshot({ teamName: 'other-team' }), + teamDataCacheByName: { + 'cached-team': cachedSnapshot, + }, + }); + + expect(selectTeamDataForName(state, 'cached-team')).toBe(cachedSnapshot); + }); + + it('returns stable empty arrays and scalar fields from team snapshots', () => { + const task = { id: 'task-1', subject: 'Build', status: 'pending' as const }; + const member = { name: 'alice', role: 'developer', currentTaskId: null, taskCount: 0 }; + const state = createState({ + teamDataCacheByName: { + 'my-team': createSnapshot({ + members: [member], + tasks: [task], + isAlive: true, + }), + }, + }); + + expect(selectTeamMemberSnapshotsForName(state, 'my-team')).toEqual([member]); + expect(selectTeamTasksForName(state, 'my-team')).toEqual([task]); + expect(selectTeamIsAliveForName(state, 'my-team')).toBe(true); + + expect(selectTeamMemberSnapshotsForName(state, 'missing-team')).toBe( + selectTeamMemberSnapshotsForName(state, 'missing-team') + ); + expect(selectTeamTasksForName(state, 'missing-team')).toBe( + selectTeamTasksForName(state, 'missing-team') + ); + expect(selectTeamIsAliveForName(state, 'missing-team')).toBeUndefined(); + }); +}); From 69f7a21771d1750e71d2abb76a11c37d11661080 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 09:42:02 +0300 Subject: [PATCH 04/41] refactor(team): extract data request keys --- src/renderer/store/slices/teamSlice.ts | 43 +++----------- .../store/team/teamDataRequestKeys.ts | 39 +++++++++++++ .../store/teamDataRequestKeys.test.ts | 58 +++++++++++++++++++ 3 files changed, 106 insertions(+), 34 deletions(-) create mode 100644 src/renderer/store/team/teamDataRequestKeys.ts create mode 100644 test/renderer/store/teamDataRequestKeys.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 58151dee..86e2c864 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -29,6 +29,14 @@ import { isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; +import { + getFullTeamDataRequestKey, + getTeamDataRequestKey, + getTeamDataRequestLabel, + getThinTeamDataRequestKey, + isTeamDataRequestKeyForTeam, + normalizeTeamGetDataOptions, +} from '../team/teamDataRequestKeys'; import { selectTeamDataForName } from '../team/teamDataSelectors'; import { areInboxMessageArraysEquivalent, @@ -155,38 +163,6 @@ interface RefreshTeamDataOptions { withDedup?: boolean; } -type TeamDataSnapshotMode = 'full' | 'thin'; - -function normalizeTeamGetDataOptions(options?: TeamGetDataOptions): TeamGetDataOptions | undefined { - return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined; -} - -function shouldIncludeMemberBranches(options?: TeamGetDataOptions): boolean { - return normalizeTeamGetDataOptions(options)?.includeMemberBranches !== false; -} - -function getTeamDataSnapshotMode(options?: TeamGetDataOptions): TeamDataSnapshotMode { - return shouldIncludeMemberBranches(options) ? 'full' : 'thin'; -} - -function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string { - const normalizedOptions = normalizeTeamGetDataOptions(options); - return `${teamName}\u0000mode:${getTeamDataSnapshotMode(normalizedOptions)}`; -} - -function getTeamDataRequestLabel(teamName: string, options?: TeamGetDataOptions): string { - const normalizedOptions = normalizeTeamGetDataOptions(options); - return `team:getData(${teamName},mode=${getTeamDataSnapshotMode(normalizedOptions)})`; -} - -function getFullTeamDataRequestKey(teamName: string): string { - return getTeamDataRequestKey(teamName); -} - -function getThinTeamDataRequestKey(teamName: string): string { - return getTeamDataRequestKey(teamName, { includeMemberBranches: false }); -} - function hasFullTeamDataRequestForTeam(teamName: string): boolean { return inFlightTeamDataRequests.has(getFullTeamDataRequestKey(teamName)); } @@ -196,9 +172,8 @@ function hasThinTeamDataRequestForTeam(teamName: string): boolean { } function clearTeamDataRequestsForTeam(teamName: string): void { - const prefix = `${teamName}\u0000`; for (const key of inFlightTeamDataRequests.keys()) { - if (key.startsWith(prefix)) { + if (isTeamDataRequestKeyForTeam(key, teamName)) { inFlightTeamDataRequests.delete(key); } } diff --git a/src/renderer/store/team/teamDataRequestKeys.ts b/src/renderer/store/team/teamDataRequestKeys.ts new file mode 100644 index 00000000..32e87773 --- /dev/null +++ b/src/renderer/store/team/teamDataRequestKeys.ts @@ -0,0 +1,39 @@ +import type { TeamGetDataOptions } from '@shared/types'; + +export type TeamDataSnapshotMode = 'full' | 'thin'; + +export function normalizeTeamGetDataOptions( + options?: TeamGetDataOptions +): TeamGetDataOptions | undefined { + return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined; +} + +export function shouldIncludeMemberBranches(options?: TeamGetDataOptions): boolean { + return normalizeTeamGetDataOptions(options)?.includeMemberBranches !== false; +} + +export function getTeamDataSnapshotMode(options?: TeamGetDataOptions): TeamDataSnapshotMode { + return shouldIncludeMemberBranches(options) ? 'full' : 'thin'; +} + +export function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string { + const normalizedOptions = normalizeTeamGetDataOptions(options); + return `${teamName}\u0000mode:${getTeamDataSnapshotMode(normalizedOptions)}`; +} + +export function getTeamDataRequestLabel(teamName: string, options?: TeamGetDataOptions): string { + const normalizedOptions = normalizeTeamGetDataOptions(options); + return `team:getData(${teamName},mode=${getTeamDataSnapshotMode(normalizedOptions)})`; +} + +export function getFullTeamDataRequestKey(teamName: string): string { + return getTeamDataRequestKey(teamName); +} + +export function getThinTeamDataRequestKey(teamName: string): string { + return getTeamDataRequestKey(teamName, { includeMemberBranches: false }); +} + +export function isTeamDataRequestKeyForTeam(requestKey: string, teamName: string): boolean { + return requestKey.startsWith(`${teamName}\u0000`); +} diff --git a/test/renderer/store/teamDataRequestKeys.test.ts b/test/renderer/store/teamDataRequestKeys.test.ts new file mode 100644 index 00000000..9409c541 --- /dev/null +++ b/test/renderer/store/teamDataRequestKeys.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { + getFullTeamDataRequestKey, + getTeamDataRequestKey, + getTeamDataRequestLabel, + getTeamDataSnapshotMode, + getThinTeamDataRequestKey, + isTeamDataRequestKeyForTeam, + normalizeTeamGetDataOptions, + shouldIncludeMemberBranches, +} from '../../../src/renderer/store/team/teamDataRequestKeys'; + +describe('teamDataRequestKeys', () => { + it('normalizes only the thin snapshot option and treats all other inputs as full snapshots', () => { + expect(normalizeTeamGetDataOptions()).toBeUndefined(); + expect(normalizeTeamGetDataOptions({})).toBeUndefined(); + expect(normalizeTeamGetDataOptions({ includeMemberBranches: true })).toBeUndefined(); + expect(normalizeTeamGetDataOptions({ includeMemberBranches: false })).toEqual({ + includeMemberBranches: false, + }); + + expect(shouldIncludeMemberBranches()).toBe(true); + expect(shouldIncludeMemberBranches({ includeMemberBranches: true })).toBe(true); + expect(shouldIncludeMemberBranches({ includeMemberBranches: false })).toBe(false); + }); + + it('maps normalized request options to stable full and thin snapshot modes', () => { + expect(getTeamDataSnapshotMode()).toBe('full'); + expect(getTeamDataSnapshotMode({ includeMemberBranches: true })).toBe('full'); + expect(getTeamDataSnapshotMode({ includeMemberBranches: false })).toBe('thin'); + }); + + it('builds request keys that preserve the existing null-separated team/mode contract', () => { + expect(getTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:full'); + expect(getTeamDataRequestKey('my-team', { includeMemberBranches: true })).toBe( + 'my-team\u0000mode:full' + ); + expect(getTeamDataRequestKey('my-team', { includeMemberBranches: false })).toBe( + 'my-team\u0000mode:thin' + ); + expect(getFullTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:full'); + expect(getThinTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:thin'); + }); + + it('builds timeout/debug labels from the same normalized mode policy', () => { + expect(getTeamDataRequestLabel('my-team')).toBe('team:getData(my-team,mode=full)'); + expect(getTeamDataRequestLabel('my-team', { includeMemberBranches: false })).toBe( + 'team:getData(my-team,mode=thin)' + ); + }); + + it('matches request keys only for the exact team prefix boundary', () => { + expect(isTeamDataRequestKeyForTeam('my-team\u0000mode:full', 'my-team')).toBe(true); + expect(isTeamDataRequestKeyForTeam('my-team-extra\u0000mode:full', 'my-team')).toBe(false); + expect(isTeamDataRequestKeyForTeam('my-team', 'my-team')).toBe(false); + }); +}); From 4a561f2cd2005cffd9069d9d0b4dba3461bb917b Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 09:50:06 +0300 Subject: [PATCH 05/41] refactor(team): extract pending reply waits --- src/renderer/store/slices/teamSlice.ts | 52 +++------------ .../store/team/teamPendingReplyWaits.ts | 45 +++++++++++++ .../store/teamPendingReplyWaits.test.ts | 65 +++++++++++++++++++ 3 files changed, 120 insertions(+), 42 deletions(-) create mode 100644 src/renderer/store/team/teamPendingReplyWaits.ts create mode 100644 test/renderer/store/teamPendingReplyWaits.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 86e2c864..c1f17995 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -49,6 +49,11 @@ import { pruneOptimisticMessages, upsertOptimisticTeamMessage, } from '../team/teamMessagesCache'; +import { + clearAllPendingReplyRefreshWaits, + clearPendingReplyRefreshWaits, + setPendingReplyRefreshEnabled, +} from '../team/teamPendingReplyWaits'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; @@ -115,6 +120,10 @@ export type { TeamMessagesCacheEntry, } from '../team/teamMessagesCache'; export { selectMemberMessagesForTeamMember, selectTeamMessages } from '../team/teamMessagesCache'; +export { + getActiveTeamPendingReplyWaits, + hasActiveTeamPendingReplyWait, +} from '../team/teamPendingReplyWaits'; const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true; @@ -148,7 +157,6 @@ const pendingFreshTeamMessagesHeadRefreshes = new Set(); const inFlightTeamMemberActivityMetaRequests = new Map>(); const pendingFreshTeamMemberActivityMetaRefreshes = new Set(); const pendingTeamPendingReplyRefreshTimers = new Map>(); -const activeTeamPendingReplyWaitSourceIdsByTeam = new Map>(); const lastResolvedTeamDataRefreshAtByTeam = new Map(); const teamLocalStateEpochByTeam = new Map(); let inFlightGlobalTasksRefresh: Promise | null = null; @@ -203,18 +211,6 @@ export function getLastResolvedTeamDataRefreshAt(teamName: string): number | und return lastResolvedTeamDataRefreshAtByTeam.get(teamName); } -export function hasActiveTeamPendingReplyWait(teamName: string): boolean { - return (activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName)?.size ?? 0) > 0; -} - -export function getActiveTeamPendingReplyWaits(): Set { - return new Set( - Array.from(activeTeamPendingReplyWaitSourceIdsByTeam.entries()) - .filter(([, sourceIds]) => sourceIds.size > 0) - .map(([teamName]) => teamName) - ); -} - export function __resetTeamSliceModuleStateForTests(): void { inFlightTeamDataRequests.clear(); inFlightRefreshTeamDataCalls.clear(); @@ -234,7 +230,7 @@ export function __resetTeamSliceModuleStateForTests(): void { clearTimeout(timer); } pendingTeamPendingReplyRefreshTimers.clear(); - activeTeamPendingReplyWaitSourceIdsByTeam.clear(); + clearAllPendingReplyRefreshWaits(); lastResolvedTeamDataRefreshAtByTeam.clear(); teamLocalStateEpochByTeam.clear(); memberSpawnStatusesIpcBackoffUntilByTeam.clear(); @@ -1082,34 +1078,6 @@ function clearPendingReplyRefreshTimer(teamName: string): void { pendingTeamPendingReplyRefreshTimers.delete(teamName); } -function clearPendingReplyRefreshWaits(teamName: string): void { - activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName); -} - -function setPendingReplyRefreshEnabled( - teamName: string, - sourceId: string, - enabled: boolean -): boolean { - if (enabled) { - const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName) ?? new Set(); - existing.add(sourceId); - activeTeamPendingReplyWaitSourceIdsByTeam.set(teamName, existing); - return true; - } - - const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName); - if (!existing) { - return false; - } - existing.delete(sourceId); - if (existing.size === 0) { - activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName); - return false; - } - return true; -} - async function refreshTaskChangePresenceForUpdatedTask( getState: () => AppState, teamName: string, diff --git a/src/renderer/store/team/teamPendingReplyWaits.ts b/src/renderer/store/team/teamPendingReplyWaits.ts new file mode 100644 index 00000000..e4c11b65 --- /dev/null +++ b/src/renderer/store/team/teamPendingReplyWaits.ts @@ -0,0 +1,45 @@ +const activeTeamPendingReplyWaitSourceIdsByTeam = new Map>(); + +export function hasActiveTeamPendingReplyWait(teamName: string): boolean { + return (activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName)?.size ?? 0) > 0; +} + +export function getActiveTeamPendingReplyWaits(): Set { + return new Set( + Array.from(activeTeamPendingReplyWaitSourceIdsByTeam.entries()) + .filter(([, sourceIds]) => sourceIds.size > 0) + .map(([teamName]) => teamName) + ); +} + +export function clearAllPendingReplyRefreshWaits(): void { + activeTeamPendingReplyWaitSourceIdsByTeam.clear(); +} + +export function clearPendingReplyRefreshWaits(teamName: string): void { + activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName); +} + +export function setPendingReplyRefreshEnabled( + teamName: string, + sourceId: string, + enabled: boolean +): boolean { + if (enabled) { + const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName) ?? new Set(); + existing.add(sourceId); + activeTeamPendingReplyWaitSourceIdsByTeam.set(teamName, existing); + return true; + } + + const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName); + if (!existing) { + return false; + } + existing.delete(sourceId); + if (existing.size === 0) { + activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName); + return false; + } + return true; +} diff --git a/test/renderer/store/teamPendingReplyWaits.test.ts b/test/renderer/store/teamPendingReplyWaits.test.ts new file mode 100644 index 00000000..45a7b1aa --- /dev/null +++ b/test/renderer/store/teamPendingReplyWaits.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + clearAllPendingReplyRefreshWaits, + clearPendingReplyRefreshWaits, + getActiveTeamPendingReplyWaits, + hasActiveTeamPendingReplyWait, + setPendingReplyRefreshEnabled, +} from '../../../src/renderer/store/team/teamPendingReplyWaits'; + +afterEach(() => { + clearAllPendingReplyRefreshWaits(); +}); + +describe('teamPendingReplyWaits', () => { + it('tracks active teams with at least one enabled source', () => { + expect(setPendingReplyRefreshEnabled('my-team', 'tab-a', true)).toBe(true); + expect(setPendingReplyRefreshEnabled('other-team', 'tab-b', true)).toBe(true); + + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(true); + expect(hasActiveTeamPendingReplyWait('other-team')).toBe(true); + expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['my-team', 'other-team'])); + }); + + it('keeps a team active until the last source is disabled', () => { + setPendingReplyRefreshEnabled('my-team', 'tab-a', true); + setPendingReplyRefreshEnabled('my-team', 'tab-b', true); + + expect(setPendingReplyRefreshEnabled('my-team', 'tab-b', false)).toBe(true); + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(true); + expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['my-team'])); + + expect(setPendingReplyRefreshEnabled('my-team', 'tab-a', false)).toBe(false); + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false); + expect(getActiveTeamPendingReplyWaits().size).toBe(0); + }); + + it('is idempotent for repeated enables from the same source', () => { + setPendingReplyRefreshEnabled('my-team', 'tab-a', true); + setPendingReplyRefreshEnabled('my-team', 'tab-a', true); + + expect(setPendingReplyRefreshEnabled('my-team', 'tab-a', false)).toBe(false); + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false); + }); + + it('returns false when disabling a source that has no active wait', () => { + expect(setPendingReplyRefreshEnabled('missing-team', 'tab-a', false)).toBe(false); + expect(getActiveTeamPendingReplyWaits().size).toBe(0); + }); + + it('clears waits by team or globally', () => { + setPendingReplyRefreshEnabled('my-team', 'tab-a', true); + setPendingReplyRefreshEnabled('other-team', 'tab-b', true); + + clearPendingReplyRefreshWaits('my-team'); + + expect(hasActiveTeamPendingReplyWait('my-team')).toBe(false); + expect(getActiveTeamPendingReplyWaits()).toEqual(new Set(['other-team'])); + + clearAllPendingReplyRefreshWaits(); + + expect(hasActiveTeamPendingReplyWait('other-team')).toBe(false); + expect(getActiveTeamPendingReplyWaits().size).toBe(0); + }); +}); From 7d08a10d6f291d45bc9fdd5079f5dbdadffec162 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 09:56:32 +0300 Subject: [PATCH 06/41] refactor(team): extract local state epoch registry --- src/renderer/store/slices/teamSlice.ts | 24 +++----- .../store/team/teamLocalStateEpoch.ts | 25 ++++++++ .../store/teamLocalStateEpoch.test.ts | 57 +++++++++++++++++++ 3 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 src/renderer/store/team/teamLocalStateEpoch.ts create mode 100644 test/renderer/store/teamLocalStateEpoch.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index c1f17995..56837103 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -38,6 +38,13 @@ import { normalizeTeamGetDataOptions, } from '../team/teamDataRequestKeys'; import { selectTeamDataForName } from '../team/teamDataSelectors'; +import { + captureTeamLocalStateEpoch, + clearAllTeamLocalStateEpochs, + hasTeamLocalStateEpoch, + invalidateTeamLocalStateEpoch, + isTeamLocalStateEpochCurrent, +} from '../team/teamLocalStateEpoch'; import { areInboxMessageArraysEquivalent, clearTeamMessageSelectorCaches, @@ -158,7 +165,6 @@ const inFlightTeamMemberActivityMetaRequests = new Map>(); const pendingFreshTeamMemberActivityMetaRefreshes = new Set(); const pendingTeamPendingReplyRefreshTimers = new Map>(); const lastResolvedTeamDataRefreshAtByTeam = new Map(); -const teamLocalStateEpochByTeam = new Map(); let inFlightGlobalTasksRefresh: Promise | null = null; let pendingFreshGlobalTasksRefresh = false; const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); @@ -232,7 +238,7 @@ export function __resetTeamSliceModuleStateForTests(): void { pendingTeamPendingReplyRefreshTimers.clear(); clearAllPendingReplyRefreshWaits(); lastResolvedTeamDataRefreshAtByTeam.clear(); - teamLocalStateEpochByTeam.clear(); + clearAllTeamLocalStateEpochs(); memberSpawnStatusesIpcBackoffUntilByTeam.clear(); teamRefreshBurstDiagnostics.clear(); memberSpawnUiEqualLastWarnAtByTeam.clear(); @@ -432,18 +438,6 @@ function buildTeamScopedProgressTombstones( }; } -function captureTeamLocalStateEpoch(teamName: string): number { - return teamLocalStateEpochByTeam.get(teamName) ?? 0; -} - -function isTeamLocalStateEpochCurrent(teamName: string, epoch: number): boolean { - return captureTeamLocalStateEpoch(teamName) === epoch; -} - -function invalidateTeamLocalStateEpoch(teamName: string): void { - teamLocalStateEpochByTeam.set(teamName, captureTeamLocalStateEpoch(teamName) + 1); -} - function beginInFlightTeamDataRefresh(teamName: string): symbol { const token = Symbol(teamName); const existing = inFlightRefreshTeamDataCalls.get(teamName); @@ -663,7 +657,7 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { hasPendingFreshMemberActivityMetaRefresh: pendingFreshTeamMemberActivityMetaRefreshes.has(teamName), hasLastResolvedTeamDataRefresh: lastResolvedTeamDataRefreshAtByTeam.has(teamName), - hasCurrentLocalStateEpoch: teamLocalStateEpochByTeam.has(teamName), + hasCurrentLocalStateEpoch: hasTeamLocalStateEpoch(teamName), hasMemberSpawnStatusesIpcBackoff: memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName), hasTeamRefreshBurstDiagnostics: teamRefreshBurstDiagnostics.has(teamName), hasMemberSpawnUiEqualLastWarn: memberSpawnUiEqualLastWarnAtByTeam.has(teamName), diff --git a/src/renderer/store/team/teamLocalStateEpoch.ts b/src/renderer/store/team/teamLocalStateEpoch.ts new file mode 100644 index 00000000..4568f082 --- /dev/null +++ b/src/renderer/store/team/teamLocalStateEpoch.ts @@ -0,0 +1,25 @@ +const teamLocalStateEpochByTeam = new Map(); + +export function captureTeamLocalStateEpoch(teamName: string): number { + return teamLocalStateEpochByTeam.get(teamName) ?? 0; +} + +export function isTeamLocalStateEpochCurrent(teamName: string, epoch: number): boolean { + return captureTeamLocalStateEpoch(teamName) === epoch; +} + +export function invalidateTeamLocalStateEpoch(teamName: string): void { + teamLocalStateEpochByTeam.set(teamName, captureTeamLocalStateEpoch(teamName) + 1); +} + +export function hasTeamLocalStateEpoch(teamName: string): boolean { + return teamLocalStateEpochByTeam.has(teamName); +} + +export function clearTeamLocalStateEpoch(teamName: string): void { + teamLocalStateEpochByTeam.delete(teamName); +} + +export function clearAllTeamLocalStateEpochs(): void { + teamLocalStateEpochByTeam.clear(); +} diff --git a/test/renderer/store/teamLocalStateEpoch.test.ts b/test/renderer/store/teamLocalStateEpoch.test.ts new file mode 100644 index 00000000..3ff503ca --- /dev/null +++ b/test/renderer/store/teamLocalStateEpoch.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + captureTeamLocalStateEpoch, + clearAllTeamLocalStateEpochs, + clearTeamLocalStateEpoch, + hasTeamLocalStateEpoch, + invalidateTeamLocalStateEpoch, + isTeamLocalStateEpochCurrent, +} from '../../../src/renderer/store/team/teamLocalStateEpoch'; + +afterEach(() => { + clearAllTeamLocalStateEpochs(); +}); + +describe('teamLocalStateEpoch', () => { + it('starts missing teams at epoch zero without materializing an entry', () => { + expect(captureTeamLocalStateEpoch('my-team')).toBe(0); + expect(isTeamLocalStateEpochCurrent('my-team', 0)).toBe(true); + expect(hasTeamLocalStateEpoch('my-team')).toBe(false); + }); + + it('increments epochs independently per team', () => { + invalidateTeamLocalStateEpoch('my-team'); + invalidateTeamLocalStateEpoch('my-team'); + invalidateTeamLocalStateEpoch('other-team'); + + expect(captureTeamLocalStateEpoch('my-team')).toBe(2); + expect(captureTeamLocalStateEpoch('other-team')).toBe(1); + expect(isTeamLocalStateEpochCurrent('my-team', 1)).toBe(false); + expect(isTeamLocalStateEpochCurrent('my-team', 2)).toBe(true); + }); + + it('clears one team epoch without touching other teams', () => { + invalidateTeamLocalStateEpoch('my-team'); + invalidateTeamLocalStateEpoch('other-team'); + + clearTeamLocalStateEpoch('my-team'); + + expect(captureTeamLocalStateEpoch('my-team')).toBe(0); + expect(hasTeamLocalStateEpoch('my-team')).toBe(false); + expect(captureTeamLocalStateEpoch('other-team')).toBe(1); + expect(hasTeamLocalStateEpoch('other-team')).toBe(true); + }); + + it('clears all materialized epochs', () => { + invalidateTeamLocalStateEpoch('my-team'); + invalidateTeamLocalStateEpoch('other-team'); + + clearAllTeamLocalStateEpochs(); + + expect(hasTeamLocalStateEpoch('my-team')).toBe(false); + expect(hasTeamLocalStateEpoch('other-team')).toBe(false); + expect(captureTeamLocalStateEpoch('my-team')).toBe(0); + expect(captureTeamLocalStateEpoch('other-team')).toBe(0); + }); +}); From e718ccf39ab5fdec2ec40568ed2b9dba4c2d3135 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 10:00:54 +0300 Subject: [PATCH 07/41] refactor(team): extract refresh timestamps --- src/renderer/store/slices/teamSlice.ts | 22 +++---- .../store/team/teamDataRefreshTimestamps.ts | 21 +++++++ .../store/teamDataRefreshTimestamps.test.ts | 60 +++++++++++++++++++ 3 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 src/renderer/store/team/teamDataRefreshTimestamps.ts create mode 100644 test/renderer/store/teamDataRefreshTimestamps.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 56837103..3a10db66 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -29,6 +29,12 @@ import { isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; +import { + clearAllLastResolvedTeamDataRefreshes, + clearLastResolvedTeamDataRefreshAt, + hasLastResolvedTeamDataRefreshAt, + recordLastResolvedTeamDataRefresh, +} from '../team/teamDataRefreshTimestamps'; import { getFullTeamDataRequestKey, getTeamDataRequestKey, @@ -116,6 +122,7 @@ import type { } from '@shared/types'; import type { StateCreator } from 'zustand'; +export { getLastResolvedTeamDataRefreshAt } from '../team/teamDataRefreshTimestamps'; export { selectTeamDataForName, selectTeamIsAliveForName, @@ -164,7 +171,6 @@ const pendingFreshTeamMessagesHeadRefreshes = new Set(); const inFlightTeamMemberActivityMetaRequests = new Map>(); const pendingFreshTeamMemberActivityMetaRefreshes = new Set(); const pendingTeamPendingReplyRefreshTimers = new Map>(); -const lastResolvedTeamDataRefreshAtByTeam = new Map(); let inFlightGlobalTasksRefresh: Promise | null = null; let pendingFreshGlobalTasksRefresh = false; const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); @@ -213,10 +219,6 @@ export function isTeamDataRefreshPending(teamName: string): boolean { ); } -export function getLastResolvedTeamDataRefreshAt(teamName: string): number | undefined { - return lastResolvedTeamDataRefreshAtByTeam.get(teamName); -} - export function __resetTeamSliceModuleStateForTests(): void { inFlightTeamDataRequests.clear(); inFlightRefreshTeamDataCalls.clear(); @@ -237,7 +239,7 @@ export function __resetTeamSliceModuleStateForTests(): void { } pendingTeamPendingReplyRefreshTimers.clear(); clearAllPendingReplyRefreshWaits(); - lastResolvedTeamDataRefreshAtByTeam.clear(); + clearAllLastResolvedTeamDataRefreshes(); clearAllTeamLocalStateEpochs(); memberSpawnStatusesIpcBackoffUntilByTeam.clear(); teamRefreshBurstDiagnostics.clear(); @@ -271,7 +273,7 @@ function clearTeamScopedTransientState(teamName: string): void { pendingFreshTeamMessagesHeadRefreshes.delete(teamName); inFlightTeamMemberActivityMetaRequests.delete(teamName); pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName); - lastResolvedTeamDataRefreshAtByTeam.delete(teamName); + clearLastResolvedTeamDataRefreshAt(teamName); memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); teamRefreshBurstDiagnostics.delete(teamName); memberSpawnUiEqualLastWarnAtByTeam.delete(teamName); @@ -656,7 +658,7 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { hasPendingFreshMessagesHeadRefresh: pendingFreshTeamMessagesHeadRefreshes.has(teamName), hasPendingFreshMemberActivityMetaRefresh: pendingFreshTeamMemberActivityMetaRefreshes.has(teamName), - hasLastResolvedTeamDataRefresh: lastResolvedTeamDataRefreshAtByTeam.has(teamName), + hasLastResolvedTeamDataRefresh: hasLastResolvedTeamDataRefreshAt(teamName), hasCurrentLocalStateEpoch: hasTeamLocalStateEpoch(teamName), hasMemberSpawnStatusesIpcBackoff: memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName), hasTeamRefreshBurstDiagnostics: teamRefreshBurstDiagnostics.has(teamName), @@ -4014,7 +4016,7 @@ export const createTeamSlice: StateCreator = (set, selectedTeamError: null, }; }); - lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); + recordLastResolvedTeamDataRefresh(teamName); try { const invalidationState = previousData @@ -4238,7 +4240,7 @@ export const createTeamSlice: StateCreator = (set, ...selectedState, }; }); - lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); + recordLastResolvedTeamDataRefresh(teamName); const invalidationState = previousData ? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks) : { cacheKeys: [], taskIds: [] }; diff --git a/src/renderer/store/team/teamDataRefreshTimestamps.ts b/src/renderer/store/team/teamDataRefreshTimestamps.ts new file mode 100644 index 00000000..9330d545 --- /dev/null +++ b/src/renderer/store/team/teamDataRefreshTimestamps.ts @@ -0,0 +1,21 @@ +const lastResolvedTeamDataRefreshAtByTeam = new Map(); + +export function getLastResolvedTeamDataRefreshAt(teamName: string): number | undefined { + return lastResolvedTeamDataRefreshAtByTeam.get(teamName); +} + +export function recordLastResolvedTeamDataRefresh(teamName: string, resolvedAt = Date.now()): void { + lastResolvedTeamDataRefreshAtByTeam.set(teamName, resolvedAt); +} + +export function hasLastResolvedTeamDataRefreshAt(teamName: string): boolean { + return lastResolvedTeamDataRefreshAtByTeam.has(teamName); +} + +export function clearLastResolvedTeamDataRefreshAt(teamName: string): void { + lastResolvedTeamDataRefreshAtByTeam.delete(teamName); +} + +export function clearAllLastResolvedTeamDataRefreshes(): void { + lastResolvedTeamDataRefreshAtByTeam.clear(); +} diff --git a/test/renderer/store/teamDataRefreshTimestamps.test.ts b/test/renderer/store/teamDataRefreshTimestamps.test.ts new file mode 100644 index 00000000..5c8bf9de --- /dev/null +++ b/test/renderer/store/teamDataRefreshTimestamps.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + clearAllLastResolvedTeamDataRefreshes, + clearLastResolvedTeamDataRefreshAt, + getLastResolvedTeamDataRefreshAt, + hasLastResolvedTeamDataRefreshAt, + recordLastResolvedTeamDataRefresh, +} from '../../../src/renderer/store/team/teamDataRefreshTimestamps'; + +afterEach(() => { + vi.useRealTimers(); + clearAllLastResolvedTeamDataRefreshes(); +}); + +describe('teamDataRefreshTimestamps', () => { + it('returns undefined for teams without a recorded refresh', () => { + expect(getLastResolvedTeamDataRefreshAt('my-team')).toBeUndefined(); + expect(hasLastResolvedTeamDataRefreshAt('my-team')).toBe(false); + }); + + it('records explicit refresh timestamps by team', () => { + recordLastResolvedTeamDataRefresh('my-team', 100); + recordLastResolvedTeamDataRefresh('other-team', 200); + + expect(getLastResolvedTeamDataRefreshAt('my-team')).toBe(100); + expect(getLastResolvedTeamDataRefreshAt('other-team')).toBe(200); + expect(hasLastResolvedTeamDataRefreshAt('my-team')).toBe(true); + }); + + it('uses Date.now by default to preserve current call-site behavior', () => { + vi.setSystemTime(new Date('2026-05-22T06:30:00.000Z')); + + recordLastResolvedTeamDataRefresh('my-team'); + + expect(getLastResolvedTeamDataRefreshAt('my-team')).toBe( + new Date('2026-05-22T06:30:00.000Z').getTime() + ); + }); + + it('clears one team timestamp without touching other teams', () => { + recordLastResolvedTeamDataRefresh('my-team', 100); + recordLastResolvedTeamDataRefresh('other-team', 200); + + clearLastResolvedTeamDataRefreshAt('my-team'); + + expect(getLastResolvedTeamDataRefreshAt('my-team')).toBeUndefined(); + expect(getLastResolvedTeamDataRefreshAt('other-team')).toBe(200); + }); + + it('clears all recorded timestamps', () => { + recordLastResolvedTeamDataRefresh('my-team', 100); + recordLastResolvedTeamDataRefresh('other-team', 200); + + clearAllLastResolvedTeamDataRefreshes(); + + expect(hasLastResolvedTeamDataRefreshAt('my-team')).toBe(false); + expect(hasLastResolvedTeamDataRefreshAt('other-team')).toBe(false); + }); +}); From 38b0a87d5dcb3bd57020aa2d38d1c1afcac4609d Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 10:08:13 +0300 Subject: [PATCH 08/41] refactor(team): extract spawn status backoff --- src/renderer/store/slices/teamSlice.ts | 23 +++--- .../team/teamMemberSpawnStatusBackoff.ts | 39 +++++++++++ .../teamMemberSpawnStatusBackoff.test.ts | 70 +++++++++++++++++++ 3 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 src/renderer/store/team/teamMemberSpawnStatusBackoff.ts create mode 100644 test/renderer/store/teamMemberSpawnStatusBackoff.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 3a10db66..05e02473 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -51,6 +51,13 @@ import { invalidateTeamLocalStateEpoch, isTeamLocalStateEpochCurrent, } from '../team/teamLocalStateEpoch'; +import { + clearAllMemberSpawnStatusesIpcBackoffs, + clearMemberSpawnStatusesIpcBackoff, + hasMemberSpawnStatusesIpcBackoff, + isMemberSpawnStatusesIpcBackoffActive, + recordMemberSpawnStatusesIpcRetryBackoff, +} from '../team/teamMemberSpawnStatusBackoff'; import { areInboxMessageArraysEquivalent, clearTeamMessageSelectorCaches, @@ -173,7 +180,6 @@ const pendingFreshTeamMemberActivityMetaRefreshes = new Set(); const pendingTeamPendingReplyRefreshTimers = new Map>(); let inFlightGlobalTasksRefresh: Promise | null = null; let pendingFreshGlobalTasksRefresh = false; -const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); const teamRefreshBurstDiagnostics = new Map< string, { windowStartedAt: number; count: number; lastWarnAt: number } @@ -241,7 +247,7 @@ export function __resetTeamSliceModuleStateForTests(): void { clearAllPendingReplyRefreshWaits(); clearAllLastResolvedTeamDataRefreshes(); clearAllTeamLocalStateEpochs(); - memberSpawnStatusesIpcBackoffUntilByTeam.clear(); + clearAllMemberSpawnStatusesIpcBackoffs(); teamRefreshBurstDiagnostics.clear(); memberSpawnUiEqualLastWarnAtByTeam.clear(); resolvedMembersSelectorCache.clear(); @@ -274,7 +280,7 @@ function clearTeamScopedTransientState(teamName: string): void { inFlightTeamMemberActivityMetaRequests.delete(teamName); pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName); clearLastResolvedTeamDataRefreshAt(teamName); - memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); + clearMemberSpawnStatusesIpcBackoff(teamName); teamRefreshBurstDiagnostics.delete(teamName); memberSpawnUiEqualLastWarnAtByTeam.delete(teamName); clearTeamScopedSelectorCaches(teamName); @@ -660,7 +666,7 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { pendingFreshTeamMemberActivityMetaRefreshes.has(teamName), hasLastResolvedTeamDataRefresh: hasLastResolvedTeamDataRefreshAt(teamName), hasCurrentLocalStateEpoch: hasTeamLocalStateEpoch(teamName), - hasMemberSpawnStatusesIpcBackoff: memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName), + hasMemberSpawnStatusesIpcBackoff: hasMemberSpawnStatusesIpcBackoff(teamName), hasTeamRefreshBurstDiagnostics: teamRefreshBurstDiagnostics.has(teamName), hasMemberSpawnUiEqualLastWarn: memberSpawnUiEqualLastWarnAtByTeam.has(teamName), }; @@ -2987,13 +2993,12 @@ export const createTeamSlice: StateCreator = (set, launchParamsByTeam: loadAllLaunchParams(), fetchMemberSpawnStatuses: async (teamName: string) => { if (!api.teams?.getMemberSpawnStatuses) return; - const backoffUntil = memberSpawnStatusesIpcBackoffUntilByTeam.get(teamName) ?? 0; - if (backoffUntil > Date.now()) { + if (isMemberSpawnStatusesIpcBackoffActive(teamName)) { return; } try { const snapshot = await api.teams.getMemberSpawnStatuses(teamName); - memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); + clearMemberSpawnStatusesIpcBackoff(teamName); set((prev) => { if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) { return {}; @@ -3057,9 +3062,9 @@ export const createTeamSlice: StateCreator = (set, } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes("No handler registered for 'team:memberSpawnStatuses'")) { - memberSpawnStatusesIpcBackoffUntilByTeam.set( + recordMemberSpawnStatusesIpcRetryBackoff( teamName, - Date.now() + MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS + MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS ); } // ignore — spawn statuses are best-effort diff --git a/src/renderer/store/team/teamMemberSpawnStatusBackoff.ts b/src/renderer/store/team/teamMemberSpawnStatusBackoff.ts new file mode 100644 index 00000000..bcb6fc71 --- /dev/null +++ b/src/renderer/store/team/teamMemberSpawnStatusBackoff.ts @@ -0,0 +1,39 @@ +const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); + +export function getMemberSpawnStatusesIpcBackoffUntil(teamName: string): number { + return memberSpawnStatusesIpcBackoffUntilByTeam.get(teamName) ?? 0; +} + +export function hasMemberSpawnStatusesIpcBackoff(teamName: string): boolean { + return memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName); +} + +export function isMemberSpawnStatusesIpcBackoffActive( + teamName: string, + now = Date.now() +): boolean { + return getMemberSpawnStatusesIpcBackoffUntil(teamName) > now; +} + +export function recordMemberSpawnStatusesIpcBackoffUntil( + teamName: string, + backoffUntil: number +): void { + memberSpawnStatusesIpcBackoffUntilByTeam.set(teamName, backoffUntil); +} + +export function recordMemberSpawnStatusesIpcRetryBackoff( + teamName: string, + retryBackoffMs: number, + now = Date.now() +): void { + recordMemberSpawnStatusesIpcBackoffUntil(teamName, now + retryBackoffMs); +} + +export function clearMemberSpawnStatusesIpcBackoff(teamName: string): void { + memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); +} + +export function clearAllMemberSpawnStatusesIpcBackoffs(): void { + memberSpawnStatusesIpcBackoffUntilByTeam.clear(); +} diff --git a/test/renderer/store/teamMemberSpawnStatusBackoff.test.ts b/test/renderer/store/teamMemberSpawnStatusBackoff.test.ts new file mode 100644 index 00000000..70ffc747 --- /dev/null +++ b/test/renderer/store/teamMemberSpawnStatusBackoff.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + clearAllMemberSpawnStatusesIpcBackoffs, + clearMemberSpawnStatusesIpcBackoff, + getMemberSpawnStatusesIpcBackoffUntil, + hasMemberSpawnStatusesIpcBackoff, + isMemberSpawnStatusesIpcBackoffActive, + recordMemberSpawnStatusesIpcBackoffUntil, + recordMemberSpawnStatusesIpcRetryBackoff, +} from '../../../src/renderer/store/team/teamMemberSpawnStatusBackoff'; + +afterEach(() => { + vi.useRealTimers(); + clearAllMemberSpawnStatusesIpcBackoffs(); +}); + +describe('teamMemberSpawnStatusBackoff', () => { + it('defaults to no backoff for unknown teams', () => { + expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(0); + expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false); + expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 100)).toBe(false); + }); + + it('tracks active backoff deadlines by team', () => { + recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150); + recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250); + + expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(150); + expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 149)).toBe(true); + expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 150)).toBe(false); + expect(isMemberSpawnStatusesIpcBackoffActive('other-team', 249)).toBe(true); + }); + + it('records retry backoff from Date.now by default', () => { + vi.setSystemTime(new Date('2026-05-22T07:00:00.000Z')); + + recordMemberSpawnStatusesIpcRetryBackoff('my-team', 5_000); + + expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe( + new Date('2026-05-22T07:00:05.000Z').getTime() + ); + }); + + it('records retry backoff from an explicit clock for deterministic callers', () => { + recordMemberSpawnStatusesIpcRetryBackoff('my-team', 5_000, 100); + + expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(5_100); + }); + + it('clears one team backoff without touching others', () => { + recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150); + recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250); + + clearMemberSpawnStatusesIpcBackoff('my-team'); + + expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false); + expect(getMemberSpawnStatusesIpcBackoffUntil('other-team')).toBe(250); + }); + + it('clears all recorded backoffs', () => { + recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150); + recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250); + + clearAllMemberSpawnStatusesIpcBackoffs(); + + expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false); + expect(hasMemberSpawnStatusesIpcBackoff('other-team')).toBe(false); + }); +}); From 34902059b2247d5fb5a9083fab7b34845f419ab0 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 10:13:17 +0300 Subject: [PATCH 09/41] refactor(team): extract spawn status warning throttle --- src/renderer/store/slices/teamSlice.ts | 23 +++--- .../teamMemberSpawnUiEqualWarningThrottle.ts | 30 ++++++++ ...mMemberSpawnUiEqualWarningThrottle.test.ts | 71 +++++++++++++++++++ 3 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle.ts create mode 100644 test/renderer/store/teamMemberSpawnUiEqualWarningThrottle.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 05e02473..bc24198f 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -58,6 +58,12 @@ import { isMemberSpawnStatusesIpcBackoffActive, recordMemberSpawnStatusesIpcRetryBackoff, } from '../team/teamMemberSpawnStatusBackoff'; +import { + clearAllMemberSpawnUiEqualLastWarns, + clearMemberSpawnUiEqualLastWarn, + hasMemberSpawnUiEqualLastWarn, + shouldLogMemberSpawnUiEqualSuppressed, +} from '../team/teamMemberSpawnUiEqualWarningThrottle'; import { areInboxMessageArraysEquivalent, clearTeamMessageSelectorCaches, @@ -184,7 +190,6 @@ const teamRefreshBurstDiagnostics = new Map< string, { windowStartedAt: number; count: number; lastWarnAt: number } >(); -const memberSpawnUiEqualLastWarnAtByTeam = new Map(); interface RefreshTeamDataOptions { withDedup?: boolean; } @@ -249,7 +254,7 @@ export function __resetTeamSliceModuleStateForTests(): void { clearAllTeamLocalStateEpochs(); clearAllMemberSpawnStatusesIpcBackoffs(); teamRefreshBurstDiagnostics.clear(); - memberSpawnUiEqualLastWarnAtByTeam.clear(); + clearAllMemberSpawnUiEqualLastWarns(); resolvedMembersSelectorCache.clear(); resolvedMemberSelectorCache.clear(); clearTeamMessageSelectorCaches(); @@ -282,7 +287,7 @@ function clearTeamScopedTransientState(teamName: string): void { clearLastResolvedTeamDataRefreshAt(teamName); clearMemberSpawnStatusesIpcBackoff(teamName); teamRefreshBurstDiagnostics.delete(teamName); - memberSpawnUiEqualLastWarnAtByTeam.delete(teamName); + clearMemberSpawnUiEqualLastWarn(teamName); clearTeamScopedSelectorCaches(teamName); } @@ -668,7 +673,7 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { hasCurrentLocalStateEpoch: hasTeamLocalStateEpoch(teamName), hasMemberSpawnStatusesIpcBackoff: hasMemberSpawnStatusesIpcBackoff(teamName), hasTeamRefreshBurstDiagnostics: teamRefreshBurstDiagnostics.has(teamName), - hasMemberSpawnUiEqualLastWarn: memberSpawnUiEqualLastWarnAtByTeam.has(teamName), + hasMemberSpawnUiEqualLastWarn: hasMemberSpawnUiEqualLastWarn(teamName), }; } @@ -957,12 +962,14 @@ function maybeLogMemberSpawnUiEqualSuppressed( teamName: string, runId: string | null | undefined ): void { - const now = Date.now(); - const lastWarnAt = memberSpawnUiEqualLastWarnAtByTeam.get(teamName) ?? 0; - if (now - lastWarnAt < MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS) { + if ( + !shouldLogMemberSpawnUiEqualSuppressed( + teamName, + MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS + ) + ) { return; } - memberSpawnUiEqualLastWarnAtByTeam.set(teamName, now); logger.debug( `[perf] member-spawn snapshot suppressed team=${teamName} runId=${runId ?? 'none'} reason=member-spawn-ui-equal` ); diff --git a/src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle.ts b/src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle.ts new file mode 100644 index 00000000..65e9a92a --- /dev/null +++ b/src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle.ts @@ -0,0 +1,30 @@ +const memberSpawnUiEqualLastWarnAtByTeam = new Map(); + +export function getMemberSpawnUiEqualLastWarnAt(teamName: string): number | undefined { + return memberSpawnUiEqualLastWarnAtByTeam.get(teamName); +} + +export function hasMemberSpawnUiEqualLastWarn(teamName: string): boolean { + return memberSpawnUiEqualLastWarnAtByTeam.has(teamName); +} + +export function shouldLogMemberSpawnUiEqualSuppressed( + teamName: string, + throttleMs: number, + now = Date.now() +): boolean { + const lastWarnAt = memberSpawnUiEqualLastWarnAtByTeam.get(teamName) ?? 0; + if (now - lastWarnAt < throttleMs) { + return false; + } + memberSpawnUiEqualLastWarnAtByTeam.set(teamName, now); + return true; +} + +export function clearMemberSpawnUiEqualLastWarn(teamName: string): void { + memberSpawnUiEqualLastWarnAtByTeam.delete(teamName); +} + +export function clearAllMemberSpawnUiEqualLastWarns(): void { + memberSpawnUiEqualLastWarnAtByTeam.clear(); +} diff --git a/test/renderer/store/teamMemberSpawnUiEqualWarningThrottle.test.ts b/test/renderer/store/teamMemberSpawnUiEqualWarningThrottle.test.ts new file mode 100644 index 00000000..7025a836 --- /dev/null +++ b/test/renderer/store/teamMemberSpawnUiEqualWarningThrottle.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + clearAllMemberSpawnUiEqualLastWarns, + clearMemberSpawnUiEqualLastWarn, + getMemberSpawnUiEqualLastWarnAt, + hasMemberSpawnUiEqualLastWarn, + shouldLogMemberSpawnUiEqualSuppressed, +} from '../../../src/renderer/store/team/teamMemberSpawnUiEqualWarningThrottle'; + +afterEach(() => { + vi.useRealTimers(); + clearAllMemberSpawnUiEqualLastWarns(); +}); + +describe('teamMemberSpawnUiEqualWarningThrottle', () => { + it('preserves the existing zero fallback boundary for unknown teams', () => { + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 1_999)).toBe(false); + expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false); + + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 2_000)).toBe(true); + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(2_000); + }); + + it('throttles repeated warnings until the boundary is reached', () => { + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000)).toBe(true); + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 11_999)).toBe(false); + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(10_000); + + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 12_000)).toBe(true); + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(12_000); + }); + + it('tracks teams independently', () => { + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000)).toBe(true); + expect(shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500)).toBe(true); + + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe(10_000); + expect(getMemberSpawnUiEqualLastWarnAt('other-team')).toBe(10_500); + }); + + it('uses Date.now by default for production callers', () => { + vi.setSystemTime(new Date('2026-05-22T07:30:00.000Z')); + + expect(shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000)).toBe(true); + + expect(getMemberSpawnUiEqualLastWarnAt('my-team')).toBe( + new Date('2026-05-22T07:30:00.000Z').getTime() + ); + }); + + it('clears one team without touching other teams', () => { + shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000); + shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500); + + clearMemberSpawnUiEqualLastWarn('my-team'); + + expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false); + expect(getMemberSpawnUiEqualLastWarnAt('other-team')).toBe(10_500); + }); + + it('clears all tracked warnings', () => { + shouldLogMemberSpawnUiEqualSuppressed('my-team', 2_000, 10_000); + shouldLogMemberSpawnUiEqualSuppressed('other-team', 2_000, 10_500); + + clearAllMemberSpawnUiEqualLastWarns(); + + expect(hasMemberSpawnUiEqualLastWarn('my-team')).toBe(false); + expect(hasMemberSpawnUiEqualLastWarn('other-team')).toBe(false); + }); +}); From 9bd85856177c9740c8ad94a469297a4c1832d989 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 10:27:22 +0300 Subject: [PATCH 10/41] refactor(team): extract refresh burst diagnostics --- src/renderer/store/slices/teamSlice.ts | 37 +++------ .../store/team/teamRefreshBurstDiagnostics.ts | 48 +++++++++++ .../teamRefreshBurstDiagnosticsStore.test.ts | 83 +++++++++++++++++++ 3 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 src/renderer/store/team/teamRefreshBurstDiagnostics.ts create mode 100644 test/renderer/store/teamRefreshBurstDiagnosticsStore.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index bc24198f..b2d8f654 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -80,6 +80,12 @@ import { clearPendingReplyRefreshWaits, setPendingReplyRefreshEnabled, } from '../team/teamPendingReplyWaits'; +import { + clearAllTeamRefreshBurstDiagnostics, + clearTeamRefreshBurstDiagnostics, + hasTeamRefreshBurstDiagnostics, + noteTeamRefreshBurst, +} from '../team/teamRefreshBurstDiagnostics'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; @@ -186,10 +192,6 @@ const pendingFreshTeamMemberActivityMetaRefreshes = new Set(); const pendingTeamPendingReplyRefreshTimers = new Map>(); let inFlightGlobalTasksRefresh: Promise | null = null; let pendingFreshGlobalTasksRefresh = false; -const teamRefreshBurstDiagnostics = new Map< - string, - { windowStartedAt: number; count: number; lastWarnAt: number } ->(); interface RefreshTeamDataOptions { withDedup?: boolean; } @@ -253,7 +255,7 @@ export function __resetTeamSliceModuleStateForTests(): void { clearAllLastResolvedTeamDataRefreshes(); clearAllTeamLocalStateEpochs(); clearAllMemberSpawnStatusesIpcBackoffs(); - teamRefreshBurstDiagnostics.clear(); + clearAllTeamRefreshBurstDiagnostics(); clearAllMemberSpawnUiEqualLastWarns(); resolvedMembersSelectorCache.clear(); resolvedMemberSelectorCache.clear(); @@ -286,7 +288,7 @@ function clearTeamScopedTransientState(teamName: string): void { pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName); clearLastResolvedTeamDataRefreshAt(teamName); clearMemberSpawnStatusesIpcBackoff(teamName); - teamRefreshBurstDiagnostics.delete(teamName); + clearTeamRefreshBurstDiagnostics(teamName); clearMemberSpawnUiEqualLastWarn(teamName); clearTeamScopedSelectorCaches(teamName); } @@ -672,7 +674,7 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { hasLastResolvedTeamDataRefresh: hasLastResolvedTeamDataRefreshAt(teamName), hasCurrentLocalStateEpoch: hasTeamLocalStateEpoch(teamName), hasMemberSpawnStatusesIpcBackoff: hasMemberSpawnStatusesIpcBackoff(teamName), - hasTeamRefreshBurstDiagnostics: teamRefreshBurstDiagnostics.has(teamName), + hasTeamRefreshBurstDiagnostics: hasTeamRefreshBurstDiagnostics(teamName), hasMemberSpawnUiEqualLastWarn: hasMemberSpawnUiEqualLastWarn(teamName), }; } @@ -838,25 +840,6 @@ function fetchTeamDataFresh( ); } -function noteTeamRefreshBurst(teamName: string): number { - const now = Date.now(); - const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? { - windowStartedAt: now, - count: 0, - lastWarnAt: 0, - }; - - if (now - diagnostic.windowStartedAt > TEAM_REFRESH_BURST_WINDOW_MS) { - diagnostic.windowStartedAt = now; - diagnostic.count = 0; - } - - diagnostic.count += 1; - - teamRefreshBurstDiagnostics.set(teamName, diagnostic); - return diagnostic.count; -} - function areLaunchSummaryCountsEqual( left: PersistedTeamLaunchSummary | undefined, right: PersistedTeamLaunchSummary | undefined @@ -4203,7 +4186,7 @@ export const createTeamSlice: StateCreator = (set, const refreshToken = beginInFlightTeamDataRefresh(teamName); // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). - noteTeamRefreshBurst(teamName); + noteTeamRefreshBurst(teamName, TEAM_REFRESH_BURST_WINDOW_MS); if (reusedInFlightRequest) { pendingFreshTeamDataRefreshes.add(teamName); } diff --git a/src/renderer/store/team/teamRefreshBurstDiagnostics.ts b/src/renderer/store/team/teamRefreshBurstDiagnostics.ts new file mode 100644 index 00000000..27205052 --- /dev/null +++ b/src/renderer/store/team/teamRefreshBurstDiagnostics.ts @@ -0,0 +1,48 @@ +interface TeamRefreshBurstDiagnostic { + windowStartedAt: number; + count: number; + lastWarnAt: number; +} + +const teamRefreshBurstDiagnostics = new Map(); + +export function hasTeamRefreshBurstDiagnostics(teamName: string): boolean { + return teamRefreshBurstDiagnostics.has(teamName); +} + +export function getTeamRefreshBurstDiagnosticForTests( + teamName: string +): TeamRefreshBurstDiagnostic | undefined { + const diagnostic = teamRefreshBurstDiagnostics.get(teamName); + return diagnostic ? { ...diagnostic } : undefined; +} + +export function noteTeamRefreshBurst( + teamName: string, + windowMs: number, + now = Date.now() +): number { + const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? { + windowStartedAt: now, + count: 0, + lastWarnAt: 0, + }; + + if (now - diagnostic.windowStartedAt > windowMs) { + diagnostic.windowStartedAt = now; + diagnostic.count = 0; + } + + diagnostic.count += 1; + + teamRefreshBurstDiagnostics.set(teamName, diagnostic); + return diagnostic.count; +} + +export function clearTeamRefreshBurstDiagnostics(teamName: string): void { + teamRefreshBurstDiagnostics.delete(teamName); +} + +export function clearAllTeamRefreshBurstDiagnostics(): void { + teamRefreshBurstDiagnostics.clear(); +} diff --git a/test/renderer/store/teamRefreshBurstDiagnosticsStore.test.ts b/test/renderer/store/teamRefreshBurstDiagnosticsStore.test.ts new file mode 100644 index 00000000..5cc3f529 --- /dev/null +++ b/test/renderer/store/teamRefreshBurstDiagnosticsStore.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + clearAllTeamRefreshBurstDiagnostics, + clearTeamRefreshBurstDiagnostics, + getTeamRefreshBurstDiagnosticForTests, + hasTeamRefreshBurstDiagnostics, + noteTeamRefreshBurst, +} from '../../../src/renderer/store/team/teamRefreshBurstDiagnostics'; + +afterEach(() => { + clearAllTeamRefreshBurstDiagnostics(); +}); + +describe('teamRefreshBurstDiagnostics store', () => { + it('creates a window on the first refresh note', () => { + expect(noteTeamRefreshBurst('my-team', 4_000, 10_000)).toBe(1); + + expect(getTeamRefreshBurstDiagnosticForTests('my-team')).toEqual({ + windowStartedAt: 10_000, + count: 1, + lastWarnAt: 0, + }); + expect(hasTeamRefreshBurstDiagnostics('my-team')).toBe(true); + }); + + it('increments inside the active burst window', () => { + expect(noteTeamRefreshBurst('my-team', 4_000, 10_000)).toBe(1); + expect(noteTeamRefreshBurst('my-team', 4_000, 13_999)).toBe(2); + expect(noteTeamRefreshBurst('my-team', 4_000, 14_000)).toBe(3); + + expect(getTeamRefreshBurstDiagnosticForTests('my-team')).toEqual({ + windowStartedAt: 10_000, + count: 3, + lastWarnAt: 0, + }); + }); + + it('resets only after now is strictly beyond the burst window', () => { + expect(noteTeamRefreshBurst('my-team', 4_000, 10_000)).toBe(1); + expect(noteTeamRefreshBurst('my-team', 4_000, 14_001)).toBe(1); + + expect(getTeamRefreshBurstDiagnosticForTests('my-team')).toEqual({ + windowStartedAt: 14_001, + count: 1, + lastWarnAt: 0, + }); + }); + + it('tracks each team independently', () => { + noteTeamRefreshBurst('my-team', 4_000, 10_000); + noteTeamRefreshBurst('my-team', 4_000, 10_500); + noteTeamRefreshBurst('other-team', 4_000, 11_000); + + expect(getTeamRefreshBurstDiagnosticForTests('my-team')?.count).toBe(2); + expect(getTeamRefreshBurstDiagnosticForTests('other-team')?.count).toBe(1); + }); + + it('clears one team or all diagnostics', () => { + noteTeamRefreshBurst('my-team', 4_000, 10_000); + noteTeamRefreshBurst('other-team', 4_000, 11_000); + + clearTeamRefreshBurstDiagnostics('my-team'); + + expect(hasTeamRefreshBurstDiagnostics('my-team')).toBe(false); + expect(hasTeamRefreshBurstDiagnostics('other-team')).toBe(true); + + clearAllTeamRefreshBurstDiagnostics(); + + expect(hasTeamRefreshBurstDiagnostics('other-team')).toBe(false); + }); + + it('returns defensive diagnostic snapshots for tests', () => { + noteTeamRefreshBurst('my-team', 4_000, 10_000); + + const snapshot = getTeamRefreshBurstDiagnosticForTests('my-team'); + if (snapshot) { + snapshot.count = 99; + } + + expect(getTeamRefreshBurstDiagnosticForTests('my-team')?.count).toBe(1); + }); +}); From c091bd8d969112f77f9b107a4936fc3a74fd623e Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 10:42:12 +0300 Subject: [PATCH 11/41] refactor(team): extract scoped state cleanup --- src/renderer/store/slices/teamSlice.ts | 165 +------------- .../store/team/teamScopedStateCleanup.ts | 190 ++++++++++++++++ .../store/teamScopedStateCleanup.test.ts | 203 ++++++++++++++++++ 3 files changed, 398 insertions(+), 160 deletions(-) create mode 100644 src/renderer/store/team/teamScopedStateCleanup.ts create mode 100644 test/renderer/store/teamScopedStateCleanup.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index b2d8f654..4a14cfd5 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -86,6 +86,11 @@ import { hasTeamRefreshBurstDiagnostics, noteTeamRefreshBurst, } from '../team/teamRefreshBurstDiagnostics'; +import { + buildTeamScopedProgressTombstones, + collectTeamScopedStateRemovals, + collectTeamScopedVisibleLoadingResets, +} from '../team/teamScopedStateCleanup'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; @@ -293,166 +298,6 @@ function clearTeamScopedTransientState(teamName: string): void { clearTeamScopedSelectorCaches(teamName); } -function collectTeamScopedVisibleLoadingResets( - state: Pick< - TeamSlice, - 'teamMessagesByName' | 'selectedTeamName' | 'selectedTeamLoading' | 'selectedTeamError' - >, - teamName: string -): Partial { - const nextTeamMessagesEntry = state.teamMessagesByName[teamName]; - const nextTeamMessagesByName = - nextTeamMessagesEntry && - (nextTeamMessagesEntry.loadingHead || nextTeamMessagesEntry.loadingOlder) - ? { - ...state.teamMessagesByName, - [teamName]: { - ...nextTeamMessagesEntry, - loadingHead: false, - loadingOlder: false, - }, - } - : null; - - const shouldResetSelectedSurface = - state.selectedTeamName === teamName && - (state.selectedTeamLoading || state.selectedTeamError != null); - - return { - ...(nextTeamMessagesByName ? { teamMessagesByName: nextTeamMessagesByName } : {}), - ...(shouldResetSelectedSurface - ? { - selectedTeamLoading: false, - selectedTeamError: null, - } - : {}), - }; -} - -function omitTeamKey(record: Record, teamName: string): Record | null { - if (!(teamName in record)) { - return null; - } - const next = { ...record }; - delete next[teamName]; - return next; -} - -function collectTeamScopedStateRemovals( - state: Pick< - TeamSlice, - | 'provisioningRuns' - | 'teamDataCacheByName' - | 'teamAgentRuntimeByTeam' - | 'teamMessagesByName' - | 'memberActivityMetaByTeam' - | 'provisioningSnapshotByTeam' - | 'currentProvisioningRunIdByTeam' - | 'currentRuntimeRunIdByTeam' - | 'provisioningStartedAtFloorByTeam' - | 'leadActivityByTeam' - | 'leadContextByTeam' - | 'activeTaskLogActivityByTeam' - | 'activeToolsByTeam' - | 'finishedVisibleByTeam' - | 'toolHistoryByTeam' - | 'memberSpawnStatusesByTeam' - | 'memberSpawnSnapshotsByTeam' - | 'provisioningErrorByTeam' - >, - teamName: string -): Partial { - const nextProvisioningRuns = Object.fromEntries( - Object.entries(state.provisioningRuns).filter(([, run]) => run.teamName !== teamName) - ) as Record; - const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName); - const nextTeamAgentRuntime = omitTeamKey(state.teamAgentRuntimeByTeam, teamName); - const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName); - const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName); - const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName); - const nextCurrentProvisioningRunId = omitTeamKey(state.currentProvisioningRunIdByTeam, teamName); - const nextCurrentRuntimeRunId = omitTeamKey(state.currentRuntimeRunIdByTeam, teamName); - const nextProvisioningStartedAtFloor = omitTeamKey( - state.provisioningStartedAtFloorByTeam, - teamName - ); - const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName); - const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName); - const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName); - const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName); - const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName); - const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName); - const nextMemberSpawnStatuses = omitTeamKey(state.memberSpawnStatusesByTeam, teamName); - const nextMemberSpawnSnapshots = omitTeamKey(state.memberSpawnSnapshotsByTeam, teamName); - const nextProvisioningErrors = omitTeamKey(state.provisioningErrorByTeam, teamName); - - return { - ...(Object.keys(nextProvisioningRuns).length !== Object.keys(state.provisioningRuns).length - ? { provisioningRuns: nextProvisioningRuns } - : {}), - ...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}), - ...(nextTeamAgentRuntime ? { teamAgentRuntimeByTeam: nextTeamAgentRuntime } : {}), - ...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}), - ...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}), - ...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}), - ...(nextCurrentProvisioningRunId - ? { currentProvisioningRunIdByTeam: nextCurrentProvisioningRunId } - : {}), - ...(nextCurrentRuntimeRunId ? { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunId } : {}), - ...(nextProvisioningStartedAtFloor - ? { provisioningStartedAtFloorByTeam: nextProvisioningStartedAtFloor } - : {}), - ...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}), - ...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}), - ...(nextActiveTaskLogActivity - ? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity } - : {}), - ...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}), - ...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}), - ...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}), - ...(nextMemberSpawnStatuses ? { memberSpawnStatusesByTeam: nextMemberSpawnStatuses } : {}), - ...(nextMemberSpawnSnapshots ? { memberSpawnSnapshotsByTeam: nextMemberSpawnSnapshots } : {}), - ...(nextProvisioningErrors ? { provisioningErrorByTeam: nextProvisioningErrors } : {}), - }; -} - -function buildTeamScopedProgressTombstones( - state: Pick< - TeamSlice, - | 'currentProvisioningRunIdByTeam' - | 'currentRuntimeRunIdByTeam' - | 'ignoredProvisioningRunIds' - | 'ignoredRuntimeRunIds' - | 'provisioningStartedAtFloorByTeam' - >, - teamName: string, - floor: string -): Pick< - TeamSlice, - 'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam' -> { - const nextIgnoredProvisioningRunIds = { ...state.ignoredProvisioningRunIds }; - const nextIgnoredRuntimeRunIds = { ...state.ignoredRuntimeRunIds }; - - const currentProvisioningRunId = state.currentProvisioningRunIdByTeam[teamName]; - const currentRuntimeRunId = state.currentRuntimeRunIdByTeam[teamName]; - if (currentProvisioningRunId) { - nextIgnoredProvisioningRunIds[currentProvisioningRunId] = teamName; - } - if (currentRuntimeRunId) { - nextIgnoredRuntimeRunIds[currentRuntimeRunId] = teamName; - } - - return { - ignoredProvisioningRunIds: nextIgnoredProvisioningRunIds, - ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, - provisioningStartedAtFloorByTeam: { - ...state.provisioningStartedAtFloorByTeam, - [teamName]: floor, - }, - }; -} - function beginInFlightTeamDataRefresh(teamName: string): symbol { const token = Symbol(teamName); const existing = inFlightRefreshTeamDataCalls.get(teamName); diff --git a/src/renderer/store/team/teamScopedStateCleanup.ts b/src/renderer/store/team/teamScopedStateCleanup.ts new file mode 100644 index 00000000..a91e89ef --- /dev/null +++ b/src/renderer/store/team/teamScopedStateCleanup.ts @@ -0,0 +1,190 @@ +interface TeamMessagesLoadingEntry { + loadingHead: boolean; + loadingOlder: boolean; +} + +interface TeamScopedVisibleLoadingResetState< + TTeamMessagesEntry extends TeamMessagesLoadingEntry, +> { + teamMessagesByName: Record; + selectedTeamName: string | null; + selectedTeamLoading: boolean; + selectedTeamError: string | null; +} + +interface TeamScopedProvisioningRun { + teamName: string; +} + +type TeamScopedRecord = Record; + +interface TeamScopedStateRemovalState< + TProvisioningRun extends TeamScopedProvisioningRun = TeamScopedProvisioningRun, +> { + provisioningRuns: Record; + teamDataCacheByName: TeamScopedRecord; + teamAgentRuntimeByTeam: TeamScopedRecord; + teamMessagesByName: TeamScopedRecord; + memberActivityMetaByTeam: TeamScopedRecord; + provisioningSnapshotByTeam: TeamScopedRecord; + currentProvisioningRunIdByTeam: TeamScopedRecord; + currentRuntimeRunIdByTeam: TeamScopedRecord; + provisioningStartedAtFloorByTeam: TeamScopedRecord; + leadActivityByTeam: TeamScopedRecord; + leadContextByTeam: TeamScopedRecord; + activeTaskLogActivityByTeam: TeamScopedRecord; + activeToolsByTeam: TeamScopedRecord; + finishedVisibleByTeam: TeamScopedRecord; + toolHistoryByTeam: TeamScopedRecord; + memberSpawnStatusesByTeam: TeamScopedRecord; + memberSpawnSnapshotsByTeam: TeamScopedRecord; + provisioningErrorByTeam: TeamScopedRecord; +} + +type TeamScopedStateRemovalKey = keyof TeamScopedStateRemovalState; + +interface TeamScopedProgressTombstoneState { + currentProvisioningRunIdByTeam: Record; + currentRuntimeRunIdByTeam: Record; + ignoredProvisioningRunIds: Record; + ignoredRuntimeRunIds: Record; + provisioningStartedAtFloorByTeam: Record; +} + +export function collectTeamScopedVisibleLoadingResets< + TTeamMessagesEntry extends TeamMessagesLoadingEntry, +>( + state: TeamScopedVisibleLoadingResetState, + teamName: string +): Partial> { + const nextTeamMessagesEntry = state.teamMessagesByName[teamName]; + const nextTeamMessagesByName = + nextTeamMessagesEntry && + (nextTeamMessagesEntry.loadingHead || nextTeamMessagesEntry.loadingOlder) + ? { + ...state.teamMessagesByName, + [teamName]: { + ...nextTeamMessagesEntry, + loadingHead: false, + loadingOlder: false, + } as TTeamMessagesEntry, + } + : null; + + const shouldResetSelectedSurface = + state.selectedTeamName === teamName && + (state.selectedTeamLoading || state.selectedTeamError != null); + + return { + ...(nextTeamMessagesByName ? { teamMessagesByName: nextTeamMessagesByName } : {}), + ...(shouldResetSelectedSurface + ? { + selectedTeamLoading: false, + selectedTeamError: null, + } + : {}), + }; +} + +function omitTeamKey>( + record: TRecord, + teamName: string +): TRecord | null { + if (!(teamName in record)) { + return null; + } + const next = { ...record }; + delete next[teamName]; + return next; +} + +export function collectTeamScopedStateRemovals( + state: TState, + teamName: string +): Partial> { + const nextProvisioningRuns = Object.fromEntries( + Object.entries(state.provisioningRuns).filter(([, run]) => run.teamName !== teamName) + ) as TState['provisioningRuns']; + const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName); + const nextTeamAgentRuntime = omitTeamKey(state.teamAgentRuntimeByTeam, teamName); + const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName); + const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName); + const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName); + const nextCurrentProvisioningRunId = omitTeamKey(state.currentProvisioningRunIdByTeam, teamName); + const nextCurrentRuntimeRunId = omitTeamKey(state.currentRuntimeRunIdByTeam, teamName); + const nextProvisioningStartedAtFloor = omitTeamKey( + state.provisioningStartedAtFloorByTeam, + teamName + ); + const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName); + const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName); + const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName); + const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName); + const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName); + const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName); + const nextMemberSpawnStatuses = omitTeamKey(state.memberSpawnStatusesByTeam, teamName); + const nextMemberSpawnSnapshots = omitTeamKey(state.memberSpawnSnapshotsByTeam, teamName); + const nextProvisioningErrors = omitTeamKey(state.provisioningErrorByTeam, teamName); + + return { + ...(Object.keys(nextProvisioningRuns).length !== Object.keys(state.provisioningRuns).length + ? { provisioningRuns: nextProvisioningRuns } + : {}), + ...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}), + ...(nextTeamAgentRuntime ? { teamAgentRuntimeByTeam: nextTeamAgentRuntime } : {}), + ...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}), + ...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}), + ...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}), + ...(nextCurrentProvisioningRunId + ? { currentProvisioningRunIdByTeam: nextCurrentProvisioningRunId } + : {}), + ...(nextCurrentRuntimeRunId ? { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunId } : {}), + ...(nextProvisioningStartedAtFloor + ? { provisioningStartedAtFloorByTeam: nextProvisioningStartedAtFloor } + : {}), + ...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}), + ...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}), + ...(nextActiveTaskLogActivity + ? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity } + : {}), + ...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}), + ...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}), + ...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}), + ...(nextMemberSpawnStatuses ? { memberSpawnStatusesByTeam: nextMemberSpawnStatuses } : {}), + ...(nextMemberSpawnSnapshots ? { memberSpawnSnapshotsByTeam: nextMemberSpawnSnapshots } : {}), + ...(nextProvisioningErrors ? { provisioningErrorByTeam: nextProvisioningErrors } : {}), + }; +} + +export function buildTeamScopedProgressTombstones( + state: TState, + teamName: string, + floor: string +): Pick< + TState, + 'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam' +> { + const nextIgnoredProvisioningRunIds = { ...state.ignoredProvisioningRunIds }; + const nextIgnoredRuntimeRunIds = { ...state.ignoredRuntimeRunIds }; + + const currentProvisioningRunId = state.currentProvisioningRunIdByTeam[teamName]; + const currentRuntimeRunId = state.currentRuntimeRunIdByTeam[teamName]; + if (currentProvisioningRunId) { + nextIgnoredProvisioningRunIds[currentProvisioningRunId] = teamName; + } + if (currentRuntimeRunId) { + nextIgnoredRuntimeRunIds[currentRuntimeRunId] = teamName; + } + + return { + ignoredProvisioningRunIds: nextIgnoredProvisioningRunIds, + ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, + provisioningStartedAtFloorByTeam: { + ...state.provisioningStartedAtFloorByTeam, + [teamName]: floor, + }, + } as Pick< + TState, + 'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam' + >; +} diff --git a/test/renderer/store/teamScopedStateCleanup.test.ts b/test/renderer/store/teamScopedStateCleanup.test.ts new file mode 100644 index 00000000..e071fd63 --- /dev/null +++ b/test/renderer/store/teamScopedStateCleanup.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildTeamScopedProgressTombstones, + collectTeamScopedStateRemovals, + collectTeamScopedVisibleLoadingResets, +} from '../../../src/renderer/store/team/teamScopedStateCleanup'; + +const teamScopedRecordKeys = [ + 'teamDataCacheByName', + 'teamAgentRuntimeByTeam', + 'teamMessagesByName', + 'memberActivityMetaByTeam', + 'provisioningSnapshotByTeam', + 'currentProvisioningRunIdByTeam', + 'currentRuntimeRunIdByTeam', + 'provisioningStartedAtFloorByTeam', + 'leadActivityByTeam', + 'leadContextByTeam', + 'activeTaskLogActivityByTeam', + 'activeToolsByTeam', + 'finishedVisibleByTeam', + 'toolHistoryByTeam', + 'memberSpawnStatusesByTeam', + 'memberSpawnSnapshotsByTeam', + 'provisioningErrorByTeam', +] as const; + +function buildRecord(label: string): Record { + return { + 'my-team': `${label}:mine`, + 'other-team': `${label}:other`, + }; +} + +function buildRemovalState(): Parameters[0] { + return { + provisioningRuns: { + 'run-mine-1': { teamName: 'my-team' }, + 'run-other': { teamName: 'other-team' }, + 'run-mine-2': { teamName: 'my-team' }, + }, + teamDataCacheByName: buildRecord('teamDataCacheByName'), + teamAgentRuntimeByTeam: buildRecord('teamAgentRuntimeByTeam'), + teamMessagesByName: buildRecord('teamMessagesByName'), + memberActivityMetaByTeam: buildRecord('memberActivityMetaByTeam'), + provisioningSnapshotByTeam: buildRecord('provisioningSnapshotByTeam'), + currentProvisioningRunIdByTeam: buildRecord('currentProvisioningRunIdByTeam'), + currentRuntimeRunIdByTeam: buildRecord('currentRuntimeRunIdByTeam'), + provisioningStartedAtFloorByTeam: buildRecord('provisioningStartedAtFloorByTeam'), + leadActivityByTeam: buildRecord('leadActivityByTeam'), + leadContextByTeam: buildRecord('leadContextByTeam'), + activeTaskLogActivityByTeam: buildRecord('activeTaskLogActivityByTeam'), + activeToolsByTeam: buildRecord('activeToolsByTeam'), + finishedVisibleByTeam: buildRecord('finishedVisibleByTeam'), + toolHistoryByTeam: buildRecord('toolHistoryByTeam'), + memberSpawnStatusesByTeam: buildRecord('memberSpawnStatusesByTeam'), + memberSpawnSnapshotsByTeam: buildRecord('memberSpawnSnapshotsByTeam'), + provisioningErrorByTeam: buildRecord('provisioningErrorByTeam'), + }; +} + +describe('teamScopedStateCleanup', () => { + it('resets visible team loading and message loading flags for the scoped team', () => { + const otherEntry = { + loadingHead: true, + loadingOlder: false, + marker: 'other', + }; + const patch = collectTeamScopedVisibleLoadingResets( + { + teamMessagesByName: { + 'my-team': { + loadingHead: true, + loadingOlder: true, + marker: 'mine', + }, + 'other-team': otherEntry, + }, + selectedTeamName: 'my-team', + selectedTeamLoading: true, + selectedTeamError: 'Boom', + }, + 'my-team' + ); + + expect(patch).toEqual({ + teamMessagesByName: { + 'my-team': { + loadingHead: false, + loadingOlder: false, + marker: 'mine', + }, + 'other-team': otherEntry, + }, + selectedTeamLoading: false, + selectedTeamError: null, + }); + }); + + it('does not emit visible loading changes when the scoped team is already idle', () => { + const patch = collectTeamScopedVisibleLoadingResets( + { + teamMessagesByName: { + 'my-team': { + loadingHead: false, + loadingOlder: false, + }, + }, + selectedTeamName: 'other-team', + selectedTeamLoading: false, + selectedTeamError: null, + }, + 'my-team' + ); + + expect(patch).toEqual({}); + }); + + it('removes scoped team records and provisioning runs while preserving other teams', () => { + const patch = collectTeamScopedStateRemovals(buildRemovalState(), 'my-team'); + + expect(patch.provisioningRuns).toEqual({ + 'run-other': { teamName: 'other-team' }, + }); + for (const key of teamScopedRecordKeys) { + expect(patch[key]).toEqual({ + 'other-team': `${key}:other`, + }); + } + }); + + it('does not emit removal changes when the team is absent', () => { + const state = buildRemovalState(); + const patch = collectTeamScopedStateRemovals(state, 'missing-team'); + + expect(patch).toEqual({}); + }); + + it('tombstones current provisioning and runtime run ids for the scoped team', () => { + const tombstones = buildTeamScopedProgressTombstones( + { + currentProvisioningRunIdByTeam: { + 'my-team': 'provisioning-run-1', + 'other-team': 'provisioning-run-2', + }, + currentRuntimeRunIdByTeam: { + 'my-team': 'runtime-run-1', + }, + ignoredProvisioningRunIds: { + old: 'old-team', + }, + ignoredRuntimeRunIds: { + 'old-runtime': 'old-team', + }, + provisioningStartedAtFloorByTeam: { + 'other-team': '2026-01-01T00:00:00.000Z', + }, + }, + 'my-team', + '2026-05-22T10:00:00.000Z' + ); + + expect(tombstones).toEqual({ + ignoredProvisioningRunIds: { + old: 'old-team', + 'provisioning-run-1': 'my-team', + }, + ignoredRuntimeRunIds: { + 'old-runtime': 'old-team', + 'runtime-run-1': 'my-team', + }, + provisioningStartedAtFloorByTeam: { + 'other-team': '2026-01-01T00:00:00.000Z', + 'my-team': '2026-05-22T10:00:00.000Z', + }, + }); + }); + + it('still records a floor when there are no current run ids to tombstone', () => { + const tombstones = buildTeamScopedProgressTombstones( + { + currentProvisioningRunIdByTeam: {}, + currentRuntimeRunIdByTeam: { + 'my-team': null, + }, + ignoredProvisioningRunIds: {}, + ignoredRuntimeRunIds: {}, + provisioningStartedAtFloorByTeam: {}, + }, + 'my-team', + '2026-05-22T10:00:00.000Z' + ); + + expect(tombstones).toEqual({ + ignoredProvisioningRunIds: {}, + ignoredRuntimeRunIds: {}, + provisioningStartedAtFloorByTeam: { + 'my-team': '2026-05-22T10:00:00.000Z', + }, + }); + }); +}); From e2031bf928201ccd7414a747b9bca44e24e89d75 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 10:48:08 +0300 Subject: [PATCH 12/41] refactor(team): extract snapshot structural sharing --- src/renderer/store/slices/teamSlice.ts | 61 +-------- .../team/teamSnapshotStructuralSharing.ts | 61 +++++++++ .../teamSnapshotStructuralSharing.test.ts | 117 ++++++++++++++++++ 3 files changed, 179 insertions(+), 60 deletions(-) create mode 100644 src/renderer/store/team/teamSnapshotStructuralSharing.ts create mode 100644 test/renderer/store/teamSnapshotStructuralSharing.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 4a14cfd5..8947b797 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -91,6 +91,7 @@ import { collectTeamScopedStateRemovals, collectTeamScopedVisibleLoadingResets, } from '../team/teamScopedStateCleanup'; +import { structurallyShareTeamSnapshot } from '../team/teamSnapshotStructuralSharing'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; @@ -532,66 +533,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function isPlainObject(value: unknown): value is Record { - if (value == null || typeof value !== 'object') { - return false; - } - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -function structurallySharePlainValue(previous: T, next: T): T { - if (Object.is(previous, next)) { - return previous; - } - - if (Array.isArray(previous) && Array.isArray(next)) { - let changed = previous.length !== next.length; - const result = next.map((nextItem, index) => { - const sharedItem = structurallySharePlainValue(previous[index], nextItem); - if (!Object.is(sharedItem, previous[index])) { - changed = true; - } - return sharedItem; - }); - return changed ? (result as T) : previous; - } - - if (isPlainObject(previous) && isPlainObject(next)) { - const previousRecord = previous as Record; - const nextRecord = next as Record; - const previousKeys = Object.keys(previousRecord); - const nextKeys = Object.keys(nextRecord); - let changed = previousKeys.length !== nextKeys.length; - const result: Record = {}; - - for (const key of nextKeys) { - if (!Object.prototype.hasOwnProperty.call(previousRecord, key)) { - changed = true; - } - const sharedValue = structurallySharePlainValue(previousRecord[key], nextRecord[key]); - if (!Object.is(sharedValue, previousRecord[key])) { - changed = true; - } - result[key] = sharedValue; - } - - return changed ? (result as T) : previous; - } - - return next; -} - -function structurallyShareTeamSnapshot( - previous: TeamViewSnapshot | null | undefined, - next: TeamViewSnapshot -): TeamViewSnapshot { - if (!previous) { - return next; - } - return structurallySharePlainValue(previous, next); -} - const ACTIVE_PROVISIONING_STATES = new Set([ 'validating', 'spawning', diff --git a/src/renderer/store/team/teamSnapshotStructuralSharing.ts b/src/renderer/store/team/teamSnapshotStructuralSharing.ts new file mode 100644 index 00000000..f90500e3 --- /dev/null +++ b/src/renderer/store/team/teamSnapshotStructuralSharing.ts @@ -0,0 +1,61 @@ +import type { TeamViewSnapshot } from '@shared/types'; + +function isPlainObject(value: unknown): value is Record { + if (value == null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +export function structurallySharePlainValue(previous: T, next: T): T { + if (Object.is(previous, next)) { + return previous; + } + + if (Array.isArray(previous) && Array.isArray(next)) { + let changed = previous.length !== next.length; + const result = next.map((nextItem, index) => { + const sharedItem = structurallySharePlainValue(previous[index], nextItem); + if (!Object.is(sharedItem, previous[index])) { + changed = true; + } + return sharedItem; + }); + return changed ? (result as T) : previous; + } + + if (isPlainObject(previous) && isPlainObject(next)) { + const previousRecord = previous as Record; + const nextRecord = next as Record; + const previousKeys = Object.keys(previousRecord); + const nextKeys = Object.keys(nextRecord); + let changed = previousKeys.length !== nextKeys.length; + const result: Record = {}; + + for (const key of nextKeys) { + if (!Object.prototype.hasOwnProperty.call(previousRecord, key)) { + changed = true; + } + const sharedValue = structurallySharePlainValue(previousRecord[key], nextRecord[key]); + if (!Object.is(sharedValue, previousRecord[key])) { + changed = true; + } + result[key] = sharedValue; + } + + return changed ? (result as T) : previous; + } + + return next; +} + +export function structurallyShareTeamSnapshot( + previous: TeamViewSnapshot | null | undefined, + next: TeamViewSnapshot +): TeamViewSnapshot { + if (!previous) { + return next; + } + return structurallySharePlainValue(previous, next); +} diff --git a/test/renderer/store/teamSnapshotStructuralSharing.test.ts b/test/renderer/store/teamSnapshotStructuralSharing.test.ts new file mode 100644 index 00000000..c5020f40 --- /dev/null +++ b/test/renderer/store/teamSnapshotStructuralSharing.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { + structurallySharePlainValue, + structurallyShareTeamSnapshot, +} from '../../../src/renderer/store/team/teamSnapshotStructuralSharing'; + +import type { TeamViewSnapshot } from '../../../src/shared/types'; + +function createSnapshot(overrides: Partial = {}): TeamViewSnapshot { + return { + teamName: 'my-team', + config: { name: 'My Team' }, + members: [], + tasks: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + ...overrides, + }; +} + +describe('teamSnapshotStructuralSharing', () => { + it('returns the next snapshot when there is no previous snapshot', () => { + const next = createSnapshot(); + + expect(structurallyShareTeamSnapshot(null, next)).toBe(next); + expect(structurallyShareTeamSnapshot(undefined, next)).toBe(next); + }); + + it('preserves the previous snapshot reference when values are deeply equal', () => { + const previous = createSnapshot({ + config: { name: 'My Team', description: 'Same description' }, + warnings: ['same warning'], + isAlive: true, + }); + const next = createSnapshot({ + config: { name: 'My Team', description: 'Same description' }, + warnings: ['same warning'], + isAlive: true, + }); + + expect(structurallyShareTeamSnapshot(previous, next)).toBe(previous); + }); + + it('replaces only changed snapshot branches while sharing unchanged branches', () => { + const previousWarnings = ['same warning']; + const previous = createSnapshot({ + config: { name: 'My Team', description: 'Old description' }, + warnings: previousWarnings, + isAlive: true, + }); + const next = createSnapshot({ + config: { name: 'My Team', description: 'New description' }, + warnings: ['same warning'], + isAlive: true, + }); + + const shared = structurallyShareTeamSnapshot(previous, next); + + expect(shared).not.toBe(previous); + expect(shared).toEqual(next); + expect(shared.config).not.toBe(previous.config); + expect(shared.warnings).toBe(previousWarnings); + expect(shared.members).toBe(previous.members); + expect(shared.tasks).toBe(previous.tasks); + expect(shared.kanbanState).toBe(previous.kanbanState); + expect(shared.processes).toBe(previous.processes); + }); + + it('shares unchanged array entries and replaces changed entries', () => { + const previous = [ + { id: 'task-1', title: 'Keep' }, + { id: 'task-2', title: 'Old' }, + ]; + const next = [ + { id: 'task-1', title: 'Keep' }, + { id: 'task-2', title: 'New' }, + ]; + + const shared = structurallySharePlainValue(previous, next); + + expect(shared).not.toBe(previous); + expect(shared).toEqual(next); + expect(shared[0]).toBe(previous[0]); + expect(shared[1]).not.toBe(previous[1]); + }); + + it('replaces objects when keys are added or removed', () => { + const previous = { id: 'task-1', title: 'Same', extra: true }; + const next = { id: 'task-1', title: 'Same' }; + + const shared = structurallySharePlainValue(previous, next); + + expect(shared).not.toBe(previous); + expect(shared).toEqual(next); + }); + + it('treats null-prototype objects as plain values', () => { + const previous = Object.assign(Object.create(null) as Record, { + id: 'task-1', + title: 'Same', + }); + const next = Object.assign(Object.create(null) as Record, { + id: 'task-1', + title: 'Same', + }); + + expect(structurallySharePlainValue(previous, next)).toBe(previous); + }); + + it('replaces non-plain objects instead of traversing them', () => { + const previous = new Date('2026-05-22T10:00:00.000Z'); + const next = new Date('2026-05-22T10:00:00.000Z'); + + expect(structurallySharePlainValue(previous, next)).toBe(next); + }); +}); From 0a1e4c6e8b85ec843fa31fb0fb9b047a7432db7a Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 10:56:12 +0300 Subject: [PATCH 13/41] refactor(team): extract member spawn snapshot equality --- src/renderer/store/slices/teamSlice.ts | 103 +--------- .../team/teamMemberSpawnSnapshotEquality.ts | 106 ++++++++++ .../teamMemberSpawnSnapshotEquality.test.ts | 186 ++++++++++++++++++ 3 files changed, 293 insertions(+), 102 deletions(-) create mode 100644 src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts create mode 100644 test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 8947b797..07cc892f 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -51,6 +51,7 @@ import { invalidateTeamLocalStateEpoch, isTeamLocalStateEpochCurrent, } from '../team/teamLocalStateEpoch'; +import { areMemberSpawnSnapshotsSemanticallyEqual } from '../team/teamMemberSpawnSnapshotEquality'; import { clearAllMemberSpawnStatusesIpcBackoffs, clearMemberSpawnStatusesIpcBackoff, @@ -120,7 +121,6 @@ import type { MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, NotificationTarget, - PersistedTeamLaunchSummary, ResolvedTeamMember, RetryFailedOpenCodeSecondaryLanesResult, SendMessageRequest, @@ -626,107 +626,6 @@ function fetchTeamDataFresh( ); } -function areLaunchSummaryCountsEqual( - left: PersistedTeamLaunchSummary | undefined, - right: PersistedTeamLaunchSummary | undefined -): boolean { - if (left === right) return true; - if (!left || !right) return left === right; - return ( - left.confirmedCount === right.confirmedCount && - left.pendingCount === right.pendingCount && - left.failedCount === right.failedCount && - left.skippedCount === right.skippedCount && - left.runtimeAlivePendingCount === right.runtimeAlivePendingCount && - left.shellOnlyPendingCount === right.shellOnlyPendingCount && - left.runtimeProcessPendingCount === right.runtimeProcessPendingCount && - left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount && - left.noRuntimePendingCount === right.noRuntimePendingCount && - left.permissionPendingCount === right.permissionPendingCount - ); -} - -function areExpectedMembersEqual( - left: readonly string[] | undefined, - right: readonly string[] | undefined -): boolean { - if (left === right) return true; - if (!left || !right) return left === right; - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - if (left[index] !== right[index]) { - return false; - } - } - return true; -} - -function areMemberSpawnStatusEntriesEqual( - left: MemberSpawnStatusEntry | undefined, - right: MemberSpawnStatusEntry | undefined -): boolean { - if (left === right) return true; - if (!left || !right) return left === right; - const leftPendingPermissionIds = [...(left.pendingPermissionRequestIds ?? [])].sort(); - const rightPendingPermissionIds = [...(right.pendingPermissionRequestIds ?? [])].sort(); - // Renderer equality intentionally ignores raw timing fields that do not change - // visible member status. This suppresses heartbeat-only churn in TeamDetailView. - return ( - left.status === right.status && - left.launchState === right.launchState && - left.error === right.error && - left.hardFailureReason === right.hardFailureReason && - left.skippedForLaunch === right.skippedForLaunch && - left.skipReason === right.skipReason && - left.skippedAt === right.skippedAt && - left.livenessSource === right.livenessSource && - left.runtimeAlive === right.runtimeAlive && - left.runtimeModel === right.runtimeModel && - left.livenessKind === right.livenessKind && - left.runtimeDiagnostic === right.runtimeDiagnostic && - left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && - left.bootstrapConfirmed === right.bootstrapConfirmed && - left.hardFailure === right.hardFailure && - leftPendingPermissionIds.length === rightPendingPermissionIds.length && - leftPendingPermissionIds.every((value, index) => value === rightPendingPermissionIds[index]) - ); -} - -function areMemberSpawnStatusesEqual( - left: Record, - right: Record -): boolean { - if (left === right) return true; - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - if (leftKeys.length !== rightKeys.length) return false; - for (const key of leftKeys) { - if (!(key in right)) { - return false; - } - if (!areMemberSpawnStatusEntriesEqual(left[key], right[key])) { - return false; - } - } - return true; -} - -function areMemberSpawnSnapshotsSemanticallyEqual( - left: MemberSpawnStatusesSnapshot | undefined, - right: MemberSpawnStatusesSnapshot -): boolean { - if (!left) return false; - return ( - left.runId === right.runId && - left.teamLaunchState === right.teamLaunchState && - left.launchPhase === right.launchPhase && - left.source === right.source && - areExpectedMembersEqual(left.expectedMembers, right.expectedMembers) && - areLaunchSummaryCountsEqual(left.summary, right.summary) && - areMemberSpawnStatusesEqual(left.statuses, right.statuses) - ); -} - function maybeLogMemberSpawnUiEqualSuppressed( teamName: string, runId: string | null | undefined diff --git a/src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts b/src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts new file mode 100644 index 00000000..3f5990e2 --- /dev/null +++ b/src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts @@ -0,0 +1,106 @@ +import type { + MemberSpawnStatusEntry, + MemberSpawnStatusesSnapshot, + PersistedTeamLaunchSummary, +} from '@shared/types'; + +export function areLaunchSummaryCountsEqual( + left: PersistedTeamLaunchSummary | undefined, + right: PersistedTeamLaunchSummary | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + return ( + left.confirmedCount === right.confirmedCount && + left.pendingCount === right.pendingCount && + left.failedCount === right.failedCount && + left.skippedCount === right.skippedCount && + left.runtimeAlivePendingCount === right.runtimeAlivePendingCount && + left.shellOnlyPendingCount === right.shellOnlyPendingCount && + left.runtimeProcessPendingCount === right.runtimeProcessPendingCount && + left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount && + left.noRuntimePendingCount === right.noRuntimePendingCount && + left.permissionPendingCount === right.permissionPendingCount + ); +} + +export function areExpectedMembersEqual( + left: readonly string[] | undefined, + right: readonly string[] | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + +export function areMemberSpawnStatusEntriesEqual( + left: MemberSpawnStatusEntry | undefined, + right: MemberSpawnStatusEntry | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + const leftPendingPermissionIds = [...(left.pendingPermissionRequestIds ?? [])].sort(); + const rightPendingPermissionIds = [...(right.pendingPermissionRequestIds ?? [])].sort(); + // Renderer equality intentionally ignores raw timing fields that do not change + // visible member status. This suppresses heartbeat-only churn in TeamDetailView. + return ( + left.status === right.status && + left.launchState === right.launchState && + left.error === right.error && + left.hardFailureReason === right.hardFailureReason && + left.skippedForLaunch === right.skippedForLaunch && + left.skipReason === right.skipReason && + left.skippedAt === right.skippedAt && + left.livenessSource === right.livenessSource && + left.runtimeAlive === right.runtimeAlive && + left.runtimeModel === right.runtimeModel && + left.livenessKind === right.livenessKind && + left.runtimeDiagnostic === right.runtimeDiagnostic && + left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && + left.bootstrapConfirmed === right.bootstrapConfirmed && + left.hardFailure === right.hardFailure && + leftPendingPermissionIds.length === rightPendingPermissionIds.length && + leftPendingPermissionIds.every((value, index) => value === rightPendingPermissionIds[index]) + ); +} + +export function areMemberSpawnStatusesEqual( + left: Record, + right: Record +): boolean { + if (left === right) return true; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + for (const key of leftKeys) { + if (!(key in right)) { + return false; + } + if (!areMemberSpawnStatusEntriesEqual(left[key], right[key])) { + return false; + } + } + return true; +} + +export function areMemberSpawnSnapshotsSemanticallyEqual( + left: MemberSpawnStatusesSnapshot | undefined, + right: MemberSpawnStatusesSnapshot +): boolean { + if (!left) return false; + return ( + left.runId === right.runId && + left.teamLaunchState === right.teamLaunchState && + left.launchPhase === right.launchPhase && + left.source === right.source && + areExpectedMembersEqual(left.expectedMembers, right.expectedMembers) && + areLaunchSummaryCountsEqual(left.summary, right.summary) && + areMemberSpawnStatusesEqual(left.statuses, right.statuses) + ); +} diff --git a/test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts b/test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts new file mode 100644 index 00000000..c372ef09 --- /dev/null +++ b/test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; + +import { + areExpectedMembersEqual, + areLaunchSummaryCountsEqual, + areMemberSpawnSnapshotsSemanticallyEqual, + areMemberSpawnStatusEntriesEqual, + areMemberSpawnStatusesEqual, +} from '../../../src/renderer/store/team/teamMemberSpawnSnapshotEquality'; + +import type { + MemberSpawnStatusEntry, + MemberSpawnStatusesSnapshot, + PersistedTeamLaunchSummary, +} from '../../../src/shared/types'; + +function createSummary( + overrides: Partial = {} +): PersistedTeamLaunchSummary { + return { + confirmedCount: 1, + pendingCount: 0, + failedCount: 0, + skippedCount: 0, + runtimeAlivePendingCount: 0, + shellOnlyPendingCount: 0, + runtimeProcessPendingCount: 0, + runtimeCandidatePendingCount: 0, + noRuntimePendingCount: 0, + permissionPendingCount: 0, + ...overrides, + }; +} + +function createStatusEntry( + overrides: Partial = {} +): MemberSpawnStatusEntry { + return { + status: 'online', + launchState: 'confirmed_alive', + updatedAt: '2026-05-22T10:00:00.000Z', + livenessSource: 'heartbeat', + runtimeAlive: true, + runtimeModel: 'gpt-5.3-codex', + livenessKind: 'confirmed_bootstrap', + runtimeDiagnostic: 'Ready', + runtimeDiagnosticSeverity: 'info', + bootstrapConfirmed: true, + hardFailure: false, + pendingPermissionRequestIds: ['perm-a', 'perm-b'], + ...overrides, + }; +} + +function createSnapshot( + overrides: Partial = {} +): MemberSpawnStatusesSnapshot { + return { + statuses: { + alice: createStatusEntry(), + }, + runId: 'run-1', + teamLaunchState: 'clean_success', + launchPhase: 'active', + expectedMembers: ['alice'], + updatedAt: '2026-05-22T10:00:00.000Z', + summary: createSummary(), + source: 'live', + ...overrides, + }; +} + +describe('teamMemberSpawnSnapshotEquality', () => { + it('compares launch summaries by visible counts', () => { + expect(areLaunchSummaryCountsEqual(createSummary(), createSummary())).toBe(true); + expect( + areLaunchSummaryCountsEqual(createSummary(), createSummary({ permissionPendingCount: 1 })) + ).toBe(false); + expect(areLaunchSummaryCountsEqual(undefined, undefined)).toBe(true); + expect(areLaunchSummaryCountsEqual(undefined, createSummary())).toBe(false); + }); + + it('compares expected members in stable order', () => { + expect(areExpectedMembersEqual(['alice', 'bob'], ['alice', 'bob'])).toBe(true); + expect(areExpectedMembersEqual(['alice', 'bob'], ['bob', 'alice'])).toBe(false); + expect(areExpectedMembersEqual(undefined, undefined)).toBe(true); + expect(areExpectedMembersEqual(undefined, [])).toBe(false); + }); + + it('ignores non-visible status churn and unordered pending permission ids', () => { + const left = createStatusEntry({ + pendingPermissionRequestIds: ['perm-b', 'perm-a'], + updatedAt: '2026-05-22T10:00:00.000Z', + agentToolAccepted: true, + firstSpawnAcceptedAt: '2026-05-22T10:00:01.000Z', + lastHeartbeatAt: '2026-05-22T10:00:02.000Z', + livenessLastCheckedAt: '2026-05-22T10:00:03.000Z', + bootstrapStalled: true, + }); + const right = createStatusEntry({ + pendingPermissionRequestIds: ['perm-a', 'perm-b'], + updatedAt: '2026-05-22T10:05:00.000Z', + agentToolAccepted: false, + firstSpawnAcceptedAt: '2026-05-22T10:05:01.000Z', + lastHeartbeatAt: '2026-05-22T10:05:02.000Z', + livenessLastCheckedAt: '2026-05-22T10:05:03.000Z', + bootstrapStalled: false, + }); + + expect(areMemberSpawnStatusEntriesEqual(left, right)).toBe(true); + }); + + it('detects visible status entry changes', () => { + expect( + areMemberSpawnStatusEntriesEqual( + createStatusEntry(), + createStatusEntry({ runtimeDiagnosticSeverity: 'warning' }) + ) + ).toBe(false); + expect( + areMemberSpawnStatusEntriesEqual( + createStatusEntry(), + createStatusEntry({ pendingPermissionRequestIds: ['perm-a'] }) + ) + ).toBe(false); + }); + + it('compares per-member status maps by keys and semantic entries', () => { + expect( + areMemberSpawnStatusesEqual( + { + alice: createStatusEntry(), + bob: createStatusEntry({ runtimeModel: 'gpt-5.4' }), + }, + { + bob: createStatusEntry({ runtimeModel: 'gpt-5.4' }), + alice: createStatusEntry(), + } + ) + ).toBe(true); + expect( + areMemberSpawnStatusesEqual( + { + alice: createStatusEntry(), + }, + { + alice: createStatusEntry(), + bob: createStatusEntry(), + } + ) + ).toBe(false); + }); + + it('compares snapshots by semantic launch fields and ignores snapshot updatedAt churn', () => { + const left = createSnapshot({ + updatedAt: '2026-05-22T10:00:00.000Z', + }); + const right = createSnapshot({ + updatedAt: '2026-05-22T10:05:00.000Z', + statuses: { + alice: createStatusEntry({ + pendingPermissionRequestIds: ['perm-b', 'perm-a'], + updatedAt: '2026-05-22T10:05:00.000Z', + }), + }, + }); + + expect(areMemberSpawnSnapshotsSemanticallyEqual(left, right)).toBe(true); + }); + + it('detects semantic snapshot changes', () => { + expect( + areMemberSpawnSnapshotsSemanticallyEqual( + createSnapshot(), + createSnapshot({ runId: 'run-2' }) + ) + ).toBe(false); + expect( + areMemberSpawnSnapshotsSemanticallyEqual( + createSnapshot(), + createSnapshot({ expectedMembers: ['alice', 'bob'] }) + ) + ).toBe(false); + expect(areMemberSpawnSnapshotsSemanticallyEqual(undefined, createSnapshot())).toBe(false); + }); +}); From 3f7b79381639808540dca0504f551232fdc29091 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 11:01:56 +0300 Subject: [PATCH 14/41] refactor(team): extract runtime snapshot equality --- src/renderer/store/slices/teamSlice.ts | 106 +-------- .../team/teamAgentRuntimeSnapshotEquality.ts | 108 ++++++++++ .../teamAgentRuntimeSnapshotEquality.test.ts | 204 ++++++++++++++++++ 3 files changed, 313 insertions(+), 105 deletions(-) create mode 100644 src/renderer/store/team/teamAgentRuntimeSnapshotEquality.ts create mode 100644 test/renderer/store/teamAgentRuntimeSnapshotEquality.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 07cc892f..bf6b7dc6 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -29,6 +29,7 @@ import { isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; +import { areTeamAgentRuntimeSnapshotsEqual } from '../team/teamAgentRuntimeSnapshotEquality'; import { clearAllLastResolvedTeamDataRefreshes, clearLastResolvedTeamDataRefreshAt, @@ -127,8 +128,6 @@ import type { SendMessageResult, TaskChangePresenceState, TaskComment, - TeamAgentRuntimeEntry, - TeamAgentRuntimeResourceSample, TeamAgentRuntimeSnapshot, TeamCreateRequest, TeamGetDataOptions, @@ -643,109 +642,6 @@ function maybeLogMemberSpawnUiEqualSuppressed( ); } -function isTeamAgentRuntimeResourceSampleLike( - value: unknown -): value is TeamAgentRuntimeResourceSample { - return Boolean(value) && typeof value === 'object'; -} - -function areTeamAgentRuntimeResourceSamplesEqual(left: unknown, right: unknown): boolean { - if (left === right) return true; - if (!isTeamAgentRuntimeResourceSampleLike(left) || !isTeamAgentRuntimeResourceSampleLike(right)) { - return false; - } - return ( - left.timestamp === right.timestamp && - left.cpuPercent === right.cpuPercent && - left.rssBytes === right.rssBytes && - left.primaryCpuPercent === right.primaryCpuPercent && - left.primaryRssBytes === right.primaryRssBytes && - left.childCpuPercent === right.childCpuPercent && - left.childRssBytes === right.childRssBytes && - left.processCount === right.processCount && - left.runtimeLoadScope === right.runtimeLoadScope && - left.runtimeLoadTruncated === right.runtimeLoadTruncated && - left.pidSource === right.pidSource && - left.pid === right.pid && - left.runtimePid === right.runtimePid - ); -} - -function areTeamAgentRuntimeEntriesEqual( - left: TeamAgentRuntimeEntry | undefined, - right: TeamAgentRuntimeEntry | undefined -): boolean { - if (left === right) return true; - if (!left || !right) return left === right; - const leftDiagnostics = Array.isArray(left.diagnostics) ? left.diagnostics : []; - const rightDiagnostics = Array.isArray(right.diagnostics) ? right.diagnostics : []; - const leftResourceHistory = Array.isArray(left.resourceHistory) ? left.resourceHistory : []; - const rightResourceHistory = Array.isArray(right.resourceHistory) ? right.resourceHistory : []; - return ( - left.memberName === right.memberName && - left.alive === right.alive && - left.restartable === right.restartable && - left.backendType === right.backendType && - left.providerId === right.providerId && - left.providerBackendId === right.providerBackendId && - left.laneId === right.laneId && - left.laneKind === right.laneKind && - left.pid === right.pid && - left.runtimeModel === right.runtimeModel && - left.rssBytes === right.rssBytes && - left.cpuPercent === right.cpuPercent && - left.primaryCpuPercent === right.primaryCpuPercent && - left.primaryRssBytes === right.primaryRssBytes && - left.childCpuPercent === right.childCpuPercent && - left.childRssBytes === right.childRssBytes && - left.processCount === right.processCount && - left.runtimeLoadScope === right.runtimeLoadScope && - left.runtimeLoadTruncated === right.runtimeLoadTruncated && - left.livenessKind === right.livenessKind && - left.pidSource === right.pidSource && - left.processCommand === right.processCommand && - left.paneId === right.paneId && - left.panePid === right.panePid && - left.paneCurrentCommand === right.paneCurrentCommand && - left.runtimePid === right.runtimePid && - left.runtimeSessionId === right.runtimeSessionId && - left.runtimeDiagnostic === right.runtimeDiagnostic && - left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && - left.runtimeLastSeenAt === right.runtimeLastSeenAt && - left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed && - leftDiagnostics.length === rightDiagnostics.length && - leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) && - leftResourceHistory.length === rightResourceHistory.length && - leftResourceHistory.every((value, index) => - areTeamAgentRuntimeResourceSamplesEqual(value, rightResourceHistory[index]) - ) - ); -} - -function areTeamAgentRuntimeSnapshotsEqual( - left: TeamAgentRuntimeSnapshot | undefined, - right: TeamAgentRuntimeSnapshot -): boolean { - if (!left) return false; - if (left.teamName !== right.teamName || left.runId !== right.runId) { - return false; - } - const leftKeys = Object.keys(left.members); - const rightKeys = Object.keys(right.members); - if (leftKeys.length !== rightKeys.length) { - return false; - } - for (const key of leftKeys) { - if (!(key in right.members)) { - return false; - } - if (!areTeamAgentRuntimeEntriesEqual(left.members[key], right.members[key])) { - return false; - } - } - return true; -} - function clearPendingReplyRefreshTimer(teamName: string): void { const existingTimer = pendingTeamPendingReplyRefreshTimers.get(teamName); if (existingTimer == null) { diff --git a/src/renderer/store/team/teamAgentRuntimeSnapshotEquality.ts b/src/renderer/store/team/teamAgentRuntimeSnapshotEquality.ts new file mode 100644 index 00000000..8f776e83 --- /dev/null +++ b/src/renderer/store/team/teamAgentRuntimeSnapshotEquality.ts @@ -0,0 +1,108 @@ +import type { + TeamAgentRuntimeEntry, + TeamAgentRuntimeResourceSample, + TeamAgentRuntimeSnapshot, +} from '@shared/types'; + +function isTeamAgentRuntimeResourceSampleLike( + value: unknown +): value is TeamAgentRuntimeResourceSample { + return Boolean(value) && typeof value === 'object'; +} + +export function areTeamAgentRuntimeResourceSamplesEqual(left: unknown, right: unknown): boolean { + if (left === right) return true; + if (!isTeamAgentRuntimeResourceSampleLike(left) || !isTeamAgentRuntimeResourceSampleLike(right)) { + return false; + } + return ( + left.timestamp === right.timestamp && + left.cpuPercent === right.cpuPercent && + left.rssBytes === right.rssBytes && + left.primaryCpuPercent === right.primaryCpuPercent && + left.primaryRssBytes === right.primaryRssBytes && + left.childCpuPercent === right.childCpuPercent && + left.childRssBytes === right.childRssBytes && + left.processCount === right.processCount && + left.runtimeLoadScope === right.runtimeLoadScope && + left.runtimeLoadTruncated === right.runtimeLoadTruncated && + left.pidSource === right.pidSource && + left.pid === right.pid && + left.runtimePid === right.runtimePid + ); +} + +export function areTeamAgentRuntimeEntriesEqual( + left: TeamAgentRuntimeEntry | undefined, + right: TeamAgentRuntimeEntry | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + const leftDiagnostics = Array.isArray(left.diagnostics) ? left.diagnostics : []; + const rightDiagnostics = Array.isArray(right.diagnostics) ? right.diagnostics : []; + const leftResourceHistory = Array.isArray(left.resourceHistory) ? left.resourceHistory : []; + const rightResourceHistory = Array.isArray(right.resourceHistory) ? right.resourceHistory : []; + return ( + left.memberName === right.memberName && + left.alive === right.alive && + left.restartable === right.restartable && + left.backendType === right.backendType && + left.providerId === right.providerId && + left.providerBackendId === right.providerBackendId && + left.laneId === right.laneId && + left.laneKind === right.laneKind && + left.pid === right.pid && + left.runtimeModel === right.runtimeModel && + left.rssBytes === right.rssBytes && + left.cpuPercent === right.cpuPercent && + left.primaryCpuPercent === right.primaryCpuPercent && + left.primaryRssBytes === right.primaryRssBytes && + left.childCpuPercent === right.childCpuPercent && + left.childRssBytes === right.childRssBytes && + left.processCount === right.processCount && + left.runtimeLoadScope === right.runtimeLoadScope && + left.runtimeLoadTruncated === right.runtimeLoadTruncated && + left.livenessKind === right.livenessKind && + left.pidSource === right.pidSource && + left.processCommand === right.processCommand && + left.paneId === right.paneId && + left.panePid === right.panePid && + left.paneCurrentCommand === right.paneCurrentCommand && + left.runtimePid === right.runtimePid && + left.runtimeSessionId === right.runtimeSessionId && + left.runtimeDiagnostic === right.runtimeDiagnostic && + left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && + left.runtimeLastSeenAt === right.runtimeLastSeenAt && + left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed && + leftDiagnostics.length === rightDiagnostics.length && + leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) && + leftResourceHistory.length === rightResourceHistory.length && + leftResourceHistory.every((value, index) => + areTeamAgentRuntimeResourceSamplesEqual(value, rightResourceHistory[index]) + ) + ); +} + +export function areTeamAgentRuntimeSnapshotsEqual( + left: TeamAgentRuntimeSnapshot | undefined, + right: TeamAgentRuntimeSnapshot +): boolean { + if (!left) return false; + if (left.teamName !== right.teamName || left.runId !== right.runId) { + return false; + } + const leftKeys = Object.keys(left.members); + const rightKeys = Object.keys(right.members); + if (leftKeys.length !== rightKeys.length) { + return false; + } + for (const key of leftKeys) { + if (!(key in right.members)) { + return false; + } + if (!areTeamAgentRuntimeEntriesEqual(left.members[key], right.members[key])) { + return false; + } + } + return true; +} diff --git a/test/renderer/store/teamAgentRuntimeSnapshotEquality.test.ts b/test/renderer/store/teamAgentRuntimeSnapshotEquality.test.ts new file mode 100644 index 00000000..c940ce5c --- /dev/null +++ b/test/renderer/store/teamAgentRuntimeSnapshotEquality.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; + +import { + areTeamAgentRuntimeEntriesEqual, + areTeamAgentRuntimeResourceSamplesEqual, + areTeamAgentRuntimeSnapshotsEqual, +} from '../../../src/renderer/store/team/teamAgentRuntimeSnapshotEquality'; + +import type { + TeamAgentRuntimeEntry, + TeamAgentRuntimeResourceSample, + TeamAgentRuntimeSnapshot, +} from '../../../src/shared/types'; + +function createResourceSample( + overrides: Partial = {} +): TeamAgentRuntimeResourceSample { + return { + timestamp: '2026-05-22T10:00:00.000Z', + cpuPercent: 4, + rssBytes: 1024, + primaryCpuPercent: 3, + primaryRssBytes: 768, + childCpuPercent: 1, + childRssBytes: 256, + processCount: 2, + runtimeLoadScope: 'process-tree', + runtimeLoadTruncated: false, + pidSource: 'agent_process_table', + pid: 111, + runtimePid: 222, + ...overrides, + }; +} + +function createRuntimeEntry(overrides: Partial = {}): TeamAgentRuntimeEntry { + return { + memberName: 'alice', + alive: true, + restartable: true, + backendType: 'process', + providerId: 'codex', + providerBackendId: 'codex-native', + laneId: 'lane-1', + laneKind: 'primary', + pid: 111, + runtimeModel: 'gpt-5.3-codex', + cwd: '/tmp/old', + rssBytes: 1024, + cpuPercent: 4, + primaryCpuPercent: 3, + primaryRssBytes: 768, + childCpuPercent: 1, + childRssBytes: 256, + processCount: 2, + runtimeLoadScope: 'process-tree', + runtimeLoadTruncated: false, + resourceHistory: [createResourceSample()], + livenessKind: 'confirmed_bootstrap', + pidSource: 'agent_process_table', + processCommand: 'codex', + paneId: '%1', + panePid: 333, + paneCurrentCommand: 'node', + runtimePid: 222, + runtimeSessionId: 'runtime-session-1', + runtimeLeaseExpiresAt: '2026-05-22T10:10:00.000Z', + runtimeLastSeenAt: '2026-05-22T10:00:00.000Z', + historicalBootstrapConfirmed: true, + runtimeDiagnostic: 'Ready', + runtimeDiagnosticSeverity: 'info', + diagnostics: ['healthy'], + updatedAt: '2026-05-22T10:00:00.000Z', + ...overrides, + }; +} + +function createRuntimeSnapshot( + overrides: Partial = {} +): TeamAgentRuntimeSnapshot { + return { + teamName: 'my-team', + updatedAt: '2026-05-22T10:00:00.000Z', + runId: 'run-1', + providerBackendId: 'codex-native', + fastMode: 'inherit', + members: { + alice: createRuntimeEntry(), + }, + ...overrides, + }; +} + +describe('teamAgentRuntimeSnapshotEquality', () => { + it('compares runtime resource samples by visible process metrics', () => { + expect( + areTeamAgentRuntimeResourceSamplesEqual(createResourceSample(), createResourceSample()) + ).toBe(true); + expect( + areTeamAgentRuntimeResourceSamplesEqual( + createResourceSample(), + createResourceSample({ cpuPercent: 5 }) + ) + ).toBe(false); + expect(areTeamAgentRuntimeResourceSamplesEqual(null, createResourceSample())).toBe(false); + }); + + it('ignores runtime entry fields that do not currently affect equality', () => { + const left = createRuntimeEntry({ + cwd: '/tmp/old', + runtimeLeaseExpiresAt: '2026-05-22T10:10:00.000Z', + updatedAt: '2026-05-22T10:00:00.000Z', + }); + const right = createRuntimeEntry({ + cwd: '/tmp/new', + runtimeLeaseExpiresAt: '2026-05-22T10:20:00.000Z', + updatedAt: '2026-05-22T10:05:00.000Z', + }); + + expect(areTeamAgentRuntimeEntriesEqual(left, right)).toBe(true); + }); + + it('detects visible runtime entry field changes', () => { + expect( + areTeamAgentRuntimeEntriesEqual( + createRuntimeEntry(), + createRuntimeEntry({ runtimeDiagnosticSeverity: 'warning' }) + ) + ).toBe(false); + expect( + areTeamAgentRuntimeEntriesEqual( + createRuntimeEntry(), + createRuntimeEntry({ resourceHistory: [createResourceSample({ rssBytes: 2048 })] }) + ) + ).toBe(false); + }); + + it('compares diagnostics and resource history arrays in stable order', () => { + expect( + areTeamAgentRuntimeEntriesEqual( + createRuntimeEntry({ diagnostics: ['a', 'b'] }), + createRuntimeEntry({ diagnostics: ['b', 'a'] }) + ) + ).toBe(false); + expect( + areTeamAgentRuntimeEntriesEqual( + createRuntimeEntry({ + resourceHistory: [ + createResourceSample({ timestamp: '2026-05-22T10:00:00.000Z' }), + createResourceSample({ timestamp: '2026-05-22T10:01:00.000Z' }), + ], + }), + createRuntimeEntry({ + resourceHistory: [ + createResourceSample({ timestamp: '2026-05-22T10:01:00.000Z' }), + createResourceSample({ timestamp: '2026-05-22T10:00:00.000Z' }), + ], + }) + ) + ).toBe(false); + }); + + it('compares runtime snapshots by team, run id, and semantic member entries', () => { + expect(areTeamAgentRuntimeSnapshotsEqual(createRuntimeSnapshot(), createRuntimeSnapshot())).toBe( + true + ); + expect( + areTeamAgentRuntimeSnapshotsEqual( + createRuntimeSnapshot(), + createRuntimeSnapshot({ runId: 'run-2' }) + ) + ).toBe(false); + expect( + areTeamAgentRuntimeSnapshotsEqual( + createRuntimeSnapshot(), + createRuntimeSnapshot({ + members: { + alice: createRuntimeEntry(), + bob: createRuntimeEntry({ memberName: 'bob' }), + }, + }) + ) + ).toBe(false); + }); + + it('ignores snapshot metadata fields that do not currently affect equality', () => { + const left = createRuntimeSnapshot({ + updatedAt: '2026-05-22T10:00:00.000Z', + providerBackendId: 'codex-native', + fastMode: 'inherit', + }); + const right = createRuntimeSnapshot({ + updatedAt: '2026-05-22T10:05:00.000Z', + providerBackendId: 'api', + fastMode: 'on', + }); + + expect(areTeamAgentRuntimeSnapshotsEqual(left, right)).toBe(true); + }); + + it('returns false when there is no previous runtime snapshot', () => { + expect(areTeamAgentRuntimeSnapshotsEqual(undefined, createRuntimeSnapshot())).toBe(false); + }); +}); From 993982311d74ea9f03dddd412c056af865565a87 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 11:08:11 +0300 Subject: [PATCH 15/41] refactor(team): extract provisioning state rules --- src/renderer/store/slices/teamSlice.ts | 40 +++--------- .../store/team/teamProvisioningStateRules.ts | 44 +++++++++++++ .../store/teamProvisioningStateRules.test.ts | 61 +++++++++++++++++++ 3 files changed, 114 insertions(+), 31 deletions(-) create mode 100644 src/renderer/store/team/teamProvisioningStateRules.ts create mode 100644 test/renderer/store/teamProvisioningStateRules.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index bf6b7dc6..d0b14d51 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -82,6 +82,11 @@ import { clearPendingReplyRefreshWaits, setPendingReplyRefreshEnabled, } from '../team/teamPendingReplyWaits'; +import { + isActiveProvisioningState, + isTerminalProvisioningState, + shouldIgnoreProvisioningProgressRegression, +} from '../team/teamProvisioningStateRules'; import { clearAllTeamRefreshBurstDiagnostics, clearTeamRefreshBurstDiagnostics, @@ -532,33 +537,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -const ACTIVE_PROVISIONING_STATES = new Set([ - 'validating', - 'spawning', - 'configuring', - 'assembling', - 'finalizing', - 'verifying', -]); -const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'disconnected', 'cancelled']); - -function shouldIgnoreProvisioningProgressRegression( - currentState: TeamProvisioningProgress['state'], - nextState: TeamProvisioningProgress['state'] -): boolean { - if (currentState === 'ready') { - return nextState !== 'ready' && nextState !== 'disconnected'; - } - if ( - currentState === 'failed' || - currentState === 'cancelled' || - currentState === 'disconnected' - ) { - return nextState !== currentState; - } - return false; -} - function isPendingProvisioningRunId(runId: string): boolean { return runId.startsWith('pending:'); } @@ -698,12 +676,12 @@ async function pollProvisioningStatus( for (let attempt = 1; attempt <= maxAttempts; attempt++) { const state = getState(); const current = state.provisioningRuns[runId]; - if (current && TERMINAL_PROVISIONING_STATES.has(current.state)) { + if (current && isTerminalProvisioningState(current.state)) { return; } try { const progress = await state.getProvisioningStatus(runId); - if (TERMINAL_PROVISIONING_STATES.has(progress.state)) { + if (isTerminalProvisioningState(progress.state)) { return; } } catch (error) { @@ -2345,7 +2323,7 @@ export function isTeamProvisioningActive( teamName: string ): boolean { const current = getCurrentProvisioningProgressForTeam(state, teamName); - return current != null && ACTIVE_PROVISIONING_STATES.has(current.state); + return current != null && isActiveProvisioningState(current.state); } function loadAllLaunchParams(): Record { @@ -5342,7 +5320,7 @@ export const createTeamSlice: StateCreator = (set, } } - if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) { + if (isCanonicalRun && isTerminalProvisioningState(progress.state)) { set((prev) => { const next = { ...prev.memberSpawnStatusesByTeam }; const nextSnapshots = { ...prev.memberSpawnSnapshotsByTeam }; diff --git a/src/renderer/store/team/teamProvisioningStateRules.ts b/src/renderer/store/team/teamProvisioningStateRules.ts new file mode 100644 index 00000000..3c574141 --- /dev/null +++ b/src/renderer/store/team/teamProvisioningStateRules.ts @@ -0,0 +1,44 @@ +import type { TeamProvisioningProgress } from '@shared/types'; + +type TeamProvisioningProgressState = TeamProvisioningProgress['state']; + +const ACTIVE_PROVISIONING_STATES: ReadonlySet = new Set([ + 'validating', + 'spawning', + 'configuring', + 'assembling', + 'finalizing', + 'verifying', +]); + +const TERMINAL_PROVISIONING_STATES: ReadonlySet = new Set([ + 'ready', + 'failed', + 'disconnected', + 'cancelled', +]); + +export function isActiveProvisioningState(state: TeamProvisioningProgressState): boolean { + return ACTIVE_PROVISIONING_STATES.has(state); +} + +export function isTerminalProvisioningState(state: TeamProvisioningProgressState): boolean { + return TERMINAL_PROVISIONING_STATES.has(state); +} + +export function shouldIgnoreProvisioningProgressRegression( + currentState: TeamProvisioningProgressState, + nextState: TeamProvisioningProgressState +): boolean { + if (currentState === 'ready') { + return nextState !== 'ready' && nextState !== 'disconnected'; + } + if ( + currentState === 'failed' || + currentState === 'cancelled' || + currentState === 'disconnected' + ) { + return nextState !== currentState; + } + return false; +} diff --git a/test/renderer/store/teamProvisioningStateRules.test.ts b/test/renderer/store/teamProvisioningStateRules.test.ts new file mode 100644 index 00000000..cc2e4dc8 --- /dev/null +++ b/test/renderer/store/teamProvisioningStateRules.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { + isActiveProvisioningState, + isTerminalProvisioningState, + shouldIgnoreProvisioningProgressRegression, +} from '../../../src/renderer/store/team/teamProvisioningStateRules'; + +import type { TeamProvisioningProgress } from '../../../src/shared/types'; + +type ProgressState = TeamProvisioningProgress['state']; + +const activeStates: ProgressState[] = [ + 'validating', + 'spawning', + 'configuring', + 'assembling', + 'finalizing', + 'verifying', +]; + +const terminalStates: ProgressState[] = ['ready', 'failed', 'disconnected', 'cancelled']; + +describe('teamProvisioningStateRules', () => { + it('classifies active provisioning states', () => { + for (const state of activeStates) { + expect(isActiveProvisioningState(state), state).toBe(true); + expect(isTerminalProvisioningState(state), state).toBe(false); + } + }); + + it('classifies terminal provisioning states', () => { + for (const state of terminalStates) { + expect(isTerminalProvisioningState(state), state).toBe(true); + expect(isActiveProvisioningState(state), state).toBe(false); + } + }); + + it('allows active state progressions and regressions to be processed', () => { + expect(shouldIgnoreProvisioningProgressRegression('spawning', 'validating')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('validating', 'spawning')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('verifying', 'ready')).toBe(false); + }); + + it('prevents ready from regressing except to disconnected', () => { + expect(shouldIgnoreProvisioningProgressRegression('ready', 'validating')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('ready', 'failed')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('ready', 'cancelled')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('ready', 'ready')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('ready', 'disconnected')).toBe(false); + }); + + it('locks failed, cancelled, and disconnected to their current terminal state', () => { + expect(shouldIgnoreProvisioningProgressRegression('failed', 'failed')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('failed', 'ready')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('cancelled', 'cancelled')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('cancelled', 'spawning')).toBe(true); + expect(shouldIgnoreProvisioningProgressRegression('disconnected', 'disconnected')).toBe(false); + expect(shouldIgnoreProvisioningProgressRegression('disconnected', 'ready')).toBe(true); + }); +}); From e723a62ed08756b759d8ae0d196a502cf928bef1 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 11:16:28 +0300 Subject: [PATCH 16/41] refactor(team): extract member activity meta helpers --- src/renderer/store/slices/teamSlice.ts | 61 +----- .../store/team/teamMemberActivityMeta.ts | 64 ++++++ .../store/teamMemberActivityMeta.test.ts | 197 ++++++++++++++++++ 3 files changed, 265 insertions(+), 57 deletions(-) create mode 100644 src/renderer/store/team/teamMemberActivityMeta.ts create mode 100644 test/renderer/store/teamMemberActivityMeta.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index d0b14d51..b018a59b 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -52,6 +52,10 @@ import { invalidateTeamLocalStateEpoch, isTeamLocalStateEpochCurrent, } from '../team/teamLocalStateEpoch'; +import { + isMemberActivityMetaStale, + structurallyShareMemberActivityFacts, +} from '../team/teamMemberActivityMeta'; import { areMemberSpawnSnapshotsSemanticallyEqual } from '../team/teamMemberSpawnSnapshotEquality'; import { clearAllMemberSpawnStatusesIpcBackoffs, @@ -1643,48 +1647,6 @@ function buildResolvedMember( }; } -function areMemberActivityMetaEntriesEqual( - left: MemberActivityMetaEntry | undefined, - right: MemberActivityMetaEntry -): boolean { - if (!left) { - return false; - } - return ( - left.memberName === right.memberName && - left.lastAuthoredMessageAt === right.lastAuthoredMessageAt && - left.messageCountExact === right.messageCountExact && - left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination - ); -} - -function structurallyShareMemberActivityFacts( - previous: Record | undefined, - next: Record -): Record { - if (!previous) { - return next; - } - - const nextKeys = Object.keys(next); - const previousKeys = Object.keys(previous); - let changed = nextKeys.length !== previousKeys.length; - const shared: Record = {}; - - for (const key of nextKeys) { - const nextEntry = next[key]; - const previousEntry = previous[key]; - if (!areMemberActivityMetaEntriesEqual(previousEntry, nextEntry)) { - changed = true; - shared[key] = nextEntry; - continue; - } - shared[key] = previousEntry; - } - - return changed ? shared : previous; -} - type ResolvedMemberSelectorState = Pick< TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' @@ -1793,21 +1755,6 @@ export function selectResolvedMemberForTeamName( return result; } -function isMemberActivityMetaStale( - state: Pick, - teamName: string -): boolean { - const meta = state.memberActivityMetaByTeam[teamName]; - const feedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision; - if (!meta) { - return true; - } - if (!feedRevision) { - return false; - } - return meta.feedRevision !== feedRevision; -} - function seedStableSlotAssignmentsForMembers( assignments: TeamGraphSlotAssignments, members: readonly TeamGraphMemberSeedInput[], diff --git a/src/renderer/store/team/teamMemberActivityMeta.ts b/src/renderer/store/team/teamMemberActivityMeta.ts new file mode 100644 index 00000000..da1b9b3a --- /dev/null +++ b/src/renderer/store/team/teamMemberActivityMeta.ts @@ -0,0 +1,64 @@ +import { getTeamMessagesCacheEntry, type TeamMessagesCacheState } from './teamMessagesCache'; + +import type { MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types'; + +export interface TeamMemberActivityMetaState extends TeamMessagesCacheState { + memberActivityMetaByTeam: Record; +} + +export function areMemberActivityMetaEntriesEqual( + left: MemberActivityMetaEntry | undefined, + right: MemberActivityMetaEntry +): boolean { + if (!left) { + return false; + } + return ( + left.memberName === right.memberName && + left.lastAuthoredMessageAt === right.lastAuthoredMessageAt && + left.messageCountExact === right.messageCountExact && + left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination + ); +} + +export function structurallyShareMemberActivityFacts( + previous: Record | undefined, + next: Record +): Record { + if (!previous) { + return next; + } + + const nextKeys = Object.keys(next); + const previousKeys = Object.keys(previous); + let changed = nextKeys.length !== previousKeys.length; + const shared: Record = {}; + + for (const key of nextKeys) { + const nextEntry = next[key]; + const previousEntry = previous[key]; + if (!areMemberActivityMetaEntriesEqual(previousEntry, nextEntry)) { + changed = true; + shared[key] = nextEntry; + continue; + } + shared[key] = previousEntry; + } + + return changed ? shared : previous; +} + +export function isMemberActivityMetaStale( + state: TeamMemberActivityMetaState, + teamName: string +): boolean { + const meta = state.memberActivityMetaByTeam[teamName]; + const feedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision; + if (!meta) { + return true; + } + if (!feedRevision) { + return false; + } + return meta.feedRevision !== feedRevision; +} diff --git a/test/renderer/store/teamMemberActivityMeta.test.ts b/test/renderer/store/teamMemberActivityMeta.test.ts new file mode 100644 index 00000000..32ca24b6 --- /dev/null +++ b/test/renderer/store/teamMemberActivityMeta.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; + +import { + areMemberActivityMetaEntriesEqual, + isMemberActivityMetaStale, + structurallyShareMemberActivityFacts, +} from '../../../src/renderer/store/team/teamMemberActivityMeta'; + +import type { TeamMessagesCacheEntry } from '../../../src/renderer/store/team/teamMessagesCache'; +import type { + MemberActivityMetaEntry, + TeamMemberActivityMeta, +} from '../../../src/shared/types'; + +function createEntry(overrides: Partial = {}): MemberActivityMetaEntry { + return { + memberName: 'alice', + lastAuthoredMessageAt: '2026-05-22T10:00:00.000Z', + messageCountExact: 3, + latestAuthoredMessageSignalsTermination: false, + ...overrides, + }; +} + +function createMeta(overrides: Partial = {}): TeamMemberActivityMeta { + return { + teamName: 'my-team', + computedAt: '2026-05-22T10:00:00.000Z', + members: { + alice: createEntry(), + }, + feedRevision: 'feed-1', + ...overrides, + }; +} + +function createMessagesEntry( + overrides: Partial = {} +): TeamMessagesCacheEntry { + return { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: 'feed-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + ...overrides, + }; +} + +describe('teamMemberActivityMeta', () => { + it('compares member activity entries by visible facts', () => { + expect(areMemberActivityMetaEntriesEqual(createEntry(), createEntry())).toBe(true); + expect( + areMemberActivityMetaEntriesEqual(createEntry(), createEntry({ messageCountExact: 4 })) + ).toBe(false); + expect( + areMemberActivityMetaEntriesEqual( + createEntry(), + createEntry({ latestAuthoredMessageSignalsTermination: true }) + ) + ).toBe(false); + expect(areMemberActivityMetaEntriesEqual(undefined, createEntry())).toBe(false); + }); + + it('returns next activity facts when there is no previous record', () => { + const next = { + alice: createEntry(), + }; + + expect(structurallyShareMemberActivityFacts(undefined, next)).toBe(next); + }); + + it('preserves the previous record when all entries are semantically equal', () => { + const previous = { + alice: createEntry(), + bob: createEntry({ memberName: 'bob', messageCountExact: 1 }), + }; + const next = { + alice: createEntry(), + bob: createEntry({ memberName: 'bob', messageCountExact: 1 }), + }; + + expect(structurallyShareMemberActivityFacts(previous, next)).toBe(previous); + }); + + it('shares unchanged entries and replaces changed entries', () => { + const previousAlice = createEntry(); + const previousBob = createEntry({ memberName: 'bob', messageCountExact: 1 }); + const nextBob = createEntry({ memberName: 'bob', messageCountExact: 2 }); + const previous = { + alice: previousAlice, + bob: previousBob, + }; + + const shared = structurallyShareMemberActivityFacts( + previous, + { + alice: createEntry(), + bob: nextBob, + } + ); + + expect(shared).not.toBe(previous); + expect(shared.alice).toBe(previousAlice); + expect(shared.bob).toBe(nextBob); + }); + + it('returns a new record when activity keys are added or removed', () => { + const previous = { + alice: createEntry(), + bob: createEntry({ memberName: 'bob' }), + }; + + const removed = structurallyShareMemberActivityFacts(previous, { + alice: createEntry(), + }); + + expect(removed).not.toBe(previous); + expect(removed).toEqual({ + alice: previous.alice, + }); + expect(removed.alice).toBe(previous.alice); + + const singlePrevious = { + alice: createEntry(), + }; + const added = structurallyShareMemberActivityFacts(singlePrevious, { + alice: createEntry(), + bob: createEntry({ memberName: 'bob' }), + }); + + expect(added).not.toBe(singlePrevious); + expect(added.alice).toBe(singlePrevious.alice); + expect(added.bob).toEqual(createEntry({ memberName: 'bob' })); + }); + + it('treats missing member activity meta as stale', () => { + expect( + isMemberActivityMetaStale( + { + memberActivityMetaByTeam: {}, + teamMessagesByName: {}, + }, + 'my-team' + ) + ).toBe(true); + }); + + it('does not require refresh when the message feed has no revision yet', () => { + expect( + isMemberActivityMetaStale( + { + memberActivityMetaByTeam: { + 'my-team': createMeta({ feedRevision: 'old-feed' }), + }, + teamMessagesByName: { + 'my-team': createMessagesEntry({ feedRevision: null }), + }, + }, + 'my-team' + ) + ).toBe(false); + }); + + it('compares member activity meta feedRevision against the messages feed revision', () => { + expect( + isMemberActivityMetaStale( + { + memberActivityMetaByTeam: { + 'my-team': createMeta({ feedRevision: 'feed-1' }), + }, + teamMessagesByName: { + 'my-team': createMessagesEntry({ feedRevision: 'feed-1' }), + }, + }, + 'my-team' + ) + ).toBe(false); + expect( + isMemberActivityMetaStale( + { + memberActivityMetaByTeam: { + 'my-team': createMeta({ feedRevision: 'feed-1' }), + }, + teamMessagesByName: { + 'my-team': createMessagesEntry({ feedRevision: 'feed-2' }), + }, + }, + 'my-team' + ) + ).toBe(true); + }); +}); From 7f828c2e6381fd405e591191ffaa63346dd3fbcc Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 11:21:27 +0300 Subject: [PATCH 17/41] refactor(team): extract error policies --- src/renderer/store/slices/teamSlice.ts | 32 ++-------- src/renderer/store/team/teamErrorPolicies.ts | 33 ++++++++++ test/renderer/store/teamErrorPolicies.test.ts | 63 +++++++++++++++++++ 3 files changed, 101 insertions(+), 27 deletions(-) create mode 100644 src/renderer/store/team/teamErrorPolicies.ts create mode 100644 test/renderer/store/teamErrorPolicies.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index b018a59b..07c33f30 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -45,6 +45,11 @@ import { normalizeTeamGetDataOptions, } from '../team/teamDataRequestKeys'; import { selectTeamDataForName } from '../team/teamDataSelectors'; +import { + mapReviewError, + mapSendMessageError, + shouldInvalidateCachedTeamDataForError, +} from '../team/teamErrorPolicies'; import { captureTeamLocalStateEpoch, clearAllTeamLocalStateEpochs, @@ -1202,24 +1207,6 @@ function preserveKnownTaskChangePresence( return changed ? mergedTasks : nextTasks; } -function mapSendMessageError(error: unknown): string { - const message = - error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; - if (message.includes('Failed to verify inbox write')) { - return 'Message was written but not verified (race). Please try again.'; - } - return message || 'Failed to send message'; -} - -function mapReviewError(error: unknown): string { - const message = - error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; - if (message.includes('Task status update verification failed')) { - return 'Failed to update task status (possible agent conflict).'; - } - return message || 'Failed to perform review action'; -} - export interface GlobalTaskDetailState { teamName: string; taskId: string; @@ -1943,15 +1930,6 @@ function isVisibleInActiveTeamSurface( }); } -function shouldInvalidateCachedTeamDataForError(teamName: string, message: string): boolean { - return ( - message === 'TEAM_DRAFT' || - message.includes('TEAM_DRAFT') || - message === `Team not found: ${teamName}` || - message === 'Team config not found' - ); -} - export interface TeamSlice { teams: TeamSummary[]; /** O(1) lookup to avoid array scans in render-hot paths */ diff --git a/src/renderer/store/team/teamErrorPolicies.ts b/src/renderer/store/team/teamErrorPolicies.ts new file mode 100644 index 00000000..51695967 --- /dev/null +++ b/src/renderer/store/team/teamErrorPolicies.ts @@ -0,0 +1,33 @@ +import { IpcError } from '@renderer/utils/unwrapIpc'; + +function getErrorMessage(error: unknown): string { + return error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; +} + +export function mapSendMessageError(error: unknown): string { + const message = getErrorMessage(error); + if (message.includes('Failed to verify inbox write')) { + return 'Message was written but not verified (race). Please try again.'; + } + return message || 'Failed to send message'; +} + +export function mapReviewError(error: unknown): string { + const message = getErrorMessage(error); + if (message.includes('Task status update verification failed')) { + return 'Failed to update task status (possible agent conflict).'; + } + return message || 'Failed to perform review action'; +} + +export function shouldInvalidateCachedTeamDataForError( + teamName: string, + message: string +): boolean { + return ( + message === 'TEAM_DRAFT' || + message.includes('TEAM_DRAFT') || + message === `Team not found: ${teamName}` || + message === 'Team config not found' + ); +} diff --git a/test/renderer/store/teamErrorPolicies.test.ts b/test/renderer/store/teamErrorPolicies.test.ts new file mode 100644 index 00000000..df8c670f --- /dev/null +++ b/test/renderer/store/teamErrorPolicies.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { + mapReviewError, + mapSendMessageError, + shouldInvalidateCachedTeamDataForError, +} from '../../../src/renderer/store/team/teamErrorPolicies'; +import { IpcError } from '../../../src/renderer/utils/unwrapIpc'; + +describe('teamErrorPolicies', () => { + it('maps send-message verification races to the user-facing retry copy', () => { + expect(mapSendMessageError(new Error('Failed to verify inbox write for message-1'))).toBe( + 'Message was written but not verified (race). Please try again.' + ); + expect( + mapSendMessageError( + new IpcError('team:sendMessage', 'Failed to verify inbox write after timeout') + ) + ).toBe('Message was written but not verified (race). Please try again.'); + }); + + it('maps send-message errors to original messages or fallback copy', () => { + expect(mapSendMessageError(new Error('Transport failed'))).toBe('Transport failed'); + expect(mapSendMessageError('plain failure')).toBe('Failed to send message'); + expect(mapSendMessageError(null)).toBe('Failed to send message'); + }); + + it('maps review verification conflicts to the user-facing conflict copy', () => { + expect(mapReviewError(new Error('Task status update verification failed for task-1'))).toBe( + 'Failed to update task status (possible agent conflict).' + ); + expect( + mapReviewError( + new IpcError('team:updateKanban', 'Task status update verification failed after retry') + ) + ).toBe('Failed to update task status (possible agent conflict).'); + }); + + it('maps review errors to original messages or fallback copy', () => { + expect(mapReviewError(new Error('Review failed'))).toBe('Review failed'); + expect(mapReviewError({ message: 'ignored non-error shape' })).toBe( + 'Failed to perform review action' + ); + expect(mapReviewError(undefined)).toBe('Failed to perform review action'); + }); + + it('invalidates cached team data for draft and missing-team errors', () => { + expect(shouldInvalidateCachedTeamDataForError('my-team', 'TEAM_DRAFT')).toBe(true); + expect( + shouldInvalidateCachedTeamDataForError('my-team', 'Cannot read team: TEAM_DRAFT') + ).toBe(true); + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team not found: my-team')).toBe(true); + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team config not found')).toBe(true); + }); + + it('does not invalidate cached team data for unrelated or other-team errors', () => { + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Network timeout')).toBe(false); + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team not found: other-team')).toBe( + false + ); + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team config missing')).toBe(false); + }); +}); From 67a6d711ebd6828ba98c588dcbe00c2744ea6bee Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 11:28:07 +0300 Subject: [PATCH 18/41] refactor(team): extract launch params helpers --- src/renderer/store/slices/teamSlice.ts | 91 +------------ src/renderer/store/team/teamLaunchParams.ts | 89 +++++++++++++ test/renderer/store/teamLaunchParams.test.ts | 132 +++++++++++++++++++ 3 files changed, 227 insertions(+), 85 deletions(-) create mode 100644 src/renderer/store/team/teamLaunchParams.ts create mode 100644 test/renderer/store/teamLaunchParams.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 07c33f30..dc41b47d 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -11,7 +11,6 @@ import { canDisplayTaskChangesForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; -import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { getMemberColorByName } from '@shared/constants/memberColors'; @@ -19,7 +18,6 @@ import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayou import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; -import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; @@ -50,6 +48,11 @@ import { mapSendMessageError, shouldInvalidateCachedTeamDataForError, } from '../team/teamErrorPolicies'; +import { + areTeamLaunchParamsEqual, + buildLaunchParamsFromRuntimeRequest, + type TeamLaunchParams, +} from '../team/teamLaunchParams'; import { captureTeamLocalStateEpoch, clearAllTeamLocalStateEpochs, @@ -126,7 +129,6 @@ import type { AddTaskCommentRequest, CreateTaskRequest, CrossTeamSendRequest, - EffortLevel, GlobalTask, InboxMessage, KanbanColumnId, @@ -148,7 +150,6 @@ import type { TeamLaunchRequest, TeamMemberActivityMeta, TeamMemberSnapshot, - TeamProviderId, TeamProvisioningProgress, TeamSummary, TeamTask, @@ -167,6 +168,7 @@ export { selectTeamMemberSnapshotsForName, selectTeamTasksForName, } from '../team/teamDataSelectors'; +export type { TeamLaunchParams } from '../team/teamLaunchParams'; export type { RefreshTeamMessagesHeadResult, TeamMessagesCacheEntry, @@ -1226,16 +1228,6 @@ export interface PendingTeamSectionFocusState { section: TeamSectionTarget; } -/** Per-team launch parameters shown in the header badge. */ -export interface TeamLaunchParams { - providerId?: TeamProviderId; - providerBackendId?: string; - model?: string; // 'opus' | 'sonnet' | 'haiku' - effort?: EffortLevel; - fastMode?: 'inherit' | 'on' | 'off'; - limitContext?: boolean; -} - const resolvedMembersSelectorCache = new Map< string, { @@ -2278,77 +2270,6 @@ 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, providerId?: TeamProviderId): string | undefined { - return extractProviderScopedBaseModel(raw, providerId); -} - -function buildLaunchParamsFromRuntimeRequest( - request: Pick< - TeamCreateRequest, - 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext' - >, - fallback?: TeamLaunchParams -): TeamLaunchParams { - const providerId = request.providerId ?? fallback?.providerId ?? 'anthropic'; - const providerChanged = - request.providerId != null && - fallback?.providerId != null && - request.providerId !== fallback.providerId; - const hasModel = Object.hasOwn(request, 'model'); - const baseModel = - hasModel && typeof request.model === 'string' - ? extractBaseModel(request.model, providerId) - : undefined; - const rawProviderBackendId = Object.hasOwn(request, 'providerBackendId') - ? request.providerBackendId - : providerChanged - ? undefined - : fallback?.providerBackendId; - return { - providerId, - providerBackendId: migrateProviderBackendId(providerId, rawProviderBackendId), - model: hasModel - ? baseModel || 'default' - : (providerChanged ? undefined : fallback?.model) || 'default', - effort: Object.hasOwn(request, 'effort') - ? request.effort - : providerChanged - ? undefined - : fallback?.effort, - fastMode: Object.hasOwn(request, 'fastMode') - ? request.fastMode - : providerChanged - ? undefined - : fallback?.fastMode, - limitContext: - typeof request.limitContext === 'boolean' - ? request.limitContext - : providerChanged - ? false - : (fallback?.limitContext ?? false), - }; -} - -function areTeamLaunchParamsEqual( - left: TeamLaunchParams | undefined, - right: TeamLaunchParams | undefined -): boolean { - if (left === right) return true; - if (!left || !right) return false; - return ( - left.providerId === right.providerId && - left.providerBackendId === right.providerBackendId && - left.model === right.model && - left.effort === right.effort && - left.fastMode === right.fastMode && - left.limitContext === right.limitContext - ); -} - const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:'; function parseToolApprovalSettings(raw: string | null): ToolApprovalSettings { diff --git a/src/renderer/store/team/teamLaunchParams.ts b/src/renderer/store/team/teamLaunchParams.ts new file mode 100644 index 00000000..02ab0f2b --- /dev/null +++ b/src/renderer/store/team/teamLaunchParams.ts @@ -0,0 +1,89 @@ +import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; + +import type { + EffortLevel, + TeamCreateRequest, + TeamFastMode, + TeamProviderId, +} from '@shared/types'; + +/** Per-team launch parameters shown in the header badge. */ +export interface TeamLaunchParams { + providerId?: TeamProviderId; + providerBackendId?: string; + model?: string; + effort?: EffortLevel; + fastMode?: TeamFastMode; + limitContext?: boolean; +} + +export function extractBaseModel( + raw?: string, + providerId?: TeamProviderId +): string | undefined { + return extractProviderScopedBaseModel(raw, providerId); +} + +export function buildLaunchParamsFromRuntimeRequest( + request: Pick< + TeamCreateRequest, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext' + >, + fallback?: TeamLaunchParams +): TeamLaunchParams { + const providerId = request.providerId ?? fallback?.providerId ?? 'anthropic'; + const providerChanged = + request.providerId != null && + fallback?.providerId != null && + request.providerId !== fallback.providerId; + const hasModel = Object.hasOwn(request, 'model'); + const baseModel = + hasModel && typeof request.model === 'string' + ? extractBaseModel(request.model, providerId) + : undefined; + const rawProviderBackendId = Object.hasOwn(request, 'providerBackendId') + ? request.providerBackendId + : providerChanged + ? undefined + : fallback?.providerBackendId; + return { + providerId, + providerBackendId: migrateProviderBackendId(providerId, rawProviderBackendId), + model: hasModel + ? baseModel || 'default' + : (providerChanged ? undefined : fallback?.model) || 'default', + effort: Object.hasOwn(request, 'effort') + ? request.effort + : providerChanged + ? undefined + : fallback?.effort, + fastMode: Object.hasOwn(request, 'fastMode') + ? request.fastMode + : providerChanged + ? undefined + : fallback?.fastMode, + limitContext: + typeof request.limitContext === 'boolean' + ? request.limitContext + : providerChanged + ? false + : (fallback?.limitContext ?? false), + }; +} + +export function areTeamLaunchParamsEqual( + left: TeamLaunchParams | undefined, + right: TeamLaunchParams | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return false; + return ( + left.providerId === right.providerId && + left.providerBackendId === right.providerBackendId && + left.model === right.model && + left.effort === right.effort && + left.fastMode === right.fastMode && + left.limitContext === right.limitContext + ); +} diff --git a/test/renderer/store/teamLaunchParams.test.ts b/test/renderer/store/teamLaunchParams.test.ts new file mode 100644 index 00000000..fc898ffc --- /dev/null +++ b/test/renderer/store/teamLaunchParams.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; + +import { + areTeamLaunchParamsEqual, + buildLaunchParamsFromRuntimeRequest, + extractBaseModel, +} from '../../../src/renderer/store/team/teamLaunchParams'; + +import type { TeamLaunchParams } from '../../../src/renderer/store/team/teamLaunchParams'; + +const codexFallback: TeamLaunchParams = { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + effort: 'medium', + fastMode: 'on', + limitContext: true, +}; + +describe('teamLaunchParams', () => { + it('extracts provider-scoped base models', () => { + expect(extractBaseModel(' opus[1m] ', 'anthropic')).toBe('opus'); + expect(extractBaseModel('sonnet', 'anthropic')).toBe('sonnet'); + expect(extractBaseModel('gpt-5.5[1m]', 'codex')).toBe('gpt-5.5[1m]'); + expect(extractBaseModel(' ', 'anthropic')).toBeUndefined(); + expect(extractBaseModel(undefined, 'anthropic')).toBeUndefined(); + }); + + it('builds default anthropic launch params without fallback', () => { + expect(buildLaunchParamsFromRuntimeRequest({})).toEqual({ + providerId: 'anthropic', + providerBackendId: undefined, + model: 'default', + effort: undefined, + fastMode: undefined, + limitContext: false, + }); + }); + + it('preserves fallback values for metadata-only requests on the same provider', () => { + expect(buildLaunchParamsFromRuntimeRequest({}, codexFallback)).toEqual(codexFallback); + }); + + it('resets provider-scoped values when the provider changes without explicit fields', () => { + expect( + buildLaunchParamsFromRuntimeRequest( + { + providerId: 'anthropic', + }, + codexFallback + ) + ).toEqual({ + providerId: 'anthropic', + providerBackendId: undefined, + model: 'default', + effort: undefined, + fastMode: undefined, + limitContext: false, + }); + }); + + it('uses explicit model, effort, fast mode, and limitContext when present', () => { + expect( + buildLaunchParamsFromRuntimeRequest( + { + providerId: 'anthropic', + model: 'haiku[1m]', + effort: 'low', + fastMode: 'off', + limitContext: false, + }, + codexFallback + ) + ).toEqual({ + providerId: 'anthropic', + providerBackendId: undefined, + model: 'haiku', + effort: 'low', + fastMode: 'off', + limitContext: false, + }); + }); + + it('treats an explicit undefined model as Default for the active provider', () => { + expect( + buildLaunchParamsFromRuntimeRequest( + { + providerId: 'codex', + providerBackendId: 'codex-native', + model: undefined, + effort: 'low', + }, + codexFallback + ) + ).toEqual({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'default', + effort: 'low', + fastMode: 'on', + limitContext: true, + }); + }); + + it('migrates legacy provider backend ids for codex requests', () => { + expect( + buildLaunchParamsFromRuntimeRequest({ + providerId: 'codex', + providerBackendId: 'api', + }) + ).toEqual({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'default', + effort: undefined, + fastMode: undefined, + limitContext: false, + }); + }); + + it('compares launch params by all persisted fields', () => { + expect(areTeamLaunchParamsEqual(codexFallback, { ...codexFallback })).toBe(true); + expect( + areTeamLaunchParamsEqual(codexFallback, { + ...codexFallback, + fastMode: 'off', + }) + ).toBe(false); + expect(areTeamLaunchParamsEqual(undefined, undefined)).toBe(true); + expect(areTeamLaunchParamsEqual(undefined, codexFallback)).toBe(false); + }); +}); From f0ca6a57c23ca36c38b61ffcf106536db3ade28c Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 11:33:30 +0300 Subject: [PATCH 19/41] refactor(team): extract tool approval parser --- src/renderer/store/slices/teamSlice.ts | 34 +------- .../store/team/teamToolApprovalSettings.ts | 42 ++++++++++ .../store/teamToolApprovalSettings.test.ts | 81 +++++++++++++++++++ 3 files changed, 124 insertions(+), 33 deletions(-) create mode 100644 src/renderer/store/team/teamToolApprovalSettings.ts create mode 100644 test/renderer/store/teamToolApprovalSettings.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index dc41b47d..b9f359ac 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -111,6 +111,7 @@ import { collectTeamScopedVisibleLoadingResets, } from '../team/teamScopedStateCleanup'; import { structurallyShareTeamSnapshot } from '../team/teamSnapshotStructuralSharing'; +import { parseToolApprovalSettings } from '../team/teamToolApprovalSettings'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; @@ -2272,39 +2273,6 @@ function saveLaunchParams(teamName: string, params: TeamLaunchParams): void { const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:'; -function parseToolApprovalSettings(raw: string | null): ToolApprovalSettings { - if (!raw) return DEFAULT_TOOL_APPROVAL_SETTINGS; - try { - const parsed = JSON.parse(raw) as Record; - const d = DEFAULT_TOOL_APPROVAL_SETTINGS; - return { - autoAllowAll: typeof parsed.autoAllowAll === 'boolean' ? parsed.autoAllowAll : d.autoAllowAll, - autoAllowFileEdits: - typeof parsed.autoAllowFileEdits === 'boolean' - ? parsed.autoAllowFileEdits - : d.autoAllowFileEdits, - autoAllowSafeBash: - typeof parsed.autoAllowSafeBash === 'boolean' - ? parsed.autoAllowSafeBash - : d.autoAllowSafeBash, - timeoutAction: - typeof parsed.timeoutAction === 'string' && - ['allow', 'deny', 'wait'].includes(parsed.timeoutAction) - ? (parsed.timeoutAction as ToolApprovalSettings['timeoutAction']) - : d.timeoutAction, - timeoutSeconds: - typeof parsed.timeoutSeconds === 'number' && - Number.isFinite(parsed.timeoutSeconds) && - parsed.timeoutSeconds >= 5 && - parsed.timeoutSeconds <= 300 - ? parsed.timeoutSeconds - : d.timeoutSeconds, - }; - } catch { - return DEFAULT_TOOL_APPROVAL_SETTINGS; - } -} - function loadToolApprovalSettingsForTeam(teamName: string): ToolApprovalSettings { return parseToolApprovalSettings(localStorage.getItem(TOOL_APPROVAL_PREFIX + teamName)); } diff --git a/src/renderer/store/team/teamToolApprovalSettings.ts b/src/renderer/store/team/teamToolApprovalSettings.ts new file mode 100644 index 00000000..8edadb0c --- /dev/null +++ b/src/renderer/store/team/teamToolApprovalSettings.ts @@ -0,0 +1,42 @@ +import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; + +import type { ToolApprovalSettings } from '@shared/types'; + +const VALID_TIMEOUT_ACTIONS: ReadonlySet = new Set([ + 'allow', + 'deny', + 'wait', +]); + +export function parseToolApprovalSettings(raw: string | null): ToolApprovalSettings { + if (!raw) return DEFAULT_TOOL_APPROVAL_SETTINGS; + try { + const parsed = JSON.parse(raw) as Record; + const d = DEFAULT_TOOL_APPROVAL_SETTINGS; + return { + autoAllowAll: typeof parsed.autoAllowAll === 'boolean' ? parsed.autoAllowAll : d.autoAllowAll, + autoAllowFileEdits: + typeof parsed.autoAllowFileEdits === 'boolean' + ? parsed.autoAllowFileEdits + : d.autoAllowFileEdits, + autoAllowSafeBash: + typeof parsed.autoAllowSafeBash === 'boolean' + ? parsed.autoAllowSafeBash + : d.autoAllowSafeBash, + timeoutAction: + typeof parsed.timeoutAction === 'string' && + VALID_TIMEOUT_ACTIONS.has(parsed.timeoutAction as ToolApprovalSettings['timeoutAction']) + ? (parsed.timeoutAction as ToolApprovalSettings['timeoutAction']) + : d.timeoutAction, + timeoutSeconds: + typeof parsed.timeoutSeconds === 'number' && + Number.isFinite(parsed.timeoutSeconds) && + parsed.timeoutSeconds >= 5 && + parsed.timeoutSeconds <= 300 + ? parsed.timeoutSeconds + : d.timeoutSeconds, + }; + } catch { + return DEFAULT_TOOL_APPROVAL_SETTINGS; + } +} diff --git a/test/renderer/store/teamToolApprovalSettings.test.ts b/test/renderer/store/teamToolApprovalSettings.test.ts new file mode 100644 index 00000000..8d7412a9 --- /dev/null +++ b/test/renderer/store/teamToolApprovalSettings.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { parseToolApprovalSettings } from '../../../src/renderer/store/team/teamToolApprovalSettings'; +import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '../../../src/shared/types/team'; + +describe('teamToolApprovalSettings', () => { + it('returns defaults for missing or invalid JSON', () => { + expect(parseToolApprovalSettings(null)).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS); + expect(parseToolApprovalSettings('')).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS); + expect(parseToolApprovalSettings('{not json')).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS); + }); + + it('parses valid complete settings', () => { + expect( + parseToolApprovalSettings( + JSON.stringify({ + autoAllowAll: true, + autoAllowFileEdits: true, + autoAllowSafeBash: true, + timeoutAction: 'allow', + timeoutSeconds: 120, + }) + ) + ).toEqual({ + autoAllowAll: true, + autoAllowFileEdits: true, + autoAllowSafeBash: true, + timeoutAction: 'allow', + timeoutSeconds: 120, + }); + }); + + it('falls back per field when values have invalid types', () => { + expect( + parseToolApprovalSettings( + JSON.stringify({ + autoAllowAll: 'yes', + autoAllowFileEdits: true, + autoAllowSafeBash: 1, + timeoutAction: 'maybe', + timeoutSeconds: '60', + }) + ) + ).toEqual({ + ...DEFAULT_TOOL_APPROVAL_SETTINGS, + autoAllowFileEdits: true, + }); + }); + + it('accepts timeout actions allow, deny, and wait', () => { + expect(parseToolApprovalSettings(JSON.stringify({ timeoutAction: 'allow' })).timeoutAction).toBe( + 'allow' + ); + expect(parseToolApprovalSettings(JSON.stringify({ timeoutAction: 'deny' })).timeoutAction).toBe( + 'deny' + ); + expect(parseToolApprovalSettings(JSON.stringify({ timeoutAction: 'wait' })).timeoutAction).toBe( + 'wait' + ); + }); + + it('accepts timeout seconds at inclusive boundaries', () => { + expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 5 })).timeoutSeconds).toBe(5); + expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 300 })).timeoutSeconds).toBe( + 300 + ); + }); + + it('rejects timeout seconds outside allowed boundaries or non-finite values', () => { + expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 4 })).timeoutSeconds).toBe( + DEFAULT_TOOL_APPROVAL_SETTINGS.timeoutSeconds + ); + expect(parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: 301 })).timeoutSeconds).toBe( + DEFAULT_TOOL_APPROVAL_SETTINGS.timeoutSeconds + ); + expect( + parseToolApprovalSettings(JSON.stringify({ timeoutSeconds: Number.POSITIVE_INFINITY })) + .timeoutSeconds + ).toBe(DEFAULT_TOOL_APPROVAL_SETTINGS.timeoutSeconds); + }); +}); From ab76e5424da1a86f737fa720ff91139369977e97 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 11:39:54 +0300 Subject: [PATCH 20/41] refactor(team): extract messages panel mode persistence --- src/renderer/store/slices/teamSlice.ts | 35 +++------- .../team/teamMessagesPanelModePersistence.ts | 29 ++++++++ .../teamMessagesPanelModePersistence.test.ts | 67 +++++++++++++++++++ 3 files changed, 104 insertions(+), 27 deletions(-) create mode 100644 src/renderer/store/team/teamMessagesPanelModePersistence.ts create mode 100644 test/renderer/store/teamMessagesPanelModePersistence.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index b9f359ac..f3ac7e96 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -89,6 +89,10 @@ import { pruneOptimisticMessages, upsertOptimisticTeamMessage, } from '../team/teamMessagesCache'; +import { + loadPersistedMessagesPanelMode, + savePersistedMessagesPanelMode, +} from '../team/teamMessagesPanelModePersistence'; import { clearAllPendingReplyRefreshWaits, clearPendingReplyRefreshWaits, @@ -175,6 +179,10 @@ export type { TeamMessagesCacheEntry, } from '../team/teamMessagesCache'; export { selectMemberMessagesForTeamMember, selectTeamMessages } from '../team/teamMessagesCache'; +export { + loadPersistedMessagesPanelMode, + savePersistedMessagesPanelMode, +} from '../team/teamMessagesPanelModePersistence'; export { getActiveTeamPendingReplyWaits, hasActiveTeamPendingReplyWait, @@ -2200,33 +2208,6 @@ export interface TeamSlice { // --- Per-team launch params persistence --- const LAUNCH_PARAMS_PREFIX = 'team:launchParams:'; -const MESSAGES_PANEL_MODE_STORAGE_KEY = 'team:messagesPanelMode'; -const DEFAULT_MESSAGES_PANEL_MODE: TeamMessagesPanelMode = 'sidebar'; -const VALID_MESSAGES_PANEL_MODES: ReadonlySet = new Set([ - 'sidebar', - 'inline', - 'bottom-sheet', - 'floating-composer', -]); - -export function loadPersistedMessagesPanelMode(): TeamMessagesPanelMode { - try { - const persisted = localStorage.getItem(MESSAGES_PANEL_MODE_STORAGE_KEY); - return VALID_MESSAGES_PANEL_MODES.has(persisted as TeamMessagesPanelMode) - ? (persisted as TeamMessagesPanelMode) - : DEFAULT_MESSAGES_PANEL_MODE; - } catch { - return DEFAULT_MESSAGES_PANEL_MODE; - } -} - -export function savePersistedMessagesPanelMode(mode: TeamMessagesPanelMode): void { - try { - localStorage.setItem(MESSAGES_PANEL_MODE_STORAGE_KEY, mode); - } catch { - // ignore - best-effort UI preference persistence - } -} export function getCurrentProvisioningProgressForTeam( state: Pick, diff --git a/src/renderer/store/team/teamMessagesPanelModePersistence.ts b/src/renderer/store/team/teamMessagesPanelModePersistence.ts new file mode 100644 index 00000000..4a53086b --- /dev/null +++ b/src/renderer/store/team/teamMessagesPanelModePersistence.ts @@ -0,0 +1,29 @@ +import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; + +const MESSAGES_PANEL_MODE_STORAGE_KEY = 'team:messagesPanelMode'; +const DEFAULT_MESSAGES_PANEL_MODE: TeamMessagesPanelMode = 'sidebar'; +const VALID_MESSAGES_PANEL_MODES: ReadonlySet = new Set([ + 'sidebar', + 'inline', + 'bottom-sheet', + 'floating-composer', +]); + +export function loadPersistedMessagesPanelMode(): TeamMessagesPanelMode { + try { + const persisted = localStorage.getItem(MESSAGES_PANEL_MODE_STORAGE_KEY); + return VALID_MESSAGES_PANEL_MODES.has(persisted as TeamMessagesPanelMode) + ? (persisted as TeamMessagesPanelMode) + : DEFAULT_MESSAGES_PANEL_MODE; + } catch { + return DEFAULT_MESSAGES_PANEL_MODE; + } +} + +export function savePersistedMessagesPanelMode(mode: TeamMessagesPanelMode): void { + try { + localStorage.setItem(MESSAGES_PANEL_MODE_STORAGE_KEY, mode); + } catch { + // ignore - best-effort UI preference persistence + } +} diff --git a/test/renderer/store/teamMessagesPanelModePersistence.test.ts b/test/renderer/store/teamMessagesPanelModePersistence.test.ts new file mode 100644 index 00000000..baea02fb --- /dev/null +++ b/test/renderer/store/teamMessagesPanelModePersistence.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + loadPersistedMessagesPanelMode, + savePersistedMessagesPanelMode, +} from '../../../src/renderer/store/team/teamMessagesPanelModePersistence'; + +import type { TeamMessagesPanelMode } from '../../../src/renderer/types/teamMessagesPanelMode'; + +const STORAGE_KEY = 'team:messagesPanelMode'; + +describe('teamMessagesPanelModePersistence', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('defaults to sidebar when no value was persisted', () => { + expect(loadPersistedMessagesPanelMode()).toBe('sidebar'); + }); + + it('loads each supported persisted mode', () => { + const modes: TeamMessagesPanelMode[] = [ + 'sidebar', + 'inline', + 'bottom-sheet', + 'floating-composer', + ]; + + for (const mode of modes) { + window.localStorage.setItem(STORAGE_KEY, mode); + + expect(loadPersistedMessagesPanelMode()).toBe(mode); + } + }); + + it('falls back to sidebar for invalid persisted values', () => { + window.localStorage.setItem(STORAGE_KEY, 'bad-mode'); + + expect(loadPersistedMessagesPanelMode()).toBe('sidebar'); + }); + + it('persists the selected mode', () => { + savePersistedMessagesPanelMode('inline'); + + expect(window.localStorage.getItem(STORAGE_KEY)).toBe('inline'); + }); + + it('falls back to sidebar when localStorage read fails', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + + expect(loadPersistedMessagesPanelMode()).toBe('sidebar'); + }); + + it('ignores localStorage write failures', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blocked'); + }); + + expect(() => savePersistedMessagesPanelMode('bottom-sheet')).not.toThrow(); + }); +}); From 169e34ce7c086d0e7b3185fb6e3f2ad4c4de32a3 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 13:34:39 +0300 Subject: [PATCH 21/41] fix(team): recover process table launch failures --- .../services/team/TeamProvisioningService.ts | 46 ++- .../team/TeamProvisioningService.test.ts | 291 ++++++++++++++++++ 2 files changed, 329 insertions(+), 8 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 29a41b5b..3173c969 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3513,6 +3513,36 @@ function isRegisteredRuntimeMetadataFailureReason(reason?: string): boolean { return reason?.trim() === 'registered runtime metadata without live process'; } +function isProcessTableUnavailableFailureReason(reason?: string): boolean { + const text = reason?.trim(); + if (!text || !mentionsProcessTableUnavailable(text)) { + return false; + } + return ( + /^process table (?:is )?unavailable$/i.test(text) || + /^runtime pid could not be verified because process table (?:is )?unavailable$/i.test(text) + ); +} + +function stripProcessTableUnavailableDiagnosticSuffix(reason: string): string | null { + const match = /^(.*?);\s*process table (?:is )?unavailable$/i.exec(reason.trim()); + const baseReason = match?.[1]?.trim(); + return baseReason && baseReason.length > 0 ? baseReason : null; +} + +function isBaseAutoClearableLaunchFailureReason(reason?: string): boolean { + return ( + isNeverSpawnedDuringLaunchReason(reason) || + isLaunchGraceWindowFailureReason(reason) || + isConfigRegistrationFailureReason(reason) || + isRegisteredRuntimeMetadataFailureReason(reason) || + isOpenCodeBridgeLaunchFailureReason(reason) || + isBootstrapMcpResourceReadFailureReason(reason) || + isBootstrapCheckInTimeoutFailureReason(reason) || + isBootstrapInstructionPromptFailureReason(reason) + ); +} + function isBootstrapMcpResourceReadFailureReason(reason?: string): boolean { const text = reason?.trim().toLowerCase() ?? ''; return ( @@ -3539,15 +3569,15 @@ function isTmuxNoServerRunningError(error: unknown): boolean { } function isAutoClearableLaunchFailureReason(reason?: string): boolean { + const text = reason?.trim(); + if (!text) { + return false; + } + const baseReason = stripProcessTableUnavailableDiagnosticSuffix(text); return ( - isNeverSpawnedDuringLaunchReason(reason) || - isLaunchGraceWindowFailureReason(reason) || - isConfigRegistrationFailureReason(reason) || - isRegisteredRuntimeMetadataFailureReason(reason) || - isOpenCodeBridgeLaunchFailureReason(reason) || - isBootstrapMcpResourceReadFailureReason(reason) || - isBootstrapCheckInTimeoutFailureReason(reason) || - isBootstrapInstructionPromptFailureReason(reason) + isBaseAutoClearableLaunchFailureReason(text) || + isProcessTableUnavailableFailureReason(text) || + (baseReason != null && isBaseAutoClearableLaunchFailureReason(baseReason)) ); } diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 39aa486d..64201867 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -583,6 +583,26 @@ function createMemberSpawnStatusEntry( }; } +type TeamProvisioningServicePrivateHarness = { + getLiveTeamAgentRuntimeMetadata: ( + teamName: string + ) => Promise>>; + attachLiveRuntimeMetadataToStatuses: ( + teamName: string, + statuses: Record>, + options?: Record + ) => Promise>>; + applyBootstrapTranscriptEvidenceOverlay: ( + snapshot: ReturnType | null + ) => Promise | null>; +}; + +function privateHarness( + svc: TeamProvisioningService +): TeamProvisioningServicePrivateHarness { + return svc as unknown as TeamProvisioningServicePrivateHarness; +} + function createMemberSpawnRun(params?: { runId?: string; teamName?: string; @@ -19594,6 +19614,95 @@ describe('TeamProvisioningService', () => { }); }); + it('heals process-table unavailable launch-state failures from runtime bootstrap proof', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-process-table-unavailable-proof-heals'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const acceptedAt = new Date(Date.now() - 90_000).toISOString(); + const proofAt = new Date(Date.now() - 60_000).toISOString(); + const proofToken = 'proof-token-alice'; + const bootstrapRunId = 'run-process-table-unavailable'; + const runtimePid = 35906; + const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'alice.runtime.jsonl'); + const processTableReason = + 'runtime pid could not be verified because process table is unavailable'; + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['alice']); + const configPath = path.join(tempTeamsBase, teamName, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + members: Array>; + }; + config.members = config.members.map((member) => + member.name === 'alice' + ? { + ...member, + agentId: `alice@${teamName}`, + backendType: 'process', + tmuxPaneId: `process:${runtimePid}`, + runtimePid, + bootstrapExpectedAfter: acceptedAt, + bootstrapProofToken: proofToken, + bootstrapRunId, + bootstrapRuntimeEventsPath: runtimeEventsPath, + } + : member + ); + fs.writeFileSync(configPath, JSON.stringify(config), 'utf8'); + const snapshot = createPersistedLaunchSnapshot({ + teamName, + leadSessionId, + launchPhase: 'finished', + expectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + runtimePid, + runtimeRunId: bootstrapRunId, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: processTableReason, + livenessKind: 'registered_only', + runtimeDiagnostic: processTableReason, + runtimeDiagnosticSeverity: 'warning', + firstSpawnAcceptedAt: acceptedAt, + lastEvaluatedAt: acceptedAt, + }, + }, + }); + fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true }); + fs.writeFileSync( + runtimeEventsPath, + `${JSON.stringify({ + version: 1, + type: 'bootstrap_confirmed', + timestamp: proofAt, + pid: runtimePid, + teamName, + agentName: 'alice', + agentId: `alice@${teamName}`, + bootstrapRunId, + source: 'member_briefing_tool_success', + bootstrapProofToken: proofToken, + })}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await privateHarness(svc).applyBootstrapTranscriptEvidenceOverlay(snapshot); + + expect(result?.members.alice).toMatchObject({ + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: true, + hardFailure: false, + hardFailureReason: undefined, + }); + }); + it('does not heal terminal bootstrap-state failures from native app-managed proof with mismatched hashes', async () => { allowConsoleLogs(); const teamName = 'zz-unit-bootstrap-state-native-runtime-proof-hash-mismatch'; @@ -22418,6 +22527,141 @@ describe('TeamProvisioningService', () => { }); }); + it('clears registered-only stale failure with process-table diagnostic suffix', async () => { + const svc = new TeamProvisioningService(); + const harness = privateHarness(svc); + harness.getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'tom', + { + alive: true, + model: 'gpt-5.4', + livenessKind: 'runtime_process', + runtimeDiagnostic: 'verified runtime process detected', + }, + ], + ]) + ); + + const reason = 'registered runtime metadata without live process; process table unavailable'; + const result = await harness.attachLiveRuntimeMetadataToStatuses('forge-labs-10', { + tom: createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + error: reason, + hardFailure: true, + hardFailureReason: reason, + livenessKind: 'registered_only', + runtimeDiagnostic: reason, + }), + }); + + expect(result.tom).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + runtimeModel: 'gpt-5.4', + livenessKind: 'runtime_process', + runtimeDiagnostic: 'verified runtime process detected', + livenessSource: 'process', + }); + }); + + it('clears process-table unavailable failure when a verified runtime process appears later', async () => { + const svc = new TeamProvisioningService(); + const harness = privateHarness(svc); + harness.getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'alice', + { + alive: true, + model: 'gpt-5.3-codex', + livenessKind: 'runtime_process', + runtimeDiagnostic: 'verified runtime process detected', + }, + ], + ]) + ); + + const result = await harness.attachLiveRuntimeMetadataToStatuses('signal-ops-10', { + alice: createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + error: 'runtime pid could not be verified because process table is unavailable', + hardFailure: true, + hardFailureReason: 'runtime pid could not be verified because process table is unavailable', + livenessKind: 'registered_only', + runtimeDiagnostic: 'runtime pid could not be verified because process table is unavailable', + runtimeDiagnosticSeverity: 'warning', + }), + }); + + expect(result.alice).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + runtimeModel: 'gpt-5.3-codex', + livenessKind: 'runtime_process', + runtimeDiagnostic: 'verified runtime process detected', + livenessSource: 'process', + }); + }); + + it('keeps process-table unavailable failure failed when only weak metadata is available', async () => { + const svc = new TeamProvisioningService(); + const harness = privateHarness(svc); + harness.getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'alice', + { + alive: false, + livenessKind: 'registered_only', + runtimeDiagnostic: 'runtime pid could not be verified because process table is unavailable', + runtimeDiagnosticSeverity: 'warning', + }, + ], + ]) + ); + + const result = await harness.attachLiveRuntimeMetadataToStatuses('signal-ops-10', { + alice: createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + error: 'runtime pid could not be verified because process table is unavailable', + hardFailure: true, + hardFailureReason: 'runtime pid could not be verified because process table is unavailable', + livenessKind: 'registered_only', + runtimeDiagnostic: 'runtime pid could not be verified because process table is unavailable', + runtimeDiagnosticSeverity: 'warning', + }), + }); + + expect(result.alice).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + hardFailure: true, + hardFailureReason: 'runtime pid could not be verified because process table is unavailable', + error: 'runtime pid could not be verified because process table is unavailable', + livenessKind: 'registered_only', + runtimeDiagnostic: 'runtime pid could not be verified because process table is unavailable', + runtimeDiagnosticSeverity: 'warning', + livenessSource: undefined, + }); + }); + it('does not clear OpenCode bridge launch failure from process-only liveness', async () => { const svc = new TeamProvisioningService(); (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( @@ -22464,6 +22708,53 @@ describe('TeamProvisioningService', () => { }); }); + it('does not clear concrete OpenCode bridge failures even when a process is visible', async () => { + const svc = new TeamProvisioningService(); + const harness = privateHarness(svc); + harness.getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: true, + model: 'openrouter/minimax/minimax-m2.5', + livenessKind: 'runtime_process', + providerId: 'opencode', + runtimeDiagnostic: 'OpenCode runtime process detected', + runtimeDiagnosticSeverity: 'info', + }, + ], + ]) + ); + + const reason = + 'OpenCode bridge failed: OpenCode app-managed bootstrap launch requires a fresh capability snapshot before state-changing launch; process table unavailable'; + const result = await harness.attachLiveRuntimeMetadataToStatuses('signal-ops-10', { + bob: createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + error: reason, + hardFailure: true, + hardFailureReason: reason, + }), + }); + + expect(result.bob).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + hardFailure: true, + hardFailureReason: reason, + error: reason, + runtimeModel: 'openrouter/minimax/minimax-m2.5', + livenessKind: 'runtime_process', + runtimeDiagnostic: 'OpenCode runtime process detected', + runtimeDiagnosticSeverity: 'info', + livenessSource: undefined, + }); + }); + it('maps suffixed live runtime metadata keys back onto canonical spawn statuses', async () => { const svc = new TeamProvisioningService(); (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( From 1db018d130e2160387a94386dd96e6e890c348be Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 13:34:55 +0300 Subject: [PATCH 22/41] fix(release): update badge on default branch --- .github/workflows/release.yml | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26efbb7b..796eaca6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -797,26 +797,34 @@ jobs: - name: Update README version badge if: ${{ inputs.publish_release }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail + DEFAULT_BRANCH="$(gh repo view "${GITHUB_REPOSITORY}" --json defaultBranchRef --jq '.defaultBranchRef.name')" + git fetch origin "${DEFAULT_BRANCH}" + BADGE_WORKTREE="$(mktemp -d)" + git worktree add --detach "${BADGE_WORKTREE}" "origin/${DEFAULT_BRANCH}" + trap 'git worktree remove --force "${BADGE_WORKTREE}" >/dev/null 2>&1 || true' EXIT + BADGE_LABEL_WIDTH=51 BADGE_VALUE="${RELEASE_TAG}" BADGE_VALUE_WIDTH=$(( ${#BADGE_VALUE} * 7 + 10 )) BADGE_WIDTH=$(( BADGE_LABEL_WIDTH + BADGE_VALUE_WIDTH )) BADGE_LABEL_X=$(( BADGE_LABEL_WIDTH / 2 )) BADGE_VALUE_X=$(( BADGE_LABEL_WIDTH + BADGE_VALUE_WIDTH / 2 )) - mkdir -p .github/badges - cat > .github/badges/version.svg < "${BADGE_WORKTREE}/.github/badges/version.svg" <version: ${BADGE_VALUE}version${BADGE_VALUE} EOF - if git diff --quiet -- .github/badges/version.svg; then + if git -C "${BADGE_WORKTREE}" diff --quiet -- .github/badges/version.svg; then exit 0 fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add .github/badges/version.svg - git commit -m "docs(readme): update release badge to ${BADGE_VALUE}" - git push + git -C "${BADGE_WORKTREE}" config user.name "github-actions[bot]" + git -C "${BADGE_WORKTREE}" config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -C "${BADGE_WORKTREE}" add .github/badges/version.svg + git -C "${BADGE_WORKTREE}" commit -m "docs(readme): update release badge to ${BADGE_VALUE}" + git -C "${BADGE_WORKTREE}" push origin "HEAD:${DEFAULT_BRANCH}" - name: Keep release as draft if: ${{ github.event_name == 'workflow_dispatch' && !inputs.publish_release }} From f8ceb856010d56556d20883f19df26b4838ae25d Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 15:01:18 +0300 Subject: [PATCH 23/41] fix(release): preserve legacy updater assets --- .github/workflows/release.yml | 85 +++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 796eaca6..5a1a00c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -687,17 +687,53 @@ jobs: ["agent-teams-ai.pacman"]="agent-teams-ai-${VERSION}.pacman" ) - for ALIAS_NAME in "${!STABLE_ALIASES[@]}"; do - VERSIONED_NAME="${STABLE_ALIASES[$ALIAS_NAME]}" - echo "Uploading stable alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}" + declare -A LEGACY_STABLE_ALIASES=( + ["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg" + ["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg" + ["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe" + ["Claude-Agent-Teams-UI.AppImage"]="Agent.Teams.AI-${VERSION}.AppImage" + ["Claude-Agent-Teams-UI-amd64.deb"]="agent-teams-ai_${VERSION}_amd64.deb" + ["Claude-Agent-Teams-UI-x86_64.rpm"]="agent-teams-ai-${VERSION}.x86_64.rpm" + ["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman" + ) + + declare -A LEGACY_UPDATER_ALIASES=( + ["Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip"]="Agent.Teams.AI-${VERSION}-arm64-mac.zip" + ["Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg" + ["Claude.Agent.Teams.UI-${VERSION}-mac.zip"]="Agent.Teams.AI-${VERSION}-x64-mac.zip" + ["Claude.Agent.Teams.UI-${VERSION}.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg" + ["Claude.Agent.Teams.UI.Setup.${VERSION}.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe" + ["Claude.Agent.Teams.UI-${VERSION}.AppImage"]="Agent.Teams.AI-${VERSION}.AppImage" + ) + + upload_aliases() { + local label="$1" + local -n aliases="$2" + + for ALIAS_NAME in "${!aliases[@]}"; do + VERSIONED_NAME="${aliases[$ALIAS_NAME]}" + echo "Uploading ${label} alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}" + download_once "${VERSIONED_NAME}" + cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${ALIAS_NAME}" + gh release upload "${TAG}" "${TMP_DIR}/${ALIAS_NAME}" --repo "$REPO" --clobber + done + } + + download_once() { + local name="$1" + if [[ -f "${TMP_DIR}/${name}" ]]; then + return + fi gh release download "${TAG}" \ --repo "$REPO" \ - --pattern "${VERSIONED_NAME}" \ + --pattern "${name}" \ --dir "$TMP_DIR" \ --clobber - cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${ALIAS_NAME}" - gh release upload "${TAG}" "${TMP_DIR}/${ALIAS_NAME}" --repo "$REPO" --clobber - done + } + + upload_aliases "stable" STABLE_ALIASES + upload_aliases "legacy stable" LEGACY_STABLE_ALIASES + upload_aliases "legacy updater" LEGACY_UPDATER_ALIASES - name: Publish canonical updater metadata env: @@ -761,26 +797,37 @@ jobs: EOF # Canonical macOS feed. - # electron-updater on GitHub still consumes a single latest-mac.yml, so we - # publish the Apple Silicon feed here and suppress Intel auto-update in-app - # until we switch to universal packaging or an arch-aware provider. + # Include both architectures so legacy Intel builds can see the + # update without downloading the Apple Silicon zip. download_asset "Agent.Teams.AI-${VERSION}-arm64-mac.zip" download_asset "Agent.Teams.AI-${VERSION}-arm64.dmg" - MAC_ZIP_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64-mac.zip")" - MAC_ZIP_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64-mac.zip")" - MAC_DMG_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64.dmg")" - MAC_DMG_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64.dmg")" + download_asset "Agent.Teams.AI-${VERSION}-x64-mac.zip" + download_asset "Agent.Teams.AI-${VERSION}-x64.dmg" + MAC_ARM64_ZIP_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64-mac.zip")" + MAC_ARM64_ZIP_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64-mac.zip")" + MAC_ARM64_DMG_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64.dmg")" + MAC_ARM64_DMG_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64.dmg")" + MAC_X64_ZIP_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-x64-mac.zip")" + MAC_X64_ZIP_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-x64-mac.zip")" + MAC_X64_DMG_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-x64.dmg")" + MAC_X64_DMG_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-x64.dmg")" cat > latest-mac.yml < Date: Fri, 22 May 2026 15:08:32 +0300 Subject: [PATCH 24/41] refactor(team): extract resolved members and graph layout --- src/renderer/store/slices/teamSlice.ts | 750 +----------------- src/renderer/store/team/teamGraphLayout.ts | 219 +++++ .../store/team/teamResolvedMembers.ts | 533 +++++++++++++ test/renderer/store/teamGraphLayout.test.ts | 133 ++++ .../store/teamResolvedMembers.test.ts | 210 +++++ 5 files changed, 1130 insertions(+), 715 deletions(-) create mode 100644 src/renderer/store/team/teamGraphLayout.ts create mode 100644 src/renderer/store/team/teamResolvedMembers.ts create mode 100644 test/renderer/store/teamGraphLayout.test.ts create mode 100644 test/renderer/store/teamResolvedMembers.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index f3ac7e96..4e5ced93 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -13,14 +13,11 @@ import { } from '@renderer/utils/taskChangeRequest'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; -import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; -import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; -import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; import { getTeamTaskWorkflowColumn, isTeamTaskFinalForCompletionNotification, @@ -48,6 +45,19 @@ import { mapSendMessageError, shouldInvalidateCachedTeamDataForError, } from '../team/teamErrorPolicies'; +import { + areTeamGraphSlotAssignmentsEqual, + DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS, + GRAPH_STABLE_SLOT_LAYOUT_VERSION, + migrateStableSlotAssignmentsForMembers, + normalizeTeamGraphGridOwnerOrder, + pruneTeamGraphSlotAssignmentsForVisibleOwners, + seedStableSlotAssignmentsForMembers, + type TeamGraphConfigMemberSeedInput, + type TeamGraphLayoutSessionState, + type TeamGraphMemberSeedInput, + type TeamGraphSlotAssignments, +} from '../team/teamGraphLayout'; import { areTeamLaunchParamsEqual, buildLaunchParamsFromRuntimeRequest, @@ -109,6 +119,12 @@ import { hasTeamRefreshBurstDiagnostics, noteTeamRefreshBurst, } from '../team/teamRefreshBurstDiagnostics'; +import { + clearResolvedMemberSelectorCaches, + clearResolvedMemberSelectorCachesForTeam, + getResolvedMemberSelectorCacheSnapshotForTeam, + shouldPreserveSelectedTeamSnapshot, +} from '../team/teamResolvedMembers'; import { buildTeamScopedProgressTombstones, collectTeamScopedStateRemovals, @@ -139,11 +155,9 @@ import type { KanbanColumnId, LeadActivityState, LeadContextUsage, - MemberActivityMetaEntry, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, NotificationTarget, - ResolvedTeamMember, RetryFailedOpenCodeSecondaryLanesResult, SendMessageRequest, SendMessageResult, @@ -154,7 +168,6 @@ import type { TeamGetDataOptions, TeamLaunchRequest, TeamMemberActivityMeta, - TeamMemberSnapshot, TeamProvisioningProgress, TeamSummary, TeamTask, @@ -173,6 +186,10 @@ export { selectTeamMemberSnapshotsForName, selectTeamTasksForName, } from '../team/teamDataSelectors'; +export { + getDefaultTeamGraphSlotAssignmentsForMembers, + isTeamGraphSlotPersistenceDisabled, +} from '../team/teamGraphLayout'; export type { TeamLaunchParams } from '../team/teamLaunchParams'; export type { RefreshTeamMessagesHeadResult, @@ -187,9 +204,11 @@ export { getActiveTeamPendingReplyWaits, hasActiveTeamPendingReplyWait, } from '../team/teamPendingReplyWaits'; +export { + selectResolvedMemberForTeamName, + selectResolvedMembersForTeamName, +} from '../team/teamResolvedMembers'; -const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; -const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true; const logger = createLogger('teamSlice'); const TEAM_GET_DATA_TIMEOUT_MS = 30_000; @@ -242,17 +261,6 @@ function clearTeamDataRequestsForTeam(teamName: string): void { } } -type TeamGraphSlotAssignments = Record; -type TeamGraphMemberSeedInput = Pick; -type TeamGraphConfigMemberSeedInput = Pick< - NonNullable[number], - 'name' | 'agentId' | 'removedAt' ->; -interface TeamGraphLayoutSessionState { - mode: 'default' | 'manual'; - signature: string | null; -} - export function isTeamDataRefreshPending(teamName: string): boolean { return ( hasFullTeamDataRequestForTeam(teamName) || @@ -287,21 +295,13 @@ export function __resetTeamSliceModuleStateForTests(): void { clearAllMemberSpawnStatusesIpcBackoffs(); clearAllTeamRefreshBurstDiagnostics(); clearAllMemberSpawnUiEqualLastWarns(); - resolvedMembersSelectorCache.clear(); - resolvedMemberSelectorCache.clear(); + clearResolvedMemberSelectorCaches(); clearTeamMessageSelectorCaches(); } function clearTeamScopedSelectorCaches(teamName: string): void { - resolvedMembersSelectorCache.delete(teamName); + clearResolvedMemberSelectorCachesForTeam(teamName); clearTeamMessageSelectorCachesForTeam(teamName); - - const teamScopedPrefix = `${teamName}:`; - for (const key of resolvedMemberSelectorCache.keys()) { - if (key.startsWith(teamScopedPrefix)) { - resolvedMemberSelectorCache.delete(key); - } - } } function clearTeamScopedTransientState(teamName: string): void { @@ -519,19 +519,15 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { hasTeamRefreshBurstDiagnostics: boolean; hasMemberSpawnUiEqualLastWarn: boolean; } { - const teamScopedPrefix = `${teamName}:`; const messageSelectorCache = getTeamMessageSelectorCacheSnapshotForTeam(teamName); - let resolvedMemberSelectorCount = 0; - - for (const key of resolvedMemberSelectorCache.keys()) { - if (key.startsWith(teamScopedPrefix)) { - resolvedMemberSelectorCount += 1; - } - } + const resolvedMemberSelectorCacheSnapshot = + getResolvedMemberSelectorCacheSnapshotForTeam(teamName); return { - hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName), - resolvedMemberSelectorCount, + hasResolvedMembersSelector: + resolvedMemberSelectorCacheSnapshot.hasResolvedMembersSelector, + resolvedMemberSelectorCount: + resolvedMemberSelectorCacheSnapshot.resolvedMemberSelectorCount, hasMergedMessagesSelector: messageSelectorCache.hasMergedMessagesSelector, memberMessagesSelectorCount: messageSelectorCache.memberMessagesSelectorCount, hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName), @@ -1237,682 +1233,6 @@ export interface PendingTeamSectionFocusState { section: TeamSectionTarget; } -const resolvedMembersSelectorCache = new Map< - string, - { - snapshotRef: TeamViewSnapshot['members']; - configMembersRef: TeamViewSnapshot['config']['members'] | undefined; - summaryRef: TeamSummary | undefined; - tasksRef: TeamViewSnapshot['tasks'] | undefined; - metaMembersRef: TeamMemberActivityMeta['members'] | undefined; - result: ResolvedTeamMember[]; - } ->(); -const resolvedMemberSelectorCache = new Map< - string, - { - snapshotMemberRef: TeamMemberSnapshot | undefined; - metaEntryRef: MemberActivityMetaEntry | undefined; - result: ResolvedTeamMember | null; - } ->(); -function resolveMemberStatus( - snapshot: TeamMemberSnapshot, - activity: MemberActivityMetaEntry | undefined -): ResolvedTeamMember['status'] { - if (activity?.latestAuthoredMessageSignalsTermination) { - return 'terminated'; - } - - if (!activity?.lastAuthoredMessageAt) { - return snapshot.currentTaskId ? 'active' : 'idle'; - } - - const ageMs = Date.now() - Date.parse(activity.lastAuthoredMessageAt); - if (Number.isNaN(ageMs)) { - return 'unknown'; - } - if (ageMs < 5 * 60 * 1000) { - return 'active'; - } - return 'idle'; -} - -function buildResolvedMembers( - snapshots: readonly TeamMemberSnapshot[], - meta: TeamMemberActivityMeta | undefined -): ResolvedTeamMember[] { - return snapshots.map((member) => buildResolvedMember(member, meta?.members[member.name])); -} - -function isDisplayableFallbackCurrentTask(task: TeamViewSnapshot['tasks'][number]): boolean { - return ( - task.status === 'in_progress' && - getTeamTaskWorkflowColumn(task) !== 'review' && - !isTeamTaskFinalForCompletionNotification(task) - ); -} - -function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMemberSnapshot[] { - const configMembers = snapshot.config.members ?? []; - const hasConfiguredTeammate = configMembers.some((member) => { - const name = member.name?.trim(); - return Boolean(name) && !member.removedAt && !isLeadMember(member); - }); - if (!hasConfiguredTeammate) { - return []; - } - - const seenNames = new Set(); - const fallbackMembers: TeamMemberSnapshot[] = []; - for (const member of configMembers) { - const name = member.name?.trim(); - if (!name) continue; - const key = name.toLowerCase(); - if (seenNames.has(key)) continue; - seenNames.add(key); - - const ownedTasks = snapshot.tasks.filter((task) => task.owner === name); - const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask); - fallbackMembers.push({ - name, - agentId: member.agentId, - currentTaskId: currentTask?.id ?? null, - taskCount: ownedTasks.length, - color: member.color ?? getMemberColorByName(name), - agentType: member.agentType, - role: member.role, - workflow: member.workflow, - isolation: member.isolation, - providerId: member.providerId, - providerBackendId: member.providerBackendId, - model: member.model, - effort: member.effort, - mcpPolicy: member.mcpPolicy, - selectedFastMode: member.fastMode, - cwd: member.cwd, - removedAt: member.removedAt, - }); - } - - return fallbackMembers; -} - -function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] { - if (!snapshot) { - return []; - } - const names = new Set(); - for (const member of snapshot.members) { - const name = member.name.trim(); - const key = name.toLowerCase(); - if (!name || key === 'user' || member.removedAt || isLeadMember(member)) { - continue; - } - names.add(key); - } - return Array.from(names).sort((left, right) => left.localeCompare(right)); -} - -function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { - return getActiveRawTeammateNameKeys(snapshot).length > 0; -} - -function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { - return Boolean(snapshot?.members.some((member) => member.removedAt)); -} - -function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { - return Boolean( - snapshot?.config.members?.some((member) => { - const name = member.name?.trim(); - return Boolean(name) && !member.removedAt && !isLeadMember(member); - }) - ); -} - -interface SummaryFallbackMemberSource { - name: string; - agentId?: string; - role?: string; - color?: string; - mcpPolicy?: TeamMemberSnapshot['mcpPolicy']; -} - -function normalizeSummaryTeammateName( - name: string | undefined | null, - leadName?: string -): string | null { - const trimmed = name?.trim(); - const normalizedName = trimmed?.toLowerCase(); - const normalizedLeadName = leadName?.trim().toLowerCase(); - if ( - !trimmed || - normalizedName === 'user' || - isLeadMember({ name: trimmed }) || - (normalizedLeadName && normalizedName === normalizedLeadName) - ) { - return null; - } - return trimmed; -} - -function getSummaryRosterTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] { - const seenNames = new Set(); - const sources: SummaryFallbackMemberSource[] = []; - for (const member of summary.members ?? []) { - const name = normalizeSummaryTeammateName(member.name, summary.leadName); - if (!name) { - continue; - } - const key = name.toLowerCase(); - if (seenNames.has(key)) { - continue; - } - seenNames.add(key); - sources.push({ - name, - agentId: member.agentId, - role: member.role, - color: member.color, - mcpPolicy: member.mcpPolicy, - }); - } - return sources; -} - -function shouldUseSummaryLaunchTeammateSources(summary: TeamSummary): boolean { - return ( - summary.partialLaunchFailure === true || - summary.teamLaunchState === 'partial_failure' || - summary.teamLaunchState === 'partial_pending' || - summary.teamLaunchState === 'partial_skipped' - ); -} - -function getSummaryLaunchTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] { - if (!shouldUseSummaryLaunchTeammateSources(summary)) { - return []; - } - - const seenNames = new Set(); - const sources: SummaryFallbackMemberSource[] = []; - for (const rawName of [...(summary.missingMembers ?? []), ...(summary.skippedMembers ?? [])]) { - const name = normalizeSummaryTeammateName(rawName, summary.leadName); - if (!name) { - continue; - } - const key = name.toLowerCase(); - if (seenNames.has(key)) { - continue; - } - seenNames.add(key); - sources.push({ name }); - } - return sources; -} - -function getSummaryLaunchTeammateNameKeys(summary: TeamSummary): string[] { - return getSummaryLaunchTeammateSources(summary) - .map((member) => member.name.toLowerCase()) - .sort((left, right) => left.localeCompare(right)); -} - -function getSummaryTeammateNameKeys(summary: TeamSummary): string[] { - const rosterNames = getSummaryRosterTeammateSources(summary) - .map((member) => member.name.toLowerCase()) - .sort((left, right) => left.localeCompare(right)); - if (rosterNames.length > 0) { - return rosterNames; - } - - const launchNames = getSummaryLaunchTeammateNameKeys(summary); - const expectedCount = summary.expectedMemberCount ?? summary.memberCount; - if (expectedCount > 0 && launchNames.length === expectedCount) { - return launchNames; - } - return []; -} - -function getSummaryFallbackTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] { - return getSummaryRosterTeammateSources(summary); -} - -function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((name, index) => name === right[index]); -} - -function summaryConfirmsActiveTeammateRoster( - current: TeamViewSnapshot, - summary: TeamSummary -): boolean { - if ((summary.expectedMemberCount ?? summary.memberCount) <= 0) { - return false; - } - - const currentNames = getActiveRawTeammateNameKeys(current); - const summaryNames = getSummaryTeammateNameKeys(summary); - if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) { - return false; - } - - return areNameKeyListsEqual(summaryNames, currentNames); -} - -function buildSummaryFallbackMemberSnapshots( - snapshot: TeamViewSnapshot, - summary: TeamSummary | undefined -): TeamMemberSnapshot[] { - if (!summary) { - return []; - } - const summaryMembers = getSummaryFallbackTeammateSources(summary); - if (summaryMembers.length === 0) { - return []; - } - - const seenNames = new Set(); - const buildSnapshot = ( - name: string, - source?: Omit, - lead = false - ): TeamMemberSnapshot | null => { - const trimmed = name.trim(); - if (!trimmed) return null; - const key = trimmed.toLowerCase(); - if (seenNames.has(key)) return null; - seenNames.add(key); - - const ownedTasks = snapshot.tasks.filter((task) => task.owner === trimmed); - const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask); - return { - name: trimmed, - agentId: source?.agentId, - currentTaskId: currentTask?.id ?? null, - taskCount: ownedTasks.length, - color: source?.color ?? getMemberColorByName(trimmed), - agentType: lead ? 'team-lead' : undefined, - role: source?.role ?? (lead ? 'Team Lead' : undefined), - mcpPolicy: source?.mcpPolicy, - }; - }; - - const teammates = summaryMembers.flatMap((member) => { - const item = buildSnapshot(member.name, member); - return item ? [item] : []; - }); - if (teammates.length === 0) { - return []; - } - - const existingLead = snapshot.members.find((member) => !member.removedAt && isLeadMember(member)); - if (existingLead) { - return [existingLead, ...teammates]; - } - - const configuredLead = snapshot.config.members?.find( - (member) => !member.removedAt && isLeadMember(member) - ); - const leadName = configuredLead?.name?.trim() || summary.leadName?.trim(); - const lead = leadName - ? buildSnapshot( - leadName, - { - agentId: configuredLead?.agentId, - role: configuredLead?.role, - color: configuredLead?.color ?? summary.leadColor, - }, - true - ) - : null; - - return lead ? [lead, ...teammates] : teammates; -} - -function getResolvableMemberSnapshots( - snapshot: TeamViewSnapshot, - summary?: TeamSummary -): readonly TeamMemberSnapshot[] { - if ( - snapshot.members.length > 0 && - (hasActiveRawTeammateRoster(snapshot) || hasRemovedRawMemberRoster(snapshot)) - ) { - return snapshot.members; - } - - const configFallbackMembers = buildConfigFallbackMemberSnapshots(snapshot); - if (configFallbackMembers.length > 0) { - return configFallbackMembers; - } - - const summaryFallbackMembers = buildSummaryFallbackMemberSnapshots(snapshot, summary); - if (summaryFallbackMembers.length > 0) { - return summaryFallbackMembers; - } - - return snapshot.members; -} - -function shouldPreserveSelectedTeamSnapshot( - current: TeamViewSnapshot | null, - baseline: TeamViewSnapshot | null | undefined, - incoming: TeamViewSnapshot, - summary: TeamSummary | undefined -): boolean { - if (!current || !hasActiveRawTeammateRoster(current)) { - return false; - } - if ( - hasActiveRawTeammateRoster(incoming) || - hasRemovedRawMemberRoster(incoming) || - hasConfigTeammateRoster(incoming) - ) { - return false; - } - const currentNames = getActiveRawTeammateNameKeys(current); - if ( - current !== baseline && - !areNameKeyListsEqual(currentNames, getActiveRawTeammateNameKeys(baseline)) - ) { - return true; - } - if (summary) { - return summaryConfirmsActiveTeammateRoster(current, summary); - } - - return false; -} - -function buildResolvedMember( - snapshot: TeamMemberSnapshot, - activity: MemberActivityMetaEntry | undefined -): ResolvedTeamMember { - return { - ...snapshot, - status: resolveMemberStatus(snapshot, activity), - messageCount: activity?.messageCountExact ?? 0, - lastActiveAt: activity?.lastAuthoredMessageAt ?? null, - }; -} - -type ResolvedMemberSelectorState = Pick< - TeamSlice, - 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' -> & - Partial>; - -function migrateStableSlotAssignmentsForMembers( - assignments: TeamGraphSlotAssignments | undefined, - members: readonly TeamGraphMemberSeedInput[] -): { assignments: TeamGraphSlotAssignments; changed: boolean } { - const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) }; - let changed = false; - - for (const member of members) { - const fallbackKey = member.name.trim(); - const stableOwnerId = getStableTeamOwnerId(member); - const fallbackAssignment = nextAssignments[fallbackKey]; - const stableAssignment = nextAssignments[stableOwnerId]; - - if (stableOwnerId !== fallbackKey && fallbackAssignment && !stableAssignment) { - nextAssignments[stableOwnerId] = fallbackAssignment; - delete nextAssignments[fallbackKey]; - changed = true; - continue; - } - - if (stableOwnerId !== fallbackKey && fallbackAssignment && stableAssignment) { - delete nextAssignments[fallbackKey]; - changed = true; - } - } - - return { assignments: nextAssignments, changed }; -} - -export function selectResolvedMembersForTeamName( - state: ResolvedMemberSelectorState, - teamName: string | null | undefined -): ResolvedTeamMember[] { - const snapshot = selectTeamDataForName(state, teamName); - if (!snapshot || !teamName) { - return []; - } - - const meta = state.memberActivityMetaByTeam[teamName]; - const metaMembers = meta?.members; - const shouldUseMemberFallback = - snapshot.members.length === 0 || - (!hasActiveRawTeammateRoster(snapshot) && !hasRemovedRawMemberRoster(snapshot)); - const configMembersRef = shouldUseMemberFallback ? snapshot.config.members : undefined; - const summaryRef = shouldUseMemberFallback ? state.teamByName?.[teamName] : undefined; - const tasksRef = shouldUseMemberFallback ? snapshot.tasks : undefined; - const cached = resolvedMembersSelectorCache.get(teamName); - if ( - cached?.snapshotRef === snapshot.members && - cached.configMembersRef === configMembersRef && - cached.summaryRef === summaryRef && - cached.tasksRef === tasksRef && - cached.metaMembersRef === metaMembers - ) { - return cached.result; - } - - const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot, summaryRef), meta); - resolvedMembersSelectorCache.set(teamName, { - snapshotRef: snapshot.members, - configMembersRef, - summaryRef, - tasksRef, - metaMembersRef: metaMembers, - result, - }); - return result; -} - -export function selectResolvedMemberForTeamName( - state: ResolvedMemberSelectorState, - teamName: string | null | undefined, - memberName: string | null | undefined -): ResolvedTeamMember | null { - const snapshot = selectTeamDataForName(state, teamName); - if (!snapshot || !teamName || !memberName) { - return null; - } - - const snapshotMember = getResolvableMemberSnapshots(snapshot, state.teamByName?.[teamName]).find( - (member) => member.name === memberName - ); - if (!snapshotMember) { - return null; - } - - const metaEntry = state.memberActivityMetaByTeam[teamName]?.members[memberName]; - const cacheKey = `${teamName}:${memberName}`; - const cached = resolvedMemberSelectorCache.get(cacheKey); - if (cached?.snapshotMemberRef === snapshotMember && cached.metaEntryRef === metaEntry) { - return cached.result; - } - - const result = buildResolvedMember(snapshotMember, metaEntry); - resolvedMemberSelectorCache.set(cacheKey, { - snapshotMemberRef: snapshotMember, - metaEntryRef: metaEntry, - result, - }); - return result; -} - -function seedStableSlotAssignmentsForMembers( - assignments: TeamGraphSlotAssignments, - members: readonly TeamGraphMemberSeedInput[], - configMembers: readonly TeamGraphConfigMemberSeedInput[] = [] -): { assignments: TeamGraphSlotAssignments; changed: boolean } { - const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers); - if ( - defaultSeed.orderedVisibleOwnerIds.length === 0 || - Object.keys(defaultSeed.assignments).length === 0 - ) { - return { assignments, changed: false }; - } - - const visibleStableOwnerIds = defaultSeed.orderedVisibleOwnerIds; - const hasAnyVisibleAssignments = visibleStableOwnerIds.some( - (stableOwnerId) => assignments[stableOwnerId] != null - ); - if (hasAnyVisibleAssignments) { - return { assignments, changed: false }; - } - - const nextAssignments: TeamGraphSlotAssignments = { ...assignments }; - visibleStableOwnerIds.forEach((stableOwnerId) => { - nextAssignments[stableOwnerId] = defaultSeed.assignments[stableOwnerId]!; - }); - - return { assignments: nextAssignments, changed: true }; -} - -function areTeamGraphSlotAssignmentsEqual( - left: TeamGraphSlotAssignments | undefined, - right: TeamGraphSlotAssignments | undefined -): boolean { - const leftEntries = Object.entries(left ?? {}); - const rightEntries = Object.entries(right ?? {}); - if (leftEntries.length !== rightEntries.length) { - return false; - } - - for (const [stableOwnerId, leftAssignment] of leftEntries) { - const rightAssignment = right?.[stableOwnerId]; - if ( - rightAssignment?.ringIndex !== leftAssignment.ringIndex || - rightAssignment.sectorIndex !== leftAssignment.sectorIndex - ) { - return false; - } - } - - return true; -} - -function normalizeTeamGraphSlotAssignmentsForVisibleOwners( - assignments: TeamGraphSlotAssignments | undefined, - visibleOwnerIds: readonly string[] -): TeamGraphSlotAssignments { - if (visibleOwnerIds.length === 0 || !assignments) { - return {}; - } - - const normalizedAssignments: TeamGraphSlotAssignments = {}; - for (const stableOwnerId of visibleOwnerIds) { - const assignment = assignments[stableOwnerId]; - if (!assignment) { - continue; - } - normalizedAssignments[stableOwnerId] = assignment; - } - return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds); -} - -function normalizeLegacySixRowOrbitAssignments( - assignments: TeamGraphSlotAssignments, - visibleOwnerIds: readonly string[] -): TeamGraphSlotAssignments { - if (visibleOwnerIds.length !== 6) { - return assignments; - } - - const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => { - const assignment = assignments[stableOwnerId]; - return assignment ? [assignment] : []; - }); - const hasLegacyTwoRowBottomMarker = visibleAssignments.some( - (assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2 - ); - let changed = false; - const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments }; - - for (const stableOwnerId of visibleOwnerIds) { - const assignment = normalizedAssignments[stableOwnerId]; - if (!assignment) { - continue; - } - - if ( - hasLegacyTwoRowBottomMarker && - assignment.ringIndex === 1 && - assignment.sectorIndex >= 0 && - assignment.sectorIndex < 3 - ) { - normalizedAssignments[stableOwnerId] = { - ringIndex: 2, - sectorIndex: assignment.sectorIndex, - }; - changed = true; - continue; - } - - if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) { - normalizedAssignments[stableOwnerId] = { - ringIndex: 2, - sectorIndex: assignment.sectorIndex - 3, - }; - changed = true; - } - } - - return changed ? normalizedAssignments : assignments; -} - -function pruneTeamGraphSlotAssignmentsForVisibleOwners( - assignments: TeamGraphSlotAssignments | undefined, - visibleOwnerIds: readonly string[] -): TeamGraphSlotAssignments | undefined { - const normalizedAssignments = normalizeTeamGraphSlotAssignmentsForVisibleOwners( - assignments, - visibleOwnerIds - ); - return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined; -} - -function normalizeTeamGraphGridOwnerOrder( - order: readonly string[] | undefined, - visibleOwnerIds: readonly string[] -): string[] { - const visibleOwnerIdSet = new Set(visibleOwnerIds); - const normalizedOrder: string[] = []; - const seenOwnerIds = new Set(); - - for (const stableOwnerId of order ?? []) { - if (!visibleOwnerIdSet.has(stableOwnerId) || seenOwnerIds.has(stableOwnerId)) { - continue; - } - normalizedOrder.push(stableOwnerId); - seenOwnerIds.add(stableOwnerId); - } - - for (const stableOwnerId of visibleOwnerIds) { - if (seenOwnerIds.has(stableOwnerId)) { - continue; - } - normalizedOrder.push(stableOwnerId); - seenOwnerIds.add(stableOwnerId); - } - - return normalizedOrder; -} - -export function getDefaultTeamGraphSlotAssignmentsForMembers( - members: readonly TeamGraphMemberSeedInput[], - configMembers: readonly TeamGraphConfigMemberSeedInput[] = [] -): TeamGraphSlotAssignments { - return buildTeamGraphDefaultLayoutSeed(members, configMembers).assignments; -} - -export function isTeamGraphSlotPersistenceDisabled(): boolean { - return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS; -} - function isVisibleInActiveTeamSurface( state: Pick, teamName: string | null | undefined diff --git a/src/renderer/store/team/teamGraphLayout.ts b/src/renderer/store/team/teamGraphLayout.ts new file mode 100644 index 00000000..6f7329e8 --- /dev/null +++ b/src/renderer/store/team/teamGraphLayout.ts @@ -0,0 +1,219 @@ +import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; +import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; + +import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; +import type { TeamMemberSnapshot, TeamViewSnapshot } from '@shared/types'; + +export const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; +export const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true; + +export type TeamGraphSlotAssignments = Record; +export type TeamGraphMemberSeedInput = Pick; +export type TeamGraphConfigMemberSeedInput = Pick< + NonNullable[number], + 'name' | 'agentId' | 'removedAt' +>; + +export interface TeamGraphLayoutSessionState { + mode: 'default' | 'manual'; + signature: string | null; +} + +export function migrateStableSlotAssignmentsForMembers( + assignments: TeamGraphSlotAssignments | undefined, + members: readonly TeamGraphMemberSeedInput[] +): { assignments: TeamGraphSlotAssignments; changed: boolean } { + const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) }; + let changed = false; + + for (const member of members) { + const fallbackKey = member.name.trim(); + const stableOwnerId = getStableTeamOwnerId(member); + const fallbackAssignment = nextAssignments[fallbackKey]; + const stableAssignment = nextAssignments[stableOwnerId]; + + if (stableOwnerId !== fallbackKey && fallbackAssignment && !stableAssignment) { + nextAssignments[stableOwnerId] = fallbackAssignment; + delete nextAssignments[fallbackKey]; + changed = true; + continue; + } + + if (stableOwnerId !== fallbackKey && fallbackAssignment && stableAssignment) { + delete nextAssignments[fallbackKey]; + changed = true; + } + } + + return { assignments: nextAssignments, changed }; +} + +export function seedStableSlotAssignmentsForMembers( + assignments: TeamGraphSlotAssignments, + members: readonly TeamGraphMemberSeedInput[], + configMembers: readonly TeamGraphConfigMemberSeedInput[] = [] +): { assignments: TeamGraphSlotAssignments; changed: boolean } { + const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers); + if ( + defaultSeed.orderedVisibleOwnerIds.length === 0 || + Object.keys(defaultSeed.assignments).length === 0 + ) { + return { assignments, changed: false }; + } + + const visibleStableOwnerIds = defaultSeed.orderedVisibleOwnerIds; + const hasAnyVisibleAssignments = visibleStableOwnerIds.some( + (stableOwnerId) => assignments[stableOwnerId] != null + ); + if (hasAnyVisibleAssignments) { + return { assignments, changed: false }; + } + + const nextAssignments: TeamGraphSlotAssignments = { ...assignments }; + visibleStableOwnerIds.forEach((stableOwnerId) => { + nextAssignments[stableOwnerId] = defaultSeed.assignments[stableOwnerId]!; + }); + + return { assignments: nextAssignments, changed: true }; +} + +export function areTeamGraphSlotAssignmentsEqual( + left: TeamGraphSlotAssignments | undefined, + right: TeamGraphSlotAssignments | undefined +): boolean { + const leftEntries = Object.entries(left ?? {}); + const rightEntries = Object.entries(right ?? {}); + if (leftEntries.length !== rightEntries.length) { + return false; + } + + for (const [stableOwnerId, leftAssignment] of leftEntries) { + const rightAssignment = right?.[stableOwnerId]; + if ( + rightAssignment?.ringIndex !== leftAssignment.ringIndex || + rightAssignment.sectorIndex !== leftAssignment.sectorIndex + ) { + return false; + } + } + + return true; +} + +export function normalizeTeamGraphSlotAssignmentsForVisibleOwners( + assignments: TeamGraphSlotAssignments | undefined, + visibleOwnerIds: readonly string[] +): TeamGraphSlotAssignments { + if (visibleOwnerIds.length === 0 || !assignments) { + return {}; + } + + const normalizedAssignments: TeamGraphSlotAssignments = {}; + for (const stableOwnerId of visibleOwnerIds) { + const assignment = assignments[stableOwnerId]; + if (!assignment) { + continue; + } + normalizedAssignments[stableOwnerId] = assignment; + } + return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds); +} + +export function normalizeLegacySixRowOrbitAssignments( + assignments: TeamGraphSlotAssignments, + visibleOwnerIds: readonly string[] +): TeamGraphSlotAssignments { + if (visibleOwnerIds.length !== 6) { + return assignments; + } + + const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => { + const assignment = assignments[stableOwnerId]; + return assignment ? [assignment] : []; + }); + const hasLegacyTwoRowBottomMarker = visibleAssignments.some( + (assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2 + ); + let changed = false; + const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments }; + + for (const stableOwnerId of visibleOwnerIds) { + const assignment = normalizedAssignments[stableOwnerId]; + if (!assignment) { + continue; + } + + if ( + hasLegacyTwoRowBottomMarker && + assignment.ringIndex === 1 && + assignment.sectorIndex >= 0 && + assignment.sectorIndex < 3 + ) { + normalizedAssignments[stableOwnerId] = { + ringIndex: 2, + sectorIndex: assignment.sectorIndex, + }; + changed = true; + continue; + } + + if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) { + normalizedAssignments[stableOwnerId] = { + ringIndex: 2, + sectorIndex: assignment.sectorIndex - 3, + }; + changed = true; + } + } + + return changed ? normalizedAssignments : assignments; +} + +export function pruneTeamGraphSlotAssignmentsForVisibleOwners( + assignments: TeamGraphSlotAssignments | undefined, + visibleOwnerIds: readonly string[] +): TeamGraphSlotAssignments | undefined { + const normalizedAssignments = normalizeTeamGraphSlotAssignmentsForVisibleOwners( + assignments, + visibleOwnerIds + ); + return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined; +} + +export function normalizeTeamGraphGridOwnerOrder( + order: readonly string[] | undefined, + visibleOwnerIds: readonly string[] +): string[] { + const visibleOwnerIdSet = new Set(visibleOwnerIds); + const normalizedOrder: string[] = []; + const seenOwnerIds = new Set(); + + for (const stableOwnerId of order ?? []) { + if (!visibleOwnerIdSet.has(stableOwnerId) || seenOwnerIds.has(stableOwnerId)) { + continue; + } + normalizedOrder.push(stableOwnerId); + seenOwnerIds.add(stableOwnerId); + } + + for (const stableOwnerId of visibleOwnerIds) { + if (seenOwnerIds.has(stableOwnerId)) { + continue; + } + normalizedOrder.push(stableOwnerId); + seenOwnerIds.add(stableOwnerId); + } + + return normalizedOrder; +} + +export function getDefaultTeamGraphSlotAssignmentsForMembers( + members: readonly TeamGraphMemberSeedInput[], + configMembers: readonly TeamGraphConfigMemberSeedInput[] = [] +): TeamGraphSlotAssignments { + return buildTeamGraphDefaultLayoutSeed(members, configMembers).assignments; +} + +export function isTeamGraphSlotPersistenceDisabled(): boolean { + return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS; +} diff --git a/src/renderer/store/team/teamResolvedMembers.ts b/src/renderer/store/team/teamResolvedMembers.ts new file mode 100644 index 00000000..1f5b093f --- /dev/null +++ b/src/renderer/store/team/teamResolvedMembers.ts @@ -0,0 +1,533 @@ +import { getMemberColorByName } from '@shared/constants/memberColors'; +import { isLeadMember } from '@shared/utils/leadDetection'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskFinalForCompletionNotification, +} from '@shared/utils/teamTaskState'; + +import { selectTeamDataForName, type TeamDataSelectorState } from './teamDataSelectors'; + +import type { + MemberActivityMetaEntry, + ResolvedTeamMember, + TeamMemberActivityMeta, + TeamMemberSnapshot, + TeamSummary, + TeamViewSnapshot, +} from '@shared/types'; + +export interface ResolvedMemberSelectorState extends TeamDataSelectorState { + memberActivityMetaByTeam: Record; + teamByName?: Record; +} + +export interface ResolvedMemberSelectorCacheSnapshot { + hasResolvedMembersSelector: boolean; + resolvedMemberSelectorCount: number; +} + +const resolvedMembersSelectorCache = new Map< + string, + { + snapshotRef: TeamViewSnapshot['members']; + configMembersRef: TeamViewSnapshot['config']['members'] | undefined; + summaryRef: TeamSummary | undefined; + tasksRef: TeamViewSnapshot['tasks'] | undefined; + metaMembersRef: TeamMemberActivityMeta['members'] | undefined; + result: ResolvedTeamMember[]; + } +>(); +const resolvedMemberSelectorCache = new Map< + string, + { + snapshotMemberRef: TeamMemberSnapshot | undefined; + metaEntryRef: MemberActivityMetaEntry | undefined; + result: ResolvedTeamMember | null; + } +>(); + +export function clearResolvedMemberSelectorCaches(): void { + resolvedMembersSelectorCache.clear(); + resolvedMemberSelectorCache.clear(); +} + +export function clearResolvedMemberSelectorCachesForTeam(teamName: string): void { + resolvedMembersSelectorCache.delete(teamName); + + const teamScopedPrefix = `${teamName}:`; + for (const key of resolvedMemberSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + resolvedMemberSelectorCache.delete(key); + } + } +} + +export function getResolvedMemberSelectorCacheSnapshotForTeam( + teamName: string +): ResolvedMemberSelectorCacheSnapshot { + const teamScopedPrefix = `${teamName}:`; + let resolvedMemberSelectorCount = 0; + + for (const key of resolvedMemberSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + resolvedMemberSelectorCount += 1; + } + } + + return { + hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName), + resolvedMemberSelectorCount, + }; +} + +function resolveMemberStatus( + snapshot: TeamMemberSnapshot, + activity: MemberActivityMetaEntry | undefined +): ResolvedTeamMember['status'] { + if (activity?.latestAuthoredMessageSignalsTermination) { + return 'terminated'; + } + + if (!activity?.lastAuthoredMessageAt) { + return snapshot.currentTaskId ? 'active' : 'idle'; + } + + const ageMs = Date.now() - Date.parse(activity.lastAuthoredMessageAt); + if (Number.isNaN(ageMs)) { + return 'unknown'; + } + if (ageMs < 5 * 60 * 1000) { + return 'active'; + } + return 'idle'; +} + +function buildResolvedMembers( + snapshots: readonly TeamMemberSnapshot[], + meta: TeamMemberActivityMeta | undefined +): ResolvedTeamMember[] { + return snapshots.map((member) => buildResolvedMember(member, meta?.members[member.name])); +} + +function isDisplayableFallbackCurrentTask(task: TeamViewSnapshot['tasks'][number]): boolean { + return ( + task.status === 'in_progress' && + getTeamTaskWorkflowColumn(task) !== 'review' && + !isTeamTaskFinalForCompletionNotification(task) + ); +} + +function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMemberSnapshot[] { + const configMembers = snapshot.config.members ?? []; + const hasConfiguredTeammate = configMembers.some((member) => { + const name = member.name?.trim(); + return Boolean(name) && !member.removedAt && !isLeadMember(member); + }); + if (!hasConfiguredTeammate) { + return []; + } + + const seenNames = new Set(); + const fallbackMembers: TeamMemberSnapshot[] = []; + for (const member of configMembers) { + const name = member.name?.trim(); + if (!name) continue; + const key = name.toLowerCase(); + if (seenNames.has(key)) continue; + seenNames.add(key); + + const ownedTasks = snapshot.tasks.filter((task) => task.owner === name); + const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask); + fallbackMembers.push({ + name, + agentId: member.agentId, + currentTaskId: currentTask?.id ?? null, + taskCount: ownedTasks.length, + color: member.color ?? getMemberColorByName(name), + agentType: member.agentType, + role: member.role, + workflow: member.workflow, + isolation: member.isolation, + providerId: member.providerId, + providerBackendId: member.providerBackendId, + model: member.model, + effort: member.effort, + mcpPolicy: member.mcpPolicy, + selectedFastMode: member.fastMode, + cwd: member.cwd, + removedAt: member.removedAt, + }); + } + + return fallbackMembers; +} + +function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] { + if (!snapshot) { + return []; + } + const names = new Set(); + for (const member of snapshot.members) { + const name = member.name.trim(); + const key = name.toLowerCase(); + if (!name || key === 'user' || member.removedAt || isLeadMember(member)) { + continue; + } + names.add(key); + } + return Array.from(names).sort((left, right) => left.localeCompare(right)); +} + +function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { + return getActiveRawTeammateNameKeys(snapshot).length > 0; +} + +function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { + return Boolean(snapshot?.members.some((member) => member.removedAt)); +} + +function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean { + return Boolean( + snapshot?.config.members?.some((member) => { + const name = member.name?.trim(); + return Boolean(name) && !member.removedAt && !isLeadMember(member); + }) + ); +} + +interface SummaryFallbackMemberSource { + name: string; + agentId?: string; + role?: string; + color?: string; + mcpPolicy?: TeamMemberSnapshot['mcpPolicy']; +} + +function normalizeSummaryTeammateName( + name: string | undefined | null, + leadName?: string +): string | null { + const trimmed = name?.trim(); + const normalizedName = trimmed?.toLowerCase(); + const normalizedLeadName = leadName?.trim().toLowerCase(); + if ( + !trimmed || + normalizedName === 'user' || + isLeadMember({ name: trimmed }) || + (normalizedLeadName && normalizedName === normalizedLeadName) + ) { + return null; + } + return trimmed; +} + +function getSummaryRosterTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] { + const seenNames = new Set(); + const sources: SummaryFallbackMemberSource[] = []; + for (const member of summary.members ?? []) { + const name = normalizeSummaryTeammateName(member.name, summary.leadName); + if (!name) { + continue; + } + const key = name.toLowerCase(); + if (seenNames.has(key)) { + continue; + } + seenNames.add(key); + sources.push({ + name, + agentId: member.agentId, + role: member.role, + color: member.color, + mcpPolicy: member.mcpPolicy, + }); + } + return sources; +} + +function shouldUseSummaryLaunchTeammateSources(summary: TeamSummary): boolean { + return ( + summary.partialLaunchFailure === true || + summary.teamLaunchState === 'partial_failure' || + summary.teamLaunchState === 'partial_pending' || + summary.teamLaunchState === 'partial_skipped' + ); +} + +function getSummaryLaunchTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] { + if (!shouldUseSummaryLaunchTeammateSources(summary)) { + return []; + } + + const seenNames = new Set(); + const sources: SummaryFallbackMemberSource[] = []; + for (const rawName of [...(summary.missingMembers ?? []), ...(summary.skippedMembers ?? [])]) { + const name = normalizeSummaryTeammateName(rawName, summary.leadName); + if (!name) { + continue; + } + const key = name.toLowerCase(); + if (seenNames.has(key)) { + continue; + } + seenNames.add(key); + sources.push({ name }); + } + return sources; +} + +function getSummaryLaunchTeammateNameKeys(summary: TeamSummary): string[] { + return getSummaryLaunchTeammateSources(summary) + .map((member) => member.name.toLowerCase()) + .sort((left, right) => left.localeCompare(right)); +} + +function getSummaryTeammateNameKeys(summary: TeamSummary): string[] { + const rosterNames = getSummaryRosterTeammateSources(summary) + .map((member) => member.name.toLowerCase()) + .sort((left, right) => left.localeCompare(right)); + if (rosterNames.length > 0) { + return rosterNames; + } + + const launchNames = getSummaryLaunchTeammateNameKeys(summary); + const expectedCount = summary.expectedMemberCount ?? summary.memberCount; + if (expectedCount > 0 && launchNames.length === expectedCount) { + return launchNames; + } + return []; +} + +function getSummaryFallbackTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] { + return getSummaryRosterTeammateSources(summary); +} + +function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((name, index) => name === right[index]); +} + +function summaryConfirmsActiveTeammateRoster( + current: TeamViewSnapshot, + summary: TeamSummary +): boolean { + if ((summary.expectedMemberCount ?? summary.memberCount) <= 0) { + return false; + } + + const currentNames = getActiveRawTeammateNameKeys(current); + const summaryNames = getSummaryTeammateNameKeys(summary); + if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) { + return false; + } + + return areNameKeyListsEqual(summaryNames, currentNames); +} + +function buildSummaryFallbackMemberSnapshots( + snapshot: TeamViewSnapshot, + summary: TeamSummary | undefined +): TeamMemberSnapshot[] { + if (!summary) { + return []; + } + const summaryMembers = getSummaryFallbackTeammateSources(summary); + if (summaryMembers.length === 0) { + return []; + } + + const seenNames = new Set(); + const buildSnapshot = ( + name: string, + source?: Omit, + lead = false + ): TeamMemberSnapshot | null => { + const trimmed = name.trim(); + if (!trimmed) return null; + const key = trimmed.toLowerCase(); + if (seenNames.has(key)) return null; + seenNames.add(key); + + const ownedTasks = snapshot.tasks.filter((task) => task.owner === trimmed); + const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask); + return { + name: trimmed, + agentId: source?.agentId, + currentTaskId: currentTask?.id ?? null, + taskCount: ownedTasks.length, + color: source?.color ?? getMemberColorByName(trimmed), + agentType: lead ? 'team-lead' : undefined, + role: source?.role ?? (lead ? 'Team Lead' : undefined), + mcpPolicy: source?.mcpPolicy, + }; + }; + + const teammates = summaryMembers.flatMap((member) => { + const item = buildSnapshot(member.name, member); + return item ? [item] : []; + }); + if (teammates.length === 0) { + return []; + } + + const existingLead = snapshot.members.find((member) => !member.removedAt && isLeadMember(member)); + if (existingLead) { + return [existingLead, ...teammates]; + } + + const configuredLead = snapshot.config.members?.find( + (member) => !member.removedAt && isLeadMember(member) + ); + const leadName = configuredLead?.name?.trim() || summary.leadName?.trim(); + const lead = leadName + ? buildSnapshot( + leadName, + { + agentId: configuredLead?.agentId, + role: configuredLead?.role, + color: configuredLead?.color ?? summary.leadColor, + }, + true + ) + : null; + + return lead ? [lead, ...teammates] : teammates; +} + +function getResolvableMemberSnapshots( + snapshot: TeamViewSnapshot, + summary?: TeamSummary +): readonly TeamMemberSnapshot[] { + if ( + snapshot.members.length > 0 && + (hasActiveRawTeammateRoster(snapshot) || hasRemovedRawMemberRoster(snapshot)) + ) { + return snapshot.members; + } + + const configFallbackMembers = buildConfigFallbackMemberSnapshots(snapshot); + if (configFallbackMembers.length > 0) { + return configFallbackMembers; + } + + const summaryFallbackMembers = buildSummaryFallbackMemberSnapshots(snapshot, summary); + if (summaryFallbackMembers.length > 0) { + return summaryFallbackMembers; + } + + return snapshot.members; +} + +export function shouldPreserveSelectedTeamSnapshot( + current: TeamViewSnapshot | null, + baseline: TeamViewSnapshot | null | undefined, + incoming: TeamViewSnapshot, + summary: TeamSummary | undefined +): boolean { + if (!current || !hasActiveRawTeammateRoster(current)) { + return false; + } + if ( + hasActiveRawTeammateRoster(incoming) || + hasRemovedRawMemberRoster(incoming) || + hasConfigTeammateRoster(incoming) + ) { + return false; + } + const currentNames = getActiveRawTeammateNameKeys(current); + if ( + current !== baseline && + !areNameKeyListsEqual(currentNames, getActiveRawTeammateNameKeys(baseline)) + ) { + return true; + } + if (summary) { + return summaryConfirmsActiveTeammateRoster(current, summary); + } + + return false; +} + +function buildResolvedMember( + snapshot: TeamMemberSnapshot, + activity: MemberActivityMetaEntry | undefined +): ResolvedTeamMember { + return { + ...snapshot, + status: resolveMemberStatus(snapshot, activity), + messageCount: activity?.messageCountExact ?? 0, + lastActiveAt: activity?.lastAuthoredMessageAt ?? null, + }; +} + +export function selectResolvedMembersForTeamName( + state: ResolvedMemberSelectorState, + teamName: string | null | undefined +): ResolvedTeamMember[] { + const snapshot = selectTeamDataForName(state, teamName); + if (!snapshot || !teamName) { + return []; + } + + const meta = state.memberActivityMetaByTeam[teamName]; + const metaMembers = meta?.members; + const shouldUseMemberFallback = + snapshot.members.length === 0 || + (!hasActiveRawTeammateRoster(snapshot) && !hasRemovedRawMemberRoster(snapshot)); + const configMembersRef = shouldUseMemberFallback ? snapshot.config.members : undefined; + const summaryRef = shouldUseMemberFallback ? state.teamByName?.[teamName] : undefined; + const tasksRef = shouldUseMemberFallback ? snapshot.tasks : undefined; + const cached = resolvedMembersSelectorCache.get(teamName); + if ( + cached?.snapshotRef === snapshot.members && + cached.configMembersRef === configMembersRef && + cached.summaryRef === summaryRef && + cached.tasksRef === tasksRef && + cached.metaMembersRef === metaMembers + ) { + return cached.result; + } + + const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot, summaryRef), meta); + resolvedMembersSelectorCache.set(teamName, { + snapshotRef: snapshot.members, + configMembersRef, + summaryRef, + tasksRef, + metaMembersRef: metaMembers, + result, + }); + return result; +} + +export function selectResolvedMemberForTeamName( + state: ResolvedMemberSelectorState, + teamName: string | null | undefined, + memberName: string | null | undefined +): ResolvedTeamMember | null { + const snapshot = selectTeamDataForName(state, teamName); + if (!snapshot || !teamName || !memberName) { + return null; + } + + const snapshotMember = getResolvableMemberSnapshots(snapshot, state.teamByName?.[teamName]).find( + (member) => member.name === memberName + ); + if (!snapshotMember) { + return null; + } + + const metaEntry = state.memberActivityMetaByTeam[teamName]?.members[memberName]; + const cacheKey = `${teamName}:${memberName}`; + const cached = resolvedMemberSelectorCache.get(cacheKey); + if (cached?.snapshotMemberRef === snapshotMember && cached.metaEntryRef === metaEntry) { + return cached.result; + } + + const result = buildResolvedMember(snapshotMember, metaEntry); + resolvedMemberSelectorCache.set(cacheKey, { + snapshotMemberRef: snapshotMember, + metaEntryRef: metaEntry, + result, + }); + return result; +} diff --git a/test/renderer/store/teamGraphLayout.test.ts b/test/renderer/store/teamGraphLayout.test.ts new file mode 100644 index 00000000..515bdd50 --- /dev/null +++ b/test/renderer/store/teamGraphLayout.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { + areTeamGraphSlotAssignmentsEqual, + getDefaultTeamGraphSlotAssignmentsForMembers, + isTeamGraphSlotPersistenceDisabled, + migrateStableSlotAssignmentsForMembers, + normalizeLegacySixRowOrbitAssignments, + normalizeTeamGraphGridOwnerOrder, + normalizeTeamGraphSlotAssignmentsForVisibleOwners, + pruneTeamGraphSlotAssignmentsForVisibleOwners, + seedStableSlotAssignmentsForMembers, +} from '../../../src/renderer/store/team/teamGraphLayout'; + +describe('teamGraphLayout', () => { + it('migrates legacy name-keyed assignments to stable owner ids', () => { + const migrated = migrateStableSlotAssignmentsForMembers( + { + alice: { ringIndex: 0, sectorIndex: 1 }, + }, + [{ name: 'alice', agentId: 'agent-a' }] + ); + + expect(migrated.changed).toBe(true); + expect(migrated.assignments).toEqual({ + 'agent-a': { ringIndex: 0, sectorIndex: 1 }, + }); + }); + + it('drops stale name-keyed assignments when stable assignments already exist', () => { + const migrated = migrateStableSlotAssignmentsForMembers( + { + alice: { ringIndex: 0, sectorIndex: 1 }, + 'agent-a': { ringIndex: 0, sectorIndex: 2 }, + }, + [{ name: 'alice', agentId: 'agent-a' }] + ); + + expect(migrated.changed).toBe(true); + expect(migrated.assignments).toEqual({ + 'agent-a': { ringIndex: 0, sectorIndex: 2 }, + }); + }); + + it('seeds default assignments only when no visible owner has a persisted assignment', () => { + const seeded = seedStableSlotAssignmentsForMembers( + { unrelated: { ringIndex: 4, sectorIndex: 0 } }, + [ + { name: 'alice', agentId: 'agent-a' }, + { name: 'bob', agentId: 'agent-b' }, + ] + ); + + expect(seeded.changed).toBe(true); + expect(Object.keys(seeded.assignments)).toEqual(['unrelated', 'agent-a', 'agent-b']); + expect(seeded.assignments['agent-a']).toEqual({ ringIndex: 0, sectorIndex: 0 }); + expect(seeded.assignments['agent-b']).toEqual({ ringIndex: 0, sectorIndex: 1 }); + + const preserved = seedStableSlotAssignmentsForMembers(seeded.assignments, [ + { name: 'alice', agentId: 'agent-a' }, + { name: 'bob', agentId: 'agent-b' }, + ]); + expect(preserved.changed).toBe(false); + expect(preserved.assignments).toBe(seeded.assignments); + }); + + it('normalizes six-owner legacy two-row orbit assignments', () => { + const ownerIds = ['a', 'b', 'c', 'd', 'e', 'f']; + const normalized = normalizeLegacySixRowOrbitAssignments( + { + a: { ringIndex: 0, sectorIndex: 0 }, + b: { ringIndex: 0, sectorIndex: 4 }, + c: { ringIndex: 1, sectorIndex: 2 }, + d: { ringIndex: 1, sectorIndex: 0 }, + }, + ownerIds + ); + + expect(normalized).toEqual({ + a: { ringIndex: 0, sectorIndex: 0 }, + b: { ringIndex: 2, sectorIndex: 1 }, + c: { ringIndex: 2, sectorIndex: 2 }, + d: { ringIndex: 2, sectorIndex: 0 }, + }); + }); + + it('normalizes and prunes assignments to visible owners', () => { + const normalized = normalizeTeamGraphSlotAssignmentsForVisibleOwners( + { + a: { ringIndex: 0, sectorIndex: 0 }, + hidden: { ringIndex: 4, sectorIndex: 4 }, + }, + ['a'] + ); + + expect(normalized).toEqual({ a: { ringIndex: 0, sectorIndex: 0 } }); + expect(pruneTeamGraphSlotAssignmentsForVisibleOwners({ hidden: { ringIndex: 4, sectorIndex: 4 } }, ['a'])) + .toBeUndefined(); + }); + + it('normalizes grid owner order by filtering stale and duplicate ids then appending missing ids', () => { + expect(normalizeTeamGraphGridOwnerOrder(['b', 'stale', 'b'], ['a', 'b', 'c'])).toEqual([ + 'b', + 'a', + 'c', + ]); + }); + + it('compares assignments by owner id and slot coordinates', () => { + expect( + areTeamGraphSlotAssignmentsEqual( + { a: { ringIndex: 0, sectorIndex: 0 } }, + { a: { ringIndex: 0, sectorIndex: 0 } } + ) + ).toBe(true); + expect( + areTeamGraphSlotAssignmentsEqual( + { a: { ringIndex: 0, sectorIndex: 0 } }, + { a: { ringIndex: 0, sectorIndex: 1 } } + ) + ).toBe(false); + }); + + it('exposes default assignment and persistence guardrail helpers', () => { + expect( + getDefaultTeamGraphSlotAssignmentsForMembers([ + { name: 'team-lead', agentId: 'lead-agent' }, + { name: 'alice', agentId: 'agent-a' }, + ]) + ).toEqual({ 'agent-a': { ringIndex: 0, sectorIndex: 0 } }); + expect(isTeamGraphSlotPersistenceDisabled()).toBe(true); + }); +}); diff --git a/test/renderer/store/teamResolvedMembers.test.ts b/test/renderer/store/teamResolvedMembers.test.ts new file mode 100644 index 00000000..6ea9f5ad --- /dev/null +++ b/test/renderer/store/teamResolvedMembers.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + clearResolvedMemberSelectorCaches, + getResolvedMemberSelectorCacheSnapshotForTeam, + selectResolvedMemberForTeamName, + selectResolvedMembersForTeamName, + shouldPreserveSelectedTeamSnapshot, +} from '../../../src/renderer/store/team/teamResolvedMembers'; + +import type { + TeamMemberActivityMeta, + TeamMemberSnapshot, + TeamSummary, + TeamTask, + TeamViewSnapshot, +} from '../../../src/shared/types'; + +function createTask(overrides: Partial = {}): TeamTask { + return { + id: 'task-1', + subject: 'Task', + owner: 'alice', + status: 'in_progress', + ...overrides, + }; +} + +function createSnapshot(overrides: Partial = {}): TeamViewSnapshot { + return { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + ...overrides, + } as TeamViewSnapshot; +} + +function createState( + snapshot: TeamViewSnapshot, + options: { + summary?: TeamSummary; + meta?: TeamMemberActivityMeta; + } = {} +) { + return { + selectedTeamName: snapshot.teamName, + selectedTeamData: snapshot, + teamDataCacheByName: { [snapshot.teamName]: snapshot }, + memberActivityMetaByTeam: options.meta ? { [snapshot.teamName]: options.meta } : {}, + teamByName: options.summary ? { [snapshot.teamName]: options.summary } : {}, + }; +} + +describe('teamResolvedMembers', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-17T12:00:00.000Z')); + clearResolvedMemberSelectorCaches(); + }); + + afterEach(() => { + vi.useRealTimers(); + clearResolvedMemberSelectorCaches(); + }); + + it('builds config fallback members when runtime snapshots are empty', () => { + const snapshot = createSnapshot({ + config: { + name: 'My Team', + members: [ + { name: 'team-lead', role: 'Lead' }, + { name: 'alice', agentId: 'agent-a', role: 'Engineer' }, + { name: 'Alice', agentId: 'duplicate' }, + ], + }, + tasks: [ + createTask({ id: 'task-active', owner: 'alice', status: 'in_progress' }), + createTask({ id: 'task-done', owner: 'alice', status: 'completed' }), + ], + }); + + const members = selectResolvedMembersForTeamName(createState(snapshot), 'my-team'); + + expect(members.map((member) => member.name)).toEqual(['team-lead', 'alice']); + expect(members[1]).toMatchObject({ + name: 'alice', + agentId: 'agent-a', + currentTaskId: 'task-active', + taskCount: 2, + role: 'Engineer', + status: 'active', + messageCount: 0, + lastActiveAt: null, + }); + }); + + it('builds summary fallback members with a lead when config and runtime snapshots are empty', () => { + const snapshot = createSnapshot(); + const summary = { + teamName: 'my-team', + displayName: 'My Team', + memberCount: 2, + taskCount: 0, + lastActivity: null, + leadName: 'lead-one', + leadColor: '#fff', + members: [ + { name: 'lead-one', role: 'Lead' }, + { name: 'bob', agentId: 'agent-b', role: 'Reviewer', color: '#123456' }, + { name: 'Bob', agentId: 'duplicate' }, + ], + } as TeamSummary; + + const members = selectResolvedMembersForTeamName(createState(snapshot, { summary }), 'my-team'); + + expect(members.map((member) => member.name)).toEqual(['lead-one', 'bob']); + expect(members[0]).toMatchObject({ agentType: 'team-lead', role: 'Team Lead' }); + expect(members[1]).toMatchObject({ + agentId: 'agent-b', + role: 'Reviewer', + color: '#123456', + }); + }); + + it('memoizes selector results until resolved-member cache is cleared', () => { + const snapshot = createSnapshot({ + members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot], + }); + const state = createState(snapshot); + + const firstMembers = selectResolvedMembersForTeamName(state, 'my-team'); + const secondMembers = selectResolvedMembersForTeamName(state, 'my-team'); + const firstAlice = selectResolvedMemberForTeamName(state, 'my-team', 'alice'); + const secondAlice = selectResolvedMemberForTeamName(state, 'my-team', 'alice'); + + expect(secondMembers).toBe(firstMembers); + expect(secondAlice).toBe(firstAlice); + expect(getResolvedMemberSelectorCacheSnapshotForTeam('my-team')).toEqual({ + hasResolvedMembersSelector: true, + resolvedMemberSelectorCount: 1, + }); + + clearResolvedMemberSelectorCaches(); + + expect(selectResolvedMembersForTeamName(state, 'my-team')).not.toBe(firstMembers); + expect(getResolvedMemberSelectorCacheSnapshotForTeam('my-team')).toEqual({ + hasResolvedMembersSelector: true, + resolvedMemberSelectorCount: 0, + }); + }); + + it('derives activity status from member activity metadata', () => { + const snapshot = createSnapshot({ + members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot], + }); + const meta = { + teamName: 'my-team', + feedRevision: 'rev-1', + computedAt: '2026-04-17T12:00:00.000Z', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-04-17T11:57:00.000Z', + messageCountExact: 3, + latestAuthoredMessageSignalsTermination: false, + }, + }, + } as TeamMemberActivityMeta; + + expect(selectResolvedMemberForTeamName(createState(snapshot, { meta }), 'my-team', 'alice')) + .toMatchObject({ + status: 'active', + messageCount: 3, + lastActiveAt: '2026-04-17T11:57:00.000Z', + }); + }); + + it('preserves the selected snapshot when an incoming empty snapshot is confirmed by summary', () => { + const current = createSnapshot({ + members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot], + }); + const incoming = createSnapshot({ members: [], config: { name: 'My Team' } }); + const summary = { + teamName: 'my-team', + displayName: 'My Team', + memberCount: 1, + expectedMemberCount: 1, + taskCount: 0, + lastActivity: null, + members: [{ name: 'alice' }], + } as TeamSummary; + + expect(shouldPreserveSelectedTeamSnapshot(current, current, incoming, summary)).toBe(true); + }); + + it('does not preserve the selected snapshot when incoming data has a config roster', () => { + const current = createSnapshot({ + members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot], + }); + const incoming = createSnapshot({ + members: [], + config: { name: 'My Team', members: [{ name: 'bob' }] }, + }); + + expect(shouldPreserveSelectedTeamSnapshot(current, current, incoming, undefined)).toBe(false); + }); +}); From 6fb0c714ef95915142b92b932d53e4546d42233b Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 15:42:25 +0300 Subject: [PATCH 25/41] fix(runtime-provider-management): surface provider diagnostics --- .../contracts/types.ts | 15 + .../registerRuntimeProviderManagementIpc.ts | 167 ++- ...TeamsRuntimeProviderManagementCliClient.ts | 1290 ++++++++++++++--- .../hooks/useRuntimeProviderManagement.ts | 110 +- .../ui/RuntimeProviderManagementPanelView.tsx | 284 +++- ...RuntimeProviderManagementCliClient.test.ts | 962 +++++++++++- ...gisterRuntimeProviderManagementIpc.test.ts | 155 +- ...RuntimeProviderManagementPanelView.test.ts | 447 ++++++ .../useRuntimeProviderManagement.test.ts | 572 ++++++++ 9 files changed, 3728 insertions(+), 274 deletions(-) diff --git a/src/features/runtime-provider-management/contracts/types.ts b/src/features/runtime-provider-management/contracts/types.ts index d0e13302..f98f6e52 100644 --- a/src/features/runtime-provider-management/contracts/types.ts +++ b/src/features/runtime-provider-management/contracts/types.ts @@ -193,6 +193,7 @@ export type RuntimeProviderManagementErrorCodeDto = | 'unsupported-runtime' | 'unsupported-action' | 'runtime-missing' + | 'runtime-misconfigured' | 'runtime-unhealthy' | 'provider-missing' | 'auth-required' @@ -201,10 +202,24 @@ export type RuntimeProviderManagementErrorCodeDto = | 'model-test-failed' | 'unsupported-auth-method'; +export interface RuntimeProviderManagementErrorDiagnosticsDto { + errorCode?: RuntimeProviderManagementErrorCodeDto | null; + summary: string | null; + likelyCause: string | null; + binaryPath: string | null; + command: string | null; + projectPath: string | null; + exitCode: number | null; + stderrPreview: string | null; + stdoutPreview: string | null; + hints: readonly string[]; +} + export interface RuntimeProviderManagementErrorDto { code: RuntimeProviderManagementErrorCodeDto; message: string; recoverable: boolean; + diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null; } export interface RuntimeProviderManagementViewResponse { diff --git a/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts b/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts index 0dbd5713..10c27974 100644 --- a/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts +++ b/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts @@ -16,6 +16,7 @@ import type { RuntimeProviderManagementConnectApiKeyInput, RuntimeProviderManagementConnectInput, RuntimeProviderManagementDirectoryResponse, + RuntimeProviderManagementErrorDto, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, RuntimeProviderManagementLoadModelsInput, @@ -32,6 +33,85 @@ import type { import type { IpcMain } from 'electron'; const logger = createLogger('Feature:RuntimeProviderManagement:IPC'); +const RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT = 1_600; +const ESCAPE_CHARACTER = String.fromCharCode(27); +const BELL_CHARACTER = String.fromCharCode(7); +const ANSI_ESCAPE_PATTERN = new RegExp(`${ESCAPE_CHARACTER}\\[[0-?]*[ -/]*[@-~]`, 'g'); +const OSC_ESCAPE_PATTERN = new RegExp( + `${ESCAPE_CHARACTER}\\][\\s\\S]*?(?:${BELL_CHARACTER}|${ESCAPE_CHARACTER}\\\\)`, + 'g' +); + +function truncateRuntimeProviderIpcErrorDetail(message: string): string { + if (message.length <= RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT) { + return message; + } + return `${message.slice(0, RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT).trimEnd()}...`; +} + +function sanitizeRuntimeProviderIpcErrorMessage(message: string): string { + const sanitized = message + .replace(OSC_ESCAPE_PATTERN, '') + .replace(ANSI_ESCAPE_PATTERN, '') + .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, 'sk-...redacted') + .replace(/\b(or-[A-Za-z0-9_-]{12,})\b/g, 'or-...redacted') + .replace(/\b(AIza[A-Za-z0-9_-]{20,})\b/g, 'AIza...redacted') + .replace( + /\b([A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|[_-]key)["'\s:=]+)([A-Za-z0-9._~+/=_-]{12,})/gi, + '$1...redacted' + ) + .replace(/\b(key["'\s:=]+)([A-Za-z0-9._~+/=_-]{12,})/gi, '$1...redacted') + .replace(/\b(bearer\s+)([A-Za-z0-9._~+/=_-]{12,})/gi, '$1...redacted') + .trim(); + return truncateRuntimeProviderIpcErrorDetail(sanitized); +} + +function getRuntimeProviderIpcErrorMessage(error: unknown, fallback: string): string { + if (typeof error === 'string') { + return sanitizeRuntimeProviderIpcErrorMessage(error) || fallback; + } + if (!(error instanceof Error) || !error.message.trim()) { + return fallback; + } + return sanitizeRuntimeProviderIpcErrorMessage(error.message) || fallback; +} + +function getRuntimeProviderIpcConnectLogDetail(error: unknown): string { + if (error instanceof Error) { + return sanitizeRuntimeProviderIpcErrorMessage(error.message) || error.name || 'Error'; + } + if (typeof error === 'string') { + return sanitizeRuntimeProviderIpcErrorMessage(error) || 'Non-Error throw'; + } + return 'Non-Error throw'; +} + +function createUnexpectedRuntimeProviderIpcError( + code: RuntimeProviderManagementErrorDto['code'], + message: string +): RuntimeProviderManagementErrorDto { + return { + code, + message, + recoverable: true, + diagnostics: { + errorCode: code, + summary: message, + likelyCause: + 'The desktop app runtime provider management handler failed before it returned a normal response.', + binaryPath: null, + command: null, + projectPath: null, + exitCode: null, + stderrPreview: message, + stdoutPreview: null, + hints: [ + 'Retry the action once after refreshing provider settings.', + 'If it repeats, copy diagnostics and attach the app logs from the same session.', + ], + }, + }; +} export function registerRuntimeProviderManagementIpc( ipcMain: IpcMain, @@ -46,15 +126,12 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.loadView(input); } catch (error) { - logger.error('Failed to load runtime provider management view', error); + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to load providers'); + logger.error('Failed to load runtime provider management view', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'runtime-unhealthy', - message: error instanceof Error ? error.message : 'Failed to load providers', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message), }; } } @@ -69,15 +146,15 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.loadProviderDirectory(input); } catch (error) { - logger.error('Failed to load runtime provider directory', error); + const message = getRuntimeProviderIpcErrorMessage( + error, + 'Failed to load provider directory' + ); + logger.error('Failed to load runtime provider directory', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'runtime-unhealthy', - message: error instanceof Error ? error.message : 'Failed to load provider directory', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message), }; } } @@ -92,15 +169,15 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.loadSetupForm(input); } catch (error) { - logger.error('Failed to load runtime provider setup form', error); + const message = getRuntimeProviderIpcErrorMessage( + error, + 'Failed to load provider setup form' + ); + logger.error('Failed to load runtime provider setup form', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'runtime-unhealthy', - message: error instanceof Error ? error.message : 'Failed to load provider setup form', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message), }; } } @@ -115,18 +192,15 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.connectProvider(input); } catch (error) { + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider'); logger.error( 'Failed to connect runtime provider', - error instanceof Error ? error.name : error + getRuntimeProviderIpcConnectLogDetail(error) ); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'auth-failed', - message: 'Failed to connect provider', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('auth-failed', message), }; } } @@ -141,18 +215,15 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.connectWithApiKey(input); } catch (error) { + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider'); logger.error( 'Failed to connect runtime provider', - error instanceof Error ? error.name : error + getRuntimeProviderIpcConnectLogDetail(error) ); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'auth-failed', - message: 'Failed to connect provider', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('auth-failed', message), }; } } @@ -167,15 +238,12 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.forgetCredential(input); } catch (error) { - logger.error('Failed to forget runtime provider credential', error); + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to forget provider'); + logger.error('Failed to forget runtime provider credential', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'unsupported-action', - message: error instanceof Error ? error.message : 'Failed to forget provider', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('unsupported-action', message), }; } } @@ -190,15 +258,12 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.loadModels(input); } catch (error) { - logger.error('Failed to load runtime provider models', error); + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to load provider models'); + logger.error('Failed to load runtime provider models', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'runtime-unhealthy', - message: error instanceof Error ? error.message : 'Failed to load provider models', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message), }; } } @@ -213,15 +278,12 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.testModel(input); } catch (error) { - logger.error('Failed to test runtime provider model', error); + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to test model'); + logger.error('Failed to test runtime provider model', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'model-test-failed', - message: error instanceof Error ? error.message : 'Failed to test model', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message), }; } } @@ -236,15 +298,12 @@ export function registerRuntimeProviderManagementIpc( try { return await feature.setDefaultModel(input); } catch (error) { - logger.error('Failed to set runtime provider default model', error); + const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to set default model'); + logger.error('Failed to set runtime provider default model', message); return { schemaVersion: 1, runtimeId: input.runtimeId, - error: { - code: 'model-test-failed', - message: error instanceof Error ? error.message : 'Failed to set default model', - recoverable: true, - }, + error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message), }; } } diff --git a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts index 9f3327f0..a2942160 100644 --- a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts +++ b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess'; @@ -28,6 +31,33 @@ import type { ChildProcessWithoutNullStreams } from 'child_process'; const COMMAND_TIMEOUT_MS = 45_000; const PROBE_COMMAND_TIMEOUT_MS = 90_000; const COMMAND_ERROR_DETAIL_LIMIT = 1_600; +const COMMAND_OUTPUT_PREVIEW_LIMIT = 1_200; +const ESCAPE_CHARACTER = String.fromCharCode(27); +const BELL_CHARACTER = String.fromCharCode(7); +const ANSI_ESCAPE_PATTERN = new RegExp(`${ESCAPE_CHARACTER}\\[[0-?]*[ -/]*[@-~]`, 'g'); +const OSC_ESCAPE_PATTERN = new RegExp( + `${ESCAPE_CHARACTER}\\][\\s\\S]*?(?:${BELL_CHARACTER}|${ESCAPE_CHARACTER}\\\\)`, + 'g' +); +const OPENCODE_BINARY_BASENAMES = new Set([ + 'opencode', + 'opencode.exe', + 'opencode.cmd', + 'opencode.ps1', +]); +const RUNTIME_PROVIDER_ERROR_CODES = new Set([ + 'unsupported-runtime', + 'unsupported-action', + 'runtime-missing', + 'runtime-misconfigured', + 'runtime-unhealthy', + 'provider-missing', + 'auth-required', + 'auth-failed', + 'model-missing', + 'model-test-failed', + 'unsupported-auth-method', +]); type RuntimeProviderManagementErrorResponse = | RuntimeProviderManagementViewResponse @@ -37,10 +67,32 @@ type RuntimeProviderManagementErrorResponse = | RuntimeProviderManagementModelsResponse | RuntimeProviderManagementModelTestResponse; +interface RuntimeProviderCommandContext { + binaryPath: string; + args: readonly string[]; + projectPath: string | null; +} + +interface RuntimeProviderCommandFailure { + message: string; + diagnostics?: RuntimeProviderManagementErrorDto['diagnostics']; +} + +class RuntimeProviderCommandOutputError extends Error { + readonly diagnostics: RuntimeProviderManagementErrorDto['diagnostics']; + + constructor(failure: RuntimeProviderCommandFailure) { + super(failure.message); + this.name = 'RuntimeProviderCommandOutputError'; + this.diagnostics = failure.diagnostics ?? null; + } +} + function errorResponse( runtimeId: RuntimeProviderManagementRuntimeId, message: string, - code: RuntimeProviderManagementErrorDto['code'] = 'runtime-unhealthy' + code: RuntimeProviderManagementErrorDto['code'] = 'runtime-unhealthy', + diagnostics: RuntimeProviderManagementErrorDto['diagnostics'] = null ): T { return { schemaVersion: 1, @@ -49,25 +101,667 @@ function errorResponse( code, message, recoverable: true, + diagnostics: withRuntimeProviderErrorCode(code, diagnostics), }, } as T; } -function extractJsonObject(raw: string): T { - const start = raw.indexOf('{'); - const end = raw.lastIndexOf('}'); - if (start < 0 || end < start) { - throw new Error('CLI did not return a JSON object'); - } - return JSON.parse(raw.slice(start, end + 1)) as T; +function commandFailureResponse( + runtimeId: RuntimeProviderManagementRuntimeId, + failure: RuntimeProviderCommandFailure, + code: RuntimeProviderManagementErrorDto['code'] = 'runtime-unhealthy' +): T { + return errorResponse(runtimeId, failure.message, code, failure.diagnostics ?? null); } -function tryExtractJsonObject(raw: string | null): T | null { +function sanitizeRuntimeProviderResponse( + response: T +): T { + const sanitizedResponse = sanitizeRuntimeProviderOutputValue(response) as T; + if (sanitizedResponse.error === null) { + const responseWithoutNullError = { ...sanitizedResponse }; + delete (responseWithoutNullError as { error?: unknown }).error; + return responseWithoutNullError as T; + } + if (!sanitizedResponse.error) { + return sanitizedResponse; + } + + return { + ...sanitizedResponse, + error: sanitizeRuntimeProviderError(sanitizedResponse.error), + }; +} + +function sanitizeRuntimeProviderError(error: unknown): RuntimeProviderManagementErrorDto { + if (!isRecord(error)) { + return { + code: 'runtime-unhealthy', + message: 'Runtime provider management command failed', + recoverable: true, + diagnostics: null, + }; + } + const rawCode = error.code; + const code = + typeof rawCode === 'string' && + RUNTIME_PROVIDER_ERROR_CODES.has(rawCode as RuntimeProviderManagementErrorDto['code']) + ? (rawCode as RuntimeProviderManagementErrorDto['code']) + : 'runtime-unhealthy'; + const diagnostics = sanitizeRuntimeProviderDiagnostics(error.diagnostics); + return { + code, + message: + sanitizeNullableRuntimeProviderText(error.message) ?? + 'Runtime provider management command failed', + recoverable: typeof error.recoverable === 'boolean' ? error.recoverable : true, + diagnostics: withRuntimeProviderErrorCode(code, diagnostics), + }; +} + +function withRuntimeProviderErrorCode( + errorCode: RuntimeProviderManagementErrorDto['code'], + diagnostics: RuntimeProviderManagementErrorDto['diagnostics'] +): RuntimeProviderManagementErrorDto['diagnostics'] { + return diagnostics ? { ...diagnostics, errorCode } : null; +} + +function sanitizeRuntimeProviderOutputValue(value: unknown): unknown { + if (typeof value === 'string') { + return sanitizeRuntimeProviderText(value); + } + if (Array.isArray(value)) { + return value.map(sanitizeRuntimeProviderOutputValue); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, sanitizeRuntimeProviderOutputValue(entry)]) + ); +} + +function sanitizeRuntimeProviderDiagnostics( + diagnostics: unknown +): RuntimeProviderManagementErrorDto['diagnostics'] { + if (!isRecord(diagnostics)) { + return null; + } + return { + errorCode: + typeof diagnostics.errorCode === 'string' && + RUNTIME_PROVIDER_ERROR_CODES.has( + diagnostics.errorCode as RuntimeProviderManagementErrorDto['code'] + ) + ? (diagnostics.errorCode as RuntimeProviderManagementErrorDto['code']) + : null, + summary: sanitizeNullableRuntimeProviderText(diagnostics.summary), + likelyCause: sanitizeNullableRuntimeProviderText(diagnostics.likelyCause), + binaryPath: sanitizeNullableRuntimeProviderText(diagnostics.binaryPath), + command: sanitizeNullableRuntimeProviderText(diagnostics.command), + projectPath: sanitizeNullableRuntimeProviderText(diagnostics.projectPath), + exitCode: typeof diagnostics.exitCode === 'number' ? diagnostics.exitCode : null, + stderrPreview: sanitizeNullableRuntimeProviderText(diagnostics.stderrPreview), + stdoutPreview: sanitizeNullableRuntimeProviderText(diagnostics.stdoutPreview), + hints: Array.isArray(diagnostics.hints) + ? diagnostics.hints + .filter((hint): hint is string => typeof hint === 'string') + .map(sanitizeRuntimeProviderText) + : [], + }; +} + +function sanitizeNullableRuntimeProviderText(value: unknown): string | null { + return typeof value === 'string' ? sanitizeRuntimeProviderText(value) : null; +} + +function extractJsonObject(raw: string): T { + const start = raw.indexOf('{'); + if (start < 0) { + throw new Error('CLI did not return a JSON object'); + } + + for (let index = start; index >= 0 && index < raw.length; index = raw.indexOf('{', index + 1)) { + const end = findJsonObjectEnd(raw, index); + if (end === null) { + continue; + } + try { + const candidate = JSON.parse(raw.slice(index, end + 1)) as T; + if (isRuntimeProviderResponseCandidate(candidate)) { + return candidate; + } + } catch { + // Keep scanning. CLI output can contain brace-looking logs before the JSON response. + } + } + + throw new Error('CLI did not return a JSON object'); +} + +function findJsonObjectEnd(raw: string, start: number): number | null { + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = start; index < raw.length; index += 1) { + const char = raw[index]; + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + if (char === '{') { + depth += 1; + continue; + } + if (char !== '}') { + continue; + } + + depth -= 1; + if (depth === 0) { + return index; + } + if (depth < 0) { + return null; + } + } + + return null; +} + +function isRuntimeProviderResponseCandidate(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return ( + typeof value.schemaVersion === 'number' && + typeof value.runtimeId === 'string' && + hasRuntimeProviderResponsePayload(value) + ); +} + +function hasRuntimeProviderResponsePayload(record: Record): boolean { + if (isRecord(record.error)) { + return isRuntimeProviderErrorPayload(record.error); + } + if ('view' in record) { + return isRuntimeProviderViewPayload(record.view); + } + if ('directory' in record) { + return isRuntimeProviderDirectoryPayload(record.directory); + } + if ('provider' in record) { + return isRuntimeProviderProviderPayload(record.provider); + } + if ('setupForm' in record) { + return isRuntimeProviderSetupFormPayload(record.setupForm); + } + if ('models' in record) { + return isRuntimeProviderModelsPayload(record.models); + } + if ('result' in record) { + return isRuntimeProviderModelTestResultPayload(record.result); + } + return false; +} + +function isRuntimeProviderErrorPayload(value: unknown): boolean { + return ( + isRecord(value) && + (typeof value.code === 'string' || + typeof value.message === 'string' || + typeof value.recoverable === 'boolean' || + 'diagnostics' in value) + ); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function hasArrayField( + record: Record, + key: K +): record is Record & Record { + return Array.isArray(record[key]); +} + +function isRuntimeProviderViewPayload(value: unknown): boolean { + return ( + isRecord(value) && + hasArrayField(value, 'providers') && + hasArrayField(value, 'diagnostics') && + value.providers.every(isRuntimeProviderProviderPayload) + ); +} + +function isRuntimeProviderProviderPayload(value: unknown): boolean { + return ( + isRecord(value) && + hasArrayField(value, 'actions') && + hasArrayField(value, 'authMethods') && + hasArrayField(value, 'ownership') + ); +} + +function isRuntimeProviderDirectoryPayload(value: unknown): boolean { + return ( + isRecord(value) && + hasArrayField(value, 'entries') && + hasArrayField(value, 'diagnostics') && + value.entries.every(isRuntimeProviderDirectoryEntryPayload) + ); +} + +function isRuntimeProviderDirectoryEntryPayload(value: unknown): boolean { + return ( + isRuntimeProviderProviderPayload(value) && isRecord(value) && hasArrayField(value, 'sources') + ); +} + +function isRuntimeProviderSetupFormPayload(value: unknown): boolean { + return ( + isRecord(value) && + hasArrayField(value, 'prompts') && + value.prompts.every(isRuntimeProviderSetupPromptPayload) + ); +} + +function isRuntimeProviderSetupPromptPayload(value: unknown): boolean { + return isRecord(value) && hasArrayField(value, 'options'); +} + +function isRuntimeProviderModelsPayload(value: unknown): boolean { + return isRecord(value) && hasArrayField(value, 'models') && hasArrayField(value, 'diagnostics'); +} + +function isRuntimeProviderModelTestResultPayload(value: unknown): boolean { + return isRecord(value) && hasArrayField(value, 'diagnostics'); +} + +function stripTerminalFormatting(value: string): string { + return value.replace(OSC_ESCAPE_PATTERN, '').replace(ANSI_ESCAPE_PATTERN, ''); +} + +function sanitizeRuntimeProviderText(value: string): string { + return redactSensitiveText(stripTerminalFormatting(value)); +} + +function redactSensitiveText(value: string): string { + return value + .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, 'sk-...redacted') + .replace(/\b(or-[A-Za-z0-9_-]{12,})\b/g, 'or-...redacted') + .replace(/\b(AIza[A-Za-z0-9_-]{20,})\b/g, 'AIza...redacted') + .replace( + /\b([A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|[_-]key)["'\s:=]+)([A-Za-z0-9._~+/=_-]{12,})/gi, + '$1...redacted' + ) + .replace(/\b(key["'\s:=]+)([A-Za-z0-9._~+/=_-]{12,})/gi, '$1...redacted') + .replace(/\b(bearer\s+)([A-Za-z0-9._~+/=_-]{12,})/gi, '$1...redacted'); +} + +function formatCommandForDisplay(context: RuntimeProviderCommandContext): string { + return [context.binaryPath, ...context.args].map(formatCommandPartForDisplay).join(' '); +} + +function formatCommandPartForDisplay(value: string): string { + if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) { + return value; + } + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function getOutputPreview(value: string | null): string | null { + const normalized = sanitizeRuntimeProviderText(value ?? '').trim(); + if (!normalized) { + return null; + } + return truncateCommandErrorDetail( + normalized.length > COMMAND_OUTPUT_PREVIEW_LIMIT + ? `${normalized.slice(0, COMMAND_OUTPUT_PREVIEW_LIMIT).trimEnd()}...` + : normalized + ); +} + +function sanitizeCommandErrorMessage(value: string): string { + return truncateCommandErrorDetail(sanitizeRuntimeProviderText(value.trim())); +} + +function outputLooksLikeOpenCodeCliHelp(value: string | null): boolean { + const normalized = stripTerminalFormatting(value ?? '').toLowerCase(); + return ( + normalized.includes('opencode providers') || + normalized.includes('opencode models') || + (normalized.includes('commands:') && normalized.includes('opencode')) + ); +} + +function binaryLooksLikeOpenCode(binaryPath: string): boolean { + return getBinaryBasenameCandidates(binaryPath).some((basename) => + OPENCODE_BINARY_BASENAMES.has(basename) + ); +} + +function getBinaryBasenameCandidates(binaryPath: string): string[] { + const basenames = new Set([path.basename(binaryPath).toLowerCase()]); + try { + basenames.add(path.basename(fs.realpathSync.native(binaryPath)).toLowerCase()); + } catch { + // Nonexistent mocked paths are handled by the literal basename above. + } + return [...basenames]; +} + +function formatNonJsonCliOutputError(input: { + context: RuntimeProviderCommandContext; + stdout?: string | null; + stderr?: string | null; + exitCode?: number | null; +}): RuntimeProviderCommandFailure { + const stdoutPreview = getOutputPreview(input.stdout ?? null); + const stderrPreview = getOutputPreview(input.stderr ?? null); + const likelyWrongBinary = + binaryLooksLikeOpenCode(input.context.binaryPath) || + outputLooksLikeOpenCodeCliHelp(input.stdout ?? null) || + outputLooksLikeOpenCodeCliHelp(input.stderr ?? null); + const likelyCause = likelyWrongBinary + ? 'The app is launching the OpenCode CLI itself instead of the Agent Teams runtime (claude-multimodel).' + : 'The runtime command printed logs, help text, or a crash message instead of JSON.'; + const hints = likelyWrongBinary + ? [ + 'Check CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH and CLAUDE_CLI_PATH.', + 'Those environment variables must not point to opencode.', + 'The expected binary is the Agent Teams runtime/orchestrator CLI, not the OpenCode CLI.', + ] + : [ + 'Open stderr preview first. It usually contains the real crash or missing dependency.', + 'Run the shown command from the same project path to reproduce the runtime output.', + ]; + const lines = [ + 'OpenCode provider settings could not read the runtime response.', + 'Expected a JSON object from the Agent Teams runtime provider command.', + `Resolved runtime binary: ${input.context.binaryPath}`, + `Command: ${formatCommandForDisplay(input.context)}`, + ]; + + if (input.context.projectPath) { + lines.push(`Project path: ${input.context.projectPath}`); + } + if (input.exitCode !== undefined) { + lines.push(`Exit code: ${String(input.exitCode ?? 'unknown')}`); + } + + if (likelyWrongBinary) { + lines.push(`Likely cause: ${likelyCause}`, ...hints); + } else { + lines.push(`Likely cause: ${likelyCause}`); + } + + if (stderrPreview) { + lines.push('stderr preview:', stderrPreview); + } + if (stdoutPreview) { + lines.push('stdout preview:', stdoutPreview); + } + if (!stderrPreview && !stdoutPreview) { + lines.push('No stdout or stderr was captured from the runtime command.'); + } + + return { + message: lines.join('\n'), + diagnostics: { + summary: 'OpenCode provider settings could not read the runtime response.', + likelyCause, + binaryPath: input.context.binaryPath, + command: formatCommandForDisplay(input.context), + projectPath: input.context.projectPath, + exitCode: input.exitCode ?? null, + stderrPreview, + stdoutPreview, + hints, + }, + }; +} + +function formatWrongRuntimeBinaryError( + context: RuntimeProviderCommandContext +): RuntimeProviderCommandFailure { + const likelyCause = 'The app resolved the OpenCode CLI itself as the Agent Teams runtime binary.'; + const hints = [ + 'Check CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH and CLAUDE_CLI_PATH.', + 'Those environment variables must not point to opencode.', + 'The expected binary is the Agent Teams runtime/orchestrator CLI, not the OpenCode CLI.', + ]; + const lines = [ + 'OpenCode provider settings are using the wrong runtime binary.', + `Resolved runtime binary: ${context.binaryPath}`, + `Command that was blocked: ${formatCommandForDisplay(context)}`, + ]; + + if (context.projectPath) { + lines.push(`Project path: ${context.projectPath}`); + } + + lines.push(`Likely cause: ${likelyCause}`, ...hints); + + return { + message: lines.join('\n'), + diagnostics: { + summary: 'OpenCode provider settings are using the wrong runtime binary.', + likelyCause, + binaryPath: context.binaryPath, + command: formatCommandForDisplay(context), + projectPath: context.projectPath, + exitCode: null, + stderrPreview: null, + stdoutPreview: null, + hints, + }, + }; +} + +function formatCommandExecutionError(input: { + context: RuntimeProviderCommandContext; + errorMessage: string; +}): RuntimeProviderCommandFailure { + const sanitizedError = sanitizeCommandErrorMessage(input.errorMessage); + const likelyCause = 'The runtime command failed before it returned JSON output.'; + const hints = [ + 'Check whether the resolved runtime binary exists and is executable.', + 'Run the shown command from the same project path to reproduce the failure.', + ]; + const lines = [ + 'OpenCode provider settings could not run the runtime command.', + `Resolved runtime binary: ${input.context.binaryPath}`, + `Command: ${formatCommandForDisplay(input.context)}`, + ]; + + if (input.context.projectPath) { + lines.push(`Project path: ${input.context.projectPath}`); + } + + lines.push(`Likely cause: ${likelyCause}`); + if (sanitizedError) { + lines.push('Error:', sanitizedError); + } + lines.push(...hints); + + return { + message: lines.join('\n'), + diagnostics: { + summary: 'OpenCode provider settings could not run the runtime command.', + likelyCause, + binaryPath: input.context.binaryPath, + command: formatCommandForDisplay(input.context), + projectPath: input.context.projectPath, + exitCode: null, + stderrPreview: sanitizedError || null, + stdoutPreview: null, + hints, + }, + }; +} + +function isCommandTimeoutMessage(value: string): boolean { + const normalized = value.toLowerCase(); + return normalized.includes('timed out') || normalized.includes('timeout'); +} + +function formatCommandTimeoutError(input: { + context: RuntimeProviderCommandContext; + errorMessage: string; + stdout?: string | null; + stderr?: string | null; +}): RuntimeProviderCommandFailure { + const stdoutPreview = getOutputPreview(input.stdout ?? null); + const stderrPreview = getOutputPreview(input.stderr ?? null); + const sanitizedError = sanitizeCommandErrorMessage(input.errorMessage); + const likelyCause = + 'The Agent Teams runtime command did not return JSON before the desktop timeout.'; + const hints = [ + 'This is not enough evidence to conclude that OpenCode auth is missing.', + 'Run the shown command from the same project path to see the runtime-side OpenCode diagnostics.', + 'If the command hangs before printing JSON, check OpenCode CLI startup, provider/model listing, local OpenCode plugins, cache/profile corruption, and Windows security software delays.', + 'If the runtime binary is stale, update Agent Teams so the runtime can return a degraded OpenCode diagnostic instead of timing out.', + ]; + const lines = [ + 'OpenCode provider settings timed out while waiting for the Agent Teams runtime.', + `Resolved runtime binary: ${input.context.binaryPath}`, + `Command: ${formatCommandForDisplay(input.context)}`, + ]; + + if (input.context.projectPath) { + lines.push(`Project path: ${input.context.projectPath}`); + } + + lines.push(`Likely cause: ${likelyCause}`); + if (sanitizedError) { + lines.push('Timeout detail:', sanitizedError); + } + if (stderrPreview) { + lines.push('stderr preview:', stderrPreview); + } + if (stdoutPreview) { + lines.push('stdout preview:', stdoutPreview); + } + lines.push(...hints); + + return { + message: lines.join('\n'), + diagnostics: { + summary: 'OpenCode provider settings timed out while waiting for the Agent Teams runtime.', + likelyCause, + binaryPath: input.context.binaryPath, + command: formatCommandForDisplay(input.context), + projectPath: input.context.projectPath, + exitCode: null, + stderrPreview: stderrPreview ?? sanitizedError, + stdoutPreview, + hints, + }, + }; +} + +function formatMissingRuntimeBinaryError( + projectPath: string | null +): RuntimeProviderCommandFailure { + const likelyCause = + 'The Agent Teams runtime/orchestrator CLI could not be resolved from the current environment.'; + const hints = [ + 'Check CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH and CLAUDE_CLI_PATH.', + 'If you are developing locally, start the desktop app from a shell that can resolve the orchestrator CLI.', + 'The expected binary is the Agent Teams runtime/orchestrator CLI, not the OpenCode CLI.', + ]; + const lines = [ + 'OpenCode provider settings could not find the Agent Teams runtime binary.', + `Likely cause: ${likelyCause}`, + ...hints, + ]; + + if (projectPath) { + lines.splice(1, 0, `Project path: ${projectPath}`); + } + + return { + message: lines.join('\n'), + diagnostics: { + summary: 'OpenCode provider settings could not find the Agent Teams runtime binary.', + likelyCause, + binaryPath: null, + command: null, + projectPath, + exitCode: null, + stderrPreview: null, + stdoutPreview: null, + hints, + }, + }; +} + +function missingRuntimeBinaryResponse( + runtimeId: RuntimeProviderManagementRuntimeId, + projectPath: string | null +): T { + return commandFailureResponse( + runtimeId, + formatMissingRuntimeBinaryError(projectPath), + 'runtime-missing' + ); +} + +function rejectWrongRuntimeBinary( + runtimeId: RuntimeProviderManagementRuntimeId, + context: RuntimeProviderCommandContext +): T | null { + if (!binaryLooksLikeOpenCode(context.binaryPath)) { + return null; + } + ClaudeBinaryResolver.clearCache(); + return commandFailureResponse( + runtimeId, + formatWrongRuntimeBinaryError(context), + 'runtime-misconfigured' + ); +} + +function extractJsonObjectWithContext( + raw: string, + context: RuntimeProviderCommandContext, + stderr: string | null = null +): T { + try { + return sanitizeRuntimeProviderResponse(extractJsonObject(raw)); + } catch { + throw new RuntimeProviderCommandOutputError( + formatNonJsonCliOutputError({ context, stdout: raw, stderr }) + ); + } +} + +function tryExtractJsonObject( + raw: string | null +): T | null { if (!raw) { return null; } try { - return extractJsonObject(raw); + return sanitizeRuntimeProviderResponse(extractJsonObject(raw)); } catch { return null; } @@ -85,7 +779,9 @@ function readErrorTextProperty(error: unknown, propertyName: 'stderr' | 'stdout' return null; } -function extractJsonObjectFromError(error: unknown): T | null { +function extractJsonObjectFromError( + error: unknown +): T | null { return ( tryExtractJsonObject(readErrorTextProperty(error, 'stdout')) ?? tryExtractJsonObject(readErrorTextProperty(error, 'stderr')) @@ -99,19 +795,59 @@ function truncateCommandErrorDetail(message: string): string { return `${message.slice(0, COMMAND_ERROR_DETAIL_LIMIT).trimEnd()}...`; } -function normalizeCommandFailure(error: unknown): string { - const stderr = readErrorTextProperty(error, 'stderr'); - if (stderr) { - return truncateCommandErrorDetail(stderr); +function normalizeCommandFailure( + error: unknown, + context?: RuntimeProviderCommandContext +): RuntimeProviderCommandFailure { + if (error instanceof RuntimeProviderCommandOutputError) { + return { + message: truncateCommandErrorDetail(error.message), + diagnostics: error.diagnostics, + }; } + const stderr = readErrorTextProperty(error, 'stderr'); const stdout = readErrorTextProperty(error, 'stdout'); + const message = error instanceof Error ? error.message : String(error); + if (context && isCommandTimeoutMessage(message)) { + return formatCommandTimeoutError({ + context, + errorMessage: message, + stdout, + stderr, + }); + } + if ( + context && + (outputLooksLikeOpenCodeCliHelp(stdout) || + outputLooksLikeOpenCodeCliHelp(stderr) || + (stdout && !stderr && binaryLooksLikeOpenCode(context.binaryPath))) + ) { + return formatNonJsonCliOutputError({ context, stdout, stderr }); + } + if (context && (stdout || stderr)) { + return formatNonJsonCliOutputError({ context, stdout, stderr }); + } + if (stderr) { + return { message: sanitizeCommandErrorMessage(stderr) }; + } if (stdout) { - return truncateCommandErrorDetail(stdout); + return { message: sanitizeCommandErrorMessage(stdout) }; } if (error instanceof Error && error.message.trim()) { - return truncateCommandErrorDetail(error.message); + if (context) { + return formatCommandExecutionError({ context, errorMessage: error.message }); + } + return { message: sanitizeCommandErrorMessage(error.message) }; } - return 'Runtime provider management command failed'; + return { message: 'Runtime provider management command failed' }; +} + +function createCommandContext( + binaryPath: string, + args: readonly string[], + projectPath: string | null +): RuntimeProviderCommandContext { + return { binaryPath, args, projectPath }; } function normalizeProjectPath(projectPath: string | null | undefined): string | null { @@ -156,6 +892,15 @@ async function resolveCliEnv(): Promise<{ }, }; } + if (binaryLooksLikeOpenCode(binaryPath)) { + return { + binaryPath, + env: { + ...process.env, + ...shellEnv, + }, + }; + } const providerAware = await buildProviderAwareCliEnv({ binaryPath, @@ -172,10 +917,11 @@ async function resolveCliEnv(): Promise<{ function collectSpawnOutput( child: ChildProcessWithoutNullStreams, stdinValue: string -): Promise<{ stdout: string; stderr: string; code: number | null }> { +): Promise<{ stdout: string; stderr: string; code: number | null; stdinError: string | null }> { return new Promise((resolve, reject) => { const stdout: Buffer[] = []; const stderr: Buffer[] = []; + let stdinError: string | null = null; let settled = false; const timeout = setTimeout(() => { @@ -184,17 +930,23 @@ function collectSpawnOutput( } settled = true; killProcessTree(child, 'SIGKILL'); - reject(new Error('Runtime provider management command timed out')); + const error = new Error('Runtime provider management command timed out'); + Object.assign(error, readSpawnOutputSnapshot(stdout, stderr)); + reject(error); }, COMMAND_TIMEOUT_MS); child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.stdin.once('error', (error: Error) => { + stdinError = error.message; + }); child.once('error', (error) => { if (settled) { return; } settled = true; clearTimeout(timeout); + Object.assign(error, readSpawnOutputSnapshot(stdout, stderr)); reject(error); }); child.once('close', (code) => { @@ -207,46 +959,96 @@ function collectSpawnOutput( stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), code, + stdinError, }); }); - child.stdin.write(stdinValue); - child.stdin.end(); + try { + child.stdin.write(stdinValue); + child.stdin.end(); + } catch (error) { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + if (error instanceof Error) { + Object.assign(error, readSpawnOutputSnapshot(stdout, stderr)); + reject(error); + return; + } + const fallbackError = new Error('Runtime provider management command stdin write failed'); + Object.assign(fallbackError, readSpawnOutputSnapshot(stdout, stderr)); + reject(fallbackError); + } }); } +function mergeSpawnStderrWithStdinError(result: { + stderr: string; + stdinError: string | null; +}): string { + if (!result.stdinError?.trim()) { + return result.stderr; + } + const stdinErrorLine = `stdin error: ${result.stdinError.trim()}`; + return result.stderr.trim() ? `${result.stderr.trimEnd()}\n${stdinErrorLine}` : stdinErrorLine; +} + +function readSpawnOutputSnapshot( + stdout: readonly Buffer[], + stderr: readonly Buffer[] +): { stdout: string; stderr: string } { + return { + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }; +} + export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProviderManagementApi { async loadView( input: RuntimeProviderManagementLoadViewInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + ['runtime', 'providers', 'view', '--runtime', input.runtimeId, '--json', '--compact'], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli( + const { stdout, stderr } = await execCli( binaryPath, - appendProjectPathArgs( - ['runtime', 'providers', 'view', '--runtime', input.runtimeId, '--json', '--compact'], - projectPath - ), + args, runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) ); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -254,16 +1056,15 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async loadProviderDirectory( input: RuntimeProviderManagementLoadDirectoryInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); const args = ['runtime', 'providers', 'directory', '--runtime', input.runtimeId, '--json']; appendOptionalArg(args, '--project-path', projectPath); appendOptionalArg(args, '--query', input.query ?? null); @@ -275,23 +1076,35 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv if (input.refresh) { args.push('--refresh'); } + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli( + const { stdout, stderr } = await execCli( binaryPath, args, runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) ); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -299,44 +1112,56 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async loadSetupForm( input: RuntimeProviderManagementLoadSetupFormInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'setup-form', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--json', + ], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli( + const { stdout, stderr } = await execCli( binaryPath, - appendProjectPathArgs( - [ - 'runtime', - 'providers', - 'setup-form', - '--runtime', - input.runtimeId, - '--provider', - input.providerId, - '--json', - ], - projectPath - ), + args, runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) ); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -344,33 +1169,41 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async connectProvider( input: RuntimeProviderManagementConnectInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'connect', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--stdin-json', + '--json', + ], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { const child = spawnCli( binaryPath, - appendProjectPathArgs( - [ - 'runtime', - 'providers', - 'connect', - '--runtime', - input.runtimeId, - '--provider', - input.providerId, - '--stdin-json', - '--json', - ], - projectPath - ), + args, runtimeProviderCommandOptions( { env, @@ -388,15 +1221,26 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv }) ); if (result.code === 0) { - return extractJsonObject(result.stdout); + return extractJsonObjectWithContext( + result.stdout, + context, + mergeSpawnStderrWithStdinError(result) + ); } try { - return extractJsonObject(result.stdout); + return sanitizeRuntimeProviderResponse( + extractJsonObject(result.stdout) + ); } catch { - return errorResponse( + return commandFailureResponse( input.runtimeId, - `Runtime provider connect command failed with exit code ${String(result.code ?? 'unknown')}.` + formatNonJsonCliOutputError({ + context, + stdout: result.stdout, + stderr: mergeSpawnStderrWithStdinError(result), + exitCode: result.code, + }) ); } } catch (error) { @@ -404,9 +1248,9 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -414,33 +1258,41 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async connectWithApiKey( input: RuntimeProviderManagementConnectApiKeyInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'connect-api-key', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--stdin-key', + '--json', + ], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { const child = spawnCli( binaryPath, - appendProjectPathArgs( - [ - 'runtime', - 'providers', - 'connect-api-key', - '--runtime', - input.runtimeId, - '--provider', - input.providerId, - '--stdin-key', - '--json', - ], - projectPath - ), + args, runtimeProviderCommandOptions( { env, @@ -451,15 +1303,26 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv ) as ChildProcessWithoutNullStreams; const result = await collectSpawnOutput(child, input.apiKey); if (result.code === 0) { - return extractJsonObject(result.stdout); + return extractJsonObjectWithContext( + result.stdout, + context, + mergeSpawnStderrWithStdinError(result) + ); } try { - return extractJsonObject(result.stdout); + return sanitizeRuntimeProviderResponse( + extractJsonObject(result.stdout) + ); } catch { - return errorResponse( + return commandFailureResponse( input.runtimeId, - `Runtime provider connect command failed with exit code ${String(result.code ?? 'unknown')}.` + formatNonJsonCliOutputError({ + context, + stdout: result.stdout, + stderr: mergeSpawnStderrWithStdinError(result), + exitCode: result.code, + }) ); } } catch (error) { @@ -467,9 +1330,9 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -477,43 +1340,55 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async forgetCredential( input: RuntimeProviderManagementForgetInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'forget', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--json', + ], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli( + const { stdout, stderr } = await execCli( binaryPath, - appendProjectPathArgs( - [ - 'runtime', - 'providers', - 'forget', - '--runtime', - input.runtimeId, - '--provider', - input.providerId, - '--json', - ], - projectPath - ), + args, runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) ); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -521,16 +1396,15 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async loadModels( input: RuntimeProviderManagementLoadModelsInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); let args = [ 'runtime', 'providers', @@ -548,21 +1422,33 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv args.push('--limit', String(Math.floor(input.limit))); } args = appendProjectPathArgs(args, projectPath); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli(binaryPath, args, { + const { stdout, stderr } = await execCli(binaryPath, args, { ...runtimeProviderCommandOptions({ env }, projectPath), timeout: COMMAND_TIMEOUT_MS, }); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error) + normalizeCommandFailure(error, context) ); } } @@ -570,46 +1456,58 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async testModel( input: RuntimeProviderManagementTestModelInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'test-model', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--model', + input.modelId, + '--json', + ], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli( + const { stdout, stderr } = await execCli( binaryPath, - appendProjectPathArgs( - [ - 'runtime', - 'providers', - 'test-model', - '--runtime', - input.runtimeId, - '--provider', - input.providerId, - '--model', - input.modelId, - '--json', - ], - projectPath - ), + args, runtimeProviderCommandOptions({ env, timeout: PROBE_COMMAND_TIMEOUT_MS }, projectPath) ); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error), + normalizeCommandFailure(error, context), 'model-test-failed' ); } @@ -618,49 +1516,61 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv async setDefaultModel( input: RuntimeProviderManagementSetDefaultModelInput ): Promise { + const projectPath = normalizeProjectPath(input.projectPath); const { binaryPath, env } = await resolveCliEnv(); if (!binaryPath) { - return errorResponse( + return missingRuntimeBinaryResponse( input.runtimeId, - 'Multimodel runtime binary was not found.', - 'runtime-missing' + projectPath ); } - const projectPath = normalizeProjectPath(input.projectPath); + const args = appendProjectPathArgs( + [ + 'runtime', + 'providers', + 'set-default', + '--runtime', + input.runtimeId, + '--provider', + input.providerId, + '--model', + input.modelId, + '--scope', + input.scope === 'all_projects' ? 'all-projects' : 'project', + '--probe', + '--compact', + '--json', + ], + projectPath + ); + const context = createCommandContext(binaryPath, args, projectPath); + const misconfigured = rejectWrongRuntimeBinary( + input.runtimeId, + context + ); + if (misconfigured) { + return misconfigured; + } try { - const { stdout } = await execCli( + const { stdout, stderr } = await execCli( binaryPath, - appendProjectPathArgs( - [ - 'runtime', - 'providers', - 'set-default', - '--runtime', - input.runtimeId, - '--provider', - input.providerId, - '--model', - input.modelId, - '--scope', - input.scope === 'all_projects' ? 'all-projects' : 'project', - '--probe', - '--compact', - '--json', - ], - projectPath - ), + args, runtimeProviderCommandOptions({ env, timeout: PROBE_COMMAND_TIMEOUT_MS }, projectPath) ); - return extractJsonObject(stdout); + return extractJsonObjectWithContext( + stdout, + context, + stderr + ); } catch (error) { const response = extractJsonObjectFromError(error); if (response) { return response; } - return errorResponse( + return commandFailureResponse( input.runtimeId, - normalizeCommandFailure(error), + normalizeCommandFailure(error, context), 'model-test-failed' ); } diff --git a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts index ee60bdd8..e9b343f6 100644 --- a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts +++ b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts @@ -13,6 +13,7 @@ import type { RuntimeProviderDefaultScopeDto, RuntimeProviderDirectoryEntryDto, RuntimeProviderDirectoryFilterDto, + RuntimeProviderManagementErrorDiagnosticsDto, RuntimeProviderManagementRuntimeId, RuntimeProviderManagementViewDto, RuntimeProviderModelDto, @@ -46,6 +47,7 @@ export interface RuntimeProviderManagementState { directoryLoading: boolean; directoryRefreshing: boolean; directoryError: string | null; + directoryErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null; directoryEntries: readonly RuntimeProviderDirectoryEntryDto[]; directoryTotalCount: number | null; directoryNextCursor: string | null; @@ -56,7 +58,9 @@ export interface RuntimeProviderManagementState { setupForm: RuntimeProviderSetupFormDto | null; setupFormLoading: boolean; setupFormError: string | null; + setupFormErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null; setupSubmitError: string | null; + setupSubmitErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null; setupMetadata: Readonly>; apiKeyValue: string; modelPickerProviderId: string | null; @@ -65,6 +69,7 @@ export interface RuntimeProviderManagementState { models: readonly RuntimeProviderModelDto[]; modelsLoading: boolean; modelsError: string | null; + modelsErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null; selectedModelId: string | null; testingModelIds: readonly string[]; savingDefaultModelId: string | null; @@ -72,6 +77,7 @@ export interface RuntimeProviderManagementState { loading: boolean; savingProviderId: string | null; error: string | null; + errorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null; successMessage: string | null; } @@ -219,6 +225,8 @@ export function useRuntimeProviderManagement( const [directoryLoading, setDirectoryLoading] = useState(false); const [directoryRefreshing, setDirectoryRefreshing] = useState(false); const [directoryError, setDirectoryError] = useState(null); + const [directoryErrorDiagnostics, setDirectoryErrorDiagnostics] = + useState(null); const [directoryEntries, setDirectoryEntries] = useState< readonly RuntimeProviderDirectoryEntryDto[] >([]); @@ -234,7 +242,11 @@ export function useRuntimeProviderManagement( const [setupForm, setSetupForm] = useState(null); const [setupFormLoading, setSetupFormLoading] = useState(false); const [setupFormError, setSetupFormError] = useState(null); + const [setupFormErrorDiagnostics, setSetupFormErrorDiagnostics] = + useState(null); const [setupSubmitError, setSetupSubmitError] = useState(null); + const [setupSubmitErrorDiagnostics, setSetupSubmitErrorDiagnostics] = + useState(null); const [setupMetadata, setSetupMetadata] = useState>({}); const [apiKeyValue, setApiKeyValue] = useState(''); const [modelPickerProviderId, setModelPickerProviderId] = useState(null); @@ -245,6 +257,8 @@ export function useRuntimeProviderManagement( const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); + const [modelsErrorDiagnostics, setModelsErrorDiagnostics] = + useState(null); const [selectedModelId, setSelectedModelId] = useState(null); const [testingModelIds, setTestingModelIds] = useState([]); const [savingDefaultModelId, setSavingDefaultModelId] = useState(null); @@ -254,6 +268,8 @@ export function useRuntimeProviderManagement( const [loading, setLoading] = useState(false); const [savingProviderId, setSavingProviderId] = useState(null); const [error, setError] = useState(null); + const [errorDiagnostics, setErrorDiagnostics] = + useState(null); const [successMessage, setSuccessMessage] = useState(null); const viewLoadRequestSeq = useRef(0); const directoryRequestSeq = useRef(0); @@ -296,6 +312,7 @@ export function useRuntimeProviderManagement( setModels([]); setModelsLoading(false); setModelsError(null); + setModelsErrorDiagnostics(null); setSelectedModelId(null); setModelResults({}); setTestingModelIds([]); @@ -313,6 +330,7 @@ export function useRuntimeProviderManagement( setModels([]); setModelsLoading(false); setModelsError(null); + setModelsErrorDiagnostics(null); setSelectedModelId(null); setModelResults({}); setTestingModelIds([]); @@ -323,24 +341,31 @@ export function useRuntimeProviderManagement( setupFormRequestSeq.current += 1; modelLoadRequestSeq.current += 1; modelProbeGenerationRef.current += 1; + setDirectoryLoading(false); + setDirectoryRefreshing(false); setDirectoryEntries([]); setDirectoryTotalCount(null); setDirectoryNextCursor(null); setDirectoryError(null); + setDirectoryErrorDiagnostics(null); setDirectorySelectedProviderId(null); setDirectoryLoaded(false); setSetupForm(null); setSetupFormLoading(false); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setActiveFormProviderId(null); setApiKeyValue(''); setSetupMetadata({}); setModels([]); setModelsLoading(false); setModelsError(null); + setModelsErrorDiagnostics(null); setSelectedModelId(null); setTestingModelIds([]); + setSavingProviderId(null); setSavingDefaultModelId(null); setModelResults({}); setSuccessMessage(null); @@ -361,6 +386,7 @@ export function useRuntimeProviderManagement( setLoading(true); } setError(null); + setErrorDiagnostics(null); try { const response = await api.runtimeProviderManagement.loadView({ runtimeId: options.runtimeId, @@ -374,6 +400,7 @@ export function useRuntimeProviderManagement( setView(null); } setError(response.error.message); + setErrorDiagnostics(response.error.diagnostics ?? null); return; } const nextView = response.view ?? null; @@ -392,6 +419,7 @@ export function useRuntimeProviderManagement( setView(null); } setError(loadError instanceof Error ? loadError.message : 'Failed to load providers'); + setErrorDiagnostics(null); } finally { if (!silent && requestIsCurrent()) { setLoading(false); @@ -434,6 +462,7 @@ export function useRuntimeProviderManagement( setDirectoryLoading(true); } setDirectoryError(null); + setDirectoryErrorDiagnostics(null); try { const response = await api.runtimeProviderManagement.loadProviderDirectory({ @@ -450,6 +479,7 @@ export function useRuntimeProviderManagement( } if (response.error) { setDirectoryError(response.error.message); + setDirectoryErrorDiagnostics(response.error.diagnostics ?? null); if ( response.error.code === 'unsupported-action' || response.error.message.toLowerCase().includes('unknown command') @@ -461,6 +491,7 @@ export function useRuntimeProviderManagement( const directory = response.directory; if (!directory) { setDirectoryError('Provider directory response was empty'); + setDirectoryErrorDiagnostics(null); return; } setDirectoryLoaded(true); @@ -474,6 +505,7 @@ export function useRuntimeProviderManagement( setDirectoryError( loadError instanceof Error ? loadError.message : 'Failed to load provider directory' ); + setDirectoryErrorDiagnostics(null); } } finally { if (requestIsCurrent()) { @@ -495,23 +527,37 @@ export function useRuntimeProviderManagement( useEffect(() => { if (!options.enabled) { viewLoadRequestSeq.current += 1; + directoryRequestSeq.current += 1; + setupFormRequestSeq.current += 1; appliedInitialProviderRef.current = null; + setView(null); + setSelectedProviderId(null); setProviderQuery(''); + setLoading(false); + setSavingProviderId(null); + setSavingDefaultModelId(null); + setError(null); + setErrorDiagnostics(null); + setSuccessMessage(null); setDirectoryLoading(false); setDirectoryRefreshing(false); setDirectoryError(null); + setDirectoryErrorDiagnostics(null); setDirectoryEntries([]); setDirectoryTotalCount(null); setDirectoryNextCursor(null); setDirectoryQuery(''); setDirectoryLoaded(false); setDirectorySelectedProviderId(null); + setDirectorySupported(true); setApiKeyValue(''); setSetupMetadata({}); setSetupForm(null); setSetupFormLoading(false); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setActiveFormProviderId(null); closeModelPickerState(); return; @@ -537,12 +583,20 @@ export function useRuntimeProviderManagement( ); return () => window.clearTimeout(timeout); - }, [directoryLoaded, directoryQuery, directorySupported, loadDirectoryPage, options.enabled]); + }, [ + currentProjectPath, + directoryLoaded, + directoryQuery, + directorySupported, + loadDirectoryPage, + options.enabled, + ]); useEffect(() => { if (!options.enabled || !modelPickerProviderId) { modelLoadRequestSeq.current += 1; setModelsLoading(false); + setModelsErrorDiagnostics(null); return; } @@ -557,6 +611,7 @@ export function useRuntimeProviderManagement( let cancelled = false; setModelsLoading(true); setModelsError(null); + setModelsErrorDiagnostics(null); void withUiTimeout( api.runtimeProviderManagement.loadModels({ runtimeId: options.runtimeId, @@ -574,6 +629,7 @@ export function useRuntimeProviderManagement( if (response.error) { setModels([]); setModelsError(response.error.message); + setModelsErrorDiagnostics(response.error.diagnostics ?? null); return; } const nextModels = response.models?.models ?? []; @@ -593,6 +649,7 @@ export function useRuntimeProviderManagement( ? modelsLoadError.message : 'Failed to load provider models' ); + setModelsErrorDiagnostics(null); } }) .finally(() => { @@ -678,7 +735,9 @@ export function useRuntimeProviderManagement( setActiveFormProviderId(null); setSetupForm(null); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setSetupMetadata({}); setApiKeyValue(''); @@ -704,6 +763,7 @@ export function useRuntimeProviderManagement( const searchAllProviders = useCallback((query: string): void => { setDirectoryQuery(query); setDirectoryError(null); + setDirectoryErrorDiagnostics(null); setDirectoryNextCursor(null); }, []); @@ -716,9 +776,12 @@ export function useRuntimeProviderManagement( setSetupMetadata({}); setSetupForm(null); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setSetupFormLoading(true); setError(null); + setErrorDiagnostics(null); setSuccessMessage(null); const projectContext = getProjectContextSnapshot(); const requestSeq = setupFormRequestSeq.current + 1; @@ -740,11 +803,13 @@ export function useRuntimeProviderManagement( } if (response.error) { setSetupFormError(response.error.message); + setSetupFormErrorDiagnostics(response.error.diagnostics ?? null); return; } setSetupForm(response.setupForm ?? null); if (!response.setupForm) { setSetupFormError('Provider setup form response was empty'); + setSetupFormErrorDiagnostics(null); } }) .catch((setupError) => { @@ -754,6 +819,7 @@ export function useRuntimeProviderManagement( setSetupFormError( setupError instanceof Error ? setupError.message : 'Failed to load provider setup form' ); + setSetupFormErrorDiagnostics(null); }) .finally(() => { if (requestIsCurrent()) { @@ -784,13 +850,17 @@ export function useRuntimeProviderManagement( setSetupForm(null); setSetupFormLoading(false); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setError(null); + setErrorDiagnostics(null); }, []); const updateApiKeyValue = useCallback((value: string): void => { setApiKeyValue(value); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); }, []); const setSetupMetadataValue = useCallback((key: string, value: string): void => { @@ -799,29 +869,35 @@ export function useRuntimeProviderManagement( [key]: value, })); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); }, []); const submitConnect = useCallback( async (providerId: string): Promise => { if (!setupForm) { setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded'); + setSetupSubmitErrorDiagnostics(setupFormErrorDiagnostics ?? null); return; } if (!setupForm.supported) { setSetupSubmitError( setupForm.disabledReason ?? 'Provider setup is not supported in the app' ); + setSetupSubmitErrorDiagnostics(null); return; } const apiKey = apiKeyValue.trim(); if (setupForm.secret?.required && !apiKey) { setSetupSubmitError(`${setupForm.secret.label} is required`); + setSetupSubmitErrorDiagnostics(null); return; } setSavingProviderId(providerId); setError(null); + setErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setSuccessMessage(null); const projectContext = getProjectContextSnapshot(); try { @@ -841,6 +917,7 @@ export function useRuntimeProviderManagement( } if (response.error) { setSetupSubmitError(response.error.message); + setSetupSubmitErrorDiagnostics(response.error.diagnostics ?? null); return; } if (response.provider) { @@ -852,7 +929,9 @@ export function useRuntimeProviderManagement( setSetupMetadata({}); setSetupForm(null); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); try { await options.onProviderChanged?.(); if (!isProjectContextCurrent(projectContext)) { @@ -869,6 +948,7 @@ export function useRuntimeProviderManagement( setError( refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' ); + setErrorDiagnostics(null); } } catch (connectError) { if (!isProjectContextCurrent(projectContext)) { @@ -877,6 +957,7 @@ export function useRuntimeProviderManagement( setSetupSubmitError( connectError instanceof Error ? connectError.message : 'Failed to connect provider' ); + setSetupSubmitErrorDiagnostics(null); } finally { if (isProjectContextCurrent(projectContext)) { setSavingProviderId(null); @@ -892,6 +973,7 @@ export function useRuntimeProviderManagement( refresh, setupForm, setupFormError, + setupFormErrorDiagnostics, setupMetadata, ] ); @@ -900,6 +982,7 @@ export function useRuntimeProviderManagement( async (providerId: string): Promise => { setSavingProviderId(providerId); setError(null); + setErrorDiagnostics(null); setSuccessMessage(null); const projectContext = getProjectContextSnapshot(); try { @@ -916,6 +999,7 @@ export function useRuntimeProviderManagement( } if (response.error) { setError(response.error.message); + setErrorDiagnostics(response.error.diagnostics ?? null); return; } if (response.provider) { @@ -938,6 +1022,7 @@ export function useRuntimeProviderManagement( setError( refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' ); + setErrorDiagnostics(null); } if (!isProjectContextCurrent(projectContext)) { return; @@ -950,6 +1035,7 @@ export function useRuntimeProviderManagement( setError( forgetError instanceof Error ? forgetError.message : 'Failed to forget credential' ); + setErrorDiagnostics(null); } finally { if (isProjectContextCurrent(projectContext)) { setSavingProviderId(null); @@ -965,6 +1051,7 @@ export function useRuntimeProviderManagement( setActiveFormProviderId(null); openModelPickerState(providerId, mode); setError(null); + setErrorDiagnostics(null); setSuccessMessage(null); }, [openModelPickerState] @@ -979,6 +1066,7 @@ export function useRuntimeProviderManagement( setSelectedModelId(modelId); setSuccessMessage(null); setError(null); + setErrorDiagnostics(null); }, []); const testModel = useCallback( @@ -994,6 +1082,7 @@ export function useRuntimeProviderManagement( current.includes(modelId) ? current : [...current, modelId] ); setError(null); + setErrorDiagnostics(null); setSuccessMessage(null); try { const response = await withUiTimeout( @@ -1007,6 +1096,10 @@ export function useRuntimeProviderManagement( 100_000 ); if (response.error) { + if (response.error.diagnostics && shouldRecordProbeResult()) { + setError(response.error.message); + setErrorDiagnostics(response.error.diagnostics); + } if (shouldRecordProbeResult()) { const result = buildFailedModelTestResult(providerId, modelId, response.error.message); setModelResults((current) => ({ @@ -1064,6 +1157,7 @@ export function useRuntimeProviderManagement( ): Promise => { setSavingDefaultModelId(modelId); setError(null); + setErrorDiagnostics(null); setSuccessMessage(null); const projectContext = getProjectContextSnapshot(); try { @@ -1084,6 +1178,7 @@ export function useRuntimeProviderManagement( } if (response.error) { setError(response.error.message); + setErrorDiagnostics(response.error.diagnostics ?? null); return; } const proofResult: RuntimeProviderModelTestResultDto = { @@ -1130,6 +1225,7 @@ export function useRuntimeProviderManagement( setError( defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default' ); + setErrorDiagnostics(null); } finally { if (isProjectContextCurrent(projectContext)) { setSavingDefaultModelId(null); @@ -1146,7 +1242,9 @@ export function useRuntimeProviderManagement( setActiveFormProviderId(null); setSetupForm(null); setSetupFormError(null); + setSetupFormErrorDiagnostics(null); setSetupSubmitError(null); + setSetupSubmitErrorDiagnostics(null); setSetupMetadata({}); setApiKeyValue(''); if (activeModelPickerProviderRef.current !== providerId) { @@ -1199,6 +1297,7 @@ export function useRuntimeProviderManagement( directoryLoading, directoryRefreshing, directoryError, + directoryErrorDiagnostics, directoryEntries, directoryTotalCount, directoryNextCursor, @@ -1209,7 +1308,9 @@ export function useRuntimeProviderManagement( setupForm, setupFormLoading, setupFormError, + setupFormErrorDiagnostics, setupSubmitError, + setupSubmitErrorDiagnostics, setupMetadata, apiKeyValue, modelPickerProviderId, @@ -1218,6 +1319,7 @@ export function useRuntimeProviderManagement( models, modelsLoading, modelsError, + modelsErrorDiagnostics, selectedModelId, testingModelIds, savingDefaultModelId, @@ -1225,18 +1327,22 @@ export function useRuntimeProviderManagement( loading, savingProviderId, error, + errorDiagnostics, successMessage, }), [ activeFormProviderId, apiKeyValue, setupForm, + setupFormErrorDiagnostics, setupFormError, setupFormLoading, + setupSubmitErrorDiagnostics, setupSubmitError, setupMetadata, directoryEntries, directoryError, + directoryErrorDiagnostics, directoryLoaded, directoryLoading, directoryNextCursor, @@ -1245,12 +1351,14 @@ export function useRuntimeProviderManagement( directorySupported, directoryTotalCount, error, + errorDiagnostics, loading, modelPickerMode, modelPickerProviderId, modelQuery, modelResults, models, + modelsErrorDiagnostics, modelsError, modelsLoading, providerQuery, diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index 4646607f..9cff5066 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; @@ -20,7 +20,9 @@ import { } from '@renderer/utils/openCodeModelRecommendations'; import { AlertTriangle, + Check, CheckCircle2, + ClipboardList, KeyRound, Loader2, RefreshCcw, @@ -47,6 +49,7 @@ import type { RuntimeProviderDefaultModelSourceDto, RuntimeProviderDefaultScopeDto, RuntimeProviderDirectoryEntryDto, + RuntimeProviderManagementErrorDiagnosticsDto, RuntimeProviderModelDto, RuntimeProviderModelTestResultDto, RuntimeProviderSetupPromptDto, @@ -84,6 +87,12 @@ interface ProviderRowProps { readonly actions: RuntimeProviderManagementActions; } +interface RuntimeProviderErrorAlertProps { + readonly message: string; + readonly diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null; + readonly testId: string; +} + type OpenCodeSettingsSection = 'models' | 'providers'; const NO_PROJECT_CONTEXT_VALUE = '__runtime-provider-no-project-context__'; @@ -338,8 +347,11 @@ function ProviderSetupFormPanel({ const form = state.setupForm?.providerId === provider.providerId ? state.setupForm : null; const loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId; const error = state.setupFormError; + const errorDiagnostics = state.setupFormErrorDiagnostics; const submitError = state.activeFormProviderId === provider.providerId ? state.setupSubmitError : null; + const submitErrorDiagnostics = + state.activeFormProviderId === provider.providerId ? state.setupSubmitErrorDiagnostics : null; const canSubmit = setupFormCanSubmit(state, provider.providerId); return ( @@ -356,9 +368,11 @@ function ProviderSetupFormPanel({ ) : null} {!loading && error ? ( -
- {error} -
+ ) : null} {!loading && form ? ( @@ -445,8 +459,12 @@ function ProviderSetupFormPanel({ ) : null} {submitError ? ( -
- {submitError} +
+
) : null} @@ -668,6 +686,228 @@ function RuntimeProviderLoadingPlaceholder(): JSX.Element { ); } +function formatRuntimeProviderDiagnosticsCopyText( + message: string, + diagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null | undefined +): string { + const lines = ['OpenCode provider settings diagnostics', '', 'Message:', message.trim()]; + if (!diagnostics) { + return lines.join('\n'); + } + const hints = diagnostics.hints ?? []; + + const fields: Array<[string, string | number | null]> = [ + ['Error code', diagnostics.errorCode ?? null], + ['Summary', diagnostics.summary], + ['Likely cause', diagnostics.likelyCause], + ['Resolved runtime binary', diagnostics.binaryPath], + ['Command', diagnostics.command], + ['Project path', diagnostics.projectPath], + ['Exit code', diagnostics.exitCode], + ]; + + lines.push('', 'Structured diagnostics:'); + for (const [label, value] of fields) { + if (value !== null && value !== '') { + lines.push(`${label}: ${String(value)}`); + } + } + + if (hints.length > 0) { + lines.push('', 'Hints:', ...hints.map((hint) => `- ${hint}`)); + } + if (diagnostics.stderrPreview) { + lines.push('', 'stderr preview:', diagnostics.stderrPreview); + } + if (diagnostics.stdoutPreview) { + lines.push('', 'stdout preview:', diagnostics.stdoutPreview); + } + + return lines.join('\n'); +} + +function getRuntimeProviderDiagnosticRows( + diagnostics: RuntimeProviderManagementErrorDiagnosticsDto +): Array<[string, string]> { + const rows: Array<[string, string | number | null]> = [ + ['Code', diagnostics.errorCode ?? null], + ['Binary', diagnostics.binaryPath], + ['Command', diagnostics.command], + ['Project', diagnostics.projectPath], + ['Exit', diagnostics.exitCode], + ]; + return rows + .filter(([, value]) => value !== null && value !== '') + .map(([label, value]) => [label, String(value)]); +} + +async function writeRuntimeProviderDiagnosticsToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall back to the selection API below. + } + } + + return copyRuntimeProviderDiagnosticsWithSelection(text); +} + +function copyRuntimeProviderDiagnosticsWithSelection(text: string): boolean { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.top = '-9999px'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + try { + return document.execCommand('copy'); + } catch { + return false; + } finally { + textarea.remove(); + } +} + +const RuntimeProviderErrorAlert = ({ + message, + diagnostics = null, + testId, +}: RuntimeProviderErrorAlertProps): JSX.Element => { + const [copied, setCopied] = useState(false); + const [headline = message, ...detailLines] = message.trim().split(/\r?\n/); + const fallbackDetails = detailLines.join('\n').trim(); + const hints = diagnostics?.hints ?? []; + const copyText = useMemo( + () => formatRuntimeProviderDiagnosticsCopyText(message, diagnostics), + [diagnostics, message] + ); + const diagnosticRows = diagnostics ? getRuntimeProviderDiagnosticRows(diagnostics) : []; + const copyDiagnostics = useCallback(async (): Promise => { + setCopied(await writeRuntimeProviderDiagnosticsToClipboard(copyText)); + }, [copyText]); + + useEffect(() => { + if (!copied) { + return; + } + const timeout = window.setTimeout(() => setCopied(false), 1_500); + return () => window.clearTimeout(timeout); + }, [copied]); + + return ( +
+ +
+
+
+ {headline || message} +
+ +
+ {diagnostics ? ( +
+ {diagnostics.likelyCause ? ( +
+ Likely cause: + {diagnostics.likelyCause} +
+ ) : null} + {diagnosticRows.length > 0 ? ( +
+ {diagnosticRows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ) : null} + {hints.length > 0 ? ( +
+
Hints
+
    + {hints.map((hint, index) => ( +
  • + {hint} +
  • + ))} +
+
+ ) : null} + {diagnostics.stderrPreview ? ( +
+                {`stderr preview:\n${diagnostics.stderrPreview}`}
+              
+ ) : null} + {diagnostics.stdoutPreview ? ( +
+                {`stdout preview:\n${diagnostics.stdoutPreview}`}
+              
+ ) : null} +
+ ) : fallbackDetails ? ( +
+            {fallbackDetails}
+          
+ ) : null} +
+
+ ); +}; + function RuntimeProviderModelLoadingSkeleton(): JSX.Element { return (
@@ -1756,9 +1996,11 @@ function ProviderModelList({
{state.modelsError ? ( -
- {state.modelsError} -
+ ) : null}
void actions.refresh()} /> {state.error ? ( -
- - {state.error} -
+ ) : null} {state.successMessage ? ( @@ -1988,9 +2224,11 @@ export function RuntimeProviderManagementPanelView({ ) : null} {state.directoryError ? ( -
- {state.directoryError} -
+ ) : null}
diff --git a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts index f7caca21..a8a6e34b 100644 --- a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts +++ b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts @@ -1,9 +1,13 @@ import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const buildProviderAwareCliEnvMock = vi.fn(); const resolveBinaryMock = vi.fn(); +const clearBinaryCacheMock = vi.fn(); const execCliMock = vi.fn(); const spawnCliMock = vi.fn(); const resolveInteractiveShellEnvMock = vi.fn(); @@ -15,12 +19,14 @@ function createSpawnProcess(stdoutPayload: unknown, exitCode = 0): { stdin: { write: ReturnType; end: ReturnType; + once: EventEmitter['once']; }; once: EventEmitter['once']; }; stdinWrite: ReturnType; } { const processEvents = new EventEmitter(); + const stdinEvents = new EventEmitter(); const stdout = new EventEmitter(); const stderr = new EventEmitter(); const stdinWrite = vi.fn(); @@ -38,6 +44,7 @@ function createSpawnProcess(stdoutPayload: unknown, exitCode = 0): { stdin: { write: stdinWrite, end: stdinEnd, + once: stdinEvents.once.bind(stdinEvents), }, once: processEvents.once.bind(processEvents), }, @@ -52,6 +59,7 @@ vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ ClaudeBinaryResolver: { resolve: () => resolveBinaryMock(), + clearCache: () => clearBinaryCacheMock(), }, })); @@ -94,8 +102,382 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { modelId: 'opencode/nemotron-3-super-free', }); - expect(response.error?.message).toBe('./cli-dev: line 47: exec: bun: not found'); - expect(response.error?.message).not.toContain('runtime providers test-model'); + expect(response.error?.message).toContain( + 'OpenCode provider settings could not read the runtime response.' + ); + expect(response.error?.message).toContain('stderr preview:'); + expect(response.error?.message).toContain('./cli-dev: line 47: exec: bun: not found'); + expect(response.error?.diagnostics?.command).toContain('runtime providers test-model'); + expect(response.error?.diagnostics?.stderrPreview).toBe( + './cli-dev: line 47: exec: bun: not found' + ); + }); + + it('redacts secrets from generic command stderr details', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stderr: 'Provider failed with api_key: sk-secret-value-123456\n', + stdout: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain('Provider failed with api_key: ...redacted'); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.diagnostics?.stderrPreview).toBe( + 'Provider failed with api_key: ...redacted' + ); + expect(response.error?.diagnostics?.command).toBe( + '/repo/cli-dev runtime providers view --runtime opencode --json --compact' + ); + }); + + it('strips terminal formatting and redacts bearer tokens from command previews', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers models'); + Object.assign(error, { + stderr: + '\u001B]8;;https://logs.example/secret\u0007\u001B[31mAuthorization: Bearer live-token-123456789\u001B[0m\u001B]8;;\u0007\n', + stdout: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadModels({ + runtimeId: 'opencode', + providerId: 'openrouter', + }); + + expect(response.error?.message).toContain('Authorization: Bearer ...redacted'); + expect(response.error?.message).not.toContain('live-token-123456789'); + expect(response.error?.message).not.toContain('logs.example/secret'); + expect(response.error?.message).not.toContain('[31m'); + expect(response.error?.message).not.toContain(']8;;'); + expect(response.error?.diagnostics?.stderrPreview).toBe( + 'Authorization: Bearer ...redacted' + ); + }); + + it('redacts non-OpenAI provider keys and generic token labels from diagnostics', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stderr: + 'Google key=AIzaSyD-test-secret-value-123456789 and token=provider-token-123456789 and OPENAI_API_KEY=plain_provider_secret_123456 and PROVIDER_TOKEN=provider_token_value_123456\n', + stdout: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain('key=...redacted'); + expect(response.error?.message).toContain('token=...redacted'); + expect(response.error?.message).toContain('OPENAI_API_KEY=...redacted'); + expect(response.error?.message).toContain('PROVIDER_TOKEN=...redacted'); + expect(response.error?.message).not.toContain('AIzaSyD-test-secret-value-123456789'); + expect(response.error?.message).not.toContain('provider-token-123456789'); + expect(response.error?.message).not.toContain('plain_provider_secret_123456'); + expect(response.error?.message).not.toContain('provider_token_value_123456'); + expect(response.error?.diagnostics?.stderrPreview).toContain('key=...redacted'); + expect(response.error?.diagnostics?.stderrPreview).toContain('token=...redacted'); + }); + + it('returns structured diagnostics for empty non-JSON command output', async () => { + execCliMock.mockResolvedValue({ + stdout: '', + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain('No stdout or stderr was captured'); + expect(response.error?.diagnostics?.command).toBe( + '/repo/cli-dev runtime providers view --runtime opencode --json --compact' + ); + expect(response.error?.diagnostics?.stdoutPreview).toBeNull(); + expect(response.error?.diagnostics?.stderrPreview).toBeNull(); + }); + + it('keeps stderr diagnostics when a zero-exit command prints malformed stdout', async () => { + execCliMock.mockResolvedValue({ + stdout: 'not json', + stderr: 'warning: api_key: sk-secret-value-123456\n', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain('stderr preview:'); + expect(response.error?.message).toContain('warning: api_key: ...redacted'); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.diagnostics?.stdoutPreview).toBe('not json'); + expect(response.error?.diagnostics?.stderrPreview).toBe('warning: api_key: ...redacted'); + }); + + it('returns structured diagnostics when the runtime binary cannot be resolved', async () => { + resolveBinaryMock.mockResolvedValue(null); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + projectPath: '/Users/test/project', + }); + + expect(response.error?.code).toBe('runtime-missing'); + expect(response.error?.message).toContain( + 'OpenCode provider settings could not find the Agent Teams runtime binary.' + ); + expect(response.error?.diagnostics?.summary).toBe( + 'OpenCode provider settings could not find the Agent Teams runtime binary.' + ); + expect(response.error?.diagnostics?.binaryPath).toBeNull(); + expect(response.error?.diagnostics?.command).toBeNull(); + expect(response.error?.diagnostics?.projectPath).toBe('/Users/test/project'); + expect(response.error?.diagnostics?.hints).toContain( + 'The expected binary is the Agent Teams runtime/orchestrator CLI, not the OpenCode CLI.' + ); + expect(buildProviderAwareCliEnvMock).not.toHaveBeenCalled(); + }); + + it('returns structured diagnostics for process errors without stdout or stderr', async () => { + execCliMock.mockRejectedValue( + new Error('spawn EACCES /repo/cli-dev with api_key: sk-secret-value-123456') + ); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + projectPath: '/Users/test/project', + }); + + expect(response.error?.message).toContain( + 'OpenCode provider settings could not run the runtime command.' + ); + expect(response.error?.message).toContain( + 'Error:\nspawn EACCES /repo/cli-dev with api_key: ...redacted' + ); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.diagnostics?.command).toBe( + '/repo/cli-dev runtime providers view --runtime opencode --json --compact --project-path /Users/test/project' + ); + expect(response.error?.diagnostics?.stderrPreview).toBe( + 'spawn EACCES /repo/cli-dev with api_key: ...redacted' + ); + }); + + it('parses the runtime JSON response after noisy brace logs', async () => { + const validResponse = { + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }; + execCliMock.mockResolvedValue({ + stdout: `debug {"noise":true}\n${JSON.stringify(validResponse)}\n`, + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error).toBeUndefined(); + expect(response.view?.runtime.state).toBe('ready'); + expect(response.view?.runtime.cliPath).toBe('/opt/homebrew/bin/opencode'); + }); + + it('accepts successful runtime responses that include an explicit null error field', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: null, + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error).toBeUndefined(); + expect(response.view?.runtime.state).toBe('ready'); + }); + + it('skips contract-looking noise that does not include a response payload', async () => { + const validResponse = { + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }; + execCliMock.mockResolvedValue({ + stdout: [ + JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + debug: 'preflight', + }), + JSON.stringify(validResponse), + ].join('\n'), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error).toBeUndefined(); + expect(response.view?.runtime.state).toBe('ready'); + expect(response.view?.title).toBe('OpenCode'); + }); + + it('does not treat JSON logs without a response payload as a successful runtime response', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + debug: 'preflight', + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain( + 'OpenCode provider settings could not read the runtime response.' + ); + expect(response.error?.diagnostics?.stdoutPreview).toContain('"debug":"preflight"'); + expect(response.view).toBeUndefined(); + }); + + it('does not treat malformed view payloads as successful runtime responses', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + }, + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain( + 'OpenCode provider settings could not read the runtime response.' + ); + expect(response.error?.diagnostics?.stdoutPreview).toContain('"title":"OpenCode"'); + expect(response.view).toBeUndefined(); + }); + + it('does not pass malformed provider entries to the renderer', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [ + { + providerId: 'openrouter', + displayName: 'OpenRouter', + state: 'connected', + ownership: ['managed'], + recommended: true, + modelCount: 4, + defaultModelId: null, + authMethods: ['api'], + detail: null, + }, + ], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toContain( + 'OpenCode provider settings could not read the runtime response.' + ); + expect(response.view).toBeUndefined(); }); it('parses JSON error responses from stdout when the CLI exits non-zero', async () => { @@ -127,6 +509,261 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { ); }); + it('redacts secrets from structured JSON error responses returned by the runtime', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'auth-failed', + message: 'Provider failed with api_key: sk-secret-value-123456', + recoverable: true, + diagnostics: { + summary: 'Auth failed for sk-secret-value-123456', + likelyCause: 'Authorization: Bearer live-token-123456789 was rejected', + binaryPath: '/repo/cli-dev', + command: '/repo/cli-dev runtime providers view', + projectPath: null, + exitCode: 1, + stderrPreview: 'api_key: sk-secret-value-123456', + stdoutPreview: 'Authorization: Bearer live-token-123456789', + hints: ['Remove sk-secret-value-123456 from config output.'], + }, + }, + }), + stderr: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + const serialized = JSON.stringify(response); + + expect(response.error?.message).toContain('api_key: ...redacted'); + expect(response.error?.diagnostics?.summary).toBe('Auth failed for sk-...redacted'); + expect(response.error?.diagnostics?.errorCode).toBe('auth-failed'); + expect(response.error?.diagnostics?.likelyCause).toBe( + 'Authorization: Bearer ...redacted was rejected' + ); + expect(response.error?.diagnostics?.stderrPreview).toBe('api_key: ...redacted'); + expect(response.error?.diagnostics?.stdoutPreview).toBe( + 'Authorization: Bearer ...redacted' + ); + expect(response.error?.diagnostics?.hints[0]).toBe( + 'Remove sk-...redacted from config output.' + ); + expect(serialized).not.toContain('sk-secret-value-123456'); + expect(serialized).not.toContain('live-token-123456789'); + }); + + it('redacts secrets from successful runtime diagnostics before they reach the renderer', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [ + { + providerId: 'openrouter', + displayName: 'OpenRouter', + state: 'connected', + ownership: ['managed'], + recommended: true, + modelCount: 4, + defaultModelId: null, + authMethods: ['api'], + actions: [], + detail: 'Connected with api_key: sk-secret-value-123456', + }, + ], + defaultModel: null, + fallbackModel: null, + diagnostics: [ + 'Authorization: Bearer live-token-123456789', + '\u001B[31mapi_key: sk-secret-value-123456\u001B[0m', + ], + }, + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + const serialized = JSON.stringify(response); + + expect(response.view?.diagnostics).toEqual([ + 'Authorization: Bearer ...redacted', + 'api_key: ...redacted', + ]); + expect(response.view?.providers[0]?.detail).toBe('Connected with api_key: ...redacted'); + expect(serialized).not.toContain('sk-secret-value-123456'); + expect(serialized).not.toContain('live-token-123456789'); + expect(serialized).not.toContain('[31m'); + }); + + it('keeps structured runtime errors when optional diagnostic fields are malformed', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'runtime-unhealthy', + message: 'Runtime returned malformed diagnostics', + recoverable: true, + diagnostics: { + summary: 'Runtime returned malformed diagnostics', + likelyCause: null, + binaryPath: '/repo/cli-dev', + command: '/repo/cli-dev runtime providers view', + projectPath: null, + exitCode: '1', + stderrPreview: null, + stdoutPreview: null, + }, + }, + }), + stderr: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.message).toBe('Runtime returned malformed diagnostics'); + expect(response.error?.diagnostics?.summary).toBe('Runtime returned malformed diagnostics'); + expect(response.error?.diagnostics?.exitCode).toBeNull(); + expect(response.error?.diagnostics?.hints).toEqual([]); + }); + + it('normalizes malformed structured runtime error objects instead of leaking them to the renderer', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers view'); + Object.assign(error, { + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'not-a-real-code', + message: 123, + recoverable: 'yes', + diagnostics: { + summary: 'api_key: sk-secret-value-123456', + }, + }, + }), + stderr: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error?.code).toBe('runtime-unhealthy'); + expect(response.error?.message).toBe('Runtime provider management command failed'); + expect(response.error?.diagnostics?.summary).toBe('api_key: ...redacted'); + expect(JSON.stringify(response)).not.toContain('sk-secret-value-123456'); + }); + + it('does not let non-object error logs shadow a later valid runtime response', async () => { + const validResponse = { + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }; + execCliMock.mockResolvedValue({ + stdout: [ + JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: 'debug preflight', + }), + JSON.stringify(validResponse), + ].join('\n'), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error).toBeUndefined(); + expect(response.view?.runtime.state).toBe('ready'); + }); + + it('does not let non-contract error object logs shadow a later valid runtime response', async () => { + const validResponse = { + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }; + execCliMock.mockResolvedValue({ + stdout: [ + JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { debug: true }, + }), + JSON.stringify(validResponse), + ].join('\n'), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error).toBeUndefined(); + expect(response.view?.runtime.state).toBe('ready'); + }); + it('parses JSON error responses from failed forget commands', async () => { const error = new Error('Command failed: /repo/cli-dev runtime providers forget'); Object.assign(error, { @@ -155,6 +792,327 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { ); }); + it('rejects the OpenCode CLI binary before running runtime provider commands', async () => { + resolveBinaryMock.mockResolvedValue('/opt/homebrew/bin/opencode'); + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ shouldNotRun: true }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + projectPath: '/Users/test/My Project', + }); + + expect(execCliMock).not.toHaveBeenCalled(); + expect(buildProviderAwareCliEnvMock).not.toHaveBeenCalled(); + expect(clearBinaryCacheMock).toHaveBeenCalledTimes(1); + expect(response.error?.code).toBe('runtime-misconfigured'); + expect(response.error?.message).toContain( + 'OpenCode provider settings are using the wrong runtime binary.' + ); + expect(response.error?.message).toContain( + 'Command that was blocked: /opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact --project-path' + ); + expect(response.error?.message).toContain( + 'The app resolved the OpenCode CLI itself as the Agent Teams runtime binary.' + ); + expect(response.error?.diagnostics?.errorCode).toBe('runtime-misconfigured'); + expect(response.error?.diagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode'); + expect(response.error?.diagnostics?.command).toBe( + "/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact --project-path '/Users/test/My Project'" + ); + expect(response.error?.diagnostics?.projectPath).toBe('/Users/test/My Project'); + expect(response.error?.diagnostics?.stdoutPreview).toBeNull(); + expect(response.error?.diagnostics?.stderrPreview).toBeNull(); + expect(response.error?.diagnostics?.hints).toContain( + 'Those environment variables must not point to opencode.' + ); + }); + + it('rejects runtime symlinks that resolve to the OpenCode CLI binary', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-runtime-')); + const opencodeTarget = path.join(tempDir, 'opencode'); + const runtimeLink = path.join(tempDir, 'claude-multimodel'); + try { + fs.writeFileSync(opencodeTarget, '#!/bin/sh\n'); + fs.symlinkSync(opencodeTarget, runtimeLink); + resolveBinaryMock.mockResolvedValue(runtimeLink); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(execCliMock).not.toHaveBeenCalled(); + expect(buildProviderAwareCliEnvMock).not.toHaveBeenCalled(); + expect(clearBinaryCacheMock).toHaveBeenCalledTimes(1); + expect(response.error?.code).toBe('runtime-misconfigured'); + expect(response.error?.diagnostics?.binaryPath).toBe(runtimeLink); + expect(response.error?.message).toContain( + 'OpenCode provider settings are using the wrong runtime binary.' + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('rejects OpenCode CLI connect commands before spawning or writing secrets', async () => { + resolveBinaryMock.mockResolvedValue('/opt/homebrew/bin/opencode.cmd'); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.connectProvider({ + runtimeId: 'opencode', + providerId: 'openrouter', + method: 'api', + apiKey: 'sk-secret-value-123456', + metadata: { + region: 'us', + }, + projectPath: '/Users/test/project', + }); + + expect(spawnCliMock).not.toHaveBeenCalled(); + expect(buildProviderAwareCliEnvMock).not.toHaveBeenCalled(); + expect(clearBinaryCacheMock).toHaveBeenCalledTimes(1); + expect(response.error?.code).toBe('runtime-misconfigured'); + expect(response.error?.diagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode.cmd'); + expect(response.error?.diagnostics?.command).toBe( + '/opt/homebrew/bin/opencode.cmd runtime providers connect --runtime opencode --provider openrouter --stdin-json --json --project-path /Users/test/project' + ); + expect(JSON.stringify(response)).not.toContain('sk-secret-value-123456'); + }); + + it('does not reject valid orchestrator paths that only contain opencode in a parent directory', async () => { + resolveBinaryMock.mockResolvedValue('/repo/opencode-runtime/cli-source'); + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + view: { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.15.6', + managedProfile: 'active', + localAuth: 'synced', + }, + providers: [], + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }, + }), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + }); + + expect(response.error).toBeUndefined(); + expect(response.view?.runtime.cliPath).toBe('/opt/homebrew/bin/opencode'); + expect(execCliMock).toHaveBeenCalledWith( + '/repo/opencode-runtime/cli-source', + expect.arrayContaining(['runtime', 'providers', 'view']), + expect.any(Object) + ); + }); + + it('explains OpenCode CLI help output instead of returning a generic JSON error', async () => { + execCliMock.mockResolvedValue({ + stdout: [ + 'Usage: opencode [command]', + '', + 'Commands:', + ' opencode providers', + ' opencode models', + 'api_key: sk-secret-value-123456', + ].join('\n'), + stderr: '', + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.loadView({ + runtimeId: 'opencode', + projectPath: '/Users/test/My Project', + }); + + expect(response.error?.message).toContain( + 'OpenCode provider settings could not read the runtime response.' + ); + expect(response.error?.message).toContain( + 'Expected a JSON object from the Agent Teams runtime provider command.' + ); + expect(response.error?.message).toContain( + 'Resolved runtime binary: /repo/cli-dev' + ); + expect(response.error?.message).toContain( + "Command: /repo/cli-dev runtime providers view --runtime opencode --json --compact --project-path '/Users/test/My Project'" + ); + expect(response.error?.message).toContain( + 'Likely cause: The app is launching the OpenCode CLI itself instead of the Agent Teams runtime' + ); + expect(response.error?.message).toContain('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH'); + expect(response.error?.message).toContain('stdout preview:'); + expect(response.error?.message).toContain('opencode providers'); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.message).toContain('api_key: ...redacted'); + expect(response.error?.diagnostics?.binaryPath).toBe('/repo/cli-dev'); + expect(response.error?.diagnostics?.command).toBe( + "/repo/cli-dev runtime providers view --runtime opencode --json --compact --project-path '/Users/test/My Project'" + ); + expect(response.error?.diagnostics?.projectPath).toBe('/Users/test/My Project'); + expect(response.error?.diagnostics?.likelyCause).toContain('OpenCode CLI itself'); + expect(response.error?.diagnostics?.hints).toContain( + 'Those environment variables must not point to opencode.' + ); + expect(response.error?.diagnostics?.stdoutPreview).toContain('api_key: ...redacted'); + expect(response.error?.diagnostics?.stdoutPreview).not.toContain('sk-secret-value-123456'); + }); + + it('formats non-JSON spawn output with exit code and stderr preview', async () => { + const { child } = createSpawnProcess('not-json', 1); + const processEvents = new EventEmitter(); + const stdinEvents = new EventEmitter(); + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const stdinWrite = vi.fn(); + const stdinEnd = vi.fn(() => { + queueMicrotask(() => { + stdout.emit('data', Buffer.from('not-json')); + stderr.emit('data', Buffer.from('runtime crashed before JSON')); + processEvents.emit('close', 1); + }); + }); + spawnCliMock.mockReturnValue({ + ...child, + stdout, + stderr, + stdin: { + write: stdinWrite, + end: stdinEnd, + once: stdinEvents.once.bind(stdinEvents), + }, + once: processEvents.once.bind(processEvents), + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.connectProvider({ + runtimeId: 'opencode', + providerId: 'openrouter', + method: 'api', + apiKey: 'sk-secret-value-123456', + metadata: {}, + }); + + expect(response.error?.message).toContain('Exit code: 1'); + expect(response.error?.message).toContain('stderr preview:'); + expect(response.error?.message).toContain('runtime crashed before JSON'); + expect(response.error?.message).toContain('stdout preview:'); + expect(response.error?.message).toContain('not-json'); + expect(response.error?.diagnostics?.exitCode).toBe(1); + expect(response.error?.diagnostics?.stderrPreview).toBe('runtime crashed before JSON'); + expect(response.error?.diagnostics?.stdoutPreview).toBe('not-json'); + expect(stdinWrite).toHaveBeenCalledWith( + JSON.stringify({ + method: 'api', + apiKey: 'sk-secret-value-123456', + metadata: {}, + }) + ); + }); + + it('captures provider stdin errors without dropping runtime diagnostics', async () => { + const processEvents = new EventEmitter(); + const stdinEvents = new EventEmitter(); + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const stdinWrite = vi.fn(() => { + queueMicrotask(() => { + stdinEvents.emit('error', new Error('write EPIPE sk-secret-value-123456')); + stdout.emit('data', Buffer.from('not-json')); + processEvents.emit('close', 1); + }); + }); + const stdinEnd = vi.fn(); + spawnCliMock.mockReturnValue({ + stdout, + stderr, + stdin: { + write: stdinWrite, + end: stdinEnd, + once: stdinEvents.once.bind(stdinEvents), + }, + once: processEvents.once.bind(processEvents), + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.connectWithApiKey({ + runtimeId: 'opencode', + providerId: 'openrouter', + apiKey: 'sk-input-secret-value-123456', + }); + + expect(response.error?.message).toContain('stdin error: write EPIPE sk-...redacted'); + expect(response.error?.message).toContain('stdout preview:'); + expect(response.error?.message).toContain('not-json'); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.message).not.toContain('sk-input-secret-value-123456'); + expect(response.error?.diagnostics?.stderrPreview).toBe( + 'stdin error: write EPIPE sk-...redacted' + ); + expect(response.error?.diagnostics?.stdoutPreview).toBe('not-json'); + expect(stdinWrite).toHaveBeenCalledWith('sk-input-secret-value-123456'); + }); + + it('keeps partial spawn stdout and stderr when a provider command times out', async () => { + vi.useFakeTimers(); + const processEvents = new EventEmitter(); + const stdinEvents = new EventEmitter(); + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const stdinWrite = vi.fn(); + const stdinEnd = vi.fn(() => { + stdout.emit('data', Buffer.from('partial non-json stdout')); + stderr.emit('data', Buffer.from('api_key: sk-secret-value-123456')); + }); + spawnCliMock.mockReturnValue({ + stdout, + stderr, + stdin: { + write: stdinWrite, + end: stdinEnd, + once: stdinEvents.once.bind(stdinEvents), + }, + once: processEvents.once.bind(processEvents), + }); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const responsePromise = client.connectWithApiKey({ + runtimeId: 'opencode', + providerId: 'openrouter', + apiKey: 'sk-input-secret-value-123456', + }); + + await vi.advanceTimersByTimeAsync(45_000); + const response = await responsePromise; + vi.useRealTimers(); + + expect(response.error?.message).toContain('stderr preview:'); + expect(response.error?.message).toContain('api_key: ...redacted'); + expect(response.error?.message).toContain('partial non-json stdout'); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.message).not.toContain('sk-input-secret-value-123456'); + expect(response.error?.diagnostics?.stderrPreview).toBe('api_key: ...redacted'); + expect(response.error?.diagnostics?.stdoutPreview).toBe('partial non-json stdout'); + expect(stdinWrite).toHaveBeenCalledWith('sk-input-secret-value-123456'); + }); + it('passes project path as cwd and CLI flag for project-aware provider management', async () => { execCliMock.mockResolvedValue({ stdout: JSON.stringify({ diff --git a/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts b/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts index df517664..6716a774 100644 --- a/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts +++ b/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main'; import { RUNTIME_PROVIDER_MANAGEMENT_CONNECT, RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY, @@ -9,16 +8,17 @@ import { RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM, RUNTIME_PROVIDER_MANAGEMENT_VIEW, } from '../../../../src/features/runtime-provider-management/contracts'; +import { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main'; -import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/features/runtime-provider-management/main'; import type { RuntimeProviderManagementDirectoryResponse, + RuntimeProviderManagementModelsResponse, + RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementSetupFormResponse, RuntimeProviderManagementViewResponse, - RuntimeProviderManagementModelsResponse, - RuntimeProviderManagementModelTestResponse, } from '../../../../src/features/runtime-provider-management/contracts'; +import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/features/runtime-provider-management/main'; import type { IpcMain } from 'electron'; describe('registerRuntimeProviderManagementIpc', () => { @@ -234,4 +234,151 @@ describe('registerRuntimeProviderManagementIpc', () => { limit: 10, }); }); + + it('sanitizes unexpected IPC error messages before returning them to the renderer', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const handlers = new Map Promise>(); + const ipcMain = { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise) => { + handlers.set(channel, handler); + }), + removeHandler: vi.fn(), + } as unknown as IpcMain; + const feature: RuntimeProviderManagementFeatureFacade = { + loadView: vi.fn(() => + Promise.reject( + new Error( + '\u001B]8;;https://logs.example/secret\u0007\u001B[31mProvider failed with api_key: sk-secret-value-123456 and Authorization: Bearer live-token-123456789 and key=AIzaSyD-test-secret-value-123456789 and OPENAI_API_KEY=plain_provider_secret_123456 and PROVIDER_TOKEN=provider_token_value_123456\u001B[0m\u001B]8;;\u0007' + ) + ) + ), + loadProviderDirectory: vi.fn(), + loadSetupForm: vi.fn(), + connectProvider: vi.fn(), + connectWithApiKey: vi.fn(), + forgetCredential: vi.fn(), + loadModels: vi.fn(), + testModel: vi.fn(), + setDefaultModel: vi.fn(), + }; + + registerRuntimeProviderManagementIpc(ipcMain, feature); + + const response = (await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_VIEW)?.( + {}, + { runtimeId: 'opencode' } + )) as RuntimeProviderManagementViewResponse; + + expect(response.error?.message).toContain('api_key: ...redacted'); + expect(response.error?.message).toContain('Authorization: Bearer ...redacted'); + expect(response.error?.message).toContain('key=...redacted'); + expect(response.error?.message).toContain('OPENAI_API_KEY=...redacted'); + expect(response.error?.message).toContain('PROVIDER_TOKEN=...redacted'); + expect(response.error?.message).not.toContain('sk-secret-value-123456'); + expect(response.error?.message).not.toContain('live-token-123456789'); + expect(response.error?.message).not.toContain('AIzaSyD-test-secret-value-123456789'); + expect(response.error?.message).not.toContain('plain_provider_secret_123456'); + expect(response.error?.message).not.toContain('provider_token_value_123456'); + expect(response.error?.message).not.toContain('logs.example/secret'); + expect(response.error?.message).not.toContain('[31m'); + expect(response.error?.message).not.toContain(']8;;'); + expect(response.error?.diagnostics?.summary).toContain('api_key: ...redacted'); + expect(response.error?.diagnostics?.errorCode).toBe('runtime-unhealthy'); + expect(response.error?.diagnostics?.stderrPreview).toContain( + 'Authorization: Bearer ...redacted' + ); + expect(JSON.stringify(response.error?.diagnostics)).not.toContain('sk-secret-value-123456'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('api_key: ...redacted'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('key=...redacted'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('sk-secret-value-123456'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('live-token-123456789'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain( + 'AIzaSyD-test-secret-value-123456789' + ); + consoleErrorSpy.mockRestore(); + }); + + it('bounds unexpected IPC diagnostics before returning them to the renderer', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const handlers = new Map Promise>(); + const ipcMain = { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise) => { + handlers.set(channel, handler); + }), + removeHandler: vi.fn(), + } as unknown as IpcMain; + const feature: RuntimeProviderManagementFeatureFacade = { + loadView: vi.fn(() => Promise.reject(new Error(`x${'y'.repeat(3_000)}`))), + loadProviderDirectory: vi.fn(), + loadSetupForm: vi.fn(), + connectProvider: vi.fn(), + connectWithApiKey: vi.fn(), + forgetCredential: vi.fn(), + loadModels: vi.fn(), + testModel: vi.fn(), + setDefaultModel: vi.fn(), + }; + + registerRuntimeProviderManagementIpc(ipcMain, feature); + + const response = (await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_VIEW)?.( + {}, + { runtimeId: 'opencode' } + )) as RuntimeProviderManagementViewResponse; + + expect(response.error?.message.endsWith('...')).toBe(true); + expect(response.error?.message.length).toBeLessThanOrEqual(1_603); + expect(response.error?.diagnostics?.stderrPreview).toBe(response.error?.message); + consoleErrorSpy.mockRestore(); + }); + + it('does not log raw secrets when connect handlers throw non-Error values', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const handlers = new Map Promise>(); + const ipcMain = { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise) => { + handlers.set(channel, handler); + }), + removeHandler: vi.fn(), + } as unknown as IpcMain; + const feature: RuntimeProviderManagementFeatureFacade = { + loadView: vi.fn(), + loadProviderDirectory: vi.fn(), + loadSetupForm: vi.fn(), + connectProvider: vi.fn(() => + Promise.reject( + 'Provider failed with api_key: sk-secret-value-123456 and token=provider-token-123456789' + ) + ), + connectWithApiKey: vi.fn(), + forgetCredential: vi.fn(), + loadModels: vi.fn(), + testModel: vi.fn(), + setDefaultModel: vi.fn(), + }; + + registerRuntimeProviderManagementIpc(ipcMain, feature); + + const response = (await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_CONNECT)?.( + {}, + { + runtimeId: 'opencode', + providerId: 'openrouter', + method: 'api', + apiKey: 'sk-input-secret-value', + metadata: {}, + } + )) as RuntimeProviderManagementProviderResponse; + + expect(response.error?.message).toContain('api_key: ...redacted'); + expect(response.error?.message).toContain('token=...redacted'); + expect(response.error?.diagnostics?.errorCode).toBe('auth-failed'); + expect(response.error?.diagnostics?.stderrPreview).toContain('token=...redacted'); + expect(JSON.stringify(response)).not.toContain('sk-input-secret-value'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('api_key: ...redacted'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).toContain('token=...redacted'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('sk-secret-value-123456'); + expect(JSON.stringify(consoleErrorSpy.mock.calls)).not.toContain('provider-token-123456789'); + consoleErrorSpy.mockRestore(); + }); }); diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts index 5e14073d..d865671d 100644 --- a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -57,6 +57,7 @@ function createState( directoryLoading: false, directoryRefreshing: false, directoryError: null, + directoryErrorDiagnostics: null, directoryEntries: [], directoryTotalCount: null, directoryNextCursor: null, @@ -67,7 +68,9 @@ function createState( setupForm: null, setupFormLoading: false, setupFormError: null, + setupFormErrorDiagnostics: null, setupSubmitError: null, + setupSubmitErrorDiagnostics: null, setupMetadata: {}, apiKeyValue: '', modelPickerProviderId: null, @@ -76,6 +79,7 @@ function createState( models: [], modelsLoading: false, modelsError: null, + modelsErrorDiagnostics: null, selectedModelId: null, testingModelIds: [], savingDefaultModelId: null, @@ -83,6 +87,7 @@ function createState( loading: false, savingProviderId: null, error: null, + errorDiagnostics: null, successMessage: null, ...overrides, }; @@ -170,6 +175,397 @@ describe('RuntimeProviderManagementPanelView', () => { expect(host.textContent).not.toContain('No launchable OpenCode model routes were reported yet'); }); + it('renders runtime command errors with a readable headline and multiline details', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const message = [ + 'OpenCode provider settings could not read the runtime response.', + 'Expected a JSON object from the Agent Teams runtime provider command.', + 'Resolved runtime binary: /opt/homebrew/bin/opencode', + 'Command: /opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact', + 'stdout preview:', + 'Commands:', + ' opencode providers', + ].join('\n'); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ error: message }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + const alert = host.querySelector('[data-testid="runtime-provider-error"]'); + const details = alert?.querySelector('pre'); + + expect(alert?.getAttribute('role')).toBe('alert'); + expect(alert?.textContent).toContain( + 'OpenCode provider settings could not read the runtime response.' + ); + expect(details?.textContent).toContain('Resolved runtime binary: /opt/homebrew/bin/opencode'); + expect(details?.textContent).toContain(' opencode providers'); + expect(details?.className).toContain('whitespace-pre-wrap'); + expect(details?.className).toContain('font-mono'); + }); + + it('copies fallback error text when structured diagnostics are unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const writeText = vi.fn((_text: string) => Promise.resolve()); + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + error: 'Runtime provider crashed\nstderr preview:\nmissing bun', + errorDiagnostics: null, + }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + await act(async () => { + Array.from(host.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Copy diagnostics')) + ?.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith( + 'OpenCode provider settings diagnostics\n\nMessage:\nRuntime provider crashed\nstderr preview:\nmissing bun' + ); + if (clipboardDescriptor) { + Object.defineProperty(navigator, 'clipboard', clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + }); + + it('copies diagnostics with the selection fallback when clipboard API is unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, 'execCommand'); + const execCommand = vi.fn(() => true); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: execCommand, + }); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + error: 'Runtime provider crashed\nstderr preview:\nmissing bun', + errorDiagnostics: null, + }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + await act(async () => { + Array.from(host.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Copy diagnostics')) + ?.click(); + await Promise.resolve(); + }); + + expect(execCommand).toHaveBeenCalledWith('copy'); + expect(host.textContent).toContain('Copied'); + expect(document.querySelector('textarea')).toBeNull(); + if (clipboardDescriptor) { + Object.defineProperty(navigator, 'clipboard', clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + if (execCommandDescriptor) { + Object.defineProperty(document, 'execCommand', execCommandDescriptor); + } else { + Reflect.deleteProperty(document, 'execCommand'); + } + }); + + it('renders structured runtime diagnostics and copies the full redacted report', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const writeText = vi.fn((_text: string) => Promise.resolve()); + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + error: 'OpenCode provider settings could not read the runtime response.', + errorDiagnostics: { + errorCode: 'runtime-unhealthy', + summary: 'OpenCode provider settings could not read the runtime response.', + likelyCause: + 'The app is launching the OpenCode CLI itself instead of the Agent Teams runtime.', + binaryPath: '/opt/homebrew/bin/opencode', + command: + '/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact', + projectPath: '/Users/test/project', + exitCode: 1, + stderrPreview: 'Command failed before JSON', + stdoutPreview: 'Commands:\n opencode providers', + hints: [ + 'Check CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH and CLAUDE_CLI_PATH.', + 'Those environment variables must not point to opencode.', + ], + }, + }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Likely cause'); + expect(host.textContent).toContain('/opt/homebrew/bin/opencode'); + expect(host.textContent).toContain('Command failed before JSON'); + expect( + host.querySelector('[data-testid="runtime-provider-error-stderr-preview"]')?.textContent + ).toContain('stderr preview'); + expect( + host.querySelector('[data-testid="runtime-provider-error-stdout-preview"]')?.textContent + ).toContain('opencode providers'); + + await act(async () => { + Array.from(host.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Copy diagnostics')) + ?.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0][0]).toContain('OpenCode provider settings diagnostics'); + expect(writeText.mock.calls[0][0]).toContain('Error code: runtime-unhealthy'); + expect(writeText.mock.calls[0][0]).toContain('Resolved runtime binary: /opt/homebrew/bin/opencode'); + expect(writeText.mock.calls[0][0]).toContain('stderr preview:'); + expect(writeText.mock.calls[0][0]).toContain('stdout preview:'); + expect(host.textContent).toContain('Copied'); + if (clipboardDescriptor) { + Object.defineProperty(navigator, 'clipboard', clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + }); + + it('does not activate a provider row when copying model diagnostics', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const writeText = vi.fn((_text: string) => Promise.resolve()); + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const actions = createActions(); + const base = createState(); + const provider = { + ...base.view!.providers[0], + state: 'connected' as const, + modelCount: 2, + actions: [ + { + id: 'test' as const, + label: 'Test', + enabled: true, + disabledReason: null, + requiresSecret: false, + ownershipScope: 'runtime' as const, + }, + ], + }; + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + view: { + ...base.view!, + providers: [provider], + }, + providers: [provider], + selectedProviderId: provider.providerId, + modelPickerProviderId: provider.providerId, + modelPickerMode: 'use', + modelsError: 'Model list failed', + modelsErrorDiagnostics: { + summary: 'Model list failed', + likelyCause: 'The runtime returned a malformed models response.', + binaryPath: '/repo/cli-dev', + command: '/repo/cli-dev runtime providers models --runtime opencode', + projectPath: '/Users/test/project', + exitCode: 1, + stderrPreview: 'bad models payload', + stdoutPreview: null, + hints: ['Retry after refreshing the runtime.'], + }, + }), + actions, + disabled: false, + }) + ); + await Promise.resolve(); + }); + + await act(async () => { + Array.from(host.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Copy diagnostics')) + ?.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(actions.selectProvider).not.toHaveBeenCalled(); + expect(actions.startConnect).not.toHaveBeenCalled(); + if (clipboardDescriptor) { + Object.defineProperty(navigator, 'clipboard', clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + }); + + it('renders structured diagnostics in provider form and model picker errors', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const provider = { + ...createState().view!.providers[0], + state: 'connected' as const, + modelCount: 4, + actions: [ + { + id: 'test' as const, + label: 'Test', + enabled: true, + disabledReason: null, + requiresSecret: false, + ownershipScope: 'runtime' as const, + }, + ], + }; + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + providers: [provider], + selectedProviderId: provider.providerId, + activeFormProviderId: provider.providerId, + modelPickerProviderId: provider.providerId, + modelPickerMode: 'use', + setupSubmitError: 'Provider connect failed before JSON.', + setupSubmitErrorDiagnostics: { + summary: 'Provider connect failed before JSON.', + likelyCause: 'The runtime command printed CLI help instead of JSON.', + binaryPath: '/opt/homebrew/bin/opencode', + command: '/opt/homebrew/bin/opencode runtime providers connect', + projectPath: null, + exitCode: 1, + stderrPreview: 'unknown command', + stdoutPreview: 'Commands:\n opencode providers', + hints: ['Check the resolved runtime binary.'], + }, + modelsError: 'Provider models failed before JSON.', + modelsErrorDiagnostics: { + summary: 'Provider models failed before JSON.', + likelyCause: 'The runtime command printed CLI help instead of JSON.', + binaryPath: '/opt/homebrew/bin/opencode', + command: '/opt/homebrew/bin/opencode runtime providers models', + projectPath: null, + exitCode: 1, + stderrPreview: 'unknown command', + stdoutPreview: 'Commands:\n opencode providers', + hints: ['Check the resolved runtime binary.'], + }, + }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + expect( + host.querySelector('[data-testid="runtime-provider-setup-submit-error"]')?.textContent + ).toContain('Provider connect failed before JSON.'); + expect( + host.querySelector('[data-testid="runtime-provider-setup-submit-error"]')?.textContent + ).toContain('/opt/homebrew/bin/opencode'); + expect(host.querySelector('[data-testid="runtime-provider-models-error"]')?.textContent).toContain( + 'Provider models failed before JSON.' + ); + expect(host.querySelector('[data-testid="runtime-provider-models-error"]')?.textContent).toContain( + 'opencode providers' + ); + }); + + it('renders provider directory errors with preserved multiline details', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const message = [ + 'OpenCode provider settings could not read the runtime response.', + 'stderr preview:', + 'runtime crashed before JSON', + ].join('\n'); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + directoryError: message, + directoryLoaded: true, + }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + const alert = host.querySelector( + '[data-testid="runtime-provider-directory-error"]' + ); + const details = alert?.querySelector('pre'); + + expect(alert?.getAttribute('role')).toBe('alert'); + expect(details?.textContent).toContain('stderr preview:'); + expect(details?.textContent).toContain('runtime crashed before JSON'); + expect(details?.className).toContain('whitespace-pre-wrap'); + }); + it('keeps project context out of the runtime summary and labels it as validation context', async () => { const host = document.createElement('div'); document.body.appendChild(host); @@ -554,6 +950,57 @@ describe('RuntimeProviderManagementPanelView', () => { expect(duplicateKeyWarnings).toHaveLength(0); }); + it('renders duplicate structured diagnostic hints without React key warnings', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + error: 'OpenCode provider settings are using the wrong runtime binary.', + errorDiagnostics: { + summary: 'OpenCode provider settings are using the wrong runtime binary.', + likelyCause: + 'The app resolved the OpenCode CLI itself as the Agent Teams runtime binary.', + binaryPath: '/opt/homebrew/bin/opencode', + command: + '/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact', + projectPath: null, + exitCode: null, + stderrPreview: null, + stdoutPreview: null, + hints: [ + 'Those environment variables must not point to opencode.', + 'Those environment variables must not point to opencode.', + ], + }, + }), + actions: createActions(), + disabled: false, + }) + ); + await Promise.resolve(); + }); + + const duplicateHints = host.textContent?.match( + /Those environment variables must not point to opencode\./g + ); + const duplicateKeyWarnings = consoleError.mock.calls.filter((call) => + call.some( + (argument) => + typeof argument === 'string' && + argument.includes('Encountered two children with the same key') + ) + ); + consoleError.mockRestore(); + + expect(duplicateHints).toHaveLength(2); + expect(duplicateKeyWarnings).toHaveLength(0); + }); + it('renders provider actions and opens API-key form state without exposing a raw secret', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts index 82a383d2..6c85685c 100644 --- a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts +++ b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts @@ -16,7 +16,10 @@ import { import type { RuntimeProviderConnectionDto, RuntimeProviderDirectoryEntryDto, + RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementModelTestResponse, + RuntimeProviderManagementProviderResponse, + RuntimeProviderManagementSetupFormResponse, RuntimeProviderManagementViewDto, RuntimeProviderManagementViewResponse, } from '../../../../src/features/runtime-provider-management/contracts'; @@ -112,6 +115,20 @@ describe('useRuntimeProviderManagement', () => { return React.createElement('div'); } + function ConfigurableHarness(props: { + enabled: boolean; + projectPath?: string | null; + }): React.ReactElement { + const hook = useRuntimeProviderManagement({ + runtimeId: 'opencode', + enabled: props.enabled, + projectPath: props.projectPath, + }); + state = hook[0]; + actions = hook[1]; + return React.createElement('div'); + } + beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); host = document.createElement('div'); @@ -174,6 +191,174 @@ describe('useRuntimeProviderManagement', () => { }); }); + it('clears structured errors and stale provider state when disabled', async () => { + const loadView = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'runtime-misconfigured', + message: 'OpenCode provider settings are using the wrong runtime binary.', + recoverable: true, + diagnostics: { + summary: 'OpenCode provider settings are using the wrong runtime binary.', + likelyCause: + 'The app resolved the OpenCode CLI itself as the Agent Teams runtime binary.', + binaryPath: '/opt/homebrew/bin/opencode', + command: + '/opt/homebrew/bin/opencode runtime providers view --runtime opencode --json --compact', + projectPath: null, + exitCode: null, + stderrPreview: null, + stdoutPreview: null, + hints: ['Those environment variables must not point to opencode.'], + }, + }, + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadView, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(ConfigurableHarness, { enabled: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(state?.error).toContain('wrong runtime binary'); + expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode'); + + await act(async () => { + root.render(React.createElement(ConfigurableHarness, { enabled: false })); + await Promise.resolve(); + }); + + expect(state?.view).toBeNull(); + expect(state?.selectedProviderId).toBeNull(); + expect(state?.error).toBeNull(); + expect(state?.errorDiagnostics).toBeNull(); + expect(state?.loading).toBe(false); + }); + + it('ignores pending directory and setup-form responses after being disabled', async () => { + let resolveDirectory: + | ((response: RuntimeProviderManagementDirectoryResponse) => void) + | null = null; + let resolveSetupForm: + | ((response: RuntimeProviderManagementSetupFormResponse) => void) + | null = null; + const directoryResponse = new Promise( + (resolve) => { + resolveDirectory = resolve; + } + ); + const setupFormResponse = new Promise( + (resolve) => { + resolveSetupForm = resolve; + } + ); + const loadView = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + view: createRuntimeView(), + }) + ); + const loadProviderDirectory = vi.fn(() => directoryResponse); + const loadSetupForm = vi.fn(() => setupFormResponse); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadView, + loadProviderDirectory, + loadSetupForm, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(ConfigurableHarness, { enabled: true })); + await Promise.resolve(); + }); + + await act(async () => { + await vi.waitFor(() => { + expect(loadProviderDirectory).toHaveBeenCalled(); + }); + actions?.startConnect('openrouter'); + }); + await act(async () => { + await vi.waitFor(() => { + expect(loadSetupForm).toHaveBeenCalled(); + }); + }); + + await act(async () => { + root.render(React.createElement(ConfigurableHarness, { enabled: false })); + await Promise.resolve(); + }); + + await act(async () => { + resolveDirectory?.({ + schemaVersion: 1, + runtimeId: 'opencode', + directory: { + runtimeId: 'opencode', + totalCount: 1, + returnedCount: 1, + query: null, + filter: 'all', + limit: 50, + cursor: null, + nextCursor: null, + entries: [createOpenAiLocalDirectoryEntry()], + diagnostics: [], + fetchedAt: '2026-05-22T00:00:00.000Z', + }, + }); + resolveSetupForm?.({ + schemaVersion: 1, + runtimeId: 'opencode', + setupForm: { + runtimeId: 'opencode', + providerId: 'openrouter', + displayName: 'OpenRouter', + method: 'api', + supported: true, + title: 'Connect OpenRouter', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'curated', + secret: { + key: 'key', + label: 'API key', + placeholder: 'Paste API key', + required: true, + }, + prompts: [], + }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(state?.directoryEntries).toEqual([]); + expect(state?.directoryLoaded).toBe(false); + expect(state?.setupForm).toBeNull(); + expect(state?.activeFormProviderId).toBeNull(); + expect(state?.setupFormLoading).toBe(false); + }); + it('ignores stale provider views after project context changes', async () => { let resolveProjectA: | ((response: { @@ -246,6 +431,143 @@ describe('useRuntimeProviderManagement', () => { }); }); + it('restarts provider directory loading when project context changes while loading', async () => { + let resolveProjectADirectory: + | ((response: RuntimeProviderManagementDirectoryResponse) => void) + | null = null; + let resolveProjectBDirectory: + | ((response: RuntimeProviderManagementDirectoryResponse) => void) + | null = null; + const projectBEntry: RuntimeProviderDirectoryEntryDto = { + ...createOpenAiLocalDirectoryEntry(), + providerId: 'project-b-provider', + displayName: 'Project B Provider', + }; + const loadView = vi.fn((input: { projectPath?: string | null }) => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + view: { + ...createRuntimeView(), + projectPath: input.projectPath ?? null, + }, + }) + ); + const loadProviderDirectory = vi.fn((input: { projectPath?: string | null }) => { + if (input.projectPath === '/tmp/project-a') { + return new Promise((resolve) => { + resolveProjectADirectory = resolve; + }); + } + return new Promise((resolve) => { + resolveProjectBDirectory = resolve; + }); + }); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadView, + loadProviderDirectory, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' })); + await Promise.resolve(); + }); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 10)); + await vi.waitFor(() => { + expect(loadProviderDirectory).toHaveBeenCalledWith({ + runtimeId: 'opencode', + projectPath: '/tmp/project-a', + query: null, + filter: 'all', + limit: 50, + cursor: null, + refresh: false, + }); + }); + }); + + await act(async () => { + root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' })); + await Promise.resolve(); + }); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 10)); + await vi.waitFor(() => { + expect(loadProviderDirectory).toHaveBeenCalledWith({ + runtimeId: 'opencode', + projectPath: '/tmp/project-b', + query: null, + filter: 'all', + limit: 50, + cursor: null, + refresh: false, + }); + }); + }); + + await act(async () => { + resolveProjectBDirectory?.({ + schemaVersion: 1, + runtimeId: 'opencode', + directory: { + runtimeId: 'opencode', + totalCount: 1, + returnedCount: 1, + query: null, + filter: 'all', + limit: 50, + cursor: null, + nextCursor: null, + fetchedAt: '2026-05-22T00:00:00.000Z', + entries: [projectBEntry], + diagnostics: [], + }, + }); + await Promise.resolve(); + }); + + expect(state?.directoryEntries.map((entry) => entry.providerId)).toEqual([ + 'project-b-provider', + ]); + + await act(async () => { + resolveProjectADirectory?.({ + schemaVersion: 1, + runtimeId: 'opencode', + directory: { + runtimeId: 'opencode', + totalCount: 1, + returnedCount: 1, + query: null, + filter: 'all', + limit: 50, + cursor: null, + nextCursor: null, + fetchedAt: '2026-05-22T00:00:00.000Z', + entries: [createOpenAiLocalDirectoryEntry()], + diagnostics: [], + }, + }); + await Promise.resolve(); + }); + + expect(state?.directoryEntries.map((entry) => entry.providerId)).toEqual([ + 'project-b-provider', + ]); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('drops stale model probe results after project context changes', async () => { const modelId = 'llama.cpp/qwen-test:0.5b'; let resolveProbe: ((value: RuntimeProviderManagementModelTestResponse) => void) | null = null; @@ -413,6 +735,153 @@ describe('useRuntimeProviderManagement', () => { }); }); + it('clears pending provider save state after project context changes', async () => { + const connectedProvider: RuntimeProviderConnectionDto = { + ...createOpenAiLocalProvider(), + ownership: ['managed'], + detail: 'Connected via managed OpenCode credential', + }; + let resolveConnect: ((value: RuntimeProviderManagementProviderResponse) => void) | null = + null; + const loadView = vi.fn((input: { projectPath?: string | null }) => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + view: { + ...createRuntimeView(), + projectPath: input.projectPath ?? null, + defaultModel: input.projectPath === '/tmp/project-b' ? 'opencode/project-b' : null, + }, + }) + ); + const loadProviderDirectory = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + directory: { + runtimeId: 'opencode', + totalCount: 1, + returnedCount: 1, + query: null, + filter: 'all', + limit: 50, + cursor: null, + nextCursor: null, + fetchedAt: '2026-04-25T00:00:00.000Z', + entries: [createOpenAiLocalDirectoryEntry()], + diagnostics: [], + }, + }) + ); + const loadSetupForm = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + setupForm: { + runtimeId: 'opencode', + providerId: 'openai', + displayName: 'OpenAI', + method: 'api', + supported: true, + title: 'Connect OpenAI', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'curated', + secret: { + key: 'key', + label: 'API key', + placeholder: 'Paste API key', + required: true, + }, + prompts: [], + }, + }) + ); + const connectProvider = vi.fn( + () => + new Promise((resolve) => { + resolveConnect = resolve; + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadView, + loadProviderDirectory, + loadSetupForm, + connectProvider, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' })); + await Promise.resolve(); + }); + + await act(async () => { + actions?.startConnect('openai'); + actions?.setApiKeyValue('sk-project-a'); + await vi.waitFor(() => { + expect(loadSetupForm).toHaveBeenCalled(); + }); + }); + + let submitPromise: Promise | null = null; + await act(async () => { + submitPromise = actions?.submitConnect('openai') ?? null; + await vi.waitFor(() => { + expect(connectProvider).toHaveBeenCalledWith({ + runtimeId: 'opencode', + providerId: 'openai', + method: 'api', + apiKey: 'sk-project-a', + metadata: {}, + projectPath: '/tmp/project-a', + }); + }); + await Promise.resolve(); + }); + + expect(state?.savingProviderId).toBe('openai'); + + await act(async () => { + root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' })); + await Promise.resolve(); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(loadView).toHaveBeenCalledWith({ + runtimeId: 'opencode', + projectPath: '/tmp/project-b', + }); + }); + + expect(state?.savingProviderId).toBeNull(); + expect(state?.activeFormProviderId).toBeNull(); + + await act(async () => { + resolveConnect?.({ + schemaVersion: 1, + runtimeId: 'opencode', + provider: connectedProvider, + }); + await submitPromise; + }); + + expect(state?.view?.providers).toEqual([]); + expect(state?.savingProviderId).toBeNull(); + expect(state?.setupSubmitError).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('refreshes view and catalog after forgetting managed auth while local auth remains', async () => { const localProvider = createOpenAiLocalProvider(); const loadView = vi.fn(() => @@ -1040,6 +1509,68 @@ describe('useRuntimeProviderManagement', () => { expect(state?.apiKeyValue).toBe('sk-bad-value'); }); + it('keeps setup form diagnostics available when submit is attempted after form load failure', async () => { + const loadSetupForm = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'runtime-misconfigured', + message: 'OpenCode provider settings are using the wrong runtime binary.', + recoverable: true, + diagnostics: { + summary: 'OpenCode provider settings are using the wrong runtime binary.', + likelyCause: 'The app resolved the OpenCode CLI itself as the runtime binary.', + binaryPath: '/opt/homebrew/bin/opencode', + command: '/opt/homebrew/bin/opencode runtime providers setup-form', + projectPath: null, + exitCode: null, + stderrPreview: null, + stdoutPreview: null, + hints: ['Those environment variables must not point to opencode.'], + }, + }, + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadSetupForm, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + act(() => { + actions?.startConnect('openrouter'); + }); + await act(async () => { + await vi.waitFor(() => { + expect(loadSetupForm).toHaveBeenCalled(); + }); + }); + + expect(state?.setupFormError).toBe( + 'OpenCode provider settings are using the wrong runtime binary.' + ); + expect(state?.setupFormErrorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode'); + + await act(async () => { + await actions?.submitConnect('openrouter'); + }); + + expect(state?.setupSubmitError).toBe( + 'OpenCode provider settings are using the wrong runtime binary.' + ); + expect(state?.setupSubmitErrorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode'); + }); + it('submits a supported setup form without a secret as a null API key', async () => { const loadSetupForm = vi.fn(() => Promise.resolve({ @@ -1443,6 +1974,47 @@ describe('useRuntimeProviderManagement', () => { expect(state?.modelResults[modelId]?.message).toBe(message); }); + it('promotes structured model probe failures to the global diagnostics alert state', async () => { + const modelId = 'openrouter/anthropic/claude-3.5-haiku'; + installRuntimeProviderManagementApi({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'runtime-misconfigured', + message: 'OpenCode provider settings are using the wrong runtime binary.', + recoverable: true, + diagnostics: { + summary: 'OpenCode provider settings are using the wrong runtime binary.', + likelyCause: 'The app resolved the OpenCode CLI itself as the runtime binary.', + binaryPath: '/opt/homebrew/bin/opencode', + command: '/opt/homebrew/bin/opencode runtime providers test-model', + projectPath: null, + exitCode: null, + stderrPreview: null, + stdoutPreview: null, + hints: ['Those environment variables must not point to opencode.'], + }, + }, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + await act(async () => { + await actions?.testModel('openrouter', modelId); + }); + + expect(state?.error).toBe('OpenCode provider settings are using the wrong runtime binary.'); + expect(state?.errorDiagnostics?.binaryPath).toBe('/opt/homebrew/bin/opencode'); + expect(state?.modelResults[modelId]).toMatchObject({ + ok: false, + message: 'OpenCode provider settings are using the wrong runtime binary.', + }); + }); + it('keeps successful model probes scoped to the model card instead of a global success banner', async () => { const modelId = 'openrouter/openai/gpt-oss-20b:free'; installRuntimeProviderManagementApi({ From 5551eea482da7fa7639d7ff34b398d5722a250aa Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 15:42:51 +0300 Subject: [PATCH 26/41] fix(runtime): harden local launch plumbing --- .../services/infrastructure/FileWatcher.ts | 20 ++++++++ .../runtime/agentTeamsMcpLaunchEnv.ts | 3 ++ .../services/team/AgentTeamsMcpHttpServer.ts | 1 + .../services/team/TeamMcpConfigBuilder.ts | 31 ++++++++++++ src/main/utils/childProcess.ts | 18 ++++++- .../runtime/providerAwareCliEnv.test.ts | 19 +++++++ .../team/AgentTeamsMcpHttpServer.test.ts | 3 ++ .../team/TeamMcpConfigBuilder.test.ts | 48 ++++++++++++++++++ test/main/utils/childProcess.test.ts | 49 +++++++++++++++++++ 9 files changed, 191 insertions(+), 1 deletion(-) diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index e22a60a7..c318debc 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -435,6 +435,10 @@ export class FileWatcher extends EventEmitter { // Guard: stop() may have been called while awaiting pathExists if (!this.isWatching) return; + // Teams deliberately use TeamTaskWatchRegistry instead of recursive fs.watch. + // Linux recursive watching expands across the whole team runtime tree and can + // hit EMFILE/ENOSPC. The registry keeps the watched surface aligned with + // processTeamsChange(): team root JSON files plus inbox JSON files only. const registry = new TeamTaskWatchRegistry({ kind: 'teams', rootPath: this.teamsPath, @@ -479,6 +483,8 @@ export class FileWatcher extends EventEmitter { // Guard: stop() may have been called while awaiting pathExists if (!this.isWatching) return; + // Tasks share the same shallow registry rule as teams. Keep polling out of + // the normal path here; it is only the known-error fallback below. const registry = new TeamTaskWatchRegistry({ kind: 'tasks', rootPath: this.tasksPath, @@ -647,6 +653,9 @@ export class FileWatcher extends EventEmitter { error: unknown, watcher?: CloseableWatcher ): boolean { + // Polling fallback is intentionally narrow. Projects/todos keep their native + // watcher retry behavior, while teams/tasks can switch to scoped polling only + // after known OS watcher-limit or platform errors from Chokidar/fs.watch. if ((watcherType !== 'teams' && watcherType !== 'tasks') || !this.isWatchLimitError(error)) { return false; } @@ -721,6 +730,8 @@ export class FileWatcher extends EventEmitter { }; runPoll(); + // This is fallback content polling after watcher failure, not the default mode. + // Keep intervals conservative and scoped to the same shallow artifacts as the registry. const timer = setInterval(runPoll, this.getTeamTaskPollIntervalMs(watcherType)); timer.unref(); @@ -799,6 +810,8 @@ export class FileWatcher extends EventEmitter { const snapshot = new Map(); const teamEntries = await this.safeReadDir(this.teamsPath); + // Fallback polling mirrors TeamTaskWatchRegistry. Do not recurse into members, + // runtime, .opencode-runtime, logs, or other deep trees from here. for (const teamEntry of teamEntries) { if (!teamEntry.isDirectory()) { continue; @@ -825,6 +838,8 @@ export class FileWatcher extends EventEmitter { const snapshot = new Map(); const teamEntries = await this.safeReadDir(this.tasksPath); + // Keep task fallback scoped to tasks//*.json. Hidden files and nested + // runtime directories are intentionally outside the public team-change surface. for (const teamEntry of teamEntries) { if (!teamEntry.isDirectory()) { continue; @@ -1351,6 +1366,9 @@ export class FileWatcher extends EventEmitter { return; } + // Keep this classifier in lockstep with TeamTaskWatchRegistry.shouldEmit(). + // If a path is emitted by the registry but ignored here, the UI will miss it. + // If a path is added here but not emitted there, Chokidar mode will never see it. if (relative === 'processes.json') { const event: TeamChangeEvent = { type: 'process', teamName, detail: relative }; this.emit('team-change', event); @@ -1414,6 +1432,8 @@ export class FileWatcher extends EventEmitter { return; } + // Keep this in sync with the tasks registry and fallback polling filters: + // only tasks//*.json is a user-visible task event. // Ignore known non-task files in ~/.claude/tasks if ( relative === '.lock' || diff --git a/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts b/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts index 1efebad6..a54cd3b5 100644 --- a/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts +++ b/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts @@ -33,6 +33,9 @@ export async function ensureAgentTeamsMcpLocalLaunchEnv( return; } + for (const [key, value] of Object.entries(launchSpec.env ?? {})) { + env[key] = value; + } env[MCP_COMMAND_ENV] = command; env[MCP_ENTRY_ENV] = entry; env[MCP_ARGS_JSON_ENV] = JSON.stringify(launchSpec.args); diff --git a/src/main/services/team/AgentTeamsMcpHttpServer.ts b/src/main/services/team/AgentTeamsMcpHttpServer.ts index da4802cc..2d5c8075 100644 --- a/src/main/services/team/AgentTeamsMcpHttpServer.ts +++ b/src/main/services/team/AgentTeamsMcpHttpServer.ts @@ -1095,6 +1095,7 @@ export class AgentTeamsMcpHttpServer { }; const childEnv = applyAgentTeamsIdentityEnv({ ...process.env, + ...launchSpec.env, AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(), AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST, diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 4405fa72..51c64f98 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -21,6 +21,7 @@ import type { TeamMemberMcpPolicy, TeamMemberMcpScope } from '@shared/types'; export interface McpLaunchSpec { command: string; args: string[]; + env?: Record; } export interface McpLaunchSpecResolveProgress { @@ -40,6 +41,7 @@ interface WriteMcpConfigOptions { const MCP_SERVER_NAME = 'agent-teams'; const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; const MCP_CONTROL_URL_ENV = 'CLAUDE_TEAM_CONTROL_URL'; +const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE'; const logger = createLogger('Service:TeamMcpConfigBuilder'); const MCP_CONFIG_PREFIX = 'agent-teams-mcp-'; const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const; @@ -58,6 +60,7 @@ const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'pro function isPackagedApp(): boolean { try { + // eslint-disable-next-line @typescript-eslint/no-require-imports const { app } = require('electron') as typeof import('electron'); return app.isPackaged; } catch { @@ -88,6 +91,25 @@ function getWorkspaceRoot(): string { return process.cwd(); } +function shouldUsePackagedElectronNodeRuntime(): boolean { + return ( + isPackagedApp() && + process.platform === 'linux' && + typeof process.execPath === 'string' && + process.execPath.trim().length > 0 + ); +} + +function buildPackagedElectronNodeLaunchSpec(entry: string): McpLaunchSpec { + return { + command: process.execPath, + args: [entry], + env: { + [ELECTRON_RUN_AS_NODE_ENV]: '1', + }, + }; +} + function getWorkspaceMcpServerDir(): string { return path.join(getWorkspaceRoot(), 'mcp-server'); } @@ -450,6 +472,14 @@ export async function resolveAgentTeamsMcpLaunchSpec( const packagedEntry = await resolvePackagedServerEntry(options); checked.push(packagedEntry); if (await pathExists(packagedEntry)) { + if (shouldUsePackagedElectronNodeRuntime()) { + emitProgress( + options, + 'electron-node-runtime-found', + 'Using bundled Electron Node runtime...' + ); + return buildPackagedElectronNodeLaunchSpec(packagedEntry); + } return { command: await resolveNodePath(options), args: [packagedEntry], @@ -520,6 +550,7 @@ export class TeamMcpConfigBuilder { args: launchSpec.args, enabled: true, env: { + ...launchSpec.env, [MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(), ...(controlApiBaseUrl ? { [MCP_CONTROL_URL_ENV]: controlApiBaseUrl } : {}), }, diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index 9d444bc3..5c87cce1 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -231,6 +231,20 @@ function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindows }; } +function resolveNpmNativeShim(content: string, launcherDir: string): DirectWindowsLauncher | null { + const nativeTarget = /(?:^|[&|])\s*"([^"]+\.(?:exe|com))"\s+%\*/im.exec(content)?.[1]; + if (!nativeTarget) { + return null; + } + + const target = resolveCmdPathTemplate(nativeTarget, launcherDir); + if (!existsSync(target)) { + return null; + } + + return { command: target, argsPrefix: [] }; +} + /** * Some Windows launchers are thin wrappers around a real JS entrypoint. * Running that entrypoint directly with an argv array avoids cmd.exe's @@ -245,7 +259,9 @@ function resolveDirectWindowsLauncher(binaryPath: string): DirectWindowsLauncher const content = readFileSync(binaryPath, 'utf8'); const launcherDir = path.dirname(binaryPath); return ( - resolveGeneratedBunLauncher(content, launcherDir) ?? resolveNpmNodeShim(content, launcherDir) + resolveGeneratedBunLauncher(content, launcherDir) ?? + resolveNpmNodeShim(content, launcherDir) ?? + resolveNpmNativeShim(content, launcherDir) ); } catch { return null; diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index 045b6bea..3ac078fb 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -276,6 +276,25 @@ describe('buildProviderAwareCliEnv', () => { ); }); + it('propagates Agent Teams MCP launch env overrides for OpenCode provider commands', async () => { + resolveAgentTeamsMcpLaunchSpecMock.mockResolvedValue({ + command: '/opt/Agent Teams AI/agent-teams-ai', + args: ['/app/mcp-server/index.js'], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }); + const { buildProviderAwareCliEnv } = + await import('../../../../src/main/services/runtime/providerAwareCliEnv'); + + const result = await buildProviderAwareCliEnv({ + providerId: 'opencode', + }); + + expect(result.env.ELECTRON_RUN_AS_NODE).toBe('1'); + expect(result.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBe( + '/opt/Agent Teams AI/agent-teams-ai' + ); + }); + it('preserves explicit local Agent Teams MCP launch env for OpenCode provider commands', async () => { const { buildProviderAwareCliEnv } = await import('../../../../src/main/services/runtime/providerAwareCliEnv'); diff --git a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts index 14539d16..4da2b028 100644 --- a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts +++ b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts @@ -127,6 +127,7 @@ describe('AgentTeamsMcpHttpServer', () => { resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], + env: { ELECTRON_RUN_AS_NODE: '1' }, }), allocatePort: async () => 41001, spawnProcess, @@ -167,6 +168,7 @@ describe('AgentTeamsMcpHttpServer', () => { '/mcp', ], expect.objectContaining({ + ELECTRON_RUN_AS_NODE: '1', AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', AGENT_TEAMS_MCP_HTTP_HOST: '127.0.0.1', AGENT_TEAMS_MCP_HTTP_PORT: '41001', @@ -521,6 +523,7 @@ describe('AgentTeamsMcpHttpServer', () => { allocatePort, spawnProcess, waitForPort, + canListenOnPort: async () => true, probeHealth: vi.fn(async () => ({ healthy: false, statusCode: null, identity: null })), }); diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 23f377bf..9374488d 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -60,6 +60,7 @@ vi.mock('@main/utils/shellEnv', async (importOriginal) => { import { clearResolvedNodePathForTests, + resolveAgentTeamsMcpLaunchSpec, TeamMcpConfigBuilder, } from '@main/services/team/TeamMcpConfigBuilder'; import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; @@ -354,6 +355,53 @@ describe('TeamMcpConfigBuilder', () => { expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled(); }); + it('uses the packaged Electron Node runtime for Linux packaged MCP launches', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath'); + const electronBinary = '/opt/Agent Teams AI/agent-teams-ai'; + setPackagedMode(true, '3.0.0'); + const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); + createdDirs.push(resourcesDir); + createPackagedServerBundle(resourcesDir, '// packaged linux server'); + setResourcesPath(resourcesDir); + + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + Object.defineProperty(process, 'execPath', { + value: electronBinary, + configurable: true, + writable: true, + }); + + try { + const launchSpec = await resolveAgentTeamsMcpLaunchSpec(); + const builder = new TeamMcpConfigBuilder(); + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + const server = readGeneratedServer(configPath); + const expectedEntry = path.join(tempAppData, 'mcp-server', '3.0.0', 'index.js'); + + expect(launchSpec).toEqual({ + command: electronBinary, + args: [expectedEntry], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }); + expect(server?.command).toBe(electronBinary); + expect(server?.args).toEqual([expectedEntry]); + expect(server?.env?.ELECTRON_RUN_AS_NODE).toBe('1'); + expect(hoisted.execCliMock).not.toHaveBeenCalled(); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + if (execPathDescriptor) { + Object.defineProperty(process, 'execPath', execPathDescriptor); + } + } + }); + it('falls back to strict shell env lookup when fast Node lookup cannot resolve Node', async () => { mockBuiltWorkspaceEntryAvailable(); const previousNodeBinary = process.env.NODE_BINARY; diff --git a/test/main/utils/childProcess.test.ts b/test/main/utils/childProcess.test.ts index 65ed6dd2..e26a6d01 100644 --- a/test/main/utils/childProcess.test.ts +++ b/test/main/utils/childProcess.test.ts @@ -102,6 +102,36 @@ function createExtensionlessNpmNodeLauncher(): { return { dir, launcher, target }; } +function createNpmNativeExeLauncher(): { + dir: string; + launcher: string; + target: string; +} { + const dir = mkdtempSync(path.join(tmpdir(), 'cat-cli-native-launcher-')); + const targetDir = path.join(dir, 'node_modules', 'opencode-ai', 'bin'); + mkdirSync(targetDir, { recursive: true }); + const target = path.join(targetDir, 'opencode.exe'); + writeFileSync(target, '', 'utf8'); + const launcher = path.join(dir, 'opencode.cmd'); + writeFileSync( + launcher, + [ + '@ECHO off', + 'GOTO start', + ':find_dp0', + 'SET dp0=%~dp0', + 'EXIT /b', + ':start', + 'SETLOCAL', + 'CALL :find_dp0', + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*', + '', + ].join('\r\n'), + 'utf8' + ); + return { dir, launcher, target }; +} + describe('cli child process helpers', () => { beforeEach(() => { vi.resetAllMocks(); @@ -220,6 +250,25 @@ describe('cli child process helpers', () => { } }); + it('runs npm native exe cmd launchers directly', () => { + setPlatform('win32'); + const fake = new EventEmitter() as ReturnType; + const spawnMock = child.spawn as unknown as Mock; + spawnMock.mockReturnValue(fake); + const { dir, launcher, target } = createNpmNativeExeLauncher(); + try { + const result = spawnCli(launcher, ['serve', '--hostname', '127.0.0.1']); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.calls[0][0]).toBe(target); + expect(spawnMock.mock.calls[0][1]).toEqual(['serve', '--hostname', '127.0.0.1']); + expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell'); + expect(spawnMock.mock.calls[0][2]).toMatchObject({ windowsHide: true }); + expect(result).toBe(fake); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('uses shell directly when path contains non-ASCII on windows', () => { setPlatform('win32'); const fake = {} as any; From 7b99a3713bec9dab146f9ab2631fe6d7c4cc8b2a Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 15:43:15 +0300 Subject: [PATCH 27/41] feat(opencode): surface runtime diagnostics and approvals --- src/main/index.ts | 10 +- .../runtime/ClaudeMultimodelBridgeService.ts | 12 +- .../services/team/TeamProvisioningService.ts | 377 +++++++++++++-- .../OpenCodeRuntimeApprovalProvider.ts | 185 ++++++++ .../RuntimeToolApprovalCoordinator.ts | 429 ++++++++++++++++++ .../bridge/OpenCodeBridgeCommandClient.ts | 89 +++- .../bridge/OpenCodeBridgeCommandContract.ts | 56 ++- .../bridge/OpenCodeBridgeDiagnosticsStore.ts | 143 ++++++ .../OpenCodeBridgeSupportDiagnostics.ts | 146 ++++++ .../bridge/OpenCodeReadinessBridge.ts | 57 ++- ...enCodeStateChangingBridgeCommandService.ts | 15 + .../readiness/OpenCodeTeamLaunchReadiness.ts | 2 + .../runtime/OpenCodeTeamRuntimeAdapter.ts | 82 ++++ .../team/runtime/TeamRuntimeAdapter.ts | 35 ++ src/main/services/team/runtime/index.ts | 3 + .../team/dialogs/CreateTeamDialog.tsx | 4 + .../team/dialogs/LaunchTeamDialog.tsx | 4 + .../ProvisioningProviderStatusList.test.ts | 152 +++++++ .../ProvisioningProviderStatusList.tsx | 144 +++++- .../providerPrepareDiagnostics.test.ts | 75 +++ .../dialogs/providerPrepareDiagnostics.ts | 339 +++++++++----- src/shared/types/team.ts | 24 +- .../openCodeWindowsAccessDenied.test.ts | 27 ++ .../utils/openCodeWindowsAccessDenied.ts | 24 + .../ClaudeMultimodelBridgeService.test.ts | 3 +- .../team/OpenCodeBridgeCommandClient.test.ts | 101 ++++- .../OpenCodeBridgeCommandContract.test.ts | 53 ++- .../OpenCodeBridgeDiagnosticsStore.test.ts | 79 ++++ .../team/OpenCodeMixedRecovery.live.test.ts | 2 + .../team/OpenCodeReadinessBridge.test.ts | 136 +++++- ...eStateChangingBridgeCommandService.test.ts | 49 +- .../OpenCodeTeamProvisioning.live.test.ts | 2 + .../team/OpenCodeTeamRuntimeAdapter.test.ts | 134 +++++- .../RuntimeToolApprovalCoordinator.test.ts | 346 ++++++++++++++ .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 194 ++++++-- .../team/TeamProvisioningService.test.ts | 183 ++++++++ ...gServiceOpenCodeSupportDiagnostics.test.ts | 119 +++++ .../TeamProvisioningServicePrepare.test.ts | 95 +++- .../services/team/openCodeLiveTestHarness.ts | 1 + .../ProvisioningProviderStatusList.test.ts | 150 ++++++ ...rPrepareDiagnosticsOpenCodeRuntime.test.ts | 46 +- 41 files changed, 3895 insertions(+), 232 deletions(-) create mode 100644 src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider.ts create mode 100644 src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts create mode 100644 src/main/services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore.ts create mode 100644 src/main/services/team/opencode/bridge/OpenCodeBridgeSupportDiagnostics.ts create mode 100644 src/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts create mode 100644 src/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts create mode 100644 src/shared/utils/__tests__/openCodeWindowsAccessDenied.test.ts create mode 100644 src/shared/utils/openCodeWindowsAccessDenied.ts create mode 100644 test/main/services/team/OpenCodeBridgeDiagnosticsStore.test.ts create mode 100644 test/main/services/team/RuntimeToolApprovalCoordinator.test.ts create mode 100644 test/main/services/team/TeamProvisioningServiceOpenCodeSupportDiagnostics.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index e5bdb94b..3ece25ff 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -147,6 +147,7 @@ import { clearAutoResumeService } from './services/team/AutoResumeService'; import { agentTeamsMcpHttpServer } from './services/team/AgentTeamsMcpHttpServer'; import { LaunchIoGovernor } from './services/team/LaunchIoGovernor'; import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient'; +import { OpenCodeBridgeDiagnosticsStore } from './services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore'; import { createOpenCodeBridgeCommandLeaseStore, createOpenCodeBridgeCommandLedgerStore, @@ -407,6 +408,9 @@ async function createOpenCodeRuntimeAdapterRegistry( }); const mcpEntry = mcpLaunchSpec.args[0]; if (mcpEntry) { + for (const [key, value] of Object.entries(mcpLaunchSpec.env ?? {})) { + targetEnv[key] = value; + } targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry; targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args); @@ -515,13 +519,16 @@ async function createOpenCodeRuntimeAdapterRegistry( } return nextEnv; }; + const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge'); const bridgeClient = new OpenCodeBridgeCommandClient({ binaryPath, tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'), env: bridgeEnv, envProvider: resolveBridgeCommandEnv, + diagnostics: new OpenCodeBridgeDiagnosticsStore({ + directory: join(bridgeControlDir, 'diagnostics'), + }), }); - const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge'); const clientIdentity = createOpenCodeBridgeClientIdentity({ appVersion: typeof app.getVersion === 'function' ? app.getVersion() : '1.3.0', gitSha: process.env.VITE_GIT_SHA ?? process.env.GIT_SHA ?? null, @@ -546,6 +553,7 @@ async function createOpenCodeRuntimeAdapterRegistry( }); const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { stateChangingCommands, + appVersion: clientIdentity.appVersion, }); openCodeLifecycleBridge = readinessBridge; return new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(readinessBridge)]); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index a01eef47..72aecb3f 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -396,11 +396,21 @@ function createRuntimeStatusErrorProviderStatus( error: unknown ): CliProviderStatus { const message = error instanceof Error ? error.message : String(error); + const lower = message.toLowerCase(); + const detailMessage = + providerId === 'opencode' && (lower.includes('timed out') || lower.includes('timeout')) + ? [ + 'OpenCode runtime status did not return before the desktop timeout.', + 'This means the Agent Teams runtime process did not produce provider-status JSON in time, not necessarily that OpenCode auth is missing.', + 'Likely causes include slow or hung OpenCode CLI startup, provider/model inventory, local OpenCode plugins, cache/profile corruption, stale bundled runtime, or Windows security software delaying child processes.', + `Raw timeout detail: ${message}`, + ].join(' ') + : message; return { ...createDefaultProviderStatus(providerId), verificationState: 'error', statusMessage: 'Provider status unavailable', - detailMessage: message, + detailMessage, }; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 3173c969..e45985c8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -110,6 +110,10 @@ import { } from '@shared/utils/inboxNoise'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; +import { + isOpenCodeWindowsAccessDeniedDiagnostic, + normalizeOpenCodeWindowsAccessDeniedDiagnostic, +} from '@shared/utils/openCodeWindowsAccessDenied'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -228,11 +232,19 @@ import { normalizeMemberDiagnosticText, shouldUseGeminiStagedLaunch, } from './provisioning/TeamProvisioningPromptBuilders'; + +import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main'; export type { RuntimeBootstrapMemberMcpLaunchConfig } from './provisioning/TeamProvisioningBootstrapSpec'; export { buildAddMemberSpawnMessage, buildRestartMemberSpawnMessage, } from './provisioning/TeamProvisioningPromptBuilders'; +import { openCodeRuntimeApprovalProvider } from './approvals/OpenCodeRuntimeApprovalProvider'; +import { + RuntimeToolApprovalCoordinator, + type RuntimeToolApprovalEntry, +} from './approvals/RuntimeToolApprovalCoordinator'; +import { isOpenCodeBridgeNoOutputDiagnostic } from './opencode/bridge/OpenCodeBridgeSupportDiagnostics'; import { buildOpenCodePromptDeliveryAttemptId, createOpenCodePromptDeliveryLedgerStore, @@ -406,10 +418,10 @@ import type { TeamRuntimeLaunchInput, TeamRuntimeLaunchResult, TeamRuntimeMemberLaunchEvidence, + TeamRuntimeMemberSpec, TeamRuntimePrepareResult, TeamRuntimeStopInput, } from './runtime'; -import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main'; type OpenCodeRuntimeMessageAdapter = TeamLaunchRuntimeAdapter & { sendMessageToMember( @@ -608,6 +620,7 @@ import type { TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamProvisioningState, + TeamProvisioningSupportDiagnostic, TeamRuntimeState, TeamTask, ToolActivityEventPayload, @@ -962,9 +975,22 @@ function pushUniqueLine(lines: string[], line: string): void { } } +function pushUniqueSupportDiagnostics( + diagnostics: TeamProvisioningSupportDiagnostic[], + incoming: readonly TeamProvisioningSupportDiagnostic[] | undefined +): void { + for (const diagnostic of incoming ?? []) { + if (!diagnostics.some((existing) => existing.id === diagnostic.id)) { + diagnostics.push({ ...diagnostic }); + } + } +} + function looksLikeOpenCodeProviderPrepareDiagnostic(value: string): boolean { const lower = value.trim().toLowerCase(); return ( + isOpenCodeBridgeNoOutputDiagnostic(value) || + isOpenCodeWindowsAccessDeniedDiagnostic(value) || lower.includes('opencode /experimental/tool') || lower.includes('/experimental/tool') || lower.includes('mcp_unavailable') || @@ -981,6 +1007,15 @@ function normalizeOpenCodePrepareDiagnostic(value: string, reason?: string): str return trimmed; } + if (isOpenCodeBridgeNoOutputDiagnostic(trimmed)) { + return 'OpenCode runtime check returned no output.'; + } + + const accessDeniedDiagnostic = normalizeOpenCodeWindowsAccessDeniedDiagnostic(trimmed); + if (accessDeniedDiagnostic) { + return accessDeniedDiagnostic; + } + if (/opencode cli (?:not detected on path|not found)/i.test(trimmed)) { return OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC; } @@ -1028,6 +1063,11 @@ function isOpenCodeModelVerificationTimeoutDiagnostic(value: string | null | und function selectOpenCodeModelPreparePrimaryReason( prepare: Extract ): string { + const providerDiagnostic = selectOpenCodePrepareProviderDiagnostic(prepare); + if (providerDiagnostic) { + return providerDiagnostic; + } + const candidates = [...prepare.diagnostics, prepare.reason] .map((entry) => entry?.trim() ?? '') .filter(Boolean); @@ -1035,6 +1075,15 @@ function selectOpenCodeModelPreparePrimaryReason( return timeoutReason ?? candidates[0] ?? prepare.reason; } +function selectOpenCodePrepareProviderDiagnostic( + prepare: Pick +): string | undefined { + return [...prepare.diagnostics, ...prepare.warnings].find( + (entry) => + isOpenCodeBridgeNoOutputDiagnostic(entry) || isOpenCodeWindowsAccessDeniedDiagnostic(entry) + ); +} + function isOpenCodeModelPrepareBusyDeferred( prepare: Extract, primaryReason: string @@ -4997,6 +5046,16 @@ export class TeamProvisioningService { | undefined = new Map>(); private readonly runtimeAdapterTraceLinesByRunId = new Map(); private readonly runtimeAdapterTraceKeyByRunId = new Map(); + private readonly runtimeToolApprovalCoordinator = new RuntimeToolApprovalCoordinator({ + getSettings: (teamName) => this.getToolApprovalSettings(teamName), + answerApproval: ({ entry, allow, message }) => + this.answerRuntimeToolApproval(entry, allow, message), + emitApprovalEvent: (event) => this.emitToolApprovalEvent(event), + showApprovalNotification: (approval) => + this.maybeShowToolApprovalOsNotification(undefined, approval), + dismissApprovalNotification: (requestId) => this.dismissApprovalNotification(requestId), + logWarning: (message) => logger.warn(message), + }); private readonly runtimeAdapterRunByTeam = new Map< string, { @@ -10423,6 +10482,7 @@ export class TeamProvisioningService { } private deleteSecondaryRuntimeRun(teamName: string, laneId: string): void { + this.clearOpenCodeRuntimeToolApprovals(teamName, { laneId, emitDismiss: true }); const runs = this.secondaryRuntimeRunByTeam.get(teamName); if (!runs) { return; @@ -10434,6 +10494,7 @@ export class TeamProvisioningService { } private clearSecondaryRuntimeRuns(teamName: string): void { + this.clearOpenCodeRuntimeToolApprovals(teamName, { emitDismiss: true }); this.secondaryRuntimeRunByTeam.delete(teamName); } @@ -11253,6 +11314,7 @@ export class TeamProvisioningService { private resetTeamScopedTransientStateForNewRun(teamName: string): void { peekAutoResumeService()?.cancelPendingAutoResume(teamName); + this.clearOpenCodeRuntimeToolApprovals(teamName, { emitDismiss: true }); this.invalidateRuntimeSnapshotCaches(teamName); this.retainedClaudeLogsByTeam.delete(teamName); this.persistedTranscriptClaudeLogsCache.delete(teamName); @@ -18260,6 +18322,7 @@ export class TeamProvisioningService { details: result.details ? [...result.details] : undefined, warnings: result.warnings ? [...result.warnings] : undefined, issues: result.issues?.map((issue) => ({ ...issue })), + supportDiagnostics: result.supportDiagnostics?.map((diagnostic) => ({ ...diagnostic })), }; } @@ -18304,6 +18367,7 @@ export class TeamProvisioningService { const details: string[] = []; const blockingMessages: string[] = []; const issues: TeamProvisioningPrepareIssue[] = []; + const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = []; const selectedModelIds = Array.from( new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean)) ); @@ -18353,9 +18417,13 @@ export class TeamProvisioningService { normalizeOpenCodePrepareDiagnostic(warning, prepareReason) ) ); + pushUniqueSupportDiagnostics(supportDiagnostics, prepare.supportDiagnostics); if (!prepare.ok) { + const providerDiagnostic = selectOpenCodePrepareProviderDiagnostic(prepare); blockingMessages.push( - normalizeOpenCodePrepareDiagnostic(`OpenCode: ${prepare.reason}`, prepare.reason) + providerDiagnostic + ? normalizeOpenCodePrepareDiagnostic(providerDiagnostic, prepare.reason) + : normalizeOpenCodePrepareDiagnostic(`OpenCode: ${prepare.reason}`, prepare.reason) ); } continue; @@ -18371,6 +18439,7 @@ export class TeamProvisioningService { warnings.push(...openCodeModelPrepare.warnings); blockingMessages.push(...openCodeModelPrepare.blockingMessages); issues.push(...openCodeModelPrepare.issues); + pushUniqueSupportDiagnostics(supportDiagnostics, openCodeModelPrepare.supportDiagnostics); continue; } @@ -18539,6 +18608,10 @@ export class TeamProvisioningService { : 'Some provider runtimes are not ready', warnings: failureWarnings.length > 0 ? failureWarnings : undefined, issues: issues.length > 0 ? issues : undefined, + supportDiagnostics: + supportDiagnostics.length > 0 + ? supportDiagnostics.map((diagnostic) => ({ ...diagnostic })) + : undefined, }; } @@ -18555,6 +18628,10 @@ export class TeamProvisioningService { : 'CLI is warmed up and ready to launch', warnings: warnings.length > 0 ? warnings : undefined, issues: issues.length > 0 ? issues : undefined, + supportDiagnostics: + supportDiagnostics.length > 0 + ? supportDiagnostics.map((diagnostic) => ({ ...diagnostic })) + : undefined, }; } @@ -18573,15 +18650,17 @@ export class TeamProvisioningService { warnings: string[]; blockingMessages: string[]; issues: TeamProvisioningPrepareIssue[]; + supportDiagnostics: TeamProvisioningSupportDiagnostic[]; }> { const details: string[] = []; const warnings: string[] = []; const blockingMessages: string[] = []; const issues: TeamProvisioningPrepareIssue[] = []; + const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = []; const startedAt = Date.now(); if (modelIds.length === 0) { - return { details, warnings, blockingMessages, issues }; + return { details, warnings, blockingMessages, issues, supportDiagnostics }; } if (verificationMode === 'compatibility') { @@ -18629,6 +18708,11 @@ export class TeamProvisioningService { reason: prepare.ok ? null : prepare.reason, diagnostics: prepare.diagnostics, warnings: prepare.warnings, + supportDiagnostics: prepare.supportDiagnostics?.map((diagnostic) => ({ + id: diagnostic.id, + kind: diagnostic.kind, + title: diagnostic.title, + })), }); return prepare; } catch (error) { @@ -18700,6 +18784,7 @@ export class TeamProvisioningService { } const { modelId, prepare } = result; + pushUniqueSupportDiagnostics(supportDiagnostics, prepare.supportDiagnostics); const prepareReason = prepare.ok ? undefined : prepare.reason; warnings.push( ...prepare.warnings.map((warning) => @@ -18800,7 +18885,7 @@ export class TeamProvisioningService { blockingMessages, }); - return { details, warnings, blockingMessages, issues }; + return { details, warnings, blockingMessages, issues, supportDiagnostics }; } private isProviderScopedOpenCodePrepareFailure( @@ -18829,11 +18914,13 @@ export class TeamProvisioningService { warnings: string[]; blockingMessages: string[]; issues: TeamProvisioningPrepareIssue[]; + supportDiagnostics: TeamProvisioningSupportDiagnostic[]; } | null> { const details: string[] = []; const warnings: string[] = []; const blockingMessages: string[] = []; const issues: TeamProvisioningPrepareIssue[] = []; + const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = []; const startedAt = Date.now(); appendPreflightDebugLog('opencode_compatibility_batch_start', { @@ -18879,11 +18966,20 @@ export class TeamProvisioningService { ok: sharedPrepare.ok, reason: sharedPrepare.ok ? null : sharedPrepare.reason, diagnostics: sharedPrepare.diagnostics, + supportDiagnostics: sharedPrepare.supportDiagnostics?.map((diagnostic) => ({ + id: diagnostic.id, + kind: diagnostic.kind, + title: diagnostic.title, + })), }); if (!sharedPrepare.ok) { + pushUniqueSupportDiagnostics(supportDiagnostics, sharedPrepare.supportDiagnostics); + const providerDiagnostic = selectOpenCodePrepareProviderDiagnostic(sharedPrepare); const primaryReason = normalizeOpenCodePrepareDiagnostic( - sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason, + providerDiagnostic ?? + sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? + sharedPrepare.reason, sharedPrepare.reason ); if (primaryReason.trim().length > 0) { @@ -18899,7 +18995,7 @@ export class TeamProvisioningService { code: sharedPrepare.reason, message: primaryReason.trim() || `OpenCode: ${sharedPrepare.reason}`, }); - return { details, warnings, blockingMessages, issues }; + return { details, warnings, blockingMessages, issues, supportDiagnostics }; } const latestReadiness = @@ -18955,7 +19051,7 @@ export class TeamProvisioningService { details, }); - return { details, warnings, blockingMessages, issues }; + return { details, warnings, blockingMessages, issues, supportDiagnostics }; } private resolveOpenCodeCompatibilityModel( @@ -21517,6 +21613,19 @@ export class TeamProvisioningService { launchResult, launchInput ); + const requestTeamColor = 'color' in input.request ? input.request.color : undefined; + const requestTeamDisplayName = + 'displayName' in input.request ? input.request.displayName : undefined; + this.syncOpenCodeRuntimeToolApprovals({ + teamName: input.request.teamName, + runId, + laneId: 'primary', + cwd: launchCwd, + members: result.members, + expectedMembers: launchInput.expectedMembers, + teamColor: requestTeamColor, + teamDisplayName: requestTeamDisplayName, + }); const success = result.teamLaunchState === 'clean_success'; const pending = result.teamLaunchState === 'partial_pending'; const failed = result.teamLaunchState === 'partial_failure'; @@ -22716,6 +22825,11 @@ export class TeamProvisioningService { const teamName = runtimeProgress.teamName; const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); this.cancelledRuntimeAdapterRunIds.add(runId); + this.clearOpenCodeRuntimeToolApprovals(teamName, { + runId, + laneId: 'primary', + emitDismiss: true, + }); this.runtimeAdapterRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); if (this.provisioningRunByTeam.get(teamName) === runId) { @@ -28122,6 +28236,47 @@ export class TeamProvisioningService { this.emitMemberSpawnChange(run, lane.member.name); } + private async applyOpenCodeSecondaryPermissionAnswerResult( + entry: RuntimeToolApprovalEntry, + result: TeamRuntimeLaunchResult + ): Promise { + const trackedRunId = this.getTrackedRunId(entry.approval.teamName); + const run = trackedRunId ? this.runs.get(trackedRunId) : null; + if (!run) { + throw new Error(`Run not found for team "${entry.approval.teamName}"`); + } + const lane = (run.mixedSecondaryLanes ?? []).find( + (candidate) => candidate.laneId === entry.laneId + ); + if (!lane) { + throw new Error( + `OpenCode secondary lane ${entry.laneId} was not found for team "${entry.approval.teamName}"` + ); + } + + const guarded = await this.guardCommittedOpenCodeSecondaryLaneEvidence({ + teamName: entry.approval.teamName, + laneId: entry.laneId, + memberName: entry.memberName, + result, + }); + lane.result = guarded; + lane.warnings = [...guarded.warnings]; + lane.diagnostics = [...guarded.diagnostics]; + lane.state = 'finished'; + await this.publishMixedSecondaryLaneStatusChange(run, lane); + this.syncOpenCodeRuntimeToolApprovals({ + teamName: entry.approval.teamName, + runId: entry.approval.runId, + laneId: entry.laneId, + cwd: entry.cwd ?? '', + members: guarded.members, + expectedMembers: entry.expectedMembers ?? [], + teamDisplayName: entry.approval.teamDisplayName, + teamColor: entry.approval.teamColor, + }); + } + private async guardCommittedOpenCodeSecondaryLaneEvidence(params: { teamName: string; laneId: string; @@ -28637,6 +28792,18 @@ export class TeamProvisioningService { await finishCancelledLane(); return; } + const laneExpectedMembers: TeamRuntimeMemberSpec[] = [ + { + name: lane.member.name, + role: lane.member.role, + workflow: lane.member.workflow, + isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined, + providerId: 'opencode', + model: lane.member.model, + effort: lane.member.effort, + cwd: laneCwd, + }, + ]; const launchOpenCodeLane = () => adapter.launch({ runId: laneRunId, @@ -28649,18 +28816,7 @@ export class TeamProvisioningService { effort: lane.member.effort, runtimeOnly: true, skipPermissions: run.request.skipPermissions !== false, - expectedMembers: [ - { - name: lane.member.name, - role: lane.member.role, - workflow: lane.member.workflow, - isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined, - providerId: 'opencode', - model: lane.member.model, - effort: lane.member.effort, - cwd: laneCwd, - }, - ], + expectedMembers: laneExpectedMembers, previousLaunchState, }); let rawResult: TeamRuntimeLaunchResult; @@ -28753,6 +28909,16 @@ export class TeamProvisioningService { ) : resultWithTiming; lane.result = normalizedResult; + this.syncOpenCodeRuntimeToolApprovals({ + teamName: run.teamName, + runId: laneRunId, + laneId: lane.laneId, + cwd: laneCwd, + members: normalizedResult.members, + expectedMembers: laneExpectedMembers, + teamColor: run.request.color, + teamDisplayName: run.request.displayName, + }); lane.warnings = [...normalizedResult.warnings]; const launchDiagnostics = appendDiagnosticOnce( [...requestedDiagnostics, ...migration.diagnostics, ...normalizedResult.diagnostics], @@ -31301,6 +31467,11 @@ export class TeamProvisioningService { startedAt: previousProgress?.startedAt ?? startedAt, updatedAt: startedAt, }); + this.clearOpenCodeRuntimeToolApprovals(teamName, { + runId, + laneId: 'primary', + emitDismiss: true, + }); this.runtimeAdapterRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); if (this.provisioningRunByTeam.get(teamName) === runId) { @@ -32668,11 +32839,13 @@ export class TeamProvisioningService { const toolName = typeof request?.tool_name === 'string' ? request.tool_name : 'Unknown'; const toolInput = (request?.input ?? {}) as Record; + const providerId = toolInput.provider === 'codex' ? 'codex' : undefined; const approval: ToolApprovalRequest = { requestId, runId: run.runId, teamName: run.teamName, + ...(providerId ? { providerId } : {}), source: 'lead', toolName, toolInput, @@ -32776,13 +32949,35 @@ export class TeamProvisioningService { this.maybeShowToolApprovalOsNotification(run, approval); } + private syncOpenCodeRuntimeToolApprovals(input: { + teamName: string; + runId: string; + laneId: string; + cwd: string; + members: Record; + expectedMembers: TeamRuntimeMemberSpec[]; + teamColor?: string; + teamDisplayName?: string; + }): void { + const entries = openCodeRuntimeApprovalProvider.collectPendingApprovals(input); + this.runtimeToolApprovalCoordinator.sync( + { + teamName: input.teamName, + runId: input.runId, + laneId: input.laneId, + providerId: 'opencode', + }, + entries + ); + } + /** * Shows a native OS notification for a pending tool approval when the app * is not in focus. On macOS, adds Allow/Deny action buttons that respond * directly from the notification without switching to the app. */ private maybeShowToolApprovalOsNotification( - run: ProvisioningRun, + run: ProvisioningRun | undefined, approval: ToolApprovalRequest ): void { const win = this.mainWindowRef; @@ -32795,13 +32990,16 @@ export class TeamProvisioningService { const snoozedUntil = config.notifications.snoozedUntil; if (snoozedUntil && Date.now() < snoozedUntil) return; - const { Notification: ElectronNotification } = require('electron') as typeof import('electron'); - if (!ElectronNotification.isSupported()) return; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { Notification: ElectronNotification } = require('electron') as Partial< + typeof import('electron') + >; + if (!ElectronNotification?.isSupported?.()) return; const isMac = process.platform === 'darwin'; const isLinux = process.platform === 'linux'; const iconPath = isMac ? undefined : getAppIconPath(); - const teamLabel = run.request.displayName ?? run.teamName; + const teamLabel = approval.teamDisplayName ?? run?.request.displayName ?? approval.teamName; const body = this.formatToolApprovalBody(approval.toolName, approval.toolInput); // Actions (Allow/Deny buttons) supported on macOS and Windows. @@ -32848,17 +33046,17 @@ export class TeamProvisioningService { cleanup(); const allow = index === 0; logger.info( - `[${run.teamName}] Tool approval ${allow ? 'allowed' : 'denied'} via OS notification` + `[${approval.teamName}] Tool approval ${allow ? 'allowed' : 'denied'} via OS notification` ); void this.respondToToolApproval( - run.teamName, - run.runId, + approval.teamName, + approval.runId, approval.requestId, allow, allow ? undefined : 'Denied via notification' ).catch((err) => { logger.error( - `[${run.teamName}] Failed to respond via notification: ${err instanceof Error ? err.message : String(err)}` + `[${approval.teamName}] Failed to respond via notification: ${err instanceof Error ? err.message : String(err)}` ); }); }); @@ -32876,6 +33074,16 @@ export class TeamProvisioningService { } } + private clearOpenCodeRuntimeToolApprovals( + teamName: string, + options: { runId?: string; laneId?: string; emitDismiss?: boolean } = {} + ): void { + this.runtimeToolApprovalCoordinator.clear(teamName, { + ...options, + providerId: 'opencode', + }); + } + private formatToolApprovalBody(toolName: string, toolInput: Record): string { switch (toolName) { case 'AskUserQuestion': @@ -33095,6 +33303,83 @@ export class TeamProvisioningService { this.inFlightResponses.delete(requestId); } } + + this.runtimeToolApprovalCoordinator.reEvaluate(); + } + + private async answerRuntimeToolApproval( + entry: RuntimeToolApprovalEntry, + allow: boolean, + _message?: string + ): Promise { + if (entry.providerId !== 'opencode') { + throw new Error(`Runtime approval provider is not supported: ${entry.providerId}`); + } + const adapter = this.getOpenCodeRuntimeAdapter(); + if (!adapter?.answerRuntimePermission) { + throw new Error('OpenCode runtime permission answer bridge is not available'); + } + + const previousLaunchState = await this.launchStateStore.read(entry.approval.teamName); + const result = await adapter.answerRuntimePermission({ + runId: entry.approval.runId, + laneId: entry.laneId, + teamName: entry.approval.teamName, + cwd: entry.cwd ?? '', + providerId: 'opencode', + memberName: entry.memberName, + requestId: entry.providerRequestId, + decision: allow ? 'allow' : 'reject', + expectedMembers: entry.expectedMembers ?? [], + previousLaunchState, + }); + + if (entry.laneId === 'primary') { + const launchInput: TeamRuntimeLaunchInput = { + runId: entry.approval.runId, + laneId: entry.laneId, + teamName: entry.approval.teamName, + cwd: entry.cwd ?? '', + providerId: 'opencode', + skipPermissions: false, + expectedMembers: entry.expectedMembers ?? [], + previousLaunchState, + }; + const { result: committed } = await this.persistOpenCodeRuntimeAdapterLaunchResult( + result, + launchInput + ); + if (committed.teamLaunchState === 'partial_failure') { + this.runtimeAdapterRunByTeam.delete(entry.approval.teamName); + } else { + this.runtimeAdapterRunByTeam.set(entry.approval.teamName, { + runId: entry.approval.runId, + providerId: 'opencode', + cwd: entry.cwd, + members: committed.members, + }); + this.aliveRunByTeam.set(entry.approval.teamName, entry.approval.runId); + } + this.syncOpenCodeRuntimeToolApprovals({ + teamName: entry.approval.teamName, + runId: entry.approval.runId, + laneId: entry.laneId, + cwd: entry.cwd ?? '', + members: committed.members, + expectedMembers: entry.expectedMembers ?? [], + teamDisplayName: entry.approval.teamDisplayName, + teamColor: entry.approval.teamColor, + }); + } else { + await this.applyOpenCodeSecondaryPermissionAnswerResult(entry, result); + } + + this.teamChangeEmitter?.({ + type: 'process', + teamName: entry.approval.teamName, + runId: entry.approval.runId, + detail: allow ? 'permission-allowed' : 'permission-denied', + }); } /** @@ -33108,6 +33393,17 @@ export class TeamProvisioningService { allow: boolean, message?: string ): Promise { + const handledByRuntime = await this.runtimeToolApprovalCoordinator.respond( + teamName, + runId, + requestId, + allow, + message + ); + if (handledByRuntime) { + return; + } + // Look in both provisioning and alive runs — control_requests arrive during provisioning too const currentRunId = this.getTrackedRunId(teamName); if (!currentRunId) throw new Error(`No active process for team "${teamName}"`); @@ -33122,8 +33418,8 @@ export class TeamProvisioningService { // to handle the race where timeout already responded and deleted the approval this.clearApprovalTimeout(requestId); if (!this.tryClaimResponse(requestId)) { - // Timeout already responded — silently exit, UI cleanup via autoResolved event - run.pendingApprovals.delete(requestId); + // Another response is already being written; leave the pending approval tracked + // until that write succeeds or fails. return; } @@ -33148,15 +33444,22 @@ export class TeamProvisioningService { approval.toolName, approval.toolInput ); - } finally { - run.pendingApprovals.delete(requestId); this.inFlightResponses.delete(requestId); + run.pendingApprovals.delete(requestId); this.dismissApprovalNotification(requestId); + } catch (error) { + this.inFlightResponses.delete(requestId); + if (run.pendingApprovals.has(requestId)) { + this.startApprovalTimeout(run, requestId); + } + throw error; } return; } if (!run.child?.stdin?.writable) { + this.inFlightResponses.delete(requestId); + this.startApprovalTimeout(run, requestId); throw new Error(`Team "${teamName}" process stdin is not writable`); } @@ -33222,11 +33525,16 @@ export class TeamProvisioningService { } }); }); - } finally { - run.pendingApprovals.delete(requestId); + } catch (error) { this.inFlightResponses.delete(requestId); - this.dismissApprovalNotification(requestId); + if (run.pendingApprovals.has(requestId)) { + this.startApprovalTimeout(run, requestId); + } + throw error; } + run.pendingApprovals.delete(requestId); + this.inFlightResponses.delete(requestId); + this.dismissApprovalNotification(requestId); } /** @@ -35740,7 +36048,6 @@ export class TeamProvisioningService { const nextMembers: Record[] = []; for (const m of membersRaw) { const name = typeof m.name === 'string' ? m.name.trim() : ''; - const agentType = typeof m.agentType === 'string' ? m.agentType : ''; if (!name) continue; if (isLeadMember(m) || name === 'user') { nextMembers.push(m); diff --git a/src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider.ts b/src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider.ts new file mode 100644 index 00000000..002cb22b --- /dev/null +++ b/src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider.ts @@ -0,0 +1,185 @@ +import type { + TeamRuntimeMemberLaunchEvidence, + TeamRuntimeMemberSpec, + TeamRuntimePendingApproval, +} from '../runtime/TeamRuntimeAdapter'; +import type { + RuntimeApprovalLaunchPolicy, + RuntimeApprovalProviderPort, + RuntimeToolApprovalAnswerInput, + RuntimeToolApprovalEntry, +} from './RuntimeToolApprovalCoordinator'; +import type { ToolApprovalRequest } from '@shared/types/team'; + +interface CollectOpenCodeRuntimeApprovalsInput { + teamName: string; + runId: string; + laneId: string; + cwd: string; + members: Record; + expectedMembers: TeamRuntimeMemberSpec[]; + teamColor?: string; + teamDisplayName?: string; + nowIso?: () => string; +} + +export class OpenCodeRuntimeApprovalProvider implements RuntimeApprovalProviderPort< + { toolApprovalMode?: 'auto' | 'manual' }, + CollectOpenCodeRuntimeApprovalsInput +> { + readonly providerId = 'opencode' as const; + + buildLaunchPolicy( + skipPermissions: boolean, + _context: { toolApprovalMode?: 'auto' | 'manual' } = {} + ): RuntimeApprovalLaunchPolicy { + return { + providerId: this.providerId, + mode: skipPermissions ? 'auto' : 'manual', + config: { + permission: skipPermissions ? 'allow' : 'ask', + }, + }; + } + + collectPendingApprovals(input: CollectOpenCodeRuntimeApprovalsInput): RuntimeToolApprovalEntry[] { + return collectOpenCodeRuntimeApprovalEntries(input); + } + + async answerApproval(_input: RuntimeToolApprovalAnswerInput): Promise { + throw new Error('OpenCode approval answers are handled by the runtime adapter bridge.'); + } + + assertManualSupported(): void { + return; + } +} + +export const openCodeRuntimeApprovalProvider = new OpenCodeRuntimeApprovalProvider(); + +export function collectOpenCodeRuntimeApprovalEntries( + input: CollectOpenCodeRuntimeApprovalsInput +): RuntimeToolApprovalEntry[] { + const entries: RuntimeToolApprovalEntry[] = []; + const nowIso = input.nowIso ?? (() => new Date().toISOString()); + for (const [memberName, member] of Object.entries(input.members)) { + for (const approval of collectOpenCodeRuntimePendingApprovals(member)) { + const providerRequestId = approval.requestId.trim(); + if (!providerRequestId) { + continue; + } + const requestId = buildOpenCodeRuntimeApprovalRequestId(input.runId, providerRequestId); + const toolName = openCodeApprovalToolName(approval); + const toolInput = openCodeApprovalToolInput(approval); + const uiRequest: ToolApprovalRequest = { + requestId, + runId: input.runId, + teamName: input.teamName, + providerId: 'opencode', + source: memberName, + toolName, + toolInput, + receivedAt: nowIso(), + teamColor: input.teamColor, + teamDisplayName: input.teamDisplayName, + runtimePermission: { + providerId: 'opencode', + laneId: input.laneId, + memberName, + providerRequestId, + sessionId: approval.sessionId ?? member.sessionId ?? null, + }, + }; + entries.push({ + providerId: 'opencode', + approval: uiRequest, + providerRequestId, + laneId: input.laneId, + memberName, + cwd: input.cwd, + expectedMembers: input.expectedMembers, + }); + } + } + return entries; +} + +function collectOpenCodeRuntimePendingApprovals( + member: TeamRuntimeMemberLaunchEvidence +): TeamRuntimePendingApproval[] { + const approvals = [...(member.pendingApprovals ?? []), ...(member.pendingPermissions ?? [])]; + const byRequestId = new Map(); + for (const approval of approvals) { + const requestId = approval.requestId.trim(); + if (!requestId || approval.providerId !== 'opencode' || byRequestId.has(requestId)) { + continue; + } + byRequestId.set(requestId, { ...approval, requestId }); + } + for (const requestId of member.pendingPermissionRequestIds ?? []) { + const trimmed = requestId.trim(); + if (!trimmed || byRequestId.has(trimmed)) { + continue; + } + byRequestId.set(trimmed, { + providerId: 'opencode', + requestId: trimmed, + sessionId: member.sessionId ?? null, + tool: null, + title: null, + kind: null, + }); + } + return Array.from(byRequestId.values()); +} + +export function buildOpenCodeRuntimeApprovalRequestId( + runId: string, + providerRequestId: string +): string { + return `opencode:${runId}:${providerRequestId}`; +} + +export function openCodeApprovalToolName(approval: TeamRuntimePendingApproval): string { + const rawTool = approval.tool?.trim() || approval.kind?.trim() || approval.title?.trim(); + const normalized = rawTool?.toLowerCase(); + switch (normalized) { + case 'bash': + case 'shell': + case 'terminal': + return 'Bash'; + case 'edit': + return 'Edit'; + case 'write': + return 'Write'; + case 'read': + return 'Read'; + default: + return rawTool || 'OpenCodeTool'; + } +} + +export function openCodeApprovalToolInput( + approval: TeamRuntimePendingApproval +): Record { + const raw: Record = + approval.raw && typeof approval.raw === 'object' ? approval.raw : {}; + const patterns = Array.isArray(raw.patterns) + ? raw.patterns.filter((value): value is string => typeof value === 'string') + : undefined; + const firstPattern = patterns?.[0]; + const title = approval.title?.trim(); + const input: Record = { + providerRequestId: approval.requestId, + provider: 'opencode', + ...(approval.sessionId ? { sessionId: approval.sessionId } : {}), + ...(approval.tool ? { tool: approval.tool } : {}), + ...(approval.kind ? { kind: approval.kind } : {}), + ...(title ? { title } : {}), + ...(patterns?.length ? { patterns } : {}), + }; + if (openCodeApprovalToolName(approval) === 'Bash' && firstPattern) { + input.command = firstPattern; + } + return input; +} diff --git a/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts b/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts new file mode 100644 index 00000000..eb921f39 --- /dev/null +++ b/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts @@ -0,0 +1,429 @@ +import { shouldAutoAllow } from '@main/utils/toolApprovalRules'; + +import type { + TeamRuntimeApprovalProviderId, + TeamRuntimeMemberSpec, +} from '../runtime/TeamRuntimeAdapter'; +import type { + ToolApprovalAutoResolved, + ToolApprovalDismiss, + ToolApprovalRequest, + ToolApprovalSettings, +} from '@shared/types/team'; + +export type RuntimeApprovalProviderId = TeamRuntimeApprovalProviderId; + +export type RuntimeApprovalDecision = 'allow' | 'deny'; + +export interface RuntimeApprovalLaunchPolicy { + providerId: RuntimeApprovalProviderId; + mode: 'auto' | 'manual'; + config: Record; +} + +export interface RuntimeApprovalProviderPort { + readonly providerId: RuntimeApprovalProviderId; + buildLaunchPolicy(skipPermissions: boolean, context: TContext): RuntimeApprovalLaunchPolicy; + collectPendingApprovals(runtimeState: TRuntimeState): RuntimeToolApprovalEntry[]; + answerApproval(input: RuntimeToolApprovalAnswerInput): Promise; + assertManualSupported(context: TContext): void; +} + +export interface RuntimeToolApprovalEntry { + providerId: RuntimeApprovalProviderId; + approval: ToolApprovalRequest; + providerRequestId: string; + laneId: string; + memberName: string; + cwd?: string; + expectedMembers?: TeamRuntimeMemberSpec[]; + metadata?: Record; +} + +export interface RuntimeToolApprovalAnswerInput { + entry: RuntimeToolApprovalEntry; + allow: boolean; + message?: string; +} + +export type RuntimeToolApprovalEvent = + | ToolApprovalRequest + | ToolApprovalDismiss + | ToolApprovalAutoResolved; + +export interface RuntimeToolApprovalCoordinatorDeps { + getSettings(teamName: string): ToolApprovalSettings; + answerApproval(input: RuntimeToolApprovalAnswerInput): Promise; + emitApprovalEvent(event: RuntimeToolApprovalEvent): void; + showApprovalNotification?(approval: ToolApprovalRequest): void; + dismissApprovalNotification?(requestId: string): void; + logWarning?(message: string): void; +} + +export interface RuntimeToolApprovalSyncScope { + teamName: string; + runId: string; + laneId?: string; + providerId?: RuntimeApprovalProviderId; +} + +export interface RuntimeToolApprovalClearOptions { + runId?: string; + laneId?: string; + providerId?: RuntimeApprovalProviderId; + emitDismiss?: boolean; +} + +export function mapAppApprovalDecisionToProviderDecision( + decision: RuntimeApprovalDecision +): 'allow' | 'reject' { + return decision === 'allow' ? 'allow' : 'reject'; +} + +export class RuntimeToolApprovalCoordinator { + private readonly approvalsByTeam = new Map>(); + private readonly timers = new Map>(); + private readonly inFlightResponses = new Set(); + + constructor(private readonly deps: RuntimeToolApprovalCoordinatorDeps) {} + + sync(scope: RuntimeToolApprovalSyncScope, entries: RuntimeToolApprovalEntry[]): void { + const observedRequestIds = new Set(); + for (const entry of entries) { + observedRequestIds.add(entry.approval.requestId); + this.register(entry); + } + + const approvals = this.approvalsByTeam.get(scope.teamName); + if (!approvals) { + return; + } + + for (const [requestId, entry] of approvals) { + if (!this.matchesScope(entry, scope)) { + continue; + } + if (observedRequestIds.has(requestId)) { + continue; + } + this.removeEntry(entry); + this.deps.emitApprovalEvent({ + autoResolved: true, + requestId, + runId: entry.approval.runId, + teamName: entry.approval.teamName, + reason: 'runtime_resolved', + } as ToolApprovalAutoResolved); + } + } + + register(entry: RuntimeToolApprovalEntry): void { + const requestId = entry.approval.requestId; + if (!requestId) { + return; + } + const approvals = this.getTeamApprovals(entry.approval.teamName); + if (approvals.has(requestId) || this.inFlightResponses.has(requestId)) { + return; + } + + const autoResult = shouldAutoAllow( + this.deps.getSettings(entry.approval.teamName), + entry.approval.toolName, + entry.approval.toolInput + ); + if (autoResult.autoAllow) { + void this.answerUntracked(entry, true, undefined, 'auto_allow_category'); + return; + } + + approvals.set(requestId, entry); + this.deps.emitApprovalEvent(entry.approval); + this.startTimeout(entry); + this.deps.showApprovalNotification?.(entry.approval); + } + + async respond( + teamName: string, + runId: string, + requestId: string, + allow: boolean, + message?: string + ): Promise { + const entry = this.approvalsByTeam.get(teamName)?.get(requestId); + if (!entry) { + return false; + } + if (entry.approval.runId !== runId) { + throw new Error( + `Stale approval: runId mismatch (expected ${entry.approval.runId}, got ${runId})` + ); + } + + this.clearTimer(requestId); + if (!this.tryClaimResponse(requestId)) { + return true; + } + + try { + await this.deps.answerApproval({ entry, allow, message }); + } catch (error) { + this.inFlightResponses.delete(requestId); + if (this.get(entry.approval.teamName, requestId) === entry) { + this.startTimeout(entry); + } + throw error; + } + this.removeEntry(entry); + this.inFlightResponses.delete(requestId); + return true; + } + + clear(teamName: string, options: RuntimeToolApprovalClearOptions = {}): number { + const approvals = this.approvalsByTeam.get(teamName); + if (!approvals) { + return 0; + } + + let removed = 0; + const removedRunIds = new Set(); + for (const entry of Array.from(approvals.values())) { + if (!this.matchesClearOptions(entry, options)) { + continue; + } + this.removeEntry(entry); + removed += 1; + removedRunIds.add(entry.approval.runId); + } + + if (removed > 0 && options.emitDismiss) { + for (const runId of removedRunIds) { + this.deps.emitApprovalEvent({ dismissed: true, teamName, runId }); + } + } + + return removed; + } + + reEvaluate(): void { + for (const approvals of Array.from(this.approvalsByTeam.values())) { + for (const entry of Array.from(approvals.values())) { + const requestId = entry.approval.requestId; + const settings = this.deps.getSettings(entry.approval.teamName); + const autoResult = shouldAutoAllow( + settings, + entry.approval.toolName, + entry.approval.toolInput + ); + if (autoResult.autoAllow) { + this.clearTimer(requestId); + void this.answerTracked(entry, true, undefined, 'auto_allow_category'); + continue; + } + + if (settings.timeoutAction === 'wait') { + this.clearTimer(requestId); + } else if (!this.timers.has(requestId)) { + this.startTimeout(entry); + } + } + } + } + + get(teamName: string, requestId: string): RuntimeToolApprovalEntry | undefined { + return this.approvalsByTeam.get(teamName)?.get(requestId); + } + + size(teamName?: string): number { + if (teamName) { + return this.approvalsByTeam.get(teamName)?.size ?? 0; + } + let total = 0; + for (const approvals of this.approvalsByTeam.values()) { + total += approvals.size; + } + return total; + } + + dispose(): void { + for (const requestId of Array.from(this.timers.keys())) { + this.clearTimer(requestId); + } + this.approvalsByTeam.clear(); + this.inFlightResponses.clear(); + } + + private startTimeout(entry: RuntimeToolApprovalEntry): void { + const { timeoutAction, timeoutSeconds } = this.deps.getSettings(entry.approval.teamName); + if (timeoutAction === 'wait') { + return; + } + + const requestId = entry.approval.requestId; + if (this.timers.has(requestId)) { + return; + } + + const timer = setTimeout(() => { + this.timers.delete(requestId); + const current = this.get(entry.approval.teamName, requestId); + if (!current) { + return; + } + const currentAction = this.deps.getSettings(entry.approval.teamName).timeoutAction; + if (currentAction === 'wait') { + return; + } + const allow = currentAction === 'allow'; + void this.answerTracked( + current, + allow, + allow ? undefined : 'Timed out - auto-denied by settings', + allow ? 'timeout_allow' : 'timeout_deny' + ); + }, timeoutSeconds * 1000); + timer.unref?.(); + this.timers.set(requestId, timer); + } + + private async answerTracked( + entry: RuntimeToolApprovalEntry, + allow: boolean, + message: string | undefined, + reason: ToolApprovalAutoResolved['reason'] + ): Promise { + const requestId = entry.approval.requestId; + if (!this.tryClaimResponse(requestId)) { + return; + } + try { + await this.deps.answerApproval({ entry, allow, message }); + this.removeEntry(entry); + this.deps.emitApprovalEvent({ + autoResolved: true, + requestId, + runId: entry.approval.runId, + teamName: entry.approval.teamName, + reason, + } as ToolApprovalAutoResolved); + } catch (error) { + this.deps.logWarning?.( + `[${entry.approval.teamName}] Failed to auto-resolve runtime approval ${requestId}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + if (this.get(entry.approval.teamName, requestId) === entry) { + this.startTimeout(entry); + } + } finally { + this.inFlightResponses.delete(requestId); + } + } + + private async answerUntracked( + entry: RuntimeToolApprovalEntry, + allow: boolean, + message: string | undefined, + reason: ToolApprovalAutoResolved['reason'] + ): Promise { + const requestId = entry.approval.requestId; + if (!this.tryClaimResponse(requestId)) { + return; + } + try { + await this.deps.answerApproval({ entry, allow, message }); + this.deps.emitApprovalEvent({ + autoResolved: true, + requestId, + runId: entry.approval.runId, + teamName: entry.approval.teamName, + reason, + } as ToolApprovalAutoResolved); + } catch (error) { + this.deps.logWarning?.( + `[${entry.approval.teamName}] Failed to auto-resolve runtime approval ${requestId}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } finally { + this.inFlightResponses.delete(requestId); + } + } + + private removeEntry(entry: RuntimeToolApprovalEntry): void { + const requestId = entry.approval.requestId; + this.clearTimer(requestId); + this.inFlightResponses.delete(requestId); + this.deps.dismissApprovalNotification?.(requestId); + const approvals = this.approvalsByTeam.get(entry.approval.teamName); + if (!approvals) { + return; + } + approvals.delete(requestId); + if (approvals.size === 0) { + this.approvalsByTeam.delete(entry.approval.teamName); + } + } + + private clearTimer(requestId: string): void { + const timer = this.timers.get(requestId); + if (!timer) { + return; + } + clearTimeout(timer); + this.timers.delete(requestId); + } + + private tryClaimResponse(requestId: string): boolean { + if (this.inFlightResponses.has(requestId)) { + return false; + } + this.inFlightResponses.add(requestId); + return true; + } + + private getTeamApprovals(teamName: string): Map { + const existing = this.approvalsByTeam.get(teamName); + if (existing) { + return existing; + } + const approvals = new Map(); + this.approvalsByTeam.set(teamName, approvals); + return approvals; + } + + private matchesScope( + entry: RuntimeToolApprovalEntry, + scope: RuntimeToolApprovalSyncScope + ): boolean { + if (entry.approval.teamName !== scope.teamName) { + return false; + } + if (entry.approval.runId !== scope.runId) { + return false; + } + if (scope.laneId && entry.laneId !== scope.laneId) { + return false; + } + if (scope.providerId && entry.providerId !== scope.providerId) { + return false; + } + return true; + } + + private matchesClearOptions( + entry: RuntimeToolApprovalEntry, + options: RuntimeToolApprovalClearOptions + ): boolean { + if (options.runId && entry.approval.runId !== options.runId) { + return false; + } + if (options.laneId && entry.laneId !== options.laneId) { + return false; + } + if (options.providerId && entry.providerId !== options.providerId) { + return false; + } + return true; + } +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts index acab846a..ab6ea9f6 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts @@ -38,6 +38,14 @@ export interface OpenCodeBridgeProcessRunner { run(input: OpenCodeBridgeProcessRunInput): Promise; } +interface OpenCodeBridgeOutputReadResult { + content: string; + outputSource: 'stdout' | 'file' | 'none'; + stdoutBytes: number; + outputFileBytes: number | null; + outputReadError: string | null; +} + export interface OpenCodeBridgeDiagnosticsSink { append(event: OpenCodeBridgeDiagnosticEvent): Promise; } @@ -185,7 +193,16 @@ export class OpenCodeBridgeCommandClient { stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES, env: await this.resolveEnv(), }); - const stdout = await this.readBridgeOutput(processResult.stdout, outputPath); + const bridgeOutput = await this.readBridgeOutput(processResult.stdout, outputPath); + const processDetails = { + exitCode: processResult.exitCode, + timedOut: processResult.timedOut, + stdoutBytes: bridgeOutput.stdoutBytes, + stderrBytes: byteLength(processResult.stderr), + outputSource: bridgeOutput.outputSource, + outputFileBytes: bridgeOutput.outputFileBytes, + outputReadError: bridgeOutput.outputReadError, + }; if (processResult.timedOut) { return this.contractFailure( @@ -196,6 +213,7 @@ export class OpenCodeBridgeCommandClient { { stderr: redactBridgeDiagnosticText(processResult.stderr), attempts: attempt, + ...processDetails, } ); } @@ -207,14 +225,14 @@ export class OpenCodeBridgeCommandClient { 'OpenCode bridge command failed', true, { - exitCode: processResult.exitCode, stderr: redactBridgeDiagnosticText(processResult.stderr), attempts: attempt, + ...processDetails, } ); } - const parsed = parseSingleBridgeJsonResult(stdout); + const parsed = parseSingleBridgeJsonResult(bridgeOutput.content); if (!parsed.ok) { if (shouldRetryEmptyReadinessStdout(command, parsed.error, attempt, maxAttempts)) { await sleep(EMPTY_STDOUT_READINESS_RETRY_DELAY_MS); @@ -222,9 +240,10 @@ export class OpenCodeBridgeCommandClient { } return this.contractFailure(envelope, 'contract_violation', parsed.error, false, { - stdoutPreview: redactBridgeDiagnosticText(stdout.slice(0, 2_000)), + stdoutPreview: redactBridgeDiagnosticText(bridgeOutput.content.slice(0, 2_000)), stderrPreview: redactBridgeDiagnosticText(processResult.stderr.slice(0, 2_000)), attempts: attempt, + ...processDetails, }); } @@ -232,6 +251,7 @@ export class OpenCodeBridgeCommandClient { if (!validation.ok) { return this.contractFailure(envelope, 'contract_violation', validation.reason, false, { attempts: attempt, + ...processDetails, }); } @@ -253,14 +273,38 @@ export class OpenCodeBridgeCommandClient { } } - private async readBridgeOutput(stdout: string, outputPath: string): Promise { + private async readBridgeOutput( + stdout: string, + outputPath: string + ): Promise { + const stdoutBytes = byteLength(stdout); if (stdout.trim().length > 0) { - return stdout; + return { + content: stdout, + outputSource: 'stdout', + stdoutBytes, + outputFileBytes: null, + outputReadError: null, + }; } try { - return await fs.readFile(outputPath, 'utf8'); - } catch { - return stdout; + const output = await fs.readFile(outputPath, 'utf8'); + const outputFileBytes = byteLength(output); + return { + content: output, + outputSource: output.trim().length > 0 ? 'file' : 'none', + stdoutBytes, + outputFileBytes, + outputReadError: null, + }; + } catch (error) { + return { + content: stdout, + outputSource: 'none', + stdoutBytes, + outputFileBytes: 0, + outputReadError: getBridgeOutputReadError(error), + }; } } @@ -291,6 +335,13 @@ export class OpenCodeBridgeCommandClient { details: Record ): Promise { const completedAt = this.clock().toISOString(); + const diagnosticDetails = { + command: envelope.command, + requestId: envelope.requestId, + cwd: redactBridgeDiagnosticText(envelope.cwd), + binaryPath: redactBridgeDiagnosticText(this.binaryPath), + ...details, + }; const diagnostic: OpenCodeBridgeDiagnosticEvent = { id: this.diagnosticIdFactory(), type: @@ -301,11 +352,11 @@ export class OpenCodeBridgeCommandClient { runId: extractRunId(envelope.body) ?? undefined, severity: retryable ? 'warning' : 'error', message, - data: details, + data: diagnosticDetails, createdAt: completedAt, }; - await this.diagnostics?.append(diagnostic); + await this.diagnostics?.append(diagnostic).catch(() => undefined); return { ok: false, @@ -318,7 +369,7 @@ export class OpenCodeBridgeCommandClient { kind, message, retryable, - details, + details: diagnosticDetails, }, diagnostics: [diagnostic], }; @@ -356,3 +407,17 @@ function bufferToString(value: string | Buffer | undefined): string { } return ''; } + +function byteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +function getBridgeOutputReadError(error: unknown): string { + if (error && typeof error === 'object' && 'code' in error) { + const code = (error as { code?: unknown }).code; + if (typeof code === 'string' && code.trim()) { + return code.trim(); + } + } + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index 0d0c50e2..7c9c36c5 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -64,6 +64,7 @@ export interface OpenCodeLaunchTeamCommandBody { teamName: string; projectPath: string; selectedModel: string; + skipPermissions?: boolean; members: OpenCodeTeamLaunchMemberCommandSpec[]; leadPrompt: string; expectedCapabilitySnapshotId: string | null; @@ -71,6 +72,15 @@ export interface OpenCodeLaunchTeamCommandBody { capabilitySnapshotRecoveryAttemptId?: string; } +export interface OpenCodeRuntimePermissionCommandData { + requestId: string; + sessionId: string | null; + tool: string | null; + title: string | null; + kind: string | null; + raw?: Record; +} + export interface OpenCodeTeamMemberLaunchCommandData { sessionId: string; launchState: OpenCodeTeamMemberLaunchBridgeState; @@ -78,6 +88,7 @@ export interface OpenCodeTeamMemberLaunchCommandData { bootstrapMode?: OpenCodeBootstrapMode; appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate; pendingPermissionRequestIds?: string[]; + pendingPermissions?: OpenCodeRuntimePermissionCommandData[]; diagnostics?: string[]; model: string; runtimePid?: number; @@ -132,6 +143,30 @@ export interface OpenCodeStopTeamCommandData { runtimeStoreManifestHighWatermark?: number | null; } +export interface OpenCodeAnswerPermissionCommandBody { + runId: string; + laneId: string; + teamId: string; + teamName: string; + projectPath: string; + memberName?: string; + requestId: string; + decision: 'allow' | 'always' | 'reject'; + expectedCapabilitySnapshotId?: string | null; + manifestHighWatermark?: number | null; +} + +export interface OpenCodeListRuntimePermissionsCommandBody { + teamId: string; + teamName: string; + laneId?: string; + projectPath?: string; +} + +export interface OpenCodeListRuntimePermissionsCommandData { + permissions: OpenCodeRuntimePermissionCommandData[]; +} + export interface OpenCodeCleanupHostsCommandBody { reason: 'startup' | 'shutdown' | 'manual' | string; mode?: 'stale' | 'force'; @@ -590,6 +625,7 @@ export function assertBridgeResultCanMutateState( command: OpenCodeBridgeCommandName; runId: string | null; capabilitySnapshotId: string | null; + allowCapabilitySnapshotRecovery?: boolean; } ): asserts result is OpenCodeBridgeSuccess { if (!result.ok) { @@ -612,12 +648,28 @@ export function assertBridgeResultCanMutateState( if ( expected.capabilitySnapshotId !== null && - result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId + result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId && + !( + expected.allowCapabilitySnapshotRecovery === true && + hasOpenCodeBridgeDataDiagnosticCode(result.data, 'opencode_capability_snapshot_recovery') + ) ) { throw new Error('OpenCode bridge capability snapshot mismatch'); } } +function hasOpenCodeBridgeDataDiagnosticCode(value: unknown, code: string): boolean { + if (!isRecord(value) || !Array.isArray(value.diagnostics)) { + return false; + } + return value.diagnostics.some((diagnostic) => { + if (!isRecord(diagnostic)) { + return false; + } + return diagnostic.code === code; + }); +} + export function validateOpenCodeBridgeHandshake(input: { handshake: OpenCodeBridgeHandshake; expectedClient: OpenCodeBridgePeerIdentity; @@ -744,6 +796,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: { manifest: RuntimeStoreManifestEvidence; idempotencyKey: string; enforceManifestHighWatermark?: boolean; + allowCapabilitySnapshotRecovery?: boolean; }): asserts input is { result: OpenCodeBridgeSuccess; requestId: string; @@ -758,6 +811,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: { command: input.command, runId: input.runId, capabilitySnapshotId: input.capabilitySnapshotId, + allowCapabilitySnapshotRecovery: input.allowCapabilitySnapshotRecovery, }); const resultManifestHighWatermark = extractManifestHighWatermark(input.result.data); diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore.ts new file mode 100644 index 00000000..58c7f210 --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore.ts @@ -0,0 +1,143 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; + +import { redactBridgeDiagnosticText } from './OpenCodeBridgeCommandClient'; + +import type { OpenCodeBridgeDiagnosticsSink } from './OpenCodeBridgeCommandClient'; +import type { OpenCodeBridgeDiagnosticEvent } from './OpenCodeBridgeCommandContract'; + +const DEFAULT_MAX_EVENTS_BYTES = 3 * 1024 * 1024; +const MAX_STRING_CHARS = 4_000; + +export interface OpenCodeBridgeDiagnosticsStoreOptions { + directory: string; + maxEventsBytes?: number; +} + +export class OpenCodeBridgeDiagnosticsStore implements OpenCodeBridgeDiagnosticsSink { + private readonly directory: string; + private readonly maxEventsBytes: number; + + constructor(options: OpenCodeBridgeDiagnosticsStoreOptions) { + this.directory = options.directory; + this.maxEventsBytes = options.maxEventsBytes ?? DEFAULT_MAX_EVENTS_BYTES; + } + + async append(event: OpenCodeBridgeDiagnosticEvent): Promise { + try { + await fs.mkdir(this.directory, { recursive: true, mode: 0o700 }); + const sanitized = sanitizeDiagnosticEvent(event); + await fs.writeFile( + path.join(this.directory, 'latest.json'), + `${JSON.stringify(sanitized, null, 2)}\n`, + { encoding: 'utf8', mode: 0o600 } + ); + await this.rotateEventsIfNeeded(); + await fs.appendFile( + path.join(this.directory, 'events.ndjson'), + `${JSON.stringify(sanitized)}\n`, + { encoding: 'utf8', mode: 0o600 } + ); + } catch { + // Best-effort diagnostics must never block provider preflight or launch. + } + } + + private async rotateEventsIfNeeded(): Promise { + const eventsPath = path.join(this.directory, 'events.ndjson'); + const stat = await fs.stat(eventsPath).catch(() => null); + if (!stat || stat.size <= this.maxEventsBytes) { + return; + } + + const content = await fs.readFile(eventsPath, 'utf8').catch(() => ''); + const keepBytes = Math.max(0, Math.floor(this.maxEventsBytes / 2)); + const tailLines = selectNdjsonTailLines(content, keepBytes); + await fs.writeFile( + eventsPath, + `${JSON.stringify({ + type: 'opencode_bridge_diagnostics_truncated', + providerId: 'opencode', + severity: 'info', + message: 'truncated previous bridge diagnostics', + createdAt: new Date().toISOString(), + })}\n${tailLines.length > 0 ? `${tailLines.join('\n')}\n` : ''}`, + { + encoding: 'utf8', + mode: 0o600, + } + ); + } +} + +function selectNdjsonTailLines(content: string, maxBytes: number): string[] { + if (maxBytes <= 0) { + return []; + } + const selected: string[] = []; + let totalBytes = 0; + const lines = content.split('\n').filter((line) => line.trim().length > 0); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + const nextBytes = Buffer.byteLength(`${line}\n`, 'utf8'); + if (selected.length > 0 && totalBytes + nextBytes > maxBytes) { + break; + } + if (selected.length === 0 || totalBytes + nextBytes <= maxBytes) { + selected.unshift(line); + totalBytes += nextBytes; + } + } + return selected; +} + +function sanitizeDiagnosticEvent( + event: OpenCodeBridgeDiagnosticEvent +): OpenCodeBridgeDiagnosticEvent { + return { + ...event, + message: sanitizeString(event.message), + ...(event.teamName ? { teamName: sanitizeString(event.teamName) } : {}), + ...(event.runId ? { runId: sanitizeString(event.runId) } : {}), + ...(event.data ? { data: sanitizeRecord(event.data) } : {}), + }; +} + +function sanitizeRecord(value: Record): Record { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, sanitizeRecordEntry(key, entry)]) + ); +} + +function sanitizeRecordEntry(key: string, entry: unknown): unknown { + const normalized = key.toLowerCase(); + if ( + normalized === 'stdin' || + normalized === 'stdout' || + normalized === 'stderr' || + normalized === 'input' + ) { + return '[omitted]'; + } + return sanitizeValue(entry); +} + +function sanitizeValue(value: unknown): unknown { + if (typeof value === 'string') { + return sanitizeString(value); + } + if (Array.isArray(value)) { + return value.map((entry) => sanitizeValue(entry)); + } + if (value && typeof value === 'object') { + return sanitizeRecord(value as Record); + } + return value; +} + +function sanitizeString(value: string): string { + const redacted = redactBridgeDiagnosticText(value); + return redacted.length > MAX_STRING_CHARS + ? `${redacted.slice(0, MAX_STRING_CHARS)}...[truncated]` + : redacted; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeSupportDiagnostics.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeSupportDiagnostics.ts new file mode 100644 index 00000000..2278b098 --- /dev/null +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeSupportDiagnostics.ts @@ -0,0 +1,146 @@ +import * as os from 'os'; + +import { redactBridgeDiagnosticText } from './OpenCodeBridgeCommandClient'; + +import type { OpenCodeBridgeFailure } from './OpenCodeBridgeCommandContract'; +import type { TeamProvisioningSupportDiagnostic } from '@shared/types/team'; + +const NO_OUTPUT_TITLE = 'OpenCode runtime check returned no output'; +const NO_OUTPUT_SUMMARY = 'OpenCode readiness bridge exited without returning diagnostic JSON.'; + +export function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean { + const lower = value?.trim().toLowerCase() ?? ''; + return ( + lower.includes('opencode runtime check returned no output') || + lower.includes('bridge stdout was empty') || + lower.includes('opencode_bridge_contract_violation') || + (lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation')) + ); +} + +export function buildOpenCodeBridgeSupportDiagnostic(input: { + result: OpenCodeBridgeFailure; + projectPath: string; + selectedModel: string | null; + appVersion?: string | null; +}): TeamProvisioningSupportDiagnostic | null { + const event = + input.result.diagnostics.find((diagnostic) => + isOpenCodeBridgeNoOutputDiagnostic(`${diagnostic.type}: ${diagnostic.message}`) + ) ?? input.result.diagnostics[0]; + const visibleError = `OpenCode readiness bridge failed: ${input.result.error.kind}: ${input.result.error.message}`; + const eventText = event ? `${event.type}: ${event.message}` : ''; + if ( + !isOpenCodeBridgeNoOutputDiagnostic(visibleError) && + !isOpenCodeBridgeNoOutputDiagnostic(eventText) + ) { + return null; + } + + const details = { + ...(event?.data ?? {}), + ...(input.result.error.details ?? {}), + }; + const createdAt = event?.createdAt ?? input.result.completedAt; + const copyText = buildOpenCodeBridgeSupportCopyText({ + createdAt, + severity: event?.severity ?? (input.result.error.retryable ? 'warning' : 'error'), + visibleError, + details, + result: input.result, + projectPath: input.projectPath, + selectedModel: input.selectedModel, + appVersion: input.appVersion ?? null, + }); + + return { + id: event?.id ?? `opencode-bridge-support-${input.result.requestId}`, + providerId: 'opencode', + kind: 'opencode_bridge_no_output', + severity: event?.severity ?? (input.result.error.retryable ? 'warning' : 'error'), + title: NO_OUTPUT_TITLE, + summary: NO_OUTPUT_SUMMARY, + copyText, + createdAt, + }; +} + +function buildOpenCodeBridgeSupportCopyText(input: { + createdAt: string; + severity: 'info' | 'warning' | 'error'; + visibleError: string; + details: Record; + result: OpenCodeBridgeFailure; + projectPath: string; + selectedModel: string | null; + appVersion: string | null; +}): string { + const command = formatDiagnosticValue(input.details.command, input.result.command); + const requestId = formatDiagnosticValue(input.details.requestId, input.result.requestId); + const stderrPreview = formatPreview(input.details.stderrPreview); + + return [ + 'Agent Teams OpenCode diagnostics', + `Time: ${input.createdAt}`, + 'Provider: opencode', + `Severity: ${input.severity}`, + `Title: ${NO_OUTPUT_TITLE}`, + `Summary: ${NO_OUTPUT_SUMMARY}`, + '', + 'Visible error:', + redactBridgeDiagnosticText(input.visibleError), + '', + 'Bridge command:', + `command: ${command}`, + `requestId: ${requestId}`, + `attempts: ${formatDiagnosticValue(input.details.attempts)}`, + `exitCode: ${formatDiagnosticValue(input.details.exitCode)}`, + `timedOut: ${formatDiagnosticValue(input.details.timedOut)}`, + `stdoutBytes: ${formatDiagnosticValue(input.details.stdoutBytes)}`, + `stderrBytes: ${formatDiagnosticValue(input.details.stderrBytes)}`, + `outputSource: ${formatDiagnosticValue(input.details.outputSource)}`, + `outputFileBytes: ${formatDiagnosticValue(input.details.outputFileBytes)}`, + `outputReadError: ${formatDiagnosticValue(input.details.outputReadError)}`, + '', + 'Environment:', + `platform: ${process.platform}`, + `arch: ${process.arch}`, + `appVersion: ${formatDiagnosticValue(input.appVersion)}`, + `projectPath: ${redactDiagnosticPath(input.projectPath)}`, + `selectedModel: ${formatDiagnosticValue(input.selectedModel)}`, + '', + 'stderrPreview:', + stderrPreview, + ].join('\n'); +} + +function formatDiagnosticValue(value: unknown, fallback: unknown = undefined): string { + const resolved = value ?? fallback; + if (resolved === null || resolved === undefined || resolved === '') { + return '(none)'; + } + if (typeof resolved === 'string') { + return redactBridgeDiagnosticText(resolved); + } + if (typeof resolved === 'number' || typeof resolved === 'boolean') { + return String(resolved); + } + return redactBridgeDiagnosticText(JSON.stringify(resolved)); +} + +function formatPreview(value: unknown): string { + const formatted = formatDiagnosticValue(value); + return formatted === '(none)' ? '(empty)' : formatted; +} + +function redactDiagnosticPath(value: string): string { + const home = os.homedir(); + const trimmed = value.trim(); + if (!trimmed) { + return '(none)'; + } + if (home && trimmed.startsWith(home)) { + return `~${trimmed.slice(home.length)}`; + } + return redactBridgeDiagnosticText(trimmed); +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index 07588dd8..b8e11c19 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -4,6 +4,7 @@ import { OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION, stableHash, } from './OpenCodeBridgeCommandContract'; +import { buildOpenCodeBridgeSupportDiagnostic } from './OpenCodeBridgeSupportDiagnostics'; import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter'; import type { @@ -11,6 +12,7 @@ import type { OpenCodeTeamLaunchReadinessState, } from '../readiness/OpenCodeTeamLaunchReadiness'; import type { + OpenCodeAnswerPermissionCommandBody, OpenCodeBackfillTaskLedgerCommandBody, OpenCodeBackfillTaskLedgerCommandData, OpenCodeBridgeCommandName, @@ -24,6 +26,8 @@ import type { OpenCodeCommandStatusCommandData, OpenCodeLaunchTeamCommandBody, OpenCodeLaunchTeamCommandData, + OpenCodeListRuntimePermissionsCommandBody, + OpenCodeListRuntimePermissionsCommandData, OpenCodeObserveMessageDeliveryCommandBody, OpenCodeObserveMessageDeliveryCommandData, OpenCodeReconcileTeamCommandBody, @@ -62,6 +66,7 @@ export interface OpenCodeReadinessBridgeOptions { observeTimeoutMs?: number; stopTimeoutMs?: number; cleanupTimeoutMs?: number; + appVersion?: string; stateChangingCommands?: Pick; } @@ -80,6 +85,7 @@ const DEFAULT_SEND_TIMEOUT_MS = 45_000; const DEFAULT_OBSERVE_TIMEOUT_MS = 20_000; const DEFAULT_STOP_TIMEOUT_MS = 30_000; const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000; +const DEFAULT_PERMISSION_TIMEOUT_MS = 30_000; const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000; const DEFAULT_COMMAND_STATUS_TIMEOUT_MS = 5_000; const OPEN_CODE_COMPLETED_COMMAND_RECOVERY_MESSAGE = @@ -118,6 +124,12 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { } this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath); + const supportDiagnostic = buildOpenCodeBridgeSupportDiagnostic({ + result, + projectPath: input.projectPath, + selectedModel: input.selectedModel, + appVersion: this.options.appVersion ?? null, + }); return blockedReadiness({ state: mapBridgeFailureToReadinessState(result.error.kind), modelId: input.selectedModel, @@ -126,6 +138,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { ...result.diagnostics.map(formatDiagnosticEvent), ], missing: [result.error.message], + supportDiagnostics: supportDiagnostic ? [supportDiagnostic] : undefined, }); } @@ -204,6 +217,40 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { }; } + async answerOpenCodeRuntimePermission( + input: OpenCodeAnswerPermissionCommandBody + ): Promise { + const result = await this.executeStateChangingCommand< + OpenCodeAnswerPermissionCommandBody, + OpenCodeLaunchTeamCommandData + >('opencode.answerPermission', input, { + teamName: input.teamName, + laneId: input.laneId, + runId: input.runId, + capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null, + cwd: input.projectPath, + timeoutMs: DEFAULT_PERMISSION_TIMEOUT_MS, + }); + return result.ok ? result.data : blockedLaunchData(input.runId, result); + } + + async listOpenCodeRuntimePermissions( + input: OpenCodeListRuntimePermissionsCommandBody + ): Promise { + const cwd = input.projectPath ?? process.cwd(); + const result = await this.bridge.execute< + OpenCodeListRuntimePermissionsCommandBody, + OpenCodeListRuntimePermissionsCommandData + >('opencode.listRuntimePermissions', input, { + cwd, + timeoutMs: DEFAULT_PERMISSION_TIMEOUT_MS, + }); + if (result.ok) { + return result.data; + } + return { permissions: [] }; + } + async cleanupOpenCodeHosts( input: OpenCodeCleanupHostsCommandBody ): Promise { @@ -565,7 +612,11 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { type OpenCodeStateChangingTeamCommandName = Extract< OpenCodeBridgeCommandName, - 'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam' | 'opencode.sendMessage' + | 'opencode.launchTeam' + | 'opencode.reconcileTeam' + | 'opencode.stopTeam' + | 'opencode.sendMessage' + | 'opencode.answerPermission' >; function blockedLaunchData( @@ -600,6 +651,7 @@ function blockedReadiness(input: { modelId: string | null; diagnostics: string[]; missing: string[]; + supportDiagnostics?: OpenCodeTeamLaunchReadiness['supportDiagnostics']; }): OpenCodeTeamLaunchReadiness { return { state: input.state, @@ -617,6 +669,9 @@ function blockedReadiness(input: { supportLevel: null, missing: dedupe(input.missing), diagnostics: dedupe(input.diagnostics), + ...(input.supportDiagnostics?.length + ? { supportDiagnostics: [...input.supportDiagnostics] } + : {}), evidence: { capabilitiesReady: false, mcpToolProofRoute: null, diff --git a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts index 850898bf..a6fc5517 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts @@ -245,6 +245,10 @@ export class OpenCodeStateChangingBridgeCommandService { manifest, idempotencyKey, enforceManifestHighWatermark, + allowCapabilitySnapshotRecovery: isOpenCodeLaunchCapabilitySnapshotRecoveryAttempt( + input.command, + input.body + ), }); } catch (error) { await this.ledger.markFailed({ @@ -354,6 +358,17 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isOpenCodeLaunchCapabilitySnapshotRecoveryAttempt( + command: OpenCodeBridgeCommandName, + body: unknown +): boolean { + if (command !== 'opencode.launchTeam' || !isRecord(body)) { + return false; + } + const recoveryAttemptId = body.capabilitySnapshotRecoveryAttemptId; + return typeof recoveryAttemptId === 'string' && recoveryAttemptId.trim().length > 0; +} + function requiresOpenCodeDeliveryAcceptanceContract( command: OpenCodeBridgeCommandName, body: unknown diff --git a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts index dd6277ef..ffcb2661 100644 --- a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts +++ b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts @@ -11,6 +11,7 @@ import { import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities'; import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability'; import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest'; +import type { TeamProvisioningSupportDiagnostic } from '@shared/types/team'; export type OpenCodeTeamLaunchReadinessState = | 'ready' @@ -57,6 +58,7 @@ export interface OpenCodeTeamLaunchReadiness { supportLevel: OpenCodeSupportLevel | null; missing: string[]; diagnostics: string[]; + supportDiagnostics?: TeamProvisioningSupportDiagnostic[]; evidence: { capabilitiesReady: boolean; mcpToolProofRoute: OpenCodeMcpToolProof['route']; diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index de4a649a..8fcbf273 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -1,12 +1,14 @@ import { randomUUID } from 'crypto'; import type { + OpenCodeAnswerPermissionCommandBody, OpenCodeBridgeRuntimeSnapshot, OpenCodeLaunchTeamCommandBody, OpenCodeLaunchTeamCommandData, OpenCodeObserveMessageDeliveryCommandBody, OpenCodeObserveMessageDeliveryCommandData, OpenCodeReconcileTeamCommandBody, + OpenCodeRuntimePermissionCommandData, OpenCodeSendMessageCommandBody, OpenCodeSendMessageCommandData, OpenCodeStopTeamCommandBody, @@ -20,6 +22,8 @@ import type { TeamRuntimeLaunchResult, TeamRuntimeMemberLaunchEvidence, TeamRuntimeMemberStopEvidence, + TeamRuntimePendingPermission, + TeamRuntimePermissionAnswerInput, TeamRuntimePrepareResult, TeamRuntimeReconcileInput, TeamRuntimeReconcileResult, @@ -52,6 +56,9 @@ export interface OpenCodeTeamRuntimeBridgePort { observeOpenCodeTeamMessageDelivery?( input: OpenCodeObserveMessageDeliveryCommandBody ): Promise; + answerOpenCodeRuntimePermission?( + input: OpenCodeAnswerPermissionCommandBody + ): Promise; } export interface OpenCodeTeamRuntimeMessageInput { @@ -199,6 +206,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { retryable: isRetryableReadinessState(readiness.state), diagnostics: mergeDiagnostics(readiness.diagnostics, readiness.missing), warnings: [], + ...(readiness.supportDiagnostics?.length + ? { supportDiagnostics: [...readiness.supportDiagnostics] } + : {}), }; } @@ -208,6 +218,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { modelId: readiness.modelId, diagnostics: readiness.diagnostics, warnings: [], + ...(readiness.supportDiagnostics?.length + ? { supportDiagnostics: [...readiness.supportDiagnostics] } + : {}), }; } @@ -290,6 +303,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { teamName: input.teamName, projectPath: input.cwd, selectedModel: model, + skipPermissions: input.skipPermissions, members: input.expectedMembers.map((member) => ({ name: member.name, role: member.role?.trim() || member.workflow?.trim() || 'teammate', @@ -547,6 +561,42 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { }; } + async answerRuntimePermission( + input: TeamRuntimePermissionAnswerInput + ): Promise { + if (!this.bridge.answerOpenCodeRuntimePermission) { + throw new Error('OpenCode permission answer bridge is not registered.'); + } + + const data = await this.bridge.answerOpenCodeRuntimePermission({ + runId: input.runId, + laneId: input.laneId?.trim() || 'primary', + teamId: input.teamName, + teamName: input.teamName, + projectPath: input.cwd, + memberName: input.memberName, + requestId: input.requestId, + decision: input.decision, + expectedCapabilitySnapshotId: null, + manifestHighWatermark: null, + }); + + return mapOpenCodeLaunchDataToRuntimeResult( + { + runId: input.runId, + teamName: input.teamName, + laneId: input.laneId, + cwd: input.cwd, + providerId: this.providerId, + skipPermissions: false, + expectedMembers: input.expectedMembers, + previousLaunchState: input.previousLaunchState, + }, + data, + [] + ); + } + async stop(input: TeamRuntimeStopInput): Promise { if (this.bridge.stopOpenCodeTeam) { const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName); @@ -701,6 +751,7 @@ function mapOpenCodeLaunchDataToRuntimeResult( bridgeMember?.model, bridgeMember?.runtimePid, bridgeMember?.pendingPermissionRequestIds, + bridgeMember?.pendingPermissions, bridgeMember != null, memberDiagnostics, input.runId, @@ -798,6 +849,33 @@ function normalizeAppManagedBootstrapCandidate( }; } +function normalizeOpenCodeRuntimePendingPermissions( + permissions: OpenCodeRuntimePermissionCommandData[] | undefined +): TeamRuntimePendingPermission[] | undefined { + if (!permissions?.length) { + return undefined; + } + const normalized: TeamRuntimePendingPermission[] = []; + const seen = new Set(); + for (const permission of permissions) { + const requestId = permission.requestId?.trim(); + if (!requestId || seen.has(requestId)) { + continue; + } + seen.add(requestId); + normalized.push({ + providerId: 'opencode', + requestId, + sessionId: permission.sessionId ?? null, + tool: permission.tool ?? null, + title: permission.title ?? null, + kind: permission.kind ?? null, + ...(permission.raw ? { raw: permission.raw } : {}), + }); + } + return normalized.length > 0 ? normalized : undefined; +} + function mapBridgeMemberToRuntimeEvidence( memberName: string, launchState: OpenCodeTeamMemberLaunchBridgeState, @@ -805,6 +883,7 @@ function mapBridgeMemberToRuntimeEvidence( model: string | undefined, runtimePid: number | undefined, pendingPermissionRequestIds: string[] | undefined, + pendingPermissions: OpenCodeRuntimePermissionCommandData[] | undefined, runtimeMaterialized: boolean, diagnostics: string[], runId: string, @@ -863,6 +942,7 @@ function mapBridgeMemberToRuntimeEvidence( : pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized ? 'warning' : undefined; + const normalizedPendingApprovals = normalizeOpenCodeRuntimePendingPermissions(pendingPermissions); return { memberName, providerId: 'opencode', @@ -887,6 +967,8 @@ function mapBridgeMemberToRuntimeEvidence( pendingPermissionRequestIds && pendingPermissionRequestIds.length > 0 ? [...new Set(pendingPermissionRequestIds)] : undefined, + pendingApprovals: normalizedPendingApprovals, + pendingPermissions: normalizedPendingApprovals, sessionId, ...(appManagedCandidatePresent ? { bootstrapEvidenceSource: 'app_managed_bootstrap' as const } diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index 808e7ce8..22a2d909 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -11,6 +11,7 @@ import type { TeamAgentRuntimeLivenessKind, TeamAgentRuntimePidSource, TeamLaunchAggregateState, + TeamProvisioningSupportDiagnostic, } from '@shared/types'; export const TEAM_RUNTIME_PROVIDER_IDS = ['anthropic', 'codex', 'gemini', 'opencode'] as const; @@ -28,6 +29,33 @@ export interface TeamRuntimeMemberSpec { cwd: string; } +export type TeamRuntimeApprovalProviderId = 'anthropic' | 'opencode' | 'codex'; + +export interface TeamRuntimePendingApproval { + providerId: Extract; + requestId: string; + sessionId?: string | null; + tool?: string | null; + title?: string | null; + kind?: string | null; + raw?: Record; +} + +export type TeamRuntimePendingPermission = TeamRuntimePendingApproval; + +export interface TeamRuntimePermissionAnswerInput { + runId: string; + teamName: string; + laneId?: string; + cwd: string; + providerId: Extract; + memberName: string; + requestId: string; + decision: 'allow' | 'reject'; + expectedMembers: TeamRuntimeMemberSpec[]; + previousLaunchState: PersistedTeamLaunchSnapshot | null; +} + export interface TeamRuntimeLaunchInput { runId: string; teamName: string; @@ -58,6 +86,7 @@ export interface TeamRuntimePrepareSuccess { modelId: string | null; diagnostics: string[]; warnings: string[]; + supportDiagnostics?: TeamProvisioningSupportDiagnostic[]; } export interface TeamRuntimePrepareFailure { @@ -67,6 +96,7 @@ export interface TeamRuntimePrepareFailure { diagnostics: string[]; warnings: string[]; retryable: boolean; + supportDiagnostics?: TeamProvisioningSupportDiagnostic[]; } export type TeamRuntimePrepareResult = TeamRuntimePrepareSuccess | TeamRuntimePrepareFailure; @@ -82,6 +112,8 @@ export interface TeamRuntimeMemberLaunchEvidence { hardFailure: boolean; hardFailureReason?: string; pendingPermissionRequestIds?: string[]; + pendingApprovals?: TeamRuntimePendingApproval[]; + pendingPermissions?: TeamRuntimePendingApproval[]; sessionId?: string; bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource; bootstrapMode?: OpenCodeBootstrapMode; @@ -171,6 +203,9 @@ export interface TeamLaunchRuntimeAdapter { launch(input: TeamRuntimeLaunchInput): Promise; reconcile(input: TeamRuntimeReconcileInput): Promise; stop(input: TeamRuntimeStopInput): Promise; + answerRuntimePermission?( + input: TeamRuntimePermissionAnswerInput + ): Promise; } export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId { diff --git a/src/main/services/team/runtime/index.ts b/src/main/services/team/runtime/index.ts index bd6de5b5..bbf41d8f 100644 --- a/src/main/services/team/runtime/index.ts +++ b/src/main/services/team/runtime/index.ts @@ -6,11 +6,14 @@ export type { export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter'; export type { TeamLaunchRuntimeAdapter, + TeamRuntimeApprovalProviderId, TeamRuntimeLaunchInput, TeamRuntimeLaunchResult, TeamRuntimeMemberLaunchEvidence, TeamRuntimeMemberSpec, TeamRuntimeMemberStopEvidence, + TeamRuntimePendingApproval, + TeamRuntimePendingPermission, TeamRuntimePrepareFailure, TeamRuntimePrepareResult, TeamRuntimePrepareSuccess, diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 808ce29f..b57abe01 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -1082,6 +1082,7 @@ export const CreateTeamDialog = ({ status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking', backendSummary: plan.backendSummary, details: plan.cachedSnapshot.details, + supportDiagnostics: undefined, }); prepareWarningsByProviderIdRef.current.delete(plan.providerId); } @@ -1150,6 +1151,7 @@ export const CreateTeamDialog = ({ status, backendSummary: plan.backendSummary, details, + supportDiagnostics: undefined, } ); commitChecks(nextChecks); @@ -1181,6 +1183,7 @@ export const CreateTeamDialog = ({ status: prepResult.status, backendSummary: plan.backendSummary, details: prepResult.details, + supportDiagnostics: prepResult.supportDiagnostics, }); commitChecks(nextChecks); applyPrepareOutcome(nextChecks, loadingMessage); @@ -1194,6 +1197,7 @@ export const CreateTeamDialog = ({ status: 'failed', backendSummary: plan.backendSummary, details: [failureMessage], + supportDiagnostics: undefined, }); prepareWarningsByProviderIdRef.current.delete(plan.providerId); commitChecks(nextChecks); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index be8a75e7..4b693263 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -1674,6 +1674,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking', backendSummary: plan.backendSummary, details: plan.cachedSnapshot.details, + supportDiagnostics: undefined, }); prepareWarningsByProviderIdRef.current.delete(plan.providerId); } @@ -1722,6 +1723,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen status, backendSummary: plan.backendSummary, details, + supportDiagnostics: undefined, }); commitChecks(nextChecks); applyPrepareOutcome(nextChecks, loadingMessage); @@ -1752,6 +1754,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen status: prepResult.status, backendSummary: plan.backendSummary, details: prepResult.details, + supportDiagnostics: prepResult.supportDiagnostics, }); commitChecks(nextChecks); applyPrepareOutcome(nextChecks, loadingMessage); @@ -1765,6 +1768,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen status: 'failed', backendSummary: plan.backendSummary, details: [failureMessage], + supportDiagnostics: undefined, }); prepareWarningsByProviderIdRef.current.delete(plan.providerId); commitChecks(nextChecks); diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts new file mode 100644 index 00000000..b6d36814 --- /dev/null +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -0,0 +1,152 @@ +import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied'; +import { describe, expect, it } from 'vitest'; + +import { getProvisioningFailureHint } from './ProvisioningProviderStatusList'; + +describe('getProvisioningFailureHint', () => { + it('returns the OpenCode Windows permissions hint for OpenCode access-denied details', () => { + expect( + getProvisioningFailureHint(null, [ + { + providerId: 'opencode', + status: 'failed', + details: [OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE], + }, + ]) + ).toBe( + 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.' + ); + }); + + it('keeps non-OpenCode access-denied details on the generic CLI hint', () => { + expect( + getProvisioningFailureHint(null, [ + { + providerId: 'anthropic', + status: 'failed', + details: ['EACCES: permission denied'], + }, + ]) + ).toBe( + 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.' + ); + }); + + it('does not treat a mixed-provider generic access-denied message as OpenCode-specific', () => { + expect( + getProvisioningFailureHint('EACCES: permission denied', [ + { + providerId: 'opencode', + status: 'ready', + details: [], + }, + { + providerId: 'anthropic', + status: 'failed', + details: ['EACCES: permission denied'], + }, + ]) + ).toBe( + 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.' + ); + }); + + it('does not prefer OpenCode access-denied notes over another provider failure', () => { + expect( + getProvisioningFailureHint(null, [ + { + providerId: 'opencode', + status: 'notes', + details: [OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE], + }, + { + providerId: 'anthropic', + status: 'failed', + details: ['EACCES: permission denied'], + }, + ]) + ).toBe( + 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.' + ); + }); + + it('uses a normalized OpenCode access-denied message for a failed mixed-provider check', () => { + expect( + getProvisioningFailureHint(OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE, [ + { + providerId: 'opencode', + status: 'failed', + details: [], + }, + { + providerId: 'anthropic', + status: 'ready', + details: [], + }, + ]) + ).toBe( + 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.' + ); + }); + + it('uses a raw OpenCode access-denied message when no other provider failed', () => { + expect( + getProvisioningFailureHint('EPERM: operation not permitted', [ + { + providerId: 'opencode', + status: 'failed', + details: [], + }, + { + providerId: 'anthropic', + status: 'ready', + details: [], + }, + ]) + ).toBe( + 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.' + ); + }); + + it('uses the OpenCode Windows permissions hint for a single OpenCode access-denied message', () => { + expect( + getProvisioningFailureHint('EPERM: operation not permitted', [ + { + providerId: 'opencode', + status: 'failed', + details: [], + }, + ]) + ).toBe( + 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.' + ); + }); + + it('keeps existing OpenCode runtime missing and MCP hints unchanged', () => { + expect( + getProvisioningFailureHint(null, [ + { + providerId: 'opencode', + status: 'failed', + details: [ + 'OpenCode runtime binary is not installed or not reachable by launch preflight.', + ], + }, + ]) + ).toBe( + 'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.' + ); + + expect( + getProvisioningFailureHint(null, [ + { + providerId: 'opencode', + status: 'failed', + details: ['OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.'], + }, + ]) + ).toBe( + 'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.' + ); + }); +}); diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index 84eb0958..dcda6c15 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -2,9 +2,17 @@ import React from 'react'; import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; -import { AlertTriangle, CheckCircle2, Loader2, SlidersHorizontal } from 'lucide-react'; +import { + isOpenCodeWindowsAccessDeniedDiagnostic, + OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE, +} from '@shared/utils/openCodeWindowsAccessDenied'; +import { AlertTriangle, Check, CheckCircle2, Copy, Loader2, SlidersHorizontal } from 'lucide-react'; -import type { CliProviderStatus, TeamProviderId } from '@shared/types'; +import type { + CliProviderStatus, + TeamProviderId, + TeamProvisioningSupportDiagnostic, +} from '@shared/types'; export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed'; export type ProvisioningPrepareState = 'idle' | 'loading' | 'ready' | 'failed'; @@ -14,6 +22,7 @@ export interface ProvisioningProviderCheck { status: ProvisioningProviderCheckStatus; backendSummary?: string | null; details: string[]; + supportDiagnostics?: TeamProvisioningSupportDiagnostic[]; } export function getProvisioningProviderLabel(providerId: TeamProviderId): string { @@ -151,6 +160,8 @@ export function getProvisioningProviderProgressMessage( type ProvisioningDetailSummary = | 'CLI binary missing' | 'OpenCode runtime missing' + | 'OpenCode Windows access blocked' + | 'OpenCode runtime check returned no output' | 'OpenCode app MCP unreachable' | 'Working directory missing' | 'CLI binary could not be started' @@ -174,6 +185,16 @@ function isSelectedModelDetail(lower: string): boolean { return lower.includes('selected model'); } +function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean { + const lower = value?.trim().toLowerCase() ?? ''; + return ( + lower.includes('opencode runtime check returned no output') || + lower.includes('bridge stdout was empty') || + lower.includes('opencode_bridge_contract_violation') || + (lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation')) + ); +} + function isFormattedModelDetail(lower: string): boolean { return ( lower.includes(' - checking...') || @@ -219,10 +240,17 @@ function getStatusLabel(status: ProvisioningProviderCheckStatus): string { function summarizeDetail( detail: string, - status: ProvisioningProviderCheckStatus + status: ProvisioningProviderCheckStatus, + providerId?: TeamProviderId ): ProvisioningDetailSummary | null { const lower = detail.toLowerCase(); + if (providerId === 'opencode' && isOpenCodeWindowsAccessDeniedDiagnostic(detail)) { + return 'OpenCode Windows access blocked'; + } + if (providerId === 'opencode' && isOpenCodeBridgeNoOutputDiagnostic(detail)) { + return 'OpenCode runtime check returned no output'; + } if (lower.includes('spawn ') && lower.includes(' enoent')) { return 'CLI binary missing'; } @@ -449,13 +477,15 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string { } const summarizedDetails = publicDetails - .map((detail) => summarizeDetail(detail, check.status)) + .map((detail) => summarizeDetail(detail, check.status, check.providerId)) .filter((detail): detail is ProvisioningDetailSummary => Boolean(detail)); const summary = check.status === 'failed' ? (summarizedDetails.find( (detail) => + detail === 'OpenCode Windows access blocked' || + detail === 'OpenCode runtime check returned no output' || detail === 'OpenCode app MCP unreachable' || detail === 'OpenCode runtime missing' || detail === 'Selected model unavailable' || @@ -472,9 +502,10 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string { function getDetailTone( detail: string, - status: ProvisioningProviderCheckStatus + status: ProvisioningProviderCheckStatus, + providerId?: TeamProviderId ): 'success' | 'failure' | 'checking' | 'neutral' { - const summary = summarizeDetail(detail, status); + const summary = summarizeDetail(detail, status, providerId); if ( summary === 'Selected model verified' || summary === 'Selected model available' || @@ -493,6 +524,8 @@ function getDetailTone( summary === 'Selected model check failed' || summary === 'CLI binary missing' || summary === 'OpenCode runtime missing' || + summary === 'OpenCode Windows access blocked' || + summary === 'OpenCode runtime check returned no output' || summary === 'OpenCode app MCP unreachable' || summary === 'Working directory missing' || summary === 'CLI binary could not be started' || @@ -510,8 +543,12 @@ function getDetailTone( return 'neutral'; } -function getDetailColorClass(detail: string, status: ProvisioningProviderCheckStatus): string { - switch (getDetailTone(detail, status)) { +function getDetailColorClass( + detail: string, + status: ProvisioningProviderCheckStatus, + providerId?: TeamProviderId +): string { + switch (getDetailTone(detail, status, providerId)) { case 'success': return 'text-emerald-400'; case 'failure': @@ -551,14 +588,14 @@ export function getPrimaryProvisioningFailureDetail( const publicDetails = getPublicProvisioningDetails(check.details); const preferredFailure = publicDetails.find( - (detail) => getDetailTone(detail, check.status) === 'failure' + (detail) => getDetailTone(detail, check.status, check.providerId) === 'failure' ); if (preferredFailure) { return preferredFailure; } const nonSuccessDetail = publicDetails.find( - (detail) => getDetailTone(detail, check.status) !== 'success' + (detail) => getDetailTone(detail, check.status, check.providerId) !== 'success' ); if (nonSuccessDetail) { return nonSuccessDetail; @@ -715,6 +752,16 @@ function getProvisioningProviderSettingsActionLabel( : null; } +function getSupportDiagnosticsPayload(check: ProvisioningProviderCheck): string | null { + if (check.providerId !== 'opencode') { + return null; + } + const payloads = (check.supportDiagnostics ?? []) + .map((diagnostic) => diagnostic.copyText.trim()) + .filter(Boolean); + return payloads.length > 0 ? payloads.join('\n\n---\n\n') : null; +} + export const ProvisioningProviderStatusList = ({ checks, className = '', @@ -726,10 +773,29 @@ export const ProvisioningProviderStatusList = ({ suppressDetailsMatching?: string | null; onOpenProviderSettings?: (providerId: TeamProviderId) => void; }): React.JSX.Element | null => { + const [copiedDiagnosticsKey, setCopiedDiagnosticsKey] = React.useState(null); + if (checks.length === 0) { return null; } + const copySupportDiagnostics = async (copyKey: string, payload: string): Promise => { + try { + const writeText = globalThis.navigator?.clipboard?.writeText; + if (typeof writeText !== 'function') { + setCopiedDiagnosticsKey(null); + return; + } + await writeText.call(globalThis.navigator.clipboard, payload); + setCopiedDiagnosticsKey(copyKey); + globalThis.setTimeout(() => { + setCopiedDiagnosticsKey((currentKey) => (currentKey === copyKey ? null : currentKey)); + }, 1500); + } catch { + setCopiedDiagnosticsKey(null); + } + }; + return (
{checks.map((check) => { @@ -740,6 +806,12 @@ export const ProvisioningProviderStatusList = ({ const settingsActionLabel = onOpenProviderSettings ? getProvisioningProviderSettingsActionLabel(check) : null; + const supportDiagnosticsPayload = getSupportDiagnosticsPayload(check); + const supportDiagnosticsKey = + supportDiagnosticsPayload && check.supportDiagnostics?.[0] + ? `${check.providerId}:${check.supportDiagnostics[0].id}` + : check.providerId; + const copiedDiagnostics = copiedDiagnosticsKey === supportDiagnosticsKey; return (
@@ -758,7 +830,11 @@ export const ProvisioningProviderStatusList = ({ {visibleDetails.map((detail, index) => (

{detail}

@@ -781,6 +857,24 @@ export const ProvisioningProviderStatusList = ({
) : null} + {supportDiagnosticsPayload ? ( +
+ +
+ ) : null}
); })} @@ -792,6 +886,34 @@ export function getProvisioningFailureHint( message: string | null | undefined, checks: ProvisioningProviderCheck[] ): string { + const failedOpenCodeChecks = checks.filter( + (check) => check.providerId === 'opencode' && check.status === 'failed' + ); + const hasFailedNonOpenCodeCheck = checks.some( + (check) => check.providerId !== 'opencode' && check.status === 'failed' + ); + const hasOpenCodeAccessDeniedDetail = failedOpenCodeChecks.some((check) => + check.details.some(isOpenCodeWindowsAccessDeniedDiagnostic) + ); + const hasOpenCodeBridgeNoOutputDetail = failedOpenCodeChecks.some((check) => + check.details.some(isOpenCodeBridgeNoOutputDiagnostic) + ); + const normalizedMessage = message?.trim() ?? ''; + const hasOpenCodeAccessDeniedMessage = + failedOpenCodeChecks.length > 0 && + (normalizedMessage === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE || + (!hasFailedNonOpenCodeCheck && isOpenCodeWindowsAccessDeniedDiagnostic(normalizedMessage))); + if (hasOpenCodeAccessDeniedDetail || hasOpenCodeAccessDeniedMessage) { + return 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'; + } + const hasOpenCodeBridgeNoOutputMessage = + failedOpenCodeChecks.length > 0 && + !hasFailedNonOpenCodeCheck && + isOpenCodeBridgeNoOutputDiagnostic(normalizedMessage); + if (hasOpenCodeBridgeNoOutputDetail || hasOpenCodeBridgeNoOutputMessage) { + return 'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.'; + } + const combined = [message ?? '', ...checks.flatMap((check) => check.details)] .join('\n') .toLowerCase(); diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts new file mode 100644 index 00000000..45592518 --- /dev/null +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -0,0 +1,75 @@ +import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied'; +import { describe, expect, it } from 'vitest'; + +import { runProviderPrepareDiagnostics } from './providerPrepareDiagnostics'; + +import type { TeamProvisioningPrepareResult } from '@shared/types'; + +describe('runProviderPrepareDiagnostics', () => { + it('normalizes OpenCode access-denied provider failures', async () => { + const result = await runProviderPrepareDiagnostics({ + cwd: 'C:\\Program Files\\locked-project', + providerId: 'opencode', + selectedModelIds: [], + prepareProvisioning: async (): Promise => ({ + ready: false, + message: 'OpenCode bridge failed: EPERM: operation not permitted, mkdir C:\\Program Files', + }), + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]); + }); + + it('keeps non-OpenCode access-denied provider failures generic', async () => { + const detail = 'EACCES: permission denied, open C:\\work\\repo'; + const result = await runProviderPrepareDiagnostics({ + cwd: 'C:\\work\\repo', + providerId: 'anthropic', + selectedModelIds: [], + prepareProvisioning: async (): Promise => ({ + ready: false, + message: detail, + }), + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([detail]); + }); + + it('normalizes OpenCode access-denied runtime note details', async () => { + const result = await runProviderPrepareDiagnostics({ + cwd: 'C:\\Program Files\\locked-project', + providerId: 'opencode', + selectedModelIds: [], + prepareProvisioning: async (): Promise => ({ + ready: true, + message: '', + warnings: ['EACCES: permission denied, open C:\\Program Files\\locked-project'], + }), + }); + + expect(result.status).toBe('notes'); + expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]); + expect(result.warnings).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]); + }); + + it('treats model-scoped OpenCode access-denied details as provider failures', async () => { + const result = await runProviderPrepareDiagnostics({ + cwd: 'C:\\Program Files\\locked-project', + providerId: 'opencode', + selectedModelIds: ['opencode/big-pickle'], + prepareProvisioning: async (): Promise => ({ + ready: false, + message: 'Selected model opencode/big-pickle is unavailable.', + details: [ + 'Selected model opencode/big-pickle is unavailable. EPERM: operation not permitted', + ], + }), + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]); + expect(result.modelResultsById).toEqual({}); + }); +}); diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts index 55cd595e..7a44f1c2 100644 --- a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts @@ -1,4 +1,8 @@ import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; +import { + isOpenCodeWindowsAccessDeniedDiagnostic, + normalizeOpenCodeWindowsAccessDeniedDiagnostic, +} from '@shared/utils/openCodeWindowsAccessDenied'; import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; import type { @@ -6,6 +10,7 @@ import type { TeamProvisioningModelCheckRequest, TeamProvisioningModelVerificationMode, TeamProvisioningPrepareResult, + TeamProvisioningSupportDiagnostic, } from '@shared/types'; export type ProviderPrepareCheckStatus = 'ready' | 'notes' | 'failed'; @@ -45,6 +50,7 @@ export interface ProviderPrepareDiagnosticsResult { details: string[]; warnings: string[]; modelResultsById: Record; + supportDiagnostics?: TeamProvisioningSupportDiagnostic[]; } type TeamProvisioningPrepareIssue = NonNullable[number]; @@ -93,6 +99,45 @@ function uniquePrepareLines(lines: (string | null | undefined)[]): string[] { return uniqueLines; } +function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean { + const lower = value?.trim().toLowerCase() ?? ''; + return ( + lower.includes('opencode runtime check returned no output') || + lower.includes('bridge stdout was empty') || + lower.includes('opencode_bridge_contract_violation') || + (lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation')) + ); +} + +function cloneSupportDiagnostics( + diagnostics: readonly TeamProvisioningSupportDiagnostic[] | undefined +): TeamProvisioningSupportDiagnostic[] { + return (diagnostics ?? []).map((diagnostic) => ({ ...diagnostic })); +} + +function mergeSupportDiagnostics( + target: TeamProvisioningSupportDiagnostic[], + incoming: readonly TeamProvisioningSupportDiagnostic[] | undefined +): void { + for (const diagnostic of incoming ?? []) { + if (!target.some((existing) => existing.id === diagnostic.id)) { + target.push({ ...diagnostic }); + } + } +} + +function withSupportDiagnostics( + result: ProviderPrepareDiagnosticsResult, + supportDiagnostics: readonly TeamProvisioningSupportDiagnostic[] +): ProviderPrepareDiagnosticsResult { + return supportDiagnostics.length > 0 + ? { + ...result, + supportDiagnostics: cloneSupportDiagnostics(supportDiagnostics), + } + : result; +} + function getModelLabel(providerId: TeamProviderId, modelId: string): string { if (isDefaultProviderModelSelection(modelId)) { return 'Default'; @@ -287,8 +332,12 @@ function looksLikeOpenCodeRuntimeFailureReason(reason: string | null | undefined } return ( + isOpenCodeBridgeNoOutputDiagnostic(reason) || + isOpenCodeWindowsAccessDeniedDiagnostic(reason) || lower.includes('opencode /experimental/tool') || lower.includes('/experimental/tool') || + lower.includes('opencode_bridge_contract_violation') || + lower.includes('bridge stdout was empty') || lower.includes('mcp_unavailable') || lower.includes('unable to connect') || lower.includes('runtime store') || @@ -339,7 +388,9 @@ function isAdvisoryOpenCodeDeepVerificationIssue( lower.includes('/experimental/tool') || lower.includes('runtime store') || lower.includes('opencode cli') || - lower.includes('opencode runtime binary'); + lower.includes('opencode runtime binary') || + isOpenCodeBridgeNoOutputDiagnostic(lower) || + isOpenCodeWindowsAccessDeniedDiagnostic(lower); if (hasHardRuntimeMarker) { return false; } @@ -476,27 +527,48 @@ function buildModelVerificationDeferredLine( : `${label} - verification deferred`; } -function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] { - return uniquePrepareLines([...(result.details ?? []), ...(result.warnings ?? [])]); +function createRuntimeDetailLines( + result: TeamProvisioningPrepareResult, + providerId: TeamProviderId +): string[] { + return uniquePrepareLines( + [...(result.details ?? []), ...(result.warnings ?? [])] + .map((detail) => normalizeRuntimeDetailLine(detail, providerId)) + .filter(Boolean) + ); } -function createRuntimeWarningLines(result: TeamProvisioningPrepareResult): string[] { +function createRuntimeWarningLines( + result: TeamProvisioningPrepareResult, + providerId: TeamProviderId +): string[] { return uniquePrepareLines( (result.warnings ?? []) - .map((warning) => normalizeRuntimeFailureDetailLine(warning)) + .map((warning) => normalizeRuntimeFailureDetailLine(warning, undefined, providerId)) .filter(Boolean) ); } function normalizeRuntimeFailureDetailLine( detail: string | null | undefined, - code?: string | null + code?: string | null, + providerId?: TeamProviderId ): string | null { const trimmed = detail?.trim(); if (!trimmed) { return null; } + if (providerId === 'opencode') { + if (isOpenCodeBridgeNoOutputDiagnostic(trimmed)) { + return 'OpenCode runtime check returned no output.'; + } + const accessDeniedDiagnostic = normalizeOpenCodeWindowsAccessDeniedDiagnostic(trimmed); + if (accessDeniedDiagnostic) { + return accessDeniedDiagnostic; + } + } + if (/opencode cli (?:not detected on path|not found)/i.test(trimmed)) { return 'OpenCode runtime binary is not installed or not reachable by launch preflight.'; } @@ -518,13 +590,33 @@ function normalizeRuntimeFailureDetailLine( return trimmed; } +function normalizeRuntimeDetailLine( + detail: string | null | undefined, + providerId: TeamProviderId +): string | null { + const trimmed = detail?.trim(); + if (!trimmed) { + return null; + } + + if (providerId !== 'opencode') { + return trimmed; + } + + if (isOpenCodeBridgeNoOutputDiagnostic(trimmed)) { + return 'OpenCode runtime check returned no output.'; + } + return normalizeOpenCodeWindowsAccessDeniedDiagnostic(trimmed) ?? trimmed; +} + function createRuntimeFailureDetailLines( runtimeDetailLines: readonly string[], - message: string | null | undefined + message: string | null | undefined, + providerId: TeamProviderId ): string[] { return uniquePrepareLines( [...runtimeDetailLines, message] - .map((detail) => normalizeRuntimeFailureDetailLine(detail)) + .map((detail) => normalizeRuntimeFailureDetailLine(detail, undefined, providerId)) .filter(Boolean) ); } @@ -957,6 +1049,7 @@ export async function runProviderPrepareDiagnostics({ ); const hasExplicitModelChecks = (selectedModelChecks?.length ?? 0) > 0; const orderedModelIds = Array.from(new Set(normalizedModelChecks.map((check) => check.model))); + const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = []; if (orderedModelIds.length === 0) { const runtimeResult = await prepareProvisioning( cwd, @@ -965,24 +1058,35 @@ export async function runProviderPrepareDiagnostics({ undefined, limitContext ); - const runtimeDetailLines = createRuntimeDetailLines(runtimeResult); - const runtimeWarnings = createRuntimeWarningLines(runtimeResult); + mergeSupportDiagnostics(supportDiagnostics, runtimeResult.supportDiagnostics); + const runtimeDetailLines = createRuntimeDetailLines(runtimeResult, providerId); + const runtimeWarnings = createRuntimeWarningLines(runtimeResult, providerId); if (!runtimeResult.ready) { - return { - status: 'failed', - details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message), - warnings: runtimeWarnings, - modelResultsById: {}, - }; + return withSupportDiagnostics( + { + status: 'failed', + details: createRuntimeFailureDetailLines( + runtimeDetailLines, + runtimeResult.message, + providerId + ), + warnings: runtimeWarnings, + modelResultsById: {}, + }, + supportDiagnostics + ); } - return { - status: runtimeWarnings.length > 0 ? 'notes' : 'ready', - details: runtimeDetailLines, - warnings: runtimeWarnings, - modelResultsById: {}, - }; + return withSupportDiagnostics( + { + status: runtimeWarnings.length > 0 ? 'notes' : 'ready', + details: runtimeDetailLines, + warnings: runtimeWarnings, + modelResultsById: {}, + }, + supportDiagnostics + ); } const reusableModelResultsById = cachedModelResultsById ?? {}; @@ -1047,16 +1151,24 @@ export async function runProviderPrepareDiagnostics({ undefined, limitContext ); - runtimeDetailLines = createRuntimeDetailLines(runtimeResult); - runtimeWarnings = createRuntimeWarningLines(runtimeResult); + mergeSupportDiagnostics(supportDiagnostics, runtimeResult.supportDiagnostics); + runtimeDetailLines = createRuntimeDetailLines(runtimeResult, providerId); + runtimeWarnings = createRuntimeWarningLines(runtimeResult, providerId); if (!runtimeResult.ready) { - return { - status: 'failed', - details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message), - warnings: runtimeWarnings, - modelResultsById: {}, - }; + return withSupportDiagnostics( + { + status: 'failed', + details: createRuntimeFailureDetailLines( + runtimeDetailLines, + runtimeResult.message, + providerId + ), + warnings: runtimeWarnings, + modelResultsById: {}, + }, + supportDiagnostics + ); } } else { const recordTerminalModelResult = ( @@ -1090,10 +1202,11 @@ export async function runProviderPrepareDiagnostics({ ? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)] : []) ); - runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter( + mergeSupportDiagnostics(supportDiagnostics, compatibilityResult.supportDiagnostics); + runtimeDetailLines = createRuntimeDetailLines(compatibilityResult, providerId).filter( (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) ); - runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter( + runtimeWarnings = createRuntimeWarningLines(compatibilityResult, providerId).filter( (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) ); @@ -1116,17 +1229,21 @@ export async function runProviderPrepareDiagnostics({ const structuredProviderScopedFailure = structuredProviderScopedIssue?.message.trim() ?? null; if (structuredProviderScopedFailure || providerScopedFailure) { - return { - status: 'failed', - details: [ - normalizeRuntimeFailureDetailLine( - structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed', - structuredProviderScopedIssue?.code - ) ?? 'OpenCode failed', - ], - warnings: [], - modelResultsById: {}, - }; + return withSupportDiagnostics( + { + status: 'failed', + details: [ + normalizeRuntimeFailureDetailLine( + structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed', + structuredProviderScopedIssue?.code, + providerId + ) ?? 'OpenCode failed', + ], + warnings: [], + modelResultsById: {}, + }, + supportDiagnostics + ); } if ( shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({ @@ -1141,15 +1258,19 @@ export async function runProviderPrepareDiagnostics({ (uncachedModelIds.length > 1 || (!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))) ) { - return { - status: 'failed', - details: createRuntimeFailureDetailLines( - runtimeDetailLines, - compatibilityResult.message - ), - warnings: runtimeWarnings, - modelResultsById: {}, - }; + return withSupportDiagnostics( + { + status: 'failed', + details: createRuntimeFailureDetailLines( + runtimeDetailLines, + compatibilityResult.message, + providerId + ), + warnings: runtimeWarnings, + modelResultsById: {}, + }, + supportDiagnostics + ); } if (!hasModelScopedEntries && uncachedModelIds.length === 1) { runtimeDetailLines = []; @@ -1204,19 +1325,22 @@ export async function runProviderPrepareDiagnostics({ ) ); - return { - status: hasFailure - ? 'failed' - : hasNotes || dedupedWarnings.length > 0 - ? 'notes' - : 'ready', - details: [ - ...filteredRuntime.runtimeDetailLines, - ...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''), - ], - warnings: dedupedWarnings, - modelResultsById: selectedModelResultsById, - }; + return withSupportDiagnostics( + { + status: hasFailure + ? 'failed' + : hasNotes || dedupedWarnings.length > 0 + ? 'notes' + : 'ready', + details: [ + ...filteredRuntime.runtimeDetailLines, + ...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''), + ], + warnings: dedupedWarnings, + modelResultsById: selectedModelResultsById, + }, + supportDiagnostics + ); } try { @@ -1231,10 +1355,11 @@ export async function runProviderPrepareDiagnostics({ ? [selectModelChecksForIds(normalizedModelChecks, compatibilityPassedModelIds)] : []) ); - runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter( + mergeSupportDiagnostics(supportDiagnostics, batchedModelResult.supportDiagnostics); + runtimeDetailLines = createRuntimeDetailLines(batchedModelResult, providerId).filter( (entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry) ); - runtimeWarnings = createRuntimeWarningLines(batchedModelResult).filter( + runtimeWarnings = createRuntimeWarningLines(batchedModelResult, providerId).filter( (entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry) ); @@ -1278,17 +1403,21 @@ export async function runProviderPrepareDiagnostics({ } handledAdvisoryDeepFailure = true; } else { - return { - status: 'failed', - details: [ - normalizeRuntimeFailureDetailLine( - failureReason, - structuredProviderScopedIssue?.code - ) ?? failureReason, - ], - warnings: [], - modelResultsById: {}, - }; + return withSupportDiagnostics( + { + status: 'failed', + details: [ + normalizeRuntimeFailureDetailLine( + failureReason, + structuredProviderScopedIssue?.code, + providerId + ) ?? failureReason, + ], + warnings: [], + modelResultsById: {}, + }, + supportDiagnostics + ); } } if ( @@ -1323,15 +1452,19 @@ export async function runProviderPrepareDiagnostics({ (compatibilityPassedModelIds.length > 1 || (!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)))) ) { - return { - status: 'failed', - details: createRuntimeFailureDetailLines( - runtimeDetailLines, - batchedModelResult.message - ), - warnings: runtimeWarnings, - modelResultsById: {}, - }; + return withSupportDiagnostics( + { + status: 'failed', + details: createRuntimeFailureDetailLines( + runtimeDetailLines, + batchedModelResult.message, + providerId + ), + warnings: runtimeWarnings, + modelResultsById: {}, + }, + supportDiagnostics + ); } if ( !handledAdvisoryDeepFailure && @@ -1385,10 +1518,10 @@ export async function runProviderPrepareDiagnostics({ ? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)] : []) ); - runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter( + runtimeDetailLines = createRuntimeDetailLines(compatibilityResult, providerId).filter( (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) ); - runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter( + runtimeWarnings = createRuntimeWarningLines(compatibilityResult, providerId).filter( (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) ); @@ -1410,7 +1543,8 @@ export async function runProviderPrepareDiagnostics({ status: 'failed', details: createRuntimeFailureDetailLines( runtimeDetailLines, - compatibilityResult.message + compatibilityResult.message, + providerId ), warnings: runtimeWarnings, modelResultsById: {}, @@ -1445,10 +1579,10 @@ export async function runProviderPrepareDiagnostics({ limitContext, 'deep' ); - runtimeDetailLines = createRuntimeDetailLines(deepResult).filter( + runtimeDetailLines = createRuntimeDetailLines(deepResult, providerId).filter( (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) ); - runtimeWarnings = createRuntimeWarningLines(deepResult).filter( + runtimeWarnings = createRuntimeWarningLines(deepResult, providerId).filter( (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) ); if ( @@ -1502,13 +1636,16 @@ export async function runProviderPrepareDiagnostics({ ) ); - return { - status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready', - details: [ - ...filteredRuntime.runtimeDetailLines, - ...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''), - ], - warnings: dedupedWarnings, - modelResultsById: selectedModelResultsById, - }; + return withSupportDiagnostics( + { + status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready', + details: [ + ...filteredRuntime.runtimeDetailLines, + ...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''), + ], + warnings: dedupedWarnings, + modelResultsById: selectedModelResultsById, + }, + supportDiagnostics + ); } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index d6b96e11..8a96ae5d 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1500,12 +1500,24 @@ export interface TeamProvisioningPrepareIssue { message: string; } +export interface TeamProvisioningSupportDiagnostic { + id: string; + providerId: TeamProviderId; + kind: string; + severity: 'info' | 'warning' | 'error'; + title: string; + summary: string; + copyText: string; + createdAt: string; +} + export interface TeamProvisioningPrepareResult { ready: boolean; message: string; details?: string[]; warnings?: string[]; issues?: TeamProvisioningPrepareIssue[]; + supportDiagnostics?: TeamProvisioningSupportDiagnostic[]; } export interface TeamProvisioningProgress { @@ -1771,6 +1783,8 @@ export interface ToolApprovalRequest { /** Run ID — prevents stale approvals after stop→launch race. */ runId: string; teamName: string; + /** Runtime/provider that owns the approval, when it is not the Anthropic CLI control protocol. */ + providerId?: TeamProviderId; /** Which process sent this (e.g. 'lead'). */ source: string; /** Tool name: 'Bash', 'Edit', 'Write', 'Read', etc. */ @@ -1783,6 +1797,14 @@ export interface ToolApprovalRequest { teamColor?: string; /** Team display name (from config or create request). */ teamDisplayName?: string; + /** Provider runtime permission metadata used to answer non-Anthropic approval APIs. */ + runtimePermission?: { + providerId: 'anthropic' | 'opencode' | 'codex'; + laneId: string; + memberName: string; + providerRequestId: string; + sessionId?: string | null; + }; /** Permission suggestions from teammate runtime (only for teammate permission_request). * FACT: Populated by Claude Code runtime, contains instructions to add permission rules. */ @@ -1837,7 +1859,7 @@ export interface ToolApprovalAutoResolved { requestId: string; runId: string; teamName: string; - reason: 'auto_allow_category' | 'timeout_allow' | 'timeout_deny'; + reason: 'auto_allow_category' | 'timeout_allow' | 'timeout_deny' | 'runtime_resolved'; } /** Union of approval events pushed from main to renderer. */ diff --git a/src/shared/utils/__tests__/openCodeWindowsAccessDenied.test.ts b/src/shared/utils/__tests__/openCodeWindowsAccessDenied.test.ts new file mode 100644 index 00000000..b9f37496 --- /dev/null +++ b/src/shared/utils/__tests__/openCodeWindowsAccessDenied.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { + isOpenCodeWindowsAccessDeniedDiagnostic, + normalizeOpenCodeWindowsAccessDeniedDiagnostic, + OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE, +} from '../openCodeWindowsAccessDenied'; + +describe('OpenCode Windows access-denied diagnostics', () => { + it.each([ + 'EPERM: operation not permitted, mkdir C:\\Program Files\\project', + 'EACCES: permission denied, open C:\\work\\repo', + 'Access is denied.', + 'permission denied while opening OpenCode runtime file', + 'operation not permitted while starting OpenCode', + ])('detects %s', (message) => { + expect(isOpenCodeWindowsAccessDeniedDiagnostic(message)).toBe(true); + expect(normalizeOpenCodeWindowsAccessDeniedDiagnostic(message)).toBe( + OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE + ); + }); + + it('does not match unrelated OpenCode diagnostics', () => { + expect(isOpenCodeWindowsAccessDeniedDiagnostic('OpenCode app MCP is unreachable')).toBe(false); + expect(normalizeOpenCodeWindowsAccessDeniedDiagnostic('OpenCode CLI not found')).toBeNull(); + }); +}); diff --git a/src/shared/utils/openCodeWindowsAccessDenied.ts b/src/shared/utils/openCodeWindowsAccessDenied.ts new file mode 100644 index 00000000..e5d6a38c --- /dev/null +++ b/src/shared/utils/openCodeWindowsAccessDenied.ts @@ -0,0 +1,24 @@ +export const OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE = + 'Windows blocked OpenCode from accessing project or runtime files. Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'; + +const OPENCODE_WINDOWS_ACCESS_DENIED_PATTERN = + /\b(?:EPERM|EACCES)\b|access is denied|permission denied|operation not permitted/i; + +export function isOpenCodeWindowsAccessDeniedDiagnostic(value: string | null | undefined): boolean { + const trimmed = value?.trim(); + if (!trimmed) { + return false; + } + return ( + trimmed === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE || + OPENCODE_WINDOWS_ACCESS_DENIED_PATTERN.test(trimmed) + ); +} + +export function normalizeOpenCodeWindowsAccessDeniedDiagnostic( + value: string | null | undefined +): string | null { + return isOpenCodeWindowsAccessDeniedDiagnostic(value) + ? OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE + : null; +} diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index b6e2fef5..9f747bb2 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -1698,8 +1698,7 @@ describe('ClaudeMultimodelBridgeService', () => { plugins: { status: 'unsupported', ownership: 'shared', - reason: - 'Plugins are not currently guaranteed for codex-native sessions in the multimodel runtime.', + reason: 'Plugin support is not yet guaranteed for this agent.', }, mcp: { status: 'unsupported', diff --git a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts index e7b13f6c..e8ef5c10 100644 --- a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts @@ -1,18 +1,18 @@ import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; - import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { OpenCodeBridgeCommandClient, - redactBridgeDiagnosticText, - resolveOpenCodeBridgeProcessCwd, type OpenCodeBridgeDiagnosticsSink, type OpenCodeBridgeProcessRunInput, - type OpenCodeBridgeProcessRunResult, type OpenCodeBridgeProcessRunner, + type OpenCodeBridgeProcessRunResult, + redactBridgeDiagnosticText, + resolveOpenCodeBridgeProcessCwd, } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient'; + import type { OpenCodeBridgeDiagnosticEvent, OpenCodeBridgeSuccess, @@ -188,6 +188,34 @@ describe('OpenCodeBridgeCommandClient', () => { }); }); + it('keeps bridge failures best-effort when the diagnostics sink fails', async () => { + runner.nextResult = { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }; + diagnostics.append.mockRejectedValueOnce(new Error('disk full')); + const client = createClient(); + + await expect( + client.execute( + 'opencode.launchTeam', + { runId: 'run-1' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ) + ).resolves.toMatchObject({ + ok: false, + error: { + kind: 'contract_violation', + message: 'Bridge stdout was empty', + }, + }); + }); + it('turns non-zero process exit into provider_error without parsing stdout', async () => { runner.nextResult = { stdout: `${JSON.stringify(bridgeSuccess())}\n`, @@ -258,6 +286,50 @@ describe('OpenCodeBridgeCommandClient', () => { expect(runner.calls).toHaveLength(2); }); + it('keeps empty readiness stdout diagnostics after the retry is exhausted', async () => { + runner.nextResults = [ + { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }, + { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }, + ]; + const client = createClient(); + + const result = await client.execute( + 'opencode.readiness', + { projectPath: '/tmp/project' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ); + + expect(result).toMatchObject({ + ok: false, + error: { + kind: 'contract_violation', + message: 'Bridge stdout was empty', + details: { + attempts: 2, + stdoutBytes: 0, + stderrBytes: 0, + outputSource: 'none', + outputFileBytes: 0, + outputReadError: 'ENOENT', + }, + }, + }); + expect(runner.calls).toHaveLength(2); + }); + it('does not retry empty stdout for state-changing bridge commands', async () => { runner.nextResults = [ { @@ -289,9 +361,30 @@ describe('OpenCodeBridgeCommandClient', () => { error: { kind: 'contract_violation', message: 'Bridge stdout was empty', + details: { + command: 'opencode.launchTeam', + requestId: 'req-1', + attempts: 1, + exitCode: 0, + timedOut: false, + stdoutBytes: 0, + stderrBytes: 0, + outputSource: 'none', + outputFileBytes: 0, + outputReadError: 'ENOENT', + }, }, }); expect(runner.calls).toHaveLength(1); + expect(diagnostics.append).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'opencode_bridge_contract_violation', + data: expect.objectContaining({ + attempts: 1, + outputReadError: 'ENOENT', + }), + }) + ); }); it('rejects bridge result envelope mismatches before caller can mutate state', async () => { diff --git a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts index f5f56267..c8a7b2c6 100644 --- a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts @@ -9,15 +9,15 @@ import { OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION, OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION, OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION, - parseSingleBridgeJsonResult, - stableHash, - validateBridgeResultEnvelope, - validateOpenCodeBridgeHandshake, type OpenCodeBridgeCommandEnvelope, type OpenCodeBridgeHandshake, type OpenCodeBridgePeerIdentity, type OpenCodeBridgeRuntimeSnapshot, type OpenCodeBridgeSuccess, + parseSingleBridgeJsonResult, + stableHash, + validateBridgeResultEnvelope, + validateOpenCodeBridgeHandshake, } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract'; describe('OpenCodeBridgeCommandContract', () => { @@ -135,6 +135,51 @@ describe('OpenCodeBridgeCommandContract', () => { ).toThrow('OpenCode bridge capability snapshot mismatch'); }); + it('allows state mutation for a launch capability recovery result', () => { + const result = bridgeSuccess({ + runtime: { capabilitySnapshotId: 'new-snapshot' }, + data: { + runId: 'run-1', + idempotencyKey: 'key-1', + runtimeStoreManifestHighWatermark: 10, + diagnostics: [ + { + code: 'opencode_capability_snapshot_recovery', + severity: 'warning', + message: 'Accepted fresh OpenCode capability snapshot after app recovery attempt.', + }, + ], + }, + }); + + expect(() => + assertBridgeResultCanMutateState(result, { + requestId: 'req-1', + command: 'opencode.launchTeam', + runId: 'run-1', + capabilitySnapshotId: 'old-snapshot', + allowCapabilitySnapshotRecovery: true, + }) + ).not.toThrow(); + }); + + it('does not allow capability recovery without orchestrator recovery evidence', () => { + const result = bridgeSuccess({ + runtime: { capabilitySnapshotId: 'new-snapshot' }, + data: { runId: 'run-1' }, + }); + + expect(() => + assertBridgeResultCanMutateState(result, { + requestId: 'req-1', + command: 'opencode.launchTeam', + runId: 'run-1', + capabilitySnapshotId: 'old-snapshot', + allowCapabilitySnapshotRecovery: true, + }) + ).toThrow('OpenCode bridge capability snapshot mismatch'); + }); + it('allows state mutation when caller has no capability snapshot evidence to compare', () => { const result = bridgeSuccess({ runtime: { capabilitySnapshotId: 'runtime-snapshot' }, diff --git a/test/main/services/team/OpenCodeBridgeDiagnosticsStore.test.ts b/test/main/services/team/OpenCodeBridgeDiagnosticsStore.test.ts new file mode 100644 index 00000000..ce213140 --- /dev/null +++ b/test/main/services/team/OpenCodeBridgeDiagnosticsStore.test.ts @@ -0,0 +1,79 @@ +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { OpenCodeBridgeDiagnosticsStore } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore'; + +let tempDir: string; + +describe('OpenCodeBridgeDiagnosticsStore', () => { + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-bridge-diagnostics-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('persists capped redacted bridge diagnostic metadata', async () => { + const store = new OpenCodeBridgeDiagnosticsStore({ + directory: tempDir, + maxEventsBytes: 512, + }); + + await store.append({ + id: 'diag-1', + type: 'opencode_bridge_contract_violation', + providerId: 'opencode', + severity: 'error', + message: 'Bridge stdout was empty', + data: { + stderrPreview: 'token=secret Authorization: Bearer live-token', + stdout: 'raw stdout should not be stored', + inputPreview: 'x'.repeat(5_000), + }, + createdAt: '2026-04-21T12:00:00.000Z', + }); + + const latest = await fs.readFile(path.join(tempDir, 'latest.json'), 'utf8'); + const events = await fs.readFile(path.join(tempDir, 'events.ndjson'), 'utf8'); + + expect(latest).toContain('token=[redacted]'); + expect(latest).toContain('Authorization: Bearer [redacted]'); + expect(latest).toContain('[truncated]'); + expect(events).toContain('opencode_bridge_contract_violation'); + expect(latest).not.toContain('secret'); + expect(events).not.toContain('live-token'); + expect(latest).toContain('"stdout": "[omitted]"'); + expect(latest).not.toContain('raw stdout should not be stored'); + }); + + it('rotates events as complete ndjson lines', async () => { + const store = new OpenCodeBridgeDiagnosticsStore({ + directory: tempDir, + maxEventsBytes: 120, + }); + + for (let index = 0; index < 4; index += 1) { + await store.append({ + id: `diag-${index}`, + type: 'opencode_bridge_contract_violation', + providerId: 'opencode', + severity: 'error', + message: `Bridge stdout was empty ${index}`, + data: { index }, + createdAt: '2026-04-21T12:00:00.000Z', + }); + } + + const lines = (await fs.readFile(path.join(tempDir, 'events.ndjson'), 'utf8')) + .split('\n') + .filter(Boolean); + + expect(lines.some((line) => line.includes('opencode_bridge_diagnostics_truncated'))).toBe( + true + ); + expect(() => lines.map((line) => JSON.parse(line))).not.toThrow(); + }); +}); diff --git a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts index e411fced..9fd1b633 100644 --- a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts +++ b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts @@ -77,6 +77,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'), AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, + ...mcpLaunchSpec.env, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), @@ -189,6 +190,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data-multi'), AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, + ...mcpLaunchSpec.env, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index dae44327..61a24612 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -8,15 +8,15 @@ import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; -import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness'; import type { - OpenCodeBridgeFailureKind, OpenCodeBridgeCommandName, + OpenCodeBridgeFailureKind, OpenCodeBridgeResult, OpenCodeBridgeSuccess, OpenCodeLaunchTeamCommandData, OpenCodeSendMessageCommandData, } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract'; +import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness'; describe('OpenCodeReadinessBridge', () => { it('executes the read-only opencode.readiness command and returns readiness data', async () => { @@ -86,6 +86,70 @@ describe('OpenCodeReadinessBridge', () => { expect(bridge.getLastOpenCodeRuntimeSnapshot('/repo')).toBeNull(); }); + it('adds copyable support diagnostics for bridge no-output contract failures', async () => { + const executor = fakeExecutor( + bridgeFailure( + 'contract_violation', + 'Bridge stdout was empty', + [ + { + id: 'diag-empty-stdout', + type: 'opencode_bridge_contract_violation', + providerId: 'opencode', + severity: 'error', + message: 'Bridge stdout was empty', + data: { + command: 'opencode.readiness', + requestId: 'req-1', + attempts: 2, + exitCode: 0, + timedOut: false, + stdoutBytes: 0, + stderrBytes: 27, + outputSource: 'none', + outputFileBytes: 0, + outputReadError: 'ENOENT', + stderrPreview: 'token=secret', + }, + createdAt: '2026-04-21T12:00:00.000Z', + }, + ], + { + attempts: 2, + outputReadError: 'ENOENT', + } + ) + ); + const bridge = new OpenCodeReadinessBridge(executor, { + appVersion: '1.3.0-test', + }); + + const result = await bridge.checkOpenCodeTeamLaunchReadiness({ + projectPath: 'D:\\project\\03_codex', + selectedModel: 'qwen3.6-2b', + requireExecutionProbe: false, + }); + + expect(result.supportDiagnostics).toEqual([ + expect.objectContaining({ + id: 'diag-empty-stdout', + providerId: 'opencode', + kind: 'opencode_bridge_no_output', + severity: 'error', + title: 'OpenCode runtime check returned no output', + summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.', + }), + ]); + expect(result.supportDiagnostics?.[0]?.copyText).toContain( + 'Agent Teams OpenCode diagnostics' + ); + expect(result.supportDiagnostics?.[0]?.copyText).toContain('outputReadError: ENOENT'); + expect(result.supportDiagnostics?.[0]?.copyText).toContain('appVersion: 1.3.0-test'); + expect(result.supportDiagnostics?.[0]?.copyText).toContain('selectedModel: qwen3.6-2b'); + expect(result.supportDiagnostics?.[0]?.copyText).toContain('token=[redacted]'); + expect(result.supportDiagnostics?.[0]?.copyText).not.toContain('token=secret'); + }); + it('executes host cleanup through the direct bridge command', async () => { const executor = fakeExecutor( bridgeCommandSuccess({ @@ -961,6 +1025,70 @@ describe('OpenCodeReadinessBridge', () => { ); expect(executor.execute).not.toHaveBeenCalled(); }); + + it('routes OpenCode permission answers through the guarded command service', async () => { + const executor = fakeExecutor( + bridgeFailure('internal_error', 'direct bridge must not run', []) + ); + const stateChangingExecute = vi.fn(); + const stateChangingCommands = { + async execute(input: { + command: OpenCodeBridgeCommandName; + body: TBody; + teamName: string; + laneId?: string | null; + runId: string | null; + }): Promise> { + stateChangingExecute(input); + return bridgeCommandSuccess({ + command: input.command, + requestId: 'guarded-permission-req-1', + data: { + runId: 'run-1', + teamLaunchState: 'ready', + members: {}, + warnings: [], + diagnostics: [], + }, + }) as unknown as OpenCodeBridgeResult; + }, + }; + const bridge = new OpenCodeReadinessBridge(executor, { stateChangingCommands }); + + await expect( + bridge.answerOpenCodeRuntimePermission({ + runId: 'run-1', + laneId: 'primary', + teamId: 'team-a', + teamName: 'team-a', + projectPath: '/repo', + memberName: 'alice', + requestId: 'perm-1', + decision: 'allow', + expectedCapabilitySnapshotId: null, + manifestHighWatermark: null, + }) + ).resolves.toMatchObject({ + runId: 'run-1', + teamLaunchState: 'ready', + }); + + expect(stateChangingExecute).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'opencode.answerPermission', + teamName: 'team-a', + laneId: 'primary', + runId: 'run-1', + capabilitySnapshotId: null, + cwd: '/repo', + body: expect.objectContaining({ + requestId: 'perm-1', + decision: 'allow', + }), + }) + ); + expect(executor.execute).not.toHaveBeenCalled(); + }); }); function fakeExecutor( @@ -1014,7 +1142,8 @@ function bridgeSuccess( function bridgeFailure( kind: OpenCodeBridgeFailureKind, message: string, - diagnostics: OpenCodeBridgeResult['diagnostics'] + diagnostics: OpenCodeBridgeResult['diagnostics'], + details?: Record ): OpenCodeBridgeResult { return { ok: false, @@ -1027,6 +1156,7 @@ function bridgeFailure( kind, message, retryable: true, + ...(details ? { details } : {}), }, diagnostics, }; diff --git a/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts b/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts index c61e1f88..437ad3d1 100644 --- a/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts +++ b/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts @@ -1,13 +1,12 @@ import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; - import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + createOpenCodeBridgeHandshakeIdentityHash, OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION, OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION, - createOpenCodeBridgeHandshakeIdentityHash, type OpenCodeBridgeCommandName, type OpenCodeBridgeHandshake, type OpenCodeBridgePeerIdentity, @@ -18,13 +17,13 @@ import { import { createOpenCodeBridgeCommandLeaseStore, createOpenCodeBridgeCommandLedgerStore, - type OpenCodeBridgeCommandLedger, type OpenCodeBridgeCommandLeaseStore, + type OpenCodeBridgeCommandLedger, } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore'; import { - OpenCodeStateChangingBridgeCommandService, type OpenCodeBridgeCommandExecutor, type OpenCodeBridgeHandshakePort, + OpenCodeStateChangingBridgeCommandService, type OpenCodeStateChangingBridgeDiagnosticsSink, type RuntimeStoreManifestReader, } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; @@ -381,6 +380,48 @@ describe('OpenCodeStateChangingBridgeCommandService', () => { await expect(leaseStore.getActive('team-a')).resolves.toBeNull(); }); + it('commits a launch result when recovery accepted a newer capability snapshot', async () => { + bridge.resultFactory = ({ body, command, options }) => + bridgeSuccess({ + requestId: options.requestId, + command, + runtime: { + providerId: 'opencode', + binaryPath: '/usr/local/bin/opencode', + binaryFingerprint: 'bin-1', + version: '1.0.0', + capabilitySnapshotId: 'cap-2', + }, + data: { + runId: 'run-1', + idempotencyKey: body.preconditions.idempotencyKey, + runtimeStoreManifestHighWatermark: 10, + diagnostics: [ + { + code: 'opencode_capability_snapshot_recovery', + severity: 'warning', + message: 'Accepted fresh OpenCode capability snapshot after app recovery attempt.', + }, + ], + }, + }); + const service = createService(); + + const result = await service.execute({ + ...buildLaunchInput(), + body: { + prompt: 'launch', + capabilitySnapshotRecoveryAttemptId: 'opencode-capability-recovery-test', + }, + }); + + expect(result.ok).toBe(true); + const idempotencyKey = bridge.calls[0].body.preconditions.idempotencyKey; + await expect(ledger.getByIdempotencyKey(idempotencyKey)).resolves.toMatchObject({ + status: 'completed', + }); + }); + function createService( overrides: { leaseAcquireTimeoutMs?: number; diff --git a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts index 57ed3cce..294bc809 100644 --- a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts +++ b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts @@ -69,6 +69,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => { PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data'), AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, + ...mcpLaunchSpec.env, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), @@ -227,6 +228,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => { PATH: withBunOnPath(process.env.PATH ?? ''), XDG_DATA_HOME: path.join(tempDir, 'xdg-data-default-model'), AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, + ...mcpLaunchSpec.env, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 8250b9ff..5aa07b88 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -141,6 +141,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(launchOpenCodeTeam).toHaveBeenCalledWith( expect.objectContaining({ selectedModel: 'openai/gpt-5.4-mini', + skipPermissions: true, expectedCapabilitySnapshotId: null, }) ); @@ -376,6 +377,32 @@ describe('OpenCodeTeamRuntimeAdapter', () => { ); }); + it('passes manual tool approval intent with a fresh capability precondition', async () => { + const launchOpenCodeTeam = vi.fn< + NonNullable + >(() => Promise.resolve(successfulOpenCodeLaunchData())); + const adapter = new OpenCodeTeamRuntimeAdapter({ + checkOpenCodeTeamLaunchReadiness: vi.fn(async () => + readiness({ state: 'ready', launchAllowed: true }) + ), + getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-manual')), + launchOpenCodeTeam, + }); + + await expect( + adapter.launch(launchInput({ skipPermissions: false })) + ).resolves.toMatchObject({ + teamLaunchState: 'clean_success', + }); + + expect(launchOpenCodeTeam).toHaveBeenCalledWith( + expect.objectContaining({ + skipPermissions: false, + expectedCapabilitySnapshotId: 'cap-manual', + }) + ); + }); + it('launches model-less Default selections with the readiness-resolved model', async () => { const launchOpenCodeTeam = vi.fn< NonNullable @@ -1357,6 +1384,23 @@ describe('OpenCodeTeamRuntimeAdapter', () => { sessionId: 'oc-session-1', launchState: 'permission_blocked', pendingPermissionRequestIds: ['perm-1', 'perm-1', 'perm-2'], + pendingPermissions: [ + { + requestId: 'perm-1', + sessionId: 'oc-session-1', + tool: 'bash', + title: 'Run git status', + kind: 'tool', + raw: { + requestID: 'perm-1', + sessionID: 'oc-session-1', + tool: 'bash', + title: 'Run git status', + kind: 'tool', + patterns: ['git status'], + }, + }, + ], diagnostics: ['waiting for permission approval'], runtimePid: 123, model: 'openai/gpt-5.4-mini', @@ -1393,6 +1437,15 @@ describe('OpenCodeTeamRuntimeAdapter', () => { providerId: 'opencode', launchState: 'runtime_pending_permission', pendingPermissionRequestIds: ['perm-1', 'perm-2'], + pendingPermissions: [ + { + providerId: 'opencode', + requestId: 'perm-1', + sessionId: 'oc-session-1', + tool: 'bash', + title: 'Run git status', + }, + ], runtimeAlive: false, agentToolAccepted: true, livenessKind: 'permission_blocked', @@ -1410,6 +1463,85 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); }); + it('answers OpenCode runtime permissions through the bridge and remaps the lane state', async () => { + const answerOpenCodeRuntimePermission = vi.fn< + NonNullable + >(async () => ({ + runId: 'run-1', + teamLaunchState: 'ready', + members: { + alice: { + sessionId: 'oc-session-1', + launchState: 'confirmed_alive', + runtimePid: 123, + model: 'openai/gpt-5.4-mini', + evidence: [ + { kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + ], + }, + }, + warnings: [], + diagnostics: [], + })); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + answerOpenCodeRuntimePermission, + }) + ); + + const result = await adapter.answerRuntimePermission({ + runId: 'run-1', + teamName: 'team-a', + laneId: 'primary', + cwd: '/repo', + providerId: 'opencode', + memberName: 'alice', + requestId: 'perm-1', + decision: 'allow', + expectedMembers: launchInput().expectedMembers, + previousLaunchState: null, + }); + + expect(answerOpenCodeRuntimePermission).toHaveBeenCalledWith({ + runId: 'run-1', + laneId: 'primary', + teamId: 'team-a', + teamName: 'team-a', + projectPath: '/repo', + memberName: 'alice', + requestId: 'perm-1', + decision: 'allow', + expectedCapabilitySnapshotId: null, + manifestHighWatermark: null, + }); + expect(result.teamLaunchState).toBe('clean_success'); + expect(result.members.alice?.launchState).toBe('confirmed_alive'); + }); + + it('fails runtime permission answers when the OpenCode answer bridge is unavailable', async () => { + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true })) + ); + + await expect( + adapter.answerRuntimePermission({ + runId: 'run-1', + teamName: 'team-a', + laneId: 'primary', + cwd: '/repo', + providerId: 'opencode', + memberName: 'alice', + requestId: 'perm-1', + decision: 'allow', + expectedMembers: launchInput().expectedMembers, + previousLaunchState: null, + }) + ).rejects.toThrow('OpenCode permission answer bridge is not registered.'); + }); + it('does not mark created bridge members without runtimePid as runtimeAlive', async () => { const launchOpenCodeTeam = vi.fn( async () => @@ -1706,7 +1838,7 @@ function launchInput(overrides: Partial = {}): TeamRunti cwd: '/repo', providerId: 'opencode', model: 'openai/gpt-5.4-mini', - skipPermissions: false, + skipPermissions: true, expectedMembers: [ { name: 'alice', diff --git a/test/main/services/team/RuntimeToolApprovalCoordinator.test.ts b/test/main/services/team/RuntimeToolApprovalCoordinator.test.ts new file mode 100644 index 00000000..9f6f5107 --- /dev/null +++ b/test/main/services/team/RuntimeToolApprovalCoordinator.test.ts @@ -0,0 +1,346 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + collectOpenCodeRuntimeApprovalEntries, + openCodeApprovalToolInput, + openCodeApprovalToolName, +} from '../../../../src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider'; +import { + RuntimeToolApprovalCoordinator, + type RuntimeToolApprovalEntry, + type RuntimeToolApprovalEvent, +} from '../../../../src/main/services/team/approvals/RuntimeToolApprovalCoordinator'; +import { + DEFAULT_TOOL_APPROVAL_SETTINGS, + type ToolApprovalSettings, +} from '../../../../src/shared/types/team'; + +import type { TeamRuntimeMemberLaunchEvidence } from '../../../../src/main/services/team/runtime'; + +function settings(overrides: Partial = {}): ToolApprovalSettings { + return { + ...DEFAULT_TOOL_APPROVAL_SETTINGS, + ...overrides, + }; +} + +function approvalEntry(overrides: Partial = {}): RuntimeToolApprovalEntry { + const approval = overrides.approval ?? { + requestId: 'opencode:run-1:perm-1', + runId: 'run-1', + teamName: 'team-a', + providerId: 'opencode' as const, + source: 'alice', + toolName: 'Bash', + toolInput: { command: 'npm test' }, + receivedAt: '2026-05-22T10:00:00.000Z', + runtimePermission: { + providerId: 'opencode' as const, + laneId: 'primary', + memberName: 'alice', + providerRequestId: 'perm-1', + sessionId: 'ses-1', + }, + }; + return { + providerId: 'opencode', + approval, + providerRequestId: 'perm-1', + laneId: 'primary', + memberName: 'alice', + cwd: '/repo', + expectedMembers: [ + { + name: 'alice', + providerId: 'opencode', + cwd: '/repo', + }, + ], + ...overrides, + }; +} + +describe('RuntimeToolApprovalCoordinator', () => { + let currentSettings: ToolApprovalSettings; + let events: RuntimeToolApprovalEvent[]; + let answers: { requestId: string; allow: boolean; message?: string }[]; + let coordinator: RuntimeToolApprovalCoordinator; + + beforeEach(() => { + vi.useFakeTimers(); + currentSettings = settings(); + events = []; + answers = []; + coordinator = new RuntimeToolApprovalCoordinator({ + getSettings: () => currentSettings, + answerApproval: async ({ entry, allow, message }) => { + answers.push({ requestId: entry.approval.requestId, allow, message }); + }, + emitApprovalEvent: (event) => { + events.push(event); + }, + }); + }); + + afterEach(() => { + coordinator.dispose(); + vi.useRealTimers(); + }); + + it('deduplicates pending runtime approvals by app request id', () => { + const entry = approvalEntry(); + + coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]); + coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]); + + expect(coordinator.size('team-a')).toBe(1); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ requestId: 'opencode:run-1:perm-1' }); + }); + + it('auto-allows matching categories without emitting a manual prompt', async () => { + currentSettings = settings({ autoAllowSafeBash: true }); + + coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]); + await vi.runAllTimersAsync(); + + expect(coordinator.size()).toBe(0); + expect(answers).toEqual([{ requestId: 'opencode:run-1:perm-1', allow: true }]); + expect(events).toEqual([ + expect.objectContaining({ + autoResolved: true, + reason: 'auto_allow_category', + requestId: 'opencode:run-1:perm-1', + }), + ]); + }); + + it('resolves timeout decisions through the provider answer callback', async () => { + currentSettings = settings({ timeoutAction: 'deny', timeoutSeconds: 5 }); + + coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]); + await vi.advanceTimersByTimeAsync(5_000); + + expect(answers).toEqual([ + { + requestId: 'opencode:run-1:perm-1', + allow: false, + message: 'Timed out - auto-denied by settings', + }, + ]); + expect(events.at(-1)).toMatchObject({ + autoResolved: true, + reason: 'timeout_deny', + requestId: 'opencode:run-1:perm-1', + }); + }); + + it('keeps timeout-resolved approvals pending when provider answer fails', async () => { + currentSettings = settings({ timeoutAction: 'deny', timeoutSeconds: 5 }); + let failNextAnswer = true; + coordinator.dispose(); + coordinator = new RuntimeToolApprovalCoordinator({ + getSettings: () => currentSettings, + answerApproval: async ({ entry, allow, message }) => { + if (failNextAnswer) { + failNextAnswer = false; + throw new Error('bridge unavailable'); + } + answers.push({ requestId: entry.approval.requestId, allow, message }); + }, + emitApprovalEvent: (event) => { + events.push(event); + }, + }); + + const entry = approvalEntry(); + coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]); + await vi.advanceTimersByTimeAsync(5_000); + + expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBe(entry); + expect(events.filter((event) => 'autoResolved' in event)).toEqual([]); + + await vi.advanceTimersByTimeAsync(5_000); + + expect(answers).toEqual([ + { + requestId: 'opencode:run-1:perm-1', + allow: false, + message: 'Timed out - auto-denied by settings', + }, + ]); + expect(events.at(-1)).toMatchObject({ + autoResolved: true, + reason: 'timeout_deny', + requestId: 'opencode:run-1:perm-1', + }); + expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBeUndefined(); + }); + + it('removes stale lane approvals when runtime state no longer reports them', () => { + coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]); + + coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, []); + + expect(coordinator.size()).toBe(0); + expect(events.at(-1)).toMatchObject({ + autoResolved: true, + reason: 'runtime_resolved', + requestId: 'opencode:run-1:perm-1', + }); + }); + + it('rejects stale UI responses by run id', async () => { + coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]); + + await expect( + coordinator.respond('team-a', 'run-old', 'opencode:run-1:perm-1', true) + ).rejects.toThrow('Stale approval: runId mismatch'); + expect(answers).toEqual([]); + }); + + it('keeps manual approvals pending when provider answer fails so users can retry', async () => { + let failNextAnswer = true; + coordinator.dispose(); + coordinator = new RuntimeToolApprovalCoordinator({ + getSettings: () => currentSettings, + answerApproval: async ({ entry, allow, message }) => { + if (failNextAnswer) { + failNextAnswer = false; + throw new Error('bridge unavailable'); + } + answers.push({ requestId: entry.approval.requestId, allow, message }); + }, + emitApprovalEvent: (event) => { + events.push(event); + }, + }); + + const entry = approvalEntry(); + coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]); + + await expect( + coordinator.respond('team-a', 'run-1', 'opencode:run-1:perm-1', true) + ).rejects.toThrow('bridge unavailable'); + expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBe(entry); + expect(coordinator.size('team-a')).toBe(1); + + await expect( + coordinator.respond('team-a', 'run-1', 'opencode:run-1:perm-1', true, 'retry') + ).resolves.toBe(true); + expect(answers).toEqual([ + { requestId: 'opencode:run-1:perm-1', allow: true, message: 'retry' }, + ]); + expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBeUndefined(); + }); + + it('leaves an in-flight approval tracked when a duplicate UI response arrives', async () => { + let releaseAnswer!: () => void; + let answerStarted!: () => void; + const answerStartedPromise = new Promise((resolve) => { + answerStarted = resolve; + }); + const releaseAnswerPromise = new Promise((resolve) => { + releaseAnswer = resolve; + }); + coordinator.dispose(); + coordinator = new RuntimeToolApprovalCoordinator({ + getSettings: () => currentSettings, + answerApproval: async ({ entry, allow, message }) => { + answerStarted(); + await releaseAnswerPromise; + answers.push({ requestId: entry.approval.requestId, allow, message }); + }, + emitApprovalEvent: (event) => { + events.push(event); + }, + }); + + const entry = approvalEntry(); + coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [entry]); + + const firstResponse = coordinator.respond('team-a', 'run-1', 'opencode:run-1:perm-1', true); + await answerStartedPromise; + await expect( + coordinator.respond('team-a', 'run-1', 'opencode:run-1:perm-1', false) + ).resolves.toBe(true); + expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBe(entry); + + releaseAnswer(); + await expect(firstResponse).resolves.toBe(true); + expect(answers).toEqual([{ requestId: 'opencode:run-1:perm-1', allow: true }]); + expect(coordinator.get('team-a', 'opencode:run-1:perm-1')).toBeUndefined(); + }); +}); + +describe('OpenCodeRuntimeApprovalProvider', () => { + it('normalizes bridge pending permissions into provider-neutral approval entries', () => { + const member: TeamRuntimeMemberLaunchEvidence = { + memberName: 'bob', + providerId: 'opencode', + launchState: 'runtime_pending_permission', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + pendingApprovals: [ + { + providerId: 'opencode', + requestId: 'perm-1', + sessionId: 'ses-1', + tool: 'bash', + raw: { patterns: ['pnpm test'] }, + }, + ], + diagnostics: [], + }; + + const entries = collectOpenCodeRuntimeApprovalEntries({ + teamName: 'team-a', + runId: 'run-1', + laneId: 'primary', + cwd: '/repo', + members: { bob: member }, + expectedMembers: [{ name: 'bob', providerId: 'opencode', cwd: '/repo' }], + nowIso: () => '2026-05-22T10:00:00.000Z', + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.approval).toMatchObject({ + requestId: 'opencode:run-1:perm-1', + providerId: 'opencode', + source: 'bob', + toolName: 'Bash', + toolInput: { + provider: 'opencode', + providerRequestId: 'perm-1', + command: 'pnpm test', + }, + runtimePermission: { + providerId: 'opencode', + laneId: 'primary', + memberName: 'bob', + providerRequestId: 'perm-1', + }, + }); + }); + + it('maps OpenCode permission display metadata without leaking protocol shape to UI', () => { + const approval = { + providerId: 'opencode' as const, + requestId: 'perm-2', + sessionId: 'ses-2', + kind: 'write', + title: 'Write file', + raw: { patterns: ['/repo/file.ts'] }, + }; + + expect(openCodeApprovalToolName(approval)).toBe('Write'); + expect(openCodeApprovalToolInput(approval)).toMatchObject({ + provider: 'opencode', + providerRequestId: 'perm-2', + patterns: ['/repo/file.ts'], + title: 'Write file', + }); + }); +}); diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 5df4b0d6..4aea6c8a 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -1,48 +1,13 @@ import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; - import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { - WorkspaceTrustCoordinator, - WorkspaceTrustExecutionPlan, -} from '../../../../src/features/workspace-trust/core/application/WorkspaceTrustCoordinator'; -import { ClaudeBinaryResolver } from '../../../../src/main/services/team/ClaudeBinaryResolver'; -import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; -import { - getMixedLaunchFallbackRecoveryError, - TeamProvisioningService, -} from '../../../../src/main/services/team/TeamProvisioningService'; -import type { - OpenCodeTeamRuntimeMessageInput, - OpenCodeTeamRuntimeMessageResult, -} from '../../../../src/main/services/team/runtime'; -import { - TeamRuntimeAdapterRegistry, - type TeamLaunchRuntimeAdapter, - type TeamRuntimeLaunchInput, - type TeamRuntimeMemberLaunchEvidence, - type TeamRuntimeMemberSpec, - type TeamRuntimeLaunchResult, - type TeamRuntimePrepareResult, - type TeamRuntimeReconcileInput, - type TeamRuntimeReconcileResult, - type TeamRuntimeStopInput, - type TeamRuntimeStopResult, -} from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter'; -import { - encodePath, - extractBaseDir, - getProjectsBasePath, - getTeamsBasePath, - setClaudeBasePathOverride, -} from '../../../../src/main/utils/pathDecoder'; -import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator'; import { agentTeamsMcpHttpServer } from '../../../../src/main/services/team/AgentTeamsMcpHttpServer'; +import { ClaudeBinaryResolver } from '../../../../src/main/services/team/ClaudeBinaryResolver'; import { - getOpenCodeRuntimeManifestPath, getOpenCodeRuntimeLaneIndexPath, + getOpenCodeRuntimeManifestPath, readCommittedOpenCodeBootstrapSessionEvidence, readOpenCodeRuntimeLaneIndex, setOpenCodeRuntimeActiveRunManifest, @@ -54,8 +19,49 @@ import { OPENCODE_RUNTIME_STORE_DESCRIPTORS, RuntimeStoreBatchWriter, } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest'; +import { + type TeamLaunchRuntimeAdapter, + TeamRuntimeAdapterRegistry, + type TeamRuntimeLaunchInput, + type TeamRuntimeLaunchResult, + type TeamRuntimeMemberLaunchEvidence, + type TeamRuntimeMemberSpec, + type TeamRuntimePermissionAnswerInput, + type TeamRuntimePrepareResult, + type TeamRuntimeReconcileInput, + type TeamRuntimeReconcileResult, + type TeamRuntimeStopInput, + type TeamRuntimeStopResult, +} from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter'; +import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; +import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator'; +import { + getMixedLaunchFallbackRecoveryError, + TeamProvisioningService, +} from '../../../../src/main/services/team/TeamProvisioningService'; +import { + encodePath, + extractBaseDir, + getProjectsBasePath, + getTeamsBasePath, + setClaudeBasePathOverride, +} from '../../../../src/main/utils/pathDecoder'; -import type { InboxMessage, TaskRef, TeamProvisioningProgress } from '../../../../src/shared/types'; +import type { + WorkspaceTrustCoordinator, + WorkspaceTrustExecutionPlan, +} from '../../../../src/features/workspace-trust/core/application/WorkspaceTrustCoordinator'; +import type { + OpenCodeTeamRuntimeMessageInput, + OpenCodeTeamRuntimeMessageResult, +} from '../../../../src/main/services/team/runtime'; +import type { + InboxMessage, + TaskRef, + TeamProvisioningProgress, + ToolApprovalEvent, + ToolApprovalRequest, +} from '../../../../src/shared/types'; const LAUNCH_MATRIX_SAFE_E2E_TIMEOUT_MS = 60_000; const WORKSPACE_TRUST_TEST_ENV_NAMES = [ @@ -375,6 +381,73 @@ describe('Team agent launch matrix safe e2e', () => { expect(statuses.summary?.pendingCount).toBe(1); }); + it('routes OpenCode runtime approval UI responses back through the adapter', async () => { + const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending'); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const approvalEvents: ToolApprovalEvent[] = []; + svc.setToolApprovalEventEmitter((event) => approvalEvents.push(event)); + + const launch = await svc.createTeam( + { + teamName: 'approve-opencode-safe-e2e', + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: false, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + () => undefined + ); + + const approval = approvalEvents.find( + (event): event is ToolApprovalRequest => + !('dismissed' in event) && !('autoResolved' in event) + ); + expect(approval).toMatchObject({ + runId: launch.runId, + teamName: 'approve-opencode-safe-e2e', + providerId: 'opencode', + source: 'alice', + toolName: 'Bash', + runtimePermission: { + providerId: 'opencode', + laneId: 'primary', + memberName: 'alice', + providerRequestId: 'perm-alice', + }, + }); + expect(approval?.toolInput).toMatchObject({ + provider: 'opencode', + providerRequestId: 'perm-alice', + command: 'git status', + }); + + await svc.respondToToolApproval( + 'approve-opencode-safe-e2e', + launch.runId!, + approval!.requestId, + true + ); + + expect(adapter.permissionAnswerInputs).toEqual([ + expect.objectContaining({ + runId: launch.runId, + teamName: 'approve-opencode-safe-e2e', + laneId: 'primary', + memberName: 'alice', + requestId: 'perm-alice', + decision: 'allow', + }), + ]); + const statuses = await svc.getMemberSpawnStatuses('approve-opencode-safe-e2e'); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(statuses.statuses.alice?.pendingPermissionRequestIds).toBeUndefined(); + }); + it('blocks createTeam at workspace trust preflight before spawn and preserves existing launch state', async () => { forceWorkspaceTrustPreflightEnv(); process.env.CLAUDE_CLI_PATH = await writeFakeClaudeCli(tempDir); @@ -18293,6 +18366,7 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter { readonly providerId = 'opencode' as const; readonly launchInputs: TeamRuntimeLaunchInput[] = []; readonly messageInputs: OpenCodeTeamRuntimeMessageInput[] = []; + readonly permissionAnswerInputs: TeamRuntimePermissionAnswerInput[] = []; readonly reconcileInputs: TeamRuntimeReconcileInput[] = []; readonly stopInputs: TeamRuntimeStopInput[] = []; @@ -18359,6 +18433,30 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter { }; } + async answerRuntimePermission( + input: TeamRuntimePermissionAnswerInput + ): Promise { + this.permissionAnswerInputs.push(input); + this.memberOutcomes = { + ...this.memberOutcomes, + [input.memberName]: input.decision === 'allow' ? 'confirmed' : 'failed', + }; + return { + runId: input.runId, + teamName: input.teamName, + launchPhase: 'finished', + teamLaunchState: this.aggregateLaunchState(input.expectedMembers), + members: Object.fromEntries( + input.expectedMembers.map((member, index) => [ + member.name, + this.buildMemberEvidence(member, index), + ]) + ), + warnings: [], + diagnostics: ['fake OpenCode permission answer'], + }; + } + async reconcile(input: TeamRuntimeReconcileInput): Promise { this.reconcileInputs.push(input); const members = Object.fromEntries( @@ -18437,6 +18535,26 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter { hardFailure: failed, hardFailureReason: failed ? 'fake_open_code_launch_failure' : undefined, pendingPermissionRequestIds: permissionPending ? [`perm-${member.name}`] : undefined, + pendingPermissions: permissionPending + ? [ + { + providerId: 'opencode', + requestId: `perm-${member.name}`, + sessionId: `session-${member.name}`, + tool: 'bash', + title: `Run git status for ${member.name}`, + kind: 'tool', + raw: { + requestID: `perm-${member.name}`, + sessionID: `session-${member.name}`, + tool: 'bash', + title: `Run git status for ${member.name}`, + kind: 'tool', + patterns: ['git status'], + }, + }, + ] + : undefined, sessionId: failed ? undefined : `session-${member.name}`, runtimePid: failed ? undefined : 10_000 + index, livenessKind, diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 64201867..54528feb 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -17786,6 +17786,189 @@ describe('TeamProvisioningService', () => { }); }); + it('tags Codex app-server control_request approvals and replies through control_response', async () => { + const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => { + cb?.(); + return true; + }); + const svc = new TeamProvisioningService(); + const events: unknown[] = []; + svc.setToolApprovalEventEmitter((event) => events.push(event)); + svc.setMainWindow({ + isDestroyed: () => false, + isFocused: () => true, + } as never); + + const run = { + teamName: 'codex-manual-team', + runId: 'run-codex-manual', + request: { color: '#2563eb', displayName: 'Codex Manual Team' }, + child: { stdin: { writable: true, write } }, + pendingApprovals: new Map(), + }; + const internals = svc as unknown as { + runs: Map; + aliveRunByTeam: Map; + handleControlRequest(run: unknown, msg: Record): void; + }; + internals.runs.set(run.runId, run); + internals.aliveRunByTeam.set(run.teamName, run.runId); + + internals.handleControlRequest(run, { + request_id: 'codex-approval-1', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + input: { + provider: 'codex', + providerRequestId: 'codex:item/commandExecution/requestApproval:item-1', + command: 'printf ok', + }, + }, + }); + + expect(events[0]).toMatchObject({ + requestId: 'codex-approval-1', + runId: run.runId, + teamName: run.teamName, + providerId: 'codex', + toolName: 'Bash', + toolInput: { + provider: 'codex', + command: 'printf ok', + }, + }); + + await svc.respondToToolApproval(run.teamName, run.runId, 'codex-approval-1', true); + + expect(write).toHaveBeenCalledTimes(1); + const firstWrite = write.mock.calls[0]?.[0]; + expect(typeof firstWrite).toBe('string'); + const payload = JSON.parse(firstWrite as string) as Record; + expect(payload).toMatchObject({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'codex-approval-1', + response: { behavior: 'allow', updatedInput: {} }, + }, + }); + }); + + it('keeps control_request approvals pending when control_response write fails so retry works', async () => { + let failNextWrite = true; + const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => { + if (failNextWrite) { + failNextWrite = false; + cb?.(new Error('broken pipe')); + return false; + } + cb?.(); + return true; + }); + const svc = new TeamProvisioningService(); + svc.setMainWindow({ + isDestroyed: () => false, + isFocused: () => true, + } as never); + + const run = { + teamName: 'anthropic-manual-team', + runId: 'run-anthropic-manual', + request: { color: '#7c3aed', displayName: 'Anthropic Manual Team' }, + child: { stdin: { writable: true, write } }, + pendingApprovals: new Map(), + }; + const internals = svc as unknown as { + runs: Map; + aliveRunByTeam: Map; + handleControlRequest(run: unknown, msg: Record): void; + }; + internals.runs.set(run.runId, run); + internals.aliveRunByTeam.set(run.teamName, run.runId); + + internals.handleControlRequest(run, { + request_id: 'anthropic-approval-retry', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + input: { command: 'printf ok' }, + }, + }); + + await expect( + svc.respondToToolApproval(run.teamName, run.runId, 'anthropic-approval-retry', true) + ).rejects.toThrow('broken pipe'); + expect( + vi.mocked(console.error).mock.calls.some((args) => args.join(' ').includes('broken pipe')) + ).toBe(true); + vi.mocked(console.error).mockClear(); + expect(run.pendingApprovals.has('anthropic-approval-retry')).toBe(true); + + await expect( + svc.respondToToolApproval(run.teamName, run.runId, 'anthropic-approval-retry', true) + ).resolves.toBeUndefined(); + expect(write).toHaveBeenCalledTimes(2); + expect(run.pendingApprovals.has('anthropic-approval-retry')).toBe(false); + }); + + it('leaves control_request approvals tracked while a duplicate UI response is in flight', async () => { + let releaseWrite: ((error?: Error | null) => void) | undefined; + const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => { + releaseWrite = cb; + return true; + }); + const svc = new TeamProvisioningService(); + svc.setMainWindow({ + isDestroyed: () => false, + isFocused: () => true, + } as never); + + const run = { + teamName: 'codex-duplicate-response-team', + runId: 'run-codex-duplicate-response', + request: { color: '#2563eb', displayName: 'Codex Duplicate Response Team' }, + child: { stdin: { writable: true, write } }, + pendingApprovals: new Map(), + }; + const internals = svc as unknown as { + runs: Map; + aliveRunByTeam: Map; + handleControlRequest(run: unknown, msg: Record): void; + }; + internals.runs.set(run.runId, run); + internals.aliveRunByTeam.set(run.teamName, run.runId); + + internals.handleControlRequest(run, { + request_id: 'codex-approval-duplicate', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + input: { + provider: 'codex', + providerRequestId: 'codex:item/commandExecution/requestApproval:item-2', + command: 'printf ok', + }, + }, + }); + + const firstResponse = svc.respondToToolApproval( + run.teamName, + run.runId, + 'codex-approval-duplicate', + true + ); + await expect( + svc.respondToToolApproval(run.teamName, run.runId, 'codex-approval-duplicate', false) + ).resolves.toBeUndefined(); + expect(write).toHaveBeenCalledTimes(1); + expect(run.pendingApprovals.has('codex-approval-duplicate')).toBe(true); + + releaseWrite?.(); + await expect(firstResponse).resolves.toBeUndefined(); + expect(run.pendingApprovals.has('codex-approval-duplicate')).toBe(false); + }); + it('keeps AskUserQuestion answers in teammate fallback control responses', async () => { const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => { cb?.(); diff --git a/test/main/services/team/TeamProvisioningServiceOpenCodeSupportDiagnostics.test.ts b/test/main/services/team/TeamProvisioningServiceOpenCodeSupportDiagnostics.test.ts new file mode 100644 index 00000000..a83e2357 --- /dev/null +++ b/test/main/services/team/TeamProvisioningServiceOpenCodeSupportDiagnostics.test.ts @@ -0,0 +1,119 @@ +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + type TeamLaunchRuntimeAdapter, + TeamRuntimeAdapterRegistry, +} from '../../../../src/main/services/team/runtime'; +import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; + +let tempRoot: string; + +describe('TeamProvisioningService OpenCode support diagnostics', () => { + beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-provisioning-opencode-support-')); + }); + + afterEach(async () => { + await fs.rm(tempRoot, { recursive: true, force: true }); + }); + + it('keeps bridge no-output selected-model failures provider-scoped with support diagnostics', async () => { + const supportDiagnostic = { + id: 'diag-empty-stdout', + providerId: 'opencode' as const, + kind: 'opencode_bridge_no_output', + severity: 'error' as const, + title: 'OpenCode runtime check returned no output', + summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.', + copyText: 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT', + createdAt: '2026-04-21T12:00:00.000Z', + }; + const prepare = vi.fn(async () => ({ + ok: false as const, + providerId: 'opencode' as const, + reason: 'unknown_error', + retryable: false, + diagnostics: [ + 'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty', + ], + warnings: [], + supportDiagnostics: [supportDiagnostic], + })); + const adapter: TeamLaunchRuntimeAdapter = { + providerId: 'opencode', + prepare, + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + }; + const service = new TeamProvisioningService(); + service.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + const result = await service.prepareForProvisioning(tempRoot, { + providerId: 'opencode', + forceFresh: true, + modelIds: ['opencode/qwen3.6-2b'], + modelVerificationMode: 'deep', + }); + + expect(result.ready).toBe(false); + expect(result.message).toBe('OpenCode runtime check returned no output.'); + expect(result.details).toEqual(['OpenCode runtime check returned no output.']); + expect(result.supportDiagnostics).toEqual([supportDiagnostic]); + expect(result.issues).toEqual([ + { + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: 'unknown_error', + message: 'OpenCode runtime check returned no output.', + }, + ]); + }); + + it('uses bridge no-output diagnostics as the model-less prepare failure message', async () => { + const supportDiagnostic = { + id: 'diag-empty-stdout', + providerId: 'opencode' as const, + kind: 'opencode_bridge_no_output', + severity: 'error' as const, + title: 'OpenCode runtime check returned no output', + summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.', + copyText: 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT', + createdAt: '2026-04-21T12:00:00.000Z', + }; + const prepare = vi.fn(async () => ({ + ok: false as const, + providerId: 'opencode' as const, + reason: 'unknown_error', + retryable: false, + diagnostics: [ + 'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty', + ], + warnings: [], + supportDiagnostics: [supportDiagnostic], + })); + const adapter: TeamLaunchRuntimeAdapter = { + providerId: 'opencode', + prepare, + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + }; + const service = new TeamProvisioningService(); + service.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + const result = await service.prepareForProvisioning(tempRoot, { + providerId: 'opencode', + forceFresh: true, + }); + + expect(result.ready).toBe(false); + expect(result.message).toBe('OpenCode runtime check returned no output.'); + expect(result.details).toEqual(['OpenCode runtime check returned no output.']); + expect(result.supportDiagnostics).toEqual([supportDiagnostic]); + }); +}); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index de377a28..29107b6e 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -1,3 +1,4 @@ +import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { spawn } from 'child_process'; import * as fs from 'fs'; @@ -97,7 +98,10 @@ vi.mock('@main/utils/childProcess', () => ({ import { ProviderConnectionService } from '@main/services/runtime/ProviderConnectionService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; -import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime'; +import { + type TeamLaunchRuntimeAdapter, + TeamRuntimeAdapterRegistry, +} from '@main/services/team/runtime'; import { buildDirectTmuxRestartEnvAssignments, TeamProvisioningService, @@ -886,15 +890,14 @@ describe('TeamProvisioningService prepare/auth behavior', () => { diagnostics: [], warnings: [], })); - const registry = new TeamRuntimeAdapterRegistry([ - { - providerId: 'opencode', - prepare, - launch: vi.fn(), - reconcile: vi.fn(), - stop: vi.fn(), - } as any, - ]); + const adapter: TeamLaunchRuntimeAdapter = { + providerId: 'opencode', + prepare, + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + }; + const registry = new TeamRuntimeAdapterRegistry([adapter]); const svc = new TeamProvisioningService(); svc.setRuntimeAdapterRegistry(registry); @@ -931,6 +934,78 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ); }); + it('uses OpenCode access-denied warnings as the model-less prepare failure message', async () => { + const prepare = vi.fn(async () => ({ + ok: false as const, + providerId: 'opencode' as const, + reason: 'unknown_error', + retryable: false, + diagnostics: [], + warnings: ['EPERM: operation not permitted, mkdir C:\\Program Files\\locked-project'], + })); + const adapter: TeamLaunchRuntimeAdapter = { + providerId: 'opencode', + prepare, + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + }; + const registry = new TeamRuntimeAdapterRegistry([adapter]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(registry); + + const result = await svc.prepareForProvisioning(tempRoot, { + providerId: 'opencode', + forceFresh: true, + }); + + expect(result.ready).toBe(false); + expect(result.message).toBe(OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE); + expect(result.warnings).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]); + }); + + it('keeps OpenCode access-denied selected-model failures provider-scoped', async () => { + const prepare = vi.fn(async () => ({ + ok: false as const, + providerId: 'opencode' as const, + reason: 'unknown_error', + retryable: false, + diagnostics: ['EPERM: operation not permitted, mkdir C:\\Program Files\\locked-project'], + warnings: [], + })); + const adapter: TeamLaunchRuntimeAdapter = { + providerId: 'opencode', + prepare, + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + }; + const registry = new TeamRuntimeAdapterRegistry([adapter]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(registry); + + const result = await svc.prepareForProvisioning(tempRoot, { + providerId: 'opencode', + forceFresh: true, + modelIds: ['opencode/big-pickle'], + modelVerificationMode: 'deep', + }); + + expect(result.ready).toBe(false); + expect(result.message).toBe(OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE); + expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]); + expect(result.warnings).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]); + expect(result.issues).toEqual([ + { + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: 'unknown_error', + message: OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE, + }, + ]); + }); + it('coalesces duplicate OpenCode compatibility preflight requests while prepare is in flight', async () => { const prepareGate: { release?: () => void } = {}; const prepare = vi.fn( diff --git a/test/main/services/team/openCodeLiveTestHarness.ts b/test/main/services/team/openCodeLiveTestHarness.ts index fa41c87c..c25da789 100644 --- a/test/main/services/team/openCodeLiveTestHarness.ts +++ b/test/main/services/team/openCodeLiveTestHarness.ts @@ -76,6 +76,7 @@ export async function createOpenCodeLiveHarness(input: { PATH: withBunOnPath(process.env.PATH ?? ''), AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(), CLAUDE_TEAM_CONTROL_URL: controlApi.baseUrl, + ...mcpLaunchSpec.env, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 06605c17..87cf896a 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -155,6 +155,156 @@ describe('ProvisioningProviderStatusList', () => { ); }); + it('gives a concrete hint for OpenCode bridge no-output failures', () => { + expect( + getProvisioningFailureHint('Runtime environment is not available - launch is blocked', [ + { + providerId: 'opencode', + status: 'failed', + backendSummary: null, + details: [ + 'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty', + ], + }, + ]) + ).toBe( + 'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.' + ); + }); + + it('renders Copy diagnostics for OpenCode support diagnostics and copies the prepared payload', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const writeText = vi.fn(async () => undefined); + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProviderStatusList, { + checks: [ + { + providerId: 'opencode', + status: 'failed', + backendSummary: 'OpenCode CLI', + details: ['OpenCode runtime check returned no output.'], + supportDiagnostics: [ + { + id: 'diag-empty-stdout', + providerId: 'opencode', + kind: 'opencode_bridge_no_output', + severity: 'error', + title: 'OpenCode runtime check returned no output', + summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.', + copyText: 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT', + createdAt: '2026-04-21T12:00:00.000Z', + }, + ], + }, + { + providerId: 'codex', + status: 'failed', + details: ['Codex failed'], + supportDiagnostics: [ + { + id: 'diag-codex', + providerId: 'codex', + kind: 'codex_debug', + severity: 'error', + title: 'Codex debug', + summary: 'Codex debug summary', + copyText: 'should not render', + createdAt: '2026-04-21T12:00:00.000Z', + }, + ], + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'OpenCode (OpenCode CLI): OpenCode runtime check returned no output' + ); + expect(host.textContent).toContain('Copy diagnostics'); + const buttons = Array.from(host.querySelectorAll('button')); + expect(buttons).toHaveLength(1); + + await act(async () => { + buttons[0]?.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith( + 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT' + ); + expect(host.textContent).toContain('Copied'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not show copied when the Clipboard API is unavailable', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProviderStatusList, { + checks: [ + { + providerId: 'opencode', + status: 'failed', + details: ['OpenCode runtime check returned no output.'], + supportDiagnostics: [ + { + id: 'diag-empty-stdout', + providerId: 'opencode', + kind: 'opencode_bridge_no_output', + severity: 'error', + title: 'OpenCode runtime check returned no output', + summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.', + copyText: 'Agent Teams OpenCode diagnostics', + createdAt: '2026-04-21T12:00:00.000Z', + }, + ], + }, + ], + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('button'); + expect(button?.textContent).toContain('Copy diagnostics'); + + await act(async () => { + button?.click(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Copy diagnostics'); + expect(host.textContent).not.toContain('Copied'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('picks the first real failure detail instead of a verified line', () => { expect( getPrimaryProvisioningFailureDetail([ diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnosticsOpenCodeRuntime.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnosticsOpenCodeRuntime.test.ts index 1650a774..ea33d7c1 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnosticsOpenCodeRuntime.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnosticsOpenCodeRuntime.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; - import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics'; +import { describe, expect, it, vi } from 'vitest'; import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/types'; @@ -72,4 +71,47 @@ describe('runProviderPrepareDiagnostics OpenCode runtime failures', () => { ]); expect(result.modelResultsById).toEqual({}); }); + + it('preserves support diagnostics for OpenCode bridge no-output provider failures', async () => { + const supportDiagnostic = { + id: 'diag-empty-stdout', + providerId: 'opencode' as const, + kind: 'opencode_bridge_no_output', + severity: 'error' as const, + title: 'OpenCode runtime check returned no output', + summary: 'OpenCode readiness bridge exited without returning diagnostic JSON.', + copyText: 'Agent Teams OpenCode diagnostics\noutputReadError: ENOENT', + createdAt: '2026-04-21T12:00:00.000Z', + }; + const prepareProvisioning = vi.fn().mockResolvedValue({ + ready: false, + message: 'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty', + details: [ + 'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty', + ], + issues: [ + { + providerId: 'opencode', + scope: 'provider', + severity: 'blocking', + code: 'unknown_error', + message: + 'OpenCode readiness bridge failed: contract_violation: Bridge stdout was empty', + }, + ], + supportDiagnostics: [supportDiagnostic], + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/Users/tester/project', + providerId: 'opencode', + selectedModelIds: ['opencode/qwen3.6-2b'], + prepareProvisioning, + }); + + expect(result.status).toBe('failed'); + expect(result.details).toEqual(['OpenCode runtime check returned no output.']); + expect(result.modelResultsById).toEqual({}); + expect(result.supportDiagnostics).toEqual([supportDiagnostic]); + }); }); From 7e6ebce0932e42239db9767fc9920820927b65c3 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 15:43:36 +0300 Subject: [PATCH 28/41] fix(team-ui): polish launch diagnostics controls --- .../extensions/plugins/PluginsPanel.tsx | 6 +- .../team/dialogs/SkipPermissionsCheckbox.tsx | 6 +- .../components/team/members/MemberCard.tsx | 663 +++++++++--------- .../members/MemberLaunchDiagnosticsButton.tsx | 5 +- src/renderer/index.css | 40 ++ src/renderer/index.html | 91 --- .../extensions/plugins/PluginsPanel.test.ts | 13 +- .../team/members/MemberCard.test.ts | 19 +- 8 files changed, 411 insertions(+), 432 deletions(-) diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index ac47abfc..bb5bbf1f 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -206,10 +206,8 @@ export const PluginsPanel = ({ return (
- In the multimodel runtime, plugins are currently guaranteed only for Anthropic - sessions. We are actively building broader plugin support for all agents, including - both universal plugins and agent-specific plugins. - {capability.reason ? ` ${capability.reason}` : ''} + Plugin support is currently guaranteed for Anthropic (Claude) sessions only. + We're working to support plugins across all agents.
); })()} diff --git a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx index 40c8bdf2..90be9c98 100644 --- a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx +++ b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx @@ -41,8 +41,8 @@ export const SkipPermissionsCheckbox: React.FC = (

- Unleash Claude's full power — no interruptions asking for permission. Autonomous - mode — all tools execute without confirmation. Be cautious with untrusted code. + Autonomous mode: team tools execute without confirmation. Be cautious with untrusted + code.

@@ -57,7 +57,7 @@ export const SkipPermissionsCheckbox: React.FC = ( >
-

Manual mode — you'll approve or deny each tool call in real-time.

+

Manual mode: you'll approve or deny each tool call in real time.

)} diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index be3575ad..361664e2 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1043,7 +1043,7 @@ export const MemberCard = memo(function MemberCard({ ) : null}
-
+
) : null} @@ -1201,336 +1202,348 @@ export const MemberCard = memo(function MemberCard({ ) : null}
) : null} - {launchFailureReason ? ( -
+
+ {showLaunchBadge ? ( + - - {renderLinkifiedText(launchFailureReason, { - linkClassName: 'underline underline-offset-2 hover:text-red-200', - stopPropagation: true, - getLinkLabel: getLaunchFailureLinkLabel, - })} - + {launchVisualState === 'starting_stale' ? ( + + ) : ( + + )} + + {launchBadgeLabel} + + {canRelaunchOpenCode ? ( + + + + + + {retryLaunchError ?? + (retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel)} + + + ) : null} + + ) : showFailedLaunchBadge ? ( + + + + + + + {displayPresenceLabel} + + + + {spawnError ?? 'Spawn failed'} + + {showCopyDiagnostics ? ( + + ) : null} + {canSkipFailedLaunch ? ( + + + + + + {skipLaunchError ?? + (skippingLaunch ? 'Skipping teammate...' : 'Skip for this launch')} + + + ) : null} + {canRetryLaunch ? ( + + + + + + {retryLaunchError ?? + (retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)} + + + ) : null} + + ) : showSkippedLaunchBadge ? ( + + + + + + + {displayPresenceLabel} + + + + + {spawnEntry?.skipReason ?? 'Skipped for this launch'} + + + {canRetryLaunch ? ( + + + + + + {retryLaunchError ?? + (retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)} + + + ) : null} + + ) : showRuntimeAdvisoryBadge ? ( + + + + + + + {runtimeAdvisoryLabel} + + + + + {runtimeAdvisoryTitle ?? runtimeAdvisoryLabel} + + + {canRelaunchRuntimeAdvisoryOpenCode ? ( + + + + + + {retryLaunchError ?? + (retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)} + + + ) : null} + {showRuntimeAdvisoryDiagnostics ? ( + + ) : null} + + ) : !activityTask ? ( + + {isRemoved ? 'removed' : displayPresenceLabel} + + ) : null} + {showStartingSkeleton ? ( +