From c57c513cf192ddd1e1bee0ceba5e8b739843b0ee Mon Sep 17 00:00:00 2001 From: infiniti <52129260+developerInfiniti@users.noreply.github.com> Date: Thu, 14 May 2026 11:12:37 +0300 Subject: [PATCH] fix(opencode): recover agenda sync after missing proof Recover OpenCode agenda sync after protocol-proof-missing delivery failures and harden Anthropic provider readiness handling. --- .../runtime/ProviderConnectionService.ts | 71 +- .../services/team/TeamProvisioningService.ts | 205 ++++- .../runtime/providerConnectionUi.ts | 8 +- .../runtime/ProviderConnectionService.test.ts | 129 ++- ...penCodeAgendaSyncRecovery.safe-e2e.test.ts | 486 +++++++++++ .../team/TeamProvisioningServiceRelay.test.ts | 783 ++++++++++++++++++ .../runtime/providerConnectionUi.test.ts | 14 + 7 files changed, 1627 insertions(+), 69 deletions(-) create mode 100644 test/main/services/team/OpenCodeAgendaSyncRecovery.safe-e2e.test.ts diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 2b2bd20d..294aa500 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -79,6 +79,7 @@ const CODEX_NATIVE_BACKEND_ID = 'codex-native'; const CODEX_LOGIN_STATUS_TIMEOUT_MS = 5_000; const ANTHROPIC_API_KEY_VERIFY_TIMEOUT_MS = 10_000; const ANTHROPIC_API_KEY_VERIFY_CACHE_TTL_MS = 60_000; +const ANTHROPIC_DEFAULT_API_BASE_URL = 'https://api.anthropic.com'; type CodexCliLoginStatus = 'logged_in' | 'not_logged_in' | 'unknown'; @@ -101,7 +102,10 @@ interface AnthropicApiKeyVerificationResult { errorMessage?: string | null; } -type AnthropicApiKeyVerifier = (apiKey: string) => Promise; +type AnthropicApiKeyVerifier = ( + apiKey: string, + baseUrl?: string | null +) => Promise; function hashCredentialForCache(value: string): string { return crypto.createHash('sha256').update(value).digest('hex'); @@ -125,13 +129,31 @@ function normalizeAnthropicApiKeyVerificationMessage( return 'unknown verification error'; } +function buildAnthropicModelsUrl(baseUrl?: string | null): string { + const url = new URL(baseUrl?.trim() || ANTHROPIC_DEFAULT_API_BASE_URL); + let pathname = url.pathname; + while (pathname.endsWith('/')) { + pathname = pathname.slice(0, -1); + } + if (pathname.endsWith('/v1/models')) { + url.pathname = pathname; + } else if (pathname.endsWith('/v1')) { + url.pathname = `${pathname}/models`; + } else { + url.pathname = `${pathname}/v1/models`; + } + url.search = ''; + return url.toString(); +} + async function verifyAnthropicApiKeyWithApi( - apiKey: string + apiKey: string, + baseUrl?: string | null ): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), ANTHROPIC_API_KEY_VERIFY_TIMEOUT_MS); try { - const response = await fetch('https://api.anthropic.com/v1/models', { + const response = await fetch(buildAnthropicModelsUrl(baseUrl), { method: 'GET', signal: controller.signal, headers: { @@ -752,16 +774,18 @@ export class ProviderConnectionService { } if (connection.apiKeyConfigured) { + const runtimeApiKeyAuthMethod = + provider.authMethod === 'api_key' || provider.authMethod === 'api_key_helper'; const runtimeVerifiedApiKey = provider.authenticated === true && - provider.authMethod === 'api_key' && + runtimeApiKeyAuthMethod && provider.verificationState === 'verified'; if (runtimeVerifiedApiKey) { return { ...provider, authenticated: true, - authMethod: 'api_key', + authMethod: provider.authMethod, subscriptionRateLimits: null, verificationState: 'verified', statusMessage: provider.statusMessage ?? 'Connected via API key', @@ -825,13 +849,14 @@ export class ProviderConnectionService { return null; } - const cacheKey = hashCredentialForCache(apiKey); + const baseUrl = this.getExternalEnvValue('ANTHROPIC_BASE_URL'); + const cacheKey = hashCredentialForCache(`${apiKey}\0${baseUrl ?? ''}`); const cached = this.anthropicApiKeyVerificationCache.get(cacheKey); if (cached && Date.now() - cached.at < ANTHROPIC_API_KEY_VERIFY_CACHE_TTL_MS) { return cached.result; } - const result = await this.anthropicApiKeyVerifier(apiKey); + const result = await this.anthropicApiKeyVerifier(apiKey, baseUrl); this.anthropicApiKeyVerificationCache.set(cacheKey, { result, at: Date.now() }); return result; } @@ -1056,21 +1081,8 @@ export class ProviderConnectionService { } private getExternalCredential(providerId: CliProviderId): ExternalCredential { - const shellEnv = getCachedShellEnv() ?? {}; - const sources = [shellEnv, process.env]; - - const findEnvValue = (envVarName: string): string | null => { - for (const source of sources) { - const value = source[envVarName]; - if (typeof value === 'string' && value.trim().length > 0) { - return value; - } - } - return null; - }; - if (providerId === 'anthropic') { - const apiKey = findEnvValue('ANTHROPIC_API_KEY'); + const apiKey = this.getExternalEnvValue('ANTHROPIC_API_KEY'); if (apiKey) { return { label: 'Detected from ANTHROPIC_API_KEY', @@ -1080,7 +1092,7 @@ export class ProviderConnectionService { } if (providerId === 'gemini') { - const apiKey = findEnvValue('GEMINI_API_KEY'); + const apiKey = this.getExternalEnvValue('GEMINI_API_KEY'); if (apiKey) { return { label: 'Detected from GEMINI_API_KEY', @@ -1090,7 +1102,7 @@ export class ProviderConnectionService { } if (providerId === 'codex') { - const nativeApiKey = findEnvValue(CODEX_NATIVE_API_KEY_ENV_VAR); + const nativeApiKey = this.getExternalEnvValue(CODEX_NATIVE_API_KEY_ENV_VAR); if (nativeApiKey) { return { label: `Detected from ${CODEX_NATIVE_API_KEY_ENV_VAR}`, @@ -1098,7 +1110,7 @@ export class ProviderConnectionService { }; } - const apiKey = findEnvValue('OPENAI_API_KEY'); + const apiKey = this.getExternalEnvValue('OPENAI_API_KEY'); if (apiKey) { return { label: 'Detected from OPENAI_API_KEY', @@ -1109,6 +1121,17 @@ export class ProviderConnectionService { return null; } + + private getExternalEnvValue(envVarName: string): string | null { + const shellEnv = getCachedShellEnv() ?? {}; + for (const source of [shellEnv, process.env]) { + const value = source[envVarName]; + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + } + return null; + } } export const providerConnectionService = ProviderConnectionService.getInstance(); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 054cd3cc..d8287614 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -36,9 +36,9 @@ import { buildWorkspaceTrustPathCandidates, buildWorkspaceTrustPreflightEnv, resolveWorkspaceTrustFeatureFlags, - type WorkspaceTrustCoordinator, type WorkspaceTrustArgsOnlyPlanRequest, type WorkspaceTrustArgsOnlyPlanResult, + type WorkspaceTrustCoordinator, type WorkspaceTrustDiagnosticsManifest, type WorkspaceTrustExecutionResult, type WorkspaceTrustFeatureFlags, @@ -2030,11 +2030,11 @@ type ProvisioningAuthSource = | 'none'; function isAnthropicApiKeyBackedAuthSource(authSource: unknown): boolean { - return ( - authSource === 'anthropic_api_key' || - authSource === 'anthropic_auth_token' || - authSource === 'anthropic_api_key_helper' - ); + return authSource === 'anthropic_api_key' || authSource === 'anthropic_api_key_helper'; +} + +function isAnthropicDirectCredentialAuthSource(authSource: unknown): boolean { + return isAnthropicApiKeyBackedAuthSource(authSource) || authSource === 'anthropic_auth_token'; } function buildAnthropicCrossProviderDirectAuthEnvPatch(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { @@ -2078,6 +2078,12 @@ interface TeamRuntimeLaunchArgsPlan { extraArgs: string[]; } +type WorkspaceTrustProviderArgsResolver = (input: { + providerId: TeamProviderId; + providerArgs: string[]; + phase: 'default-model-resolution'; +}) => string[]; + interface CrossProviderMemberArgsResult { args: string[]; providerArgsByProvider: Map; @@ -6168,6 +6174,18 @@ export class TeamProvisioningService { }).args; } + private createDefaultModelWorkspaceTrustProviderArgsResolver( + plan: Pick + ): WorkspaceTrustProviderArgsResolver { + return (input) => + this.applyWorkspaceTrustArgPatches({ + args: input.providerArgs, + patches: plan.launchArgPatches, + targetProvider: input.providerId, + targetSurface: 'default_model_probe', + }); + } + private async planWorkspaceTrustArgsOnlySafely( request: WorkspaceTrustArgsOnlyPlanRequest ): Promise { @@ -12740,9 +12758,21 @@ export class TeamProvisioningService { const foregroundMessages = inboxMessages.filter( (message) => message.messageKind !== 'member_work_sync_nudge' ); - const blockingForegroundMessages = foregroundMessages.filter( - (message) => !this.isCurrentReviewPickupRequestForegroundMessage(message, input) - ); + const agendaSyncRecoveryBypassMessageIds = + await this.getOpenCodeAgendaSyncRecoveryBypassMessageIds({ + teamName: input.teamName, + memberName: input.memberName, + workSyncIntent: input.workSyncIntent, + taskRefs: input.taskRefs, + foregroundMessages, + }); + const blockingForegroundMessages = foregroundMessages.filter((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + return ( + !agendaSyncRecoveryBypassMessageIds.has(messageId) && + !this.isCurrentReviewPickupRequestForegroundMessage(message, input) + ); + }); const unreadForeground = blockingForegroundMessages.find( (message) => !message.read && @@ -17246,16 +17276,15 @@ export class TeamProvisioningService { return envResolution; }; - let shouldRequireRuntimePingForAnthropicApiKey = - isAnthropicApiKeyBackedAuthSource(authSource); + let shouldRequireRuntimePingForAnthropicDirectCredential = + isAnthropicDirectCredentialAuthSource(authSource); if ( resolveTeamProviderId(providerId) === 'anthropic' && - !shouldRequireRuntimePingForAnthropicApiKey + !shouldRequireRuntimePingForAnthropicDirectCredential ) { const resolvedEnv = await ensureEnvResolution(); - shouldRequireRuntimePingForAnthropicApiKey = isAnthropicApiKeyBackedAuthSource( - resolvedEnv.authSource - ); + shouldRequireRuntimePingForAnthropicDirectCredential = + isAnthropicDirectCredentialAuthSource(resolvedEnv.authSource); if (resolvedEnv.authSource === 'configured_api_key_missing' && resolvedEnv.warning) { blockingMessages.push( providerIds.length > 1 @@ -17266,7 +17295,10 @@ export class TeamProvisioningService { } } - if (opts?.modelVerificationMode !== 'deep' && !shouldRequireRuntimePingForAnthropicApiKey) { + if ( + opts?.modelVerificationMode !== 'deep' && + !shouldRequireRuntimePingForAnthropicDirectCredential + ) { return; } const resolvedEnv = await ensureEnvResolution(); @@ -17293,7 +17325,7 @@ export class TeamProvisioningService { const prefixedWarning = providerIds.length > 1 ? `${providerLabel}: ${diagnostic.warning}` : diagnostic.warning; if ( - shouldRequireRuntimePingForAnthropicApiKey && + shouldRequireRuntimePingForAnthropicDirectCredential && this.isAuthFailureWarning(diagnostic.warning, 'probe') ) { blockingMessages.push(prefixedWarning); @@ -17318,7 +17350,7 @@ export class TeamProvisioningService { const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); const isBlockingPreflightWarning = authSource === 'configured_api_key_missing' || - (isAnthropicApiKeyBackedAuthSource(authSource) && isAuthFailure) || + (isAnthropicDirectCredentialAuthSource(authSource) && isAuthFailure) || ((authSource === 'none' || authSource === 'codex_runtime' || authSource === 'gemini_runtime') && @@ -17333,7 +17365,7 @@ export class TeamProvisioningService { isAuthFailure ) { blockingMessages.push(prefixedWarning); - } else if (isAnthropicApiKeyBackedAuthSource(authSource) && isAuthFailure) { + } else if (isAnthropicDirectCredentialAuthSource(authSource) && isAuthFailure) { blockingMessages.push(prefixedWarning); } else if (isBinaryProbeWarning(probeResult.warning)) { blockingMessages.push(prefixedWarning); @@ -19188,17 +19220,8 @@ export class TeamProvisioningService { featureFlags: workspaceTrustFeatureFlags, }) : { launchArgPatches: [] }; - const workspaceTrustProviderArgsResolver = (input: { - providerId: TeamProviderId; - providerArgs: string[]; - phase: 'default-model-resolution'; - }): string[] => - this.applyWorkspaceTrustArgPatches({ - args: input.providerArgs, - patches: workspaceTrustEarlyPlan.launchArgPatches, - targetProvider: input.providerId, - targetSurface: 'default_model_probe', - }); + const workspaceTrustProviderArgsResolver = + this.createDefaultModelWorkspaceTrustProviderArgsResolver(workspaceTrustEarlyPlan); const materializedMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ claudePath, cwd: request.cwd, @@ -20435,17 +20458,8 @@ export class TeamProvisioningService { featureFlags: workspaceTrustFeatureFlags, }) : { launchArgPatches: [] }; - const workspaceTrustProviderArgsResolver = (input: { - providerId: TeamProviderId; - providerArgs: string[]; - phase: 'default-model-resolution'; - }): string[] => - this.applyWorkspaceTrustArgPatches({ - args: input.providerArgs, - patches: workspaceTrustEarlyPlan.launchArgPatches, - targetProvider: input.providerId, - targetSurface: 'default_model_probe', - }); + const workspaceTrustProviderArgsResolver = + this.createDefaultModelWorkspaceTrustProviderArgsResolver(workspaceTrustEarlyPlan); const materializedMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ claudePath, @@ -22134,6 +22148,110 @@ export class TeamProvisioningService { return typeof message.messageId === 'string' && message.messageId.trim().length > 0; } + private async getOpenCodeAgendaSyncRecoveryBypassMessageIds(input: { + teamName: string; + memberName: string; + workSyncIntent?: 'agenda_sync' | 'review_pickup'; + taskRefs?: TaskRef[]; + foregroundMessages: InboxMessage[]; + }): Promise> { + const bypassMessageIds = new Set(); + if (input.workSyncIntent !== 'agenda_sync') { + return bypassMessageIds; + } + + const expectedRefs = this.normalizeOpenCodeTaskRefsForComparison(input.taskRefs); + if (expectedRefs.length === 0) { + return bypassMessageIds; + } + + const candidateMessages = input.foregroundMessages.filter( + (message): message is InboxMessage & { messageId: string } => { + if (!this.hasStableMessageId(message)) { + return false; + } + if (typeof message.text !== 'string' || message.text.trim().length === 0) { + return false; + } + if (Array.isArray(message.attachments) && message.attachments.length > 0) { + return false; + } + return true; + } + ); + if (candidateMessages.length === 0) { + return bypassMessageIds; + } + + const identity = await this.resolveOpenCodeMemberDeliveryIdentity( + input.teamName, + input.memberName + ).catch(() => null); + if (!identity?.ok) { + return bypassMessageIds; + } + + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), input.teamName).catch( + () => null + ); + if (laneIndex?.lanes[identity.laneId]?.state !== 'active') { + return bypassMessageIds; + } + + const records = await this.createOpenCodePromptDeliveryLedger(input.teamName, identity.laneId) + .list() + .catch(() => null); + const proofMissingRecords = (records ?? []).filter( + (record) => + record.teamName.trim().toLowerCase() === input.teamName.trim().toLowerCase() && + record.memberName.trim().toLowerCase() === + identity.canonicalMemberName.trim().toLowerCase() && + record.laneId === identity.laneId && + record.status === 'failed_terminal' && + !record.inboxReadCommittedAt && + this.openCodeTaskRefsOverlap(record.taskRefs, expectedRefs) && + this.isOpenCodeProtocolProofMissingRecord(record) + ); + if (proofMissingRecords.length === 0) { + return bypassMessageIds; + } + + const proofMissingMessageIds = new Set( + proofMissingRecords.map((record) => record.inboxMessageId.trim()).filter(Boolean) + ); + for (const message of candidateMessages) { + const messageId = message.messageId.trim(); + if (proofMissingMessageIds.has(messageId)) { + bypassMessageIds.add(messageId); + } + } + + return bypassMessageIds; + } + + private isOpenCodeProtocolProofMissingRecord( + record: OpenCodePromptDeliveryLedgerRecord + ): boolean { + return [record.lastReason, ...record.diagnostics].some( + (reason) => + typeof reason === 'string' && + classifyOpenCodeRuntimeDeliveryReasonCode(reason) === 'protocol_proof_missing' + ); + } + + private openCodeTaskRefsOverlap( + left: readonly TaskRef[] | undefined, + right: readonly TaskRef[] | undefined + ): boolean { + const leftRefs = this.normalizeOpenCodeTaskRefsForComparison(left); + const rightRefs = this.normalizeOpenCodeTaskRefsForComparison(right); + if (leftRefs.length === 0 || rightRefs.length === 0) { + return false; + } + const rightKeys = new Set(rightRefs.map((taskRef) => this.openCodeTaskRefKey(taskRef))); + return leftRefs.some((taskRef) => rightKeys.has(this.openCodeTaskRefKey(taskRef))); + } + private isCurrentReviewPickupRequestForegroundMessage( message: InboxMessage, input: { workSyncIntent?: 'agenda_sync' | 'review_pickup'; taskRefs?: TaskRef[] } @@ -32643,7 +32761,10 @@ export class TeamProvisioningService { if (env.anthropicApiKeyHelper) { usesAnthropicApiKeyHelper = true; Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch); - } else if (providerId === 'anthropic' && isAnthropicApiKeyBackedAuthSource(env.authSource)) { + } else if ( + providerId === 'anthropic' && + isAnthropicDirectCredentialAuthSource(env.authSource) + ) { Object.assign(envPatch, buildAnthropicCrossProviderDirectAuthEnvPatch(env.env)); } const flattenedArgs = diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts index b033598e..127fa2fb 100644 --- a/src/renderer/components/runtime/providerConnectionUi.ts +++ b/src/renderer/components/runtime/providerConnectionUi.ts @@ -125,7 +125,7 @@ function isAnthropicApiKeyModeReady(provider: CliProviderStatus): boolean { provider.connection?.configuredAuthMode === 'api_key' && provider.connection.apiKeyConfigured === true && provider.authenticated === true && - provider.authMethod === 'api_key' && + (provider.authMethod === 'api_key' || provider.authMethod === 'api_key_helper') && provider.verificationState === 'verified' ); } @@ -343,7 +343,11 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin return 'Saved API key available in Manage'; } - if (provider.authMethod !== 'api_key' && provider.providerId === 'anthropic') { + if ( + provider.providerId === 'anthropic' && + provider.authMethod !== 'api_key' && + provider.authMethod !== 'api_key_helper' + ) { return provider.connection.apiKeySource === 'stored' ? 'API key also configured in Manage' : (provider.connection.apiKeySourceLabel ?? 'API key is configured'); diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 52f89f1a..57ba565c 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -35,6 +35,8 @@ vi.mock('@main/utils/childProcess', () => ({ describe('ProviderConnectionService', () => { const originalOpenAiApiKey = process.env.OPENAI_API_KEY; const originalCodexApiKey = process.env.CODEX_API_KEY; + const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY; + const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL; function createConfig(authMode: 'auto' | 'oauth' | 'api_key' = 'auto') { return { @@ -62,6 +64,8 @@ describe('ProviderConnectionService', () => { execCliMock.mockResolvedValue({ stdout: 'Logged in using ChatGPT', stderr: '' }); delete process.env.OPENAI_API_KEY; delete process.env.CODEX_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_BASE_URL; }); afterEach(() => { @@ -76,6 +80,18 @@ describe('ProviderConnectionService', () => { } else { process.env.CODEX_API_KEY = originalCodexApiKey; } + + if (originalAnthropicApiKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey; + } + + if (originalAnthropicBaseUrl === undefined) { + delete process.env.ANTHROPIC_BASE_URL; + } else { + process.env.ANTHROPIC_BASE_URL = originalAnthropicBaseUrl; + } }); it('removes Anthropic environment credentials when OAuth mode is selected', async () => { @@ -340,7 +356,7 @@ describe('ProviderConnectionService', () => { apiKeySourceLabel: 'Stored in app', }, }); - expect(verifyAnthropicApiKey).toHaveBeenCalledWith('stored-key'); + expect(verifyAnthropicApiKey).toHaveBeenCalledWith('stored-key', null); }); it('reports Anthropic API key mode as connected after direct API verification succeeds', async () => { @@ -400,6 +416,66 @@ describe('ProviderConnectionService', () => { expect(verifyAnthropicApiKey).toHaveBeenCalledTimes(1); }); + it('verifies Anthropic API keys against ANTHROPIC_BASE_URL when configured', async () => { + getCachedShellEnvMock.mockReturnValue({ + ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic/', + }); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + const fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + try { + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'ANTHROPIC_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => createConfig('api_key'), + } as never + ); + + await service.enrichProviderStatus({ + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + statusMessage: 'Connected via Anthropic subscription', + models: ['claude-sonnet-4-6'], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { mcp: 'unsupported', skills: 'unsupported', plugins: 'unsupported' }, + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + } as never); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://gateway.example/anthropic/v1/models', + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-api-key': 'stored-key', + 'anthropic-version': '2023-06-01', + }), + method: 'GET', + }) + ); + } finally { + vi.unstubAllGlobals(); + } + }); + it('reports an invalid Anthropic API key after direct API verification fails', async () => { const { ProviderConnectionService } = await import('@main/services/runtime/ProviderConnectionService'); @@ -508,6 +584,57 @@ describe('ProviderConnectionService', () => { }); }); + it('treats Anthropic API-key helper runtime auth as verified API-key mode', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + const verifyAnthropicApiKey = vi.fn().mockResolvedValue({ state: 'unknown' }); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'ANTHROPIC_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => createConfig('api_key'), + } as never, + undefined, + verifyAnthropicApiKey + ); + + const status = await service.enrichProviderStatus({ + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'api_key_helper', + verificationState: 'verified', + statusMessage: null, + models: ['claude-sonnet-4-6'], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { mcp: 'unsupported', skills: 'unsupported', plugins: 'unsupported' }, + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + } as never); + + expect(status).toMatchObject({ + authenticated: true, + authMethod: 'api_key_helper', + verificationState: 'verified', + statusMessage: 'Connected via API key', + }); + expect(verifyAnthropicApiKey).not.toHaveBeenCalled(); + }); + it('does not treat a subscription session as connected when Anthropic API key mode has no key', async () => { const { ProviderConnectionService } = await import('@main/services/runtime/ProviderConnectionService'); diff --git a/test/main/services/team/OpenCodeAgendaSyncRecovery.safe-e2e.test.ts b/test/main/services/team/OpenCodeAgendaSyncRecovery.safe-e2e.test.ts new file mode 100644 index 00000000..1b224c6c --- /dev/null +++ b/test/main/services/team/OpenCodeAgendaSyncRecovery.safe-e2e.test.ts @@ -0,0 +1,486 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createMemberWorkSyncFeature } from '@features/member-work-sync/main'; +import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; +import { + OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION, + type OpenCodePromptDeliveryLedgerRecord, +} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger'; +import { + getOpenCodeLaneScopedRuntimeFilePath, + getOpenCodeRuntimeLaneIndexPath, +} from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; +import { getTeamsBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; + +import type { InboxMessage, TaskRef } from '@shared/types/team'; + +const tempRoots: string[] = []; + +function makeTempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-agenda-sync-e2e-')); + tempRoots.push(root); + return root; +} + +afterEach(() => { + setClaudeBasePathOverride(null); + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +async function waitForAssertion(assertion: () => Promise | void): Promise { + const deadline = Date.now() + 5_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + await assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + if (lastError) { + throw lastError; + } + await assertion(); +} + +async function seedNonBlockingShadowCollectingMetrics(input: { + teamsBasePath: string; + teamName: string; + memberName: string; +}): Promise { + const metricsPath = path.join( + input.teamsBasePath, + input.teamName, + '.member-work-sync', + 'indexes', + 'metrics.json' + ); + await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true }); + await fs.promises.writeFile( + metricsPath, + `${JSON.stringify( + { + schemaVersion: 2, + members: { + [input.memberName]: { + memberName: input.memberName, + state: 'caught_up', + agendaFingerprint: 'agenda:v1:seed', + actionableCount: 0, + evaluatedAt: '2026-01-01T00:00:00.000Z', + }, + }, + recentEvents: Array.from({ length: 18 }, (_, index) => ({ + id: `seed-status-${index}`, + teamName: input.teamName, + memberName: input.memberName, + kind: 'status_evaluated', + state: 'caught_up', + agendaFingerprint: `agenda:v1:seed-${index}`, + recordedAt: new Date(Date.UTC(2026, 0, 1, index * 6)).toISOString(), + actionableCount: 0, + })), + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function seedTeamConfig(input: { + teamsBasePath: string; + teamName: string; + memberName: string; +}): Promise { + const configPath = path.join(input.teamsBasePath, input.teamName, 'config.json'); + await fs.promises.mkdir(path.dirname(configPath), { recursive: true }); + await fs.promises.writeFile( + configPath, + `${JSON.stringify( + { + name: input.teamName, + projectPath: path.join(input.teamsBasePath, input.teamName, 'project'), + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { + name: input.memberName, + role: 'developer', + providerId: 'opencode', + model: 'openrouter/test', + }, + ], + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function seedInbox(input: { + teamsBasePath: string; + teamName: string; + memberName: string; + messages: InboxMessage[]; +}): Promise { + const inboxPath = path.join( + input.teamsBasePath, + input.teamName, + 'inboxes', + `${input.memberName}.json` + ); + await fs.promises.mkdir(path.dirname(inboxPath), { recursive: true }); + await fs.promises.writeFile(inboxPath, `${JSON.stringify(input.messages, null, 2)}\n`, 'utf8'); +} + +async function readInboxMessages(input: { + teamsBasePath: string; + teamName: string; + memberName: string; +}): Promise { + const inboxPath = path.join( + input.teamsBasePath, + input.teamName, + 'inboxes', + `${input.memberName}.json` + ); + const raw = await fs.promises.readFile(inboxPath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? (parsed as InboxMessage[]) : []; +} + +async function readMemberOutboxItems(input: { + teamsBasePath: string; + teamName: string; + memberName: string; +}): Promise< + Record< + string, + { status?: string; lastError?: string; nextAttemptAt?: string; deliveredMessageId?: string } + > +> { + const outboxPath = path.join( + input.teamsBasePath, + input.teamName, + 'members', + input.memberName, + '.member-work-sync', + 'outbox.json' + ); + const raw = await fs.promises.readFile(outboxPath, 'utf8'); + const parsed = JSON.parse(raw) as { + items?: Record< + string, + { status?: string; lastError?: string; nextAttemptAt?: string; deliveredMessageId?: string } + >; + }; + return parsed.items ?? {}; +} + +async function seedOpenCodeRuntimeLane(input: { + teamsBasePath: string; + teamName: string; + laneId: string; + records: OpenCodePromptDeliveryLedgerRecord[]; +}): Promise { + const now = '2026-02-23T17:30:00.000Z'; + const laneIndexPath = getOpenCodeRuntimeLaneIndexPath(input.teamsBasePath, input.teamName); + await fs.promises.mkdir(path.dirname(laneIndexPath), { recursive: true }); + await fs.promises.writeFile( + laneIndexPath, + `${JSON.stringify( + { + version: 1, + updatedAt: now, + lanes: { + [input.laneId]: { + laneId: input.laneId, + state: 'active', + updatedAt: now, + }, + }, + }, + null, + 2 + )}\n`, + 'utf8' + ); + + const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: input.teamsBasePath, + teamName: input.teamName, + laneId: input.laneId, + fileName: 'opencode-prompt-delivery-ledger.json', + }); + await fs.promises.mkdir(path.dirname(ledgerPath), { recursive: true }); + await fs.promises.writeFile( + ledgerPath, + `${JSON.stringify( + { + schemaVersion: OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION, + updatedAt: now, + data: input.records, + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +function buildProofMissingRecord(input: { + teamName: string; + memberName: string; + laneId: string; + inboxMessageId: string; + taskRefs: TaskRef[]; +}): OpenCodePromptDeliveryLedgerRecord { + return { + id: `opencode-prompt:${input.inboxMessageId}`, + teamName: input.teamName, + memberName: input.memberName, + laneId: input.laneId, + runId: 'run-1', + runtimeSessionId: 'session-1', + inboxMessageId: input.inboxMessageId, + inboxTimestamp: '2026-02-23T17:31:00.000Z', + source: 'watcher', + messageKind: 'default', + replyRecipient: 'team-lead', + actionMode: 'do', + taskRefs: input.taskRefs, + payloadHash: 'sha256:test', + status: 'failed_terminal', + responseState: 'responded_non_visible_tool', + attempts: 3, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: '2026-02-23T17:31:10.000Z', + lastObservedAt: '2026-02-23T17:31:15.000Z', + acceptedAt: '2026-02-23T17:31:05.000Z', + respondedAt: '2026-02-23T17:31:15.000Z', + failedAt: '2026-02-23T17:31:20.000Z', + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: 'msg-user', + observedAssistantMessageId: 'msg-assistant', + observedAssistantPreview: null, + observedToolCallNames: ['task_get', 'glob'], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: 'non_visible_tool_without_task_progress', + diagnostics: ['non_visible_tool_without_task_progress'], + createdAt: '2026-02-23T17:31:00.000Z', + updatedAt: '2026-02-23T17:31:20.000Z', + }; +} + +function createFeature(input: { + teamsBasePath: string; + teamName: string; + memberName: string; + service: TeamProvisioningService; + nudgeDeliveryWake: { schedule: ReturnType }; +}) { + return createMemberWorkSyncFeature({ + teamsBasePath: input.teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: input.teamName, + members: [{ name: input.memberName, providerId: 'opencode' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Recover OpenCode agenda sync', + status: 'pending', + owner: input.memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName: input.teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + extraBusySignals: [ + { + isBusy: (busyInput) => input.service.getOpenCodeMemberDeliveryBusyStatus(busyInput), + }, + ], + nudgeDeliveryWake: input.nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); +} + +describe('OpenCode agenda-sync proof-missing recovery safe e2e', () => { + it('delivers a work-sync nudge without marking the proof-missing foreground message read', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-opencode-agenda-sync-recovery'; + const memberName = 'jack'; + const laneId = 'secondary:opencode:jack'; + const taskRef: TaskRef = { teamName, taskId: 'task-1', displayId: '11111111' }; + const foregroundMessageId = 'proof-missing-message-1'; + const service = new TeamProvisioningService(); + const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined) }; + const feature = createFeature({ + teamsBasePath, + teamName, + memberName, + service, + nudgeDeliveryWake, + }); + + try { + await seedTeamConfig({ teamsBasePath, teamName, memberName }); + await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + await seedInbox({ + teamsBasePath, + teamName, + memberName, + messages: [ + { + from: 'team-lead', + to: memberName, + text: 'Please continue task #11111111.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: foregroundMessageId, + messageKind: 'default', + taskRefs: [taskRef], + }, + ], + }); + await seedOpenCodeRuntimeLane({ + teamsBasePath, + teamName, + laneId, + records: [ + buildProofMissingRecord({ + teamName, + memberName, + laneId, + inboxMessageId: foregroundMessageId, + taskRefs: [taskRef], + }), + ], + }); + + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + const inbox = await readInboxMessages({ teamsBasePath, teamName, memberName }); + const foreground = inbox.find((message) => message.messageId === foregroundMessageId); + const nudges = inbox.filter((message) => message.messageKind === 'member_work_sync_nudge'); + expect(foreground).toMatchObject({ read: false }); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ + teamName, + memberName, + messageId: nudges[0]?.messageId, + providerId: 'opencode', + reason: 'member_work_sync_nudge_inserted', + delayMs: 500, + }); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual([ + expect.objectContaining({ + status: 'delivered', + deliveredMessageId: nudges[0]?.messageId, + }), + ]); + }); + } finally { + await feature.dispose(); + } + }); + + it('keeps the nudge retryable when unread foreground lacks proof-missing ledger evidence', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-opencode-agenda-sync-no-proof'; + const memberName = 'jack'; + const laneId = 'secondary:opencode:jack'; + const taskRef: TaskRef = { teamName, taskId: 'task-1', displayId: '11111111' }; + const service = new TeamProvisioningService(); + const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined) }; + const feature = createFeature({ + teamsBasePath, + teamName, + memberName, + service, + nudgeDeliveryWake, + }); + + try { + await seedTeamConfig({ teamsBasePath, teamName, memberName }); + await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + await seedInbox({ + teamsBasePath, + teamName, + memberName, + messages: [ + { + from: 'team-lead', + to: memberName, + text: 'Please continue task #11111111.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'foreground-message-1', + messageKind: 'default', + taskRefs: [taskRef], + }, + ], + }); + await seedOpenCodeRuntimeLane({ teamsBasePath, teamName, laneId, records: [] }); + + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + const inbox = await readInboxMessages({ teamsBasePath, teamName, memberName }); + expect(inbox.filter((message) => message.messageKind === 'member_work_sync_nudge')).toEqual( + [] + ); + expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual([ + expect.objectContaining({ + status: 'failed_retryable', + lastError: 'member_busy:opencode_foreground_inbox_unread', + }), + ]); + }); + } finally { + await feature.dispose(); + } + }); +}); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index efeb8d0e..0ba41953 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -243,6 +243,107 @@ function attachAliveRun( return { writeSpy, runId }; } +function buildOpenCodeProofMissingRecord(input: { + teamName: string; + memberName: string; + laneId: string; + inboxMessageId: string; + taskRefs: Array<{ teamName: string; taskId: string; displayId: string }>; +}): Record { + return { + id: `opencode-prompt:${input.inboxMessageId}`, + teamName: input.teamName, + memberName: input.memberName, + laneId: input.laneId, + runId: null, + runtimeSessionId: null, + inboxMessageId: input.inboxMessageId, + inboxTimestamp: '2026-02-23T17:31:00.000Z', + source: 'watcher', + messageKind: 'default', + replyRecipient: 'team-lead', + actionMode: 'do', + taskRefs: input.taskRefs, + payloadHash: 'sha256:test', + status: 'failed_terminal', + responseState: 'responded_non_visible_tool', + attempts: 3, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: '2026-02-23T17:31:10.000Z', + lastObservedAt: '2026-02-23T17:31:15.000Z', + acceptedAt: '2026-02-23T17:31:05.000Z', + respondedAt: '2026-02-23T17:31:15.000Z', + failedAt: '2026-02-23T17:31:20.000Z', + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: 'msg-user', + observedAssistantMessageId: 'msg-assistant', + observedAssistantPreview: null, + observedToolCallNames: ['task_get', 'glob'], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: 'non_visible_tool_without_task_progress', + diagnostics: ['non_visible_tool_without_task_progress'], + createdAt: '2026-02-23T17:31:00.000Z', + updatedAt: '2026-02-23T17:31:20.000Z', + }; +} + +function seedOpenCodeBusyStatusFixture(input: { + service: TeamProvisioningService; + teamName: string; + laneId: string; + inboxMessages: unknown[]; + memberName?: string; + laneState?: 'active' | 'stopped'; + ledgerRecords?: Record[]; + activeRecord?: Record | null; +}): void { + const memberName = input.memberName ?? 'jack'; + const teamsBasePath = getTeamsBasePath(); + hoisted.files.set( + `${teamsBasePath}/${input.teamName}/config.json`, + JSON.stringify({ + name: input.teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: memberName, role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + `${teamsBasePath}/${input.teamName}/inboxes/${memberName}.json`, + JSON.stringify(input.inboxMessages) + ); + (input.service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({ + ok: true, + canonicalMemberName: memberName, + laneId: input.laneId, + })); + vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + [input.laneId]: { + laneId: input.laneId, + state: input.laneState ?? 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }); + vi.spyOn(input.service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({ + list: vi.fn(async () => input.ledgerRecords ?? []), + getActiveForMember: vi.fn(async () => input.activeRecord ?? null), + }); +} + async function waitForCapture(service: TeamProvisioningService): Promise { const runs = (service as unknown as { runs: Map }).runs; const run = runs.get('run-1') as any; @@ -3127,6 +3228,688 @@ Messages: }); }); + it('allows OpenCode agenda-sync recovery past the exact proof-missing foreground message', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const teamsBasePath = getTeamsBasePath(); + const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' }; + hoisted.files.set( + `${teamsBasePath}/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + `${teamsBasePath}/${teamName}/inboxes/jack.json`, + JSON.stringify([ + { + from: 'team-lead', + to: 'jack', + text: 'Please continue task #task1234.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'proof-missing-message-1', + messageKind: 'default', + taskRefs: [taskRef], + }, + ]) + ); + (service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({ + ok: true, + canonicalMemberName: 'jack', + laneId, + })); + vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + [laneId]: { + laneId, + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }); + vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({ + list: vi.fn(async () => [ + buildOpenCodeProofMissingRecord({ + teamName, + memberName: 'jack', + laneId, + inboxMessageId: 'proof-missing-message-1', + taskRefs: [taskRef], + }), + ]), + getActiveForMember: vi.fn(async () => null), + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:32:00.000Z', + workSyncIntent: 'agenda_sync', + taskRefs: [taskRef], + }); + + expect(busy).toEqual({ busy: false }); + }); + + it('allows OpenCode agenda-sync recovery for legacy proof-missing foreground ids', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' }; + const legacyMessage = { + from: 'team-lead', + to: 'jack', + text: 'Please continue task #task1234.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageKind: 'default', + taskRefs: [taskRef], + }; + const legacyMessageId = buildLegacyInboxMessageId( + legacyMessage.from, + legacyMessage.timestamp, + legacyMessage.text + ); + seedOpenCodeBusyStatusFixture({ + service, + teamName, + laneId, + inboxMessages: [legacyMessage], + ledgerRecords: [ + buildOpenCodeProofMissingRecord({ + teamName, + memberName: 'jack', + laneId, + inboxMessageId: legacyMessageId, + taskRefs: [taskRef], + }), + ], + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:32:00.000Z', + workSyncIntent: 'agenda_sync', + taskRefs: [taskRef], + }); + + expect(busy).toEqual({ busy: false }); + }); + + it('keeps newer same-task OpenCode foreground messages busy during agenda-sync recovery', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' }; + seedOpenCodeBusyStatusFixture({ + service, + teamName, + laneId, + inboxMessages: [ + { + from: 'team-lead', + to: 'jack', + text: 'Please continue task #task1234.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'proof-missing-message-1', + messageKind: 'default', + taskRefs: [taskRef], + }, + { + from: 'team-lead', + to: 'jack', + text: 'Dependency resolved. Please re-check #task1234.', + timestamp: '2026-02-23T17:31:40.000Z', + read: false, + messageId: 'same-task-follow-up-1', + messageKind: 'default', + taskRefs: [taskRef], + }, + ], + ledgerRecords: [ + buildOpenCodeProofMissingRecord({ + teamName, + memberName: 'jack', + laneId, + inboxMessageId: 'proof-missing-message-1', + taskRefs: [taskRef], + }), + ], + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:32:00.000Z', + workSyncIntent: 'agenda_sync', + taskRefs: [taskRef], + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_foreground_inbox_unread', + activeMessageId: 'same-task-follow-up-1', + }); + }); + + it('keeps OpenCode agenda-sync busy when proof-missing recovery evidence is absent', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const teamsBasePath = getTeamsBasePath(); + const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' }; + hoisted.files.set( + `${teamsBasePath}/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + `${teamsBasePath}/${teamName}/inboxes/jack.json`, + JSON.stringify([ + { + from: 'team-lead', + to: 'jack', + text: 'Please continue task #task1234.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'foreground-message-1', + messageKind: 'default', + taskRefs: [taskRef], + }, + ]) + ); + (service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({ + ok: true, + canonicalMemberName: 'jack', + laneId, + })); + vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + [laneId]: { + laneId, + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }); + vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({ + list: vi.fn(async () => []), + getActiveForMember: vi.fn(async () => null), + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:31:10.000Z', + workSyncIntent: 'agenda_sync', + taskRefs: [taskRef], + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_foreground_inbox_unread', + activeMessageId: 'foreground-message-1', + }); + }); + + it('keeps unrelated unread OpenCode foreground messages busy during agenda-sync recovery', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const teamsBasePath = getTeamsBasePath(); + const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' }; + hoisted.files.set( + `${teamsBasePath}/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + `${teamsBasePath}/${teamName}/inboxes/jack.json`, + JSON.stringify([ + { + from: 'team-lead', + to: 'jack', + text: 'Please continue task #task1234.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'proof-missing-message-1', + messageKind: 'default', + taskRefs: [taskRef], + }, + { + from: 'user', + to: 'jack', + text: 'Unrelated direct instruction.', + timestamp: '2026-02-23T17:31:20.000Z', + read: false, + messageId: 'unrelated-message-1', + messageKind: 'direct', + }, + ]) + ); + (service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({ + ok: true, + canonicalMemberName: 'jack', + laneId, + })); + vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + [laneId]: { + laneId, + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }); + vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({ + list: vi.fn(async () => [ + buildOpenCodeProofMissingRecord({ + teamName, + memberName: 'jack', + laneId, + inboxMessageId: 'proof-missing-message-1', + taskRefs: [taskRef], + }), + ]), + getActiveForMember: vi.fn(async () => null), + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:31:30.000Z', + workSyncIntent: 'agenda_sync', + taskRefs: [taskRef], + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_foreground_inbox_unread', + activeMessageId: 'unrelated-message-1', + }); + }); + + it('keeps OpenCode agenda-sync busy when an active prompt ledger record exists after recovery bypass', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const teamsBasePath = getTeamsBasePath(); + const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' }; + const proofMissingRecord = buildOpenCodeProofMissingRecord({ + teamName, + memberName: 'jack', + laneId, + inboxMessageId: 'proof-missing-message-1', + taskRefs: [taskRef], + }); + hoisted.files.set( + `${teamsBasePath}/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + `${teamsBasePath}/${teamName}/inboxes/jack.json`, + JSON.stringify([ + { + from: 'team-lead', + to: 'jack', + text: 'Please continue task #task1234.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'proof-missing-message-1', + messageKind: 'default', + taskRefs: [taskRef], + }, + ]) + ); + (service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({ + ok: true, + canonicalMemberName: 'jack', + laneId, + })); + vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + [laneId]: { + laneId, + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }); + vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({ + list: vi.fn(async () => [proofMissingRecord]), + getActiveForMember: vi.fn(async () => ({ + ...proofMissingRecord, + id: 'opencode-prompt:active-nudge-1', + inboxMessageId: 'active-nudge-1', + messageKind: 'member_work_sync_nudge', + status: 'accepted', + nextAttemptAt: '2026-02-23T17:33:00.000Z', + })), + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:32:00.000Z', + workSyncIntent: 'agenda_sync', + taskRefs: [taskRef], + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_prompt_delivery_active:member_work_sync_nudge', + activeMessageId: 'active-nudge-1', + retryAfterIso: '2026-02-23T17:33:00.000Z', + }); + }); + + it('keeps OpenCode agenda-sync busy for same-task proof-missing messages with attachments', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const teamsBasePath = getTeamsBasePath(); + const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' }; + hoisted.files.set( + `${teamsBasePath}/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + `${teamsBasePath}/${teamName}/inboxes/jack.json`, + JSON.stringify([ + { + from: 'team-lead', + to: 'jack', + text: 'Please continue task #task1234.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'proof-missing-message-1', + messageKind: 'default', + taskRefs: [taskRef], + attachments: [{ id: 'attachment-1', filename: 'notes.txt', mimeType: 'text/plain' }], + }, + ]) + ); + (service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({ + ok: true, + canonicalMemberName: 'jack', + laneId, + })); + vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + [laneId]: { + laneId, + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }); + vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({ + list: vi.fn(async () => [ + buildOpenCodeProofMissingRecord({ + teamName, + memberName: 'jack', + laneId, + inboxMessageId: 'proof-missing-message-1', + taskRefs: [taskRef], + }), + ]), + getActiveForMember: vi.fn(async () => null), + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:32:00.000Z', + workSyncIntent: 'agenda_sync', + taskRefs: [taskRef], + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_foreground_inbox_unread', + activeMessageId: 'proof-missing-message-1', + }); + }); + + it('keeps OpenCode proof-missing foreground messages busy outside agenda-sync recovery', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' }; + seedOpenCodeBusyStatusFixture({ + service, + teamName, + laneId, + inboxMessages: [ + { + from: 'team-lead', + to: 'jack', + text: 'Please continue task #task1234.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'proof-missing-message-1', + messageKind: 'default', + taskRefs: [taskRef], + }, + ], + ledgerRecords: [ + buildOpenCodeProofMissingRecord({ + teamName, + memberName: 'jack', + laneId, + inboxMessageId: 'proof-missing-message-1', + taskRefs: [taskRef], + }), + ], + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:32:00.000Z', + taskRefs: [taskRef], + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_foreground_inbox_unread', + activeMessageId: 'proof-missing-message-1', + }); + }); + + it('keeps OpenCode agenda-sync busy when proof-missing record task refs do not overlap', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' }; + const otherTaskRef = { teamName, taskId: 'task-9999', displayId: 'task9999' }; + seedOpenCodeBusyStatusFixture({ + service, + teamName, + laneId, + inboxMessages: [ + { + from: 'team-lead', + to: 'jack', + text: 'Please continue task #task1234.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'proof-missing-message-1', + messageKind: 'default', + taskRefs: [taskRef], + }, + ], + ledgerRecords: [ + buildOpenCodeProofMissingRecord({ + teamName, + memberName: 'jack', + laneId, + inboxMessageId: 'proof-missing-message-1', + taskRefs: [otherTaskRef], + }), + ], + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:32:00.000Z', + workSyncIntent: 'agenda_sync', + taskRefs: [taskRef], + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_foreground_inbox_unread', + activeMessageId: 'proof-missing-message-1', + }); + }); + + it('keeps OpenCode agenda-sync busy when terminal ledger reason is not proof missing', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' }; + seedOpenCodeBusyStatusFixture({ + service, + teamName, + laneId, + inboxMessages: [ + { + from: 'team-lead', + to: 'jack', + text: 'Please continue task #task1234.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'terminal-message-1', + messageKind: 'default', + taskRefs: [taskRef], + }, + ], + ledgerRecords: [ + { + ...buildOpenCodeProofMissingRecord({ + teamName, + memberName: 'jack', + laneId, + inboxMessageId: 'terminal-message-1', + taskRefs: [taskRef], + }), + responseState: 'permission_blocked', + lastReason: 'permission_blocked', + diagnostics: ['permission_blocked'], + }, + ], + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:32:00.000Z', + workSyncIntent: 'agenda_sync', + taskRefs: [taskRef], + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_foreground_inbox_unread', + activeMessageId: 'terminal-message-1', + }); + }); + + it('keeps OpenCode agenda-sync busy when the proof-missing lane is inactive', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' }; + seedOpenCodeBusyStatusFixture({ + service, + teamName, + laneId, + laneState: 'stopped', + inboxMessages: [ + { + from: 'team-lead', + to: 'jack', + text: 'Please continue task #task1234.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'proof-missing-message-1', + messageKind: 'default', + taskRefs: [taskRef], + }, + ], + ledgerRecords: [ + buildOpenCodeProofMissingRecord({ + teamName, + memberName: 'jack', + laneId, + inboxMessageId: 'proof-missing-message-1', + taskRefs: [taskRef], + }), + ], + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:32:00.000Z', + workSyncIntent: 'agenda_sync', + taskRefs: [taskRef], + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_foreground_inbox_unread', + activeMessageId: 'proof-missing-message-1', + }); + }); + it('does not treat the current unread OpenCode review request as busy for review-pickup checks', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; diff --git a/test/renderer/components/runtime/providerConnectionUi.test.ts b/test/renderer/components/runtime/providerConnectionUi.test.ts index bc740d5d..b2620e4d 100644 --- a/test/renderer/components/runtime/providerConnectionUi.test.ts +++ b/test/renderer/components/runtime/providerConnectionUi.test.ts @@ -198,6 +198,20 @@ describe('providerConnectionUi', () => { expect(getProviderCredentialSummary(provider)).toBe('Stored in app'); }); + it('shows Anthropic API key helper as verified API-key mode', () => { + const provider = createAnthropicProvider({ + authenticated: true, + authMethod: 'api_key_helper', + configuredAuthMode: 'api_key', + apiKeyConfigured: true, + apiKeySource: 'stored', + apiKeySourceLabel: 'Stored in app', + }); + + expect(formatProviderStatusText(provider)).toBe('Connected via API key'); + expect(getProviderCredentialSummary(provider)).toBe('Stored in app'); + }); + it('does not show API key mode as connected when only a stored key is known', () => { const provider = createAnthropicProvider({ authenticated: false,