566 lines
17 KiB
TypeScript
566 lines
17 KiB
TypeScript
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 { Mock } from 'vitest';
|
|
|
|
import type { MemberWorkSyncNudgeDeliveryWakePort } from '@features/member-work-sync/core/application/ports';
|
|
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;
|
|
statusEventCount?: number;
|
|
}): Promise<void> {
|
|
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<void> {
|
|
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<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',
|
|
};
|
|
}
|
|
|
|
type TestNudgeDeliveryWake = MemberWorkSyncNudgeDeliveryWakePort & {
|
|
schedule: Mock<MemberWorkSyncNudgeDeliveryWakePort['schedule']>;
|
|
};
|
|
|
|
function createNudgeDeliveryWake(): TestNudgeDeliveryWake {
|
|
return {
|
|
schedule: vi.fn<MemberWorkSyncNudgeDeliveryWakePort['schedule']>(async () => undefined),
|
|
};
|
|
}
|
|
|
|
function createFeature(input: {
|
|
teamsBasePath: string;
|
|
teamName: string;
|
|
memberName: string;
|
|
service: TeamProvisioningService;
|
|
nudgeDeliveryWake: TestNudgeDeliveryWake;
|
|
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 = createNudgeDeliveryWake();
|
|
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 = createNudgeDeliveryWake();
|
|
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 = createNudgeDeliveryWake();
|
|
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();
|
|
}
|
|
});
|
|
});
|