import { createMemberWorkSyncFeature } from '@features/member-work-sync/main'; 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 { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; import { getTeamsBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { InboxMessage, TaskRef } from '@shared/types/team'; const tempRoots: string[] = []; function makeTempRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-agenda-sync-e2e-')); tempRoots.push(root); return root; } afterEach(() => { setClaudeBasePathOverride(null); for (const root of tempRoots.splice(0)) { fs.rmSync(root, { recursive: true, force: true }); } }); async function waitForAssertion(assertion: () => Promise | void): Promise { const deadline = Date.now() + 5_000; let lastError: unknown; while (Date.now() < deadline) { try { await assertion(); return; } catch (error) { lastError = error; await new Promise((resolve) => setTimeout(resolve, 10)); } } if (lastError) { throw lastError; } await assertion(); } async function seedNonBlockingShadowCollectingMetrics(input: { teamsBasePath: string; teamName: string; memberName: string; statusEventCount?: number; }): Promise { const statusEventCount = input.statusEventCount ?? 18; 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: statusEventCount }, (_, 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; providerId?: 'opencode' | 'codex'; }): Promise { const providerId = input.providerId ?? 'opencode'; 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, model: providerId === 'codex' ? 'gpt-5.4-mini' : 'openrouter/test', }, ], }, null, 2 )}\n`, 'utf8' ); } async function seedInbox(input: { teamsBasePath: string; teamName: string; memberName: string; messages: InboxMessage[]; }): Promise { const inboxPath = path.join( input.teamsBasePath, input.teamName, 'inboxes', `${input.memberName}.json` ); await fs.promises.mkdir(path.dirname(inboxPath), { recursive: true }); await fs.promises.writeFile(inboxPath, `${JSON.stringify(input.messages, null, 2)}\n`, 'utf8'); } async function readInboxMessages(input: { teamsBasePath: string; teamName: string; memberName: string; }): Promise { const inboxPath = path.join( input.teamsBasePath, input.teamName, 'inboxes', `${input.memberName}.json` ); const raw = await fs.promises.readFile(inboxPath, 'utf8'); const parsed = JSON.parse(raw) as unknown; return Array.isArray(parsed) ? (parsed as InboxMessage[]) : []; } async function readMemberOutboxItems(input: { teamsBasePath: string; teamName: string; memberName: string; }): Promise< Record< string, { status?: string; lastError?: string; nextAttemptAt?: string; deliveredMessageId?: string } > > { const outboxPath = path.join( input.teamsBasePath, input.teamName, 'members', input.memberName, '.member-work-sync', 'outbox.json' ); const raw = await fs.promises.readFile(outboxPath, 'utf8'); const parsed = JSON.parse(raw) as { items?: Record< string, { status?: string; lastError?: string; nextAttemptAt?: string; deliveredMessageId?: string } >; }; return parsed.items ?? {}; } async function seedOpenCodeRuntimeLane(input: { teamsBasePath: string; teamName: string; laneId: string; records: OpenCodePromptDeliveryLedgerRecord[]; }): Promise { const now = '2026-02-23T17:30:00.000Z'; const laneIndexPath = getOpenCodeRuntimeLaneIndexPath(input.teamsBasePath, input.teamName); await fs.promises.mkdir(path.dirname(laneIndexPath), { recursive: true }); await fs.promises.writeFile( laneIndexPath, `${JSON.stringify( { version: 1, updatedAt: now, lanes: { [input.laneId]: { laneId: input.laneId, state: 'active', updatedAt: now, }, }, }, null, 2 )}\n`, 'utf8' ); const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({ teamsBasePath: input.teamsBasePath, teamName: input.teamName, laneId: input.laneId, fileName: 'opencode-prompt-delivery-ledger.json', }); await fs.promises.mkdir(path.dirname(ledgerPath), { recursive: true }); await fs.promises.writeFile( ledgerPath, `${JSON.stringify( { schemaVersion: OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION, updatedAt: now, data: input.records, }, null, 2 )}\n`, 'utf8' ); } function buildProofMissingRecord(input: { teamName: string; memberName: string; laneId: string; inboxMessageId: string; taskRefs: TaskRef[]; }): OpenCodePromptDeliveryLedgerRecord { return { id: `opencode-prompt:${input.inboxMessageId}`, teamName: input.teamName, memberName: input.memberName, laneId: input.laneId, runId: 'run-1', runtimeSessionId: 'session-1', inboxMessageId: input.inboxMessageId, inboxTimestamp: '2026-02-23T17:31:00.000Z', source: 'watcher', messageKind: 'default', replyRecipient: 'team-lead', actionMode: 'do', taskRefs: input.taskRefs, payloadHash: 'sha256:test', status: 'failed_terminal', responseState: 'responded_non_visible_tool', attempts: 3, maxAttempts: 3, acceptanceUnknown: false, nextAttemptAt: null, lastAttemptAt: '2026-02-23T17:31:10.000Z', lastObservedAt: '2026-02-23T17:31:15.000Z', acceptedAt: '2026-02-23T17:31:05.000Z', respondedAt: '2026-02-23T17:31:15.000Z', failedAt: '2026-02-23T17:31:20.000Z', inboxReadCommittedAt: null, inboxReadCommitError: null, prePromptCursor: null, postPromptCursor: null, deliveredUserMessageId: 'msg-user', observedAssistantMessageId: 'msg-assistant', observedAssistantPreview: null, observedToolCallNames: ['task_get', 'glob'], observedVisibleMessageId: null, visibleReplyMessageId: null, visibleReplyInbox: null, visibleReplyCorrelation: null, lastReason: 'non_visible_tool_without_task_progress', diagnostics: ['non_visible_tool_without_task_progress'], createdAt: '2026-02-23T17:31:00.000Z', updatedAt: '2026-02-23T17:31:20.000Z', }; } function createFeature(input: { teamsBasePath: string; teamName: string; memberName: string; service: TeamProvisioningService; nudgeDeliveryWake: { schedule: ReturnType }; providerId?: 'opencode' | 'codex'; }) { const providerId = input.providerId ?? 'opencode'; return createMemberWorkSyncFeature({ teamsBasePath: input.teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: input.teamName, members: [{ name: input.memberName, providerId }], })), } 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: providerId === 'opencode' ? [ { isBusy: (busyInput) => input.service.getOpenCodeMemberDeliveryBusyStatus(busyInput), }, ] : [], nudgeDeliveryWake: input.nudgeDeliveryWake, queueQuietWindowMs: 1, }); } describe('OpenCode agenda-sync proof-missing recovery safe e2e', () => { it('delivers a Codex work-sync nudge during shadow collection with prefixed MCP aliases and schedules a Codex wake', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-codex-agenda-sync-nudge'; const memberName = 'bob'; const service = new TeamProvisioningService(); const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined) }; const feature = createFeature({ teamsBasePath, teamName, memberName, service, nudgeDeliveryWake, providerId: 'codex', }); try { await seedTeamConfig({ teamsBasePath, teamName, memberName, providerId: 'codex' }); await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName, }); await feature.refreshStatus({ teamName, memberName }); await feature.dispatchDueNudges([teamName]); await waitForAssertion(async () => { const inbox = await readInboxMessages({ teamsBasePath, teamName, memberName }); const nudges = inbox.filter((message) => message.messageKind === 'member_work_sync_nudge'); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('Required sync action: call member_work_sync_status'); expect(nudges[0]?.text).toContain('mcp__agent-teams__member_work_sync_status'); expect(nudges[0]?.text).toContain('mcp__agent-teams__member_work_sync_report'); expect(nudges[0]?.text).toContain('Do not search the filesystem'); await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ phase2Readiness: { state: 'collecting_shadow_data' }, }); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ teamName, memberName, messageId: nudges[0]?.messageId, providerId: 'codex', 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('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(); } }); });