agent-ecosystem/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts

366 lines
11 KiB
TypeScript

import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature,
} from '@features/member-work-sync/main';
import { buildMemberWorkSyncOutboxEnsureInput } from '@features/member-work-sync/core/domain';
import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore';
import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths';
import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter';
import { RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV } from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment';
import { getTeamsBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
const tempRoots: string[] = [];
function makeTempRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'member-work-sync-feature-'));
tempRoots.push(root);
return root;
}
afterEach(() => {
setClaudeBasePathOverride(null);
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
async function seedShadowReadyMetrics(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: 20 }, (_, 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)).toISOString(),
actionableCount: 0,
})),
},
null,
2
)}\n`,
'utf8'
);
}
async function waitForAssertion(assertion: () => Promise<void> | void): Promise<void> {
const deadline = Date.now() + 1_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();
}
describe('createMemberWorkSyncFeature composition', () => {
it('dispatches a due nudge through the real outbox and inbox by default', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);
const teamsBasePath = getTeamsBasePath();
const teamName = 'team-a';
const memberName = 'bob';
const feature = createMemberWorkSyncFeature({
teamsBasePath,
configReader: {
getConfig: vi.fn(async () => ({
name: teamName,
members: [{ name: memberName }],
})),
} as never,
taskReader: {
getTasks: vi.fn(async () => [
{
id: 'task-1',
displayId: '11111111',
subject: 'Ship sync',
status: 'pending',
owner: memberName,
},
]),
} as never,
kanbanManager: {
getState: vi.fn(async () => ({
teamName,
reviewers: [],
tasks: {},
})),
} as never,
membersMetaStore: {
getMembers: vi.fn(async () => []),
} as never,
});
try {
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
const status = await feature.refreshStatus({ teamName, memberName });
expect(status).toMatchObject({
state: 'needs_sync',
shadow: { wouldNudge: true },
});
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
phase2Readiness: { state: 'shadow_ready' },
});
const outboxInput = buildMemberWorkSyncOutboxEnsureInput({
status,
hash: new NodeHashAdapter(),
nowIso: status.evaluatedAt,
});
expect(outboxInput).not.toBeNull();
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
ok: true,
outcome: 'created',
});
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
claimed: 1,
delivered: 1,
superseded: 0,
retryable: 0,
terminal: 0,
});
await expect(
fs.promises.readFile(path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`), {
encoding: 'utf8',
})
).resolves.toContain(outboxInput!.id);
} finally {
await feature.dispose();
}
});
it('plans and dispatches due nudges after queued reconcile by default', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);
const teamsBasePath = getTeamsBasePath();
const teamName = 'team-a';
const memberName = 'bob';
const feature = createMemberWorkSyncFeature({
teamsBasePath,
configReader: {
getConfig: vi.fn(async () => ({
name: teamName,
members: [{ name: memberName }],
})),
} as never,
taskReader: {
getTasks: vi.fn(async () => [
{
id: 'task-1',
displayId: '11111111',
subject: 'Ship sync',
status: 'pending',
owner: memberName,
},
]),
} as never,
kanbanManager: {
getState: vi.fn(async () => ({
teamName,
reviewers: [],
tasks: {},
})),
} as never,
membersMetaStore: {
getMembers: vi.fn(async () => []),
} as never,
queueQuietWindowMs: 1,
});
try {
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
feature.noteTeamChange({
type: 'task',
teamName,
taskId: 'task-1',
} as never);
await waitForAssertion(async () => {
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
const inbox = await fs.promises.readFile(
path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`),
'utf8'
);
expect(inbox).toContain('member_work_sync_nudge');
expect(inbox).toContain(`member-work-sync:${teamName}:${memberName}:agenda:v1:`);
});
} finally {
await feature.dispose();
}
});
it('uses snapshot config reads for startup roster materialization', async () => {
const getConfig = vi.fn(async () => ({ members: [] }));
const getConfigSnapshot = vi.fn(async () => ({
members: [{ name: 'alice' }],
}));
const feature = createMemberWorkSyncFeature({
teamsBasePath: makeTempRoot(),
configReader: {
getConfig,
getConfigSnapshot,
} as never,
taskReader: {} as never,
kanbanManager: {} as never,
membersMetaStore: {
getMembers: vi.fn(async () => []),
} as never,
});
try {
await feature.enqueueStartupScan(['my-team']);
expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
expect(getConfig).not.toHaveBeenCalled();
} finally {
await feature.dispose();
}
});
it('builds Claude Stop hook settings with nudges active by default', async () => {
const root = makeTempRoot();
const feature = createMemberWorkSyncFeature({
teamsBasePath: root,
configReader: {} as never,
taskReader: {} as never,
kanbanManager: {} as never,
membersMetaStore: {} as never,
});
try {
const settings = await feature.buildRuntimeTurnSettledHookSettings({ provider: 'claude' });
expect(settings).toMatchObject({
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: expect.stringContaining('agent-teams:member-work-sync-turn-settled:v1'),
},
],
},
],
},
});
await expect(
fs.promises.stat(
path.join(root, '.member-work-sync/runtime-hooks/bin/turn-settled-hook-v1.sh')
)
).resolves.toMatchObject({ mode: expect.any(Number) });
} finally {
await feature.dispose();
}
});
it('builds Codex turn-settled environment with nudges active by default', async () => {
const root = makeTempRoot();
const feature = createMemberWorkSyncFeature({
teamsBasePath: root,
configReader: {} as never,
taskReader: {} as never,
kanbanManager: {} as never,
membersMetaStore: {} as never,
});
try {
const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'codex' });
expect(env).toEqual({
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(
root,
'.member-work-sync/runtime-hooks'
),
});
await expect(
fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming'))
).resolves.toMatchObject({ mode: expect.any(Number) });
} finally {
await feature.dispose();
}
});
it('builds OpenCode turn-settled environment with nudges active by default', async () => {
const root = makeTempRoot();
const feature = createMemberWorkSyncFeature({
teamsBasePath: root,
configReader: {} as never,
taskReader: {} as never,
kanbanManager: {} as never,
membersMetaStore: {} as never,
});
try {
const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' });
expect(env).toEqual({
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(
root,
'.member-work-sync/runtime-hooks'
),
});
await expect(
fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming'))
).resolves.toMatchObject({ mode: expect.any(Number) });
} finally {
await feature.dispose();
}
});
it('builds OpenCode bridge environment before feature facade initialization', async () => {
const root = makeTempRoot();
const env = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
teamsBasePath: root,
provider: 'opencode',
});
expect(env).toEqual({
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(
root,
'.member-work-sync/runtime-hooks'
),
});
await expect(
fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming'))
).resolves.toMatchObject({ mode: expect.any(Number) });
});
});