chore: merge origin dev into local dev
This commit is contained in:
commit
8ab190bad8
7 changed files with 1626 additions and 69 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ import {
|
|||
buildWorkspaceTrustPathCandidates,
|
||||
buildWorkspaceTrustPreflightEnv,
|
||||
resolveWorkspaceTrustFeatureFlags,
|
||||
type WorkspaceTrustCoordinator,
|
||||
type WorkspaceTrustArgsOnlyPlanRequest,
|
||||
type WorkspaceTrustArgsOnlyPlanResult,
|
||||
type WorkspaceTrustCoordinator,
|
||||
type WorkspaceTrustDiagnosticsManifest,
|
||||
type WorkspaceTrustExecutionResult,
|
||||
type WorkspaceTrustFeatureFlags,
|
||||
|
|
@ -2074,11 +2074,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 {
|
||||
|
|
@ -2122,6 +2122,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[]>;
|
||||
|
|
@ -6228,6 +6234,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> {
|
||||
|
|
@ -12887,11 +12905,22 @@ export class TeamProvisioningService {
|
|||
const foregroundMessages = inboxMessages.filter(
|
||||
(message) => message.messageKind !== 'member_work_sync_nudge'
|
||||
);
|
||||
const blockingForegroundMessages = foregroundMessages.filter(
|
||||
(message) =>
|
||||
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) &&
|
||||
!this.isCurrentProofMissingRecoveryForegroundMessage(message, input)
|
||||
);
|
||||
);
|
||||
});
|
||||
const unreadForeground = blockingForegroundMessages.find(
|
||||
(message) =>
|
||||
!message.read &&
|
||||
|
|
@ -17397,16 +17426,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
|
||||
|
|
@ -17417,7 +17445,10 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
if (opts?.modelVerificationMode !== 'deep' && !shouldRequireRuntimePingForAnthropicApiKey) {
|
||||
if (
|
||||
opts?.modelVerificationMode !== 'deep' &&
|
||||
!shouldRequireRuntimePingForAnthropicDirectCredential
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const resolvedEnv = await ensureEnvResolution();
|
||||
|
|
@ -17444,7 +17475,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);
|
||||
|
|
@ -17469,7 +17500,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') &&
|
||||
|
|
@ -17484,7 +17515,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);
|
||||
|
|
@ -19365,17 +19396,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,
|
||||
|
|
@ -20612,17 +20634,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,
|
||||
|
|
@ -22332,6 +22345,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[] }
|
||||
|
|
@ -32860,7 +32977,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 =
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -3214,6 +3315,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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue