fix(opencode): recover agenda sync after missing proof

Recover OpenCode agenda sync after protocol-proof-missing delivery failures and harden Anthropic provider readiness handling.
This commit is contained in:
infiniti 2026-05-14 11:12:37 +03:00 committed by GitHub
parent 7c0b57cae4
commit c57c513cf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1627 additions and 69 deletions

View file

@ -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<AnthropicApiKeyVerificationResult>;
type AnthropicApiKeyVerifier = (
apiKey: string,
baseUrl?: string | null
) => Promise<AnthropicApiKeyVerificationResult>;
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<AnthropicApiKeyVerificationResult> {
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();

View file

@ -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<TeamProviderId, string[]>;
@ -6168,6 +6174,18 @@ export class TeamProvisioningService {
}).args;
}
private createDefaultModelWorkspaceTrustProviderArgsResolver(
plan: Pick<WorkspaceTrustArgsOnlyPlanResult, 'launchArgPatches'>
): WorkspaceTrustProviderArgsResolver {
return (input) =>
this.applyWorkspaceTrustArgPatches({
args: input.providerArgs,
patches: plan.launchArgPatches,
targetProvider: input.providerId,
targetSurface: 'default_model_probe',
});
}
private async planWorkspaceTrustArgsOnlySafely(
request: WorkspaceTrustArgsOnlyPlanRequest
): Promise<WorkspaceTrustArgsOnlyPlanResult> {
@ -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<Set<string>> {
const bypassMessageIds = new Set<string>();
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 =

View file

@ -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');

View file

@ -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');

View file

@ -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> | void): Promise<void> {
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<void> {
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<void> {
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<void> {
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<InboxMessage[]> {
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<void> {
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<typeof vi.fn> };
}) {
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();
}
});
});

View file

@ -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<string, unknown> {
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<string, unknown>[];
activeRecord?: Record<string, unknown> | 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<any> {
const runs = (service as unknown as { runs: Map<string, unknown> }).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';

View file

@ -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,