test: expand claude work sync live scenarios

This commit is contained in:
777genius 2026-04-29 19:05:32 +03:00
parent 7cb6cddee8
commit bfd8b30ad5

View file

@ -49,6 +49,26 @@ const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_
const DEFAULT_MODEL = 'sonnet'; const DEFAULT_MODEL = 'sonnet';
const DEFAULT_EFFORT = 'low' as const; const DEFAULT_EFFORT = 'low' as const;
type ClaudeStopHookLiveScenarioState = 'still_working' | 'caught_up';
interface ClaudeStopHookLiveScenarioContext {
marker: string;
memberName: string;
teamName: string;
controlUrl: string;
taskId?: string;
}
interface ClaudeStopHookLiveScenario {
markerSuffix: string;
subjectPrefix: string;
expectedState: ClaudeStopHookLiveScenarioState;
expectedTaskStatus: 'in_progress' | 'completed';
expectedMarkerText(marker: string): string;
buildTaskPromptLines(context: ClaudeStopHookLiveScenarioContext): string[];
buildInstructionLines(context: Required<ClaudeStopHookLiveScenarioContext>): string[];
}
liveDescribe('Member work sync Claude Stop hook live e2e', () => { liveDescribe('Member work sync Claude Stop hook live e2e', () => {
let tempDir: string; let tempDir: string;
let tempClaudeRoot: string; let tempClaudeRoot: string;
@ -124,17 +144,18 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
} }
}); });
it( async function runClaudeStopHookLiveScenario(
'launches a real Claude teammate, accepts its work-sync report, and ingests its Stop hook event', scenario: ClaudeStopHookLiveScenario
async () => { ): Promise<void> {
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim(); const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
expect(orchestratorCli).toBeTruthy(); expect(orchestratorCli).toBeTruthy();
await assertExecutable(orchestratorCli!); await assertExecutable(orchestratorCli!);
const model = process.env.MEMBER_WORK_SYNC_CLAUDE_MODEL?.trim() || DEFAULT_MODEL; const model = process.env.MEMBER_WORK_SYNC_CLAUDE_MODEL?.trim() || DEFAULT_MODEL;
const marker = `member-work-sync-claude-stop-live-${Date.now()}`; const startedAt = Date.now();
const marker = `member-work-sync-claude-stop-live-${scenario.markerSuffix}-${startedAt}`;
const memberName = 'alice'; const memberName = 'alice';
teamName = `member-work-sync-claude-stop-${Date.now()}`; teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`;
const projectPath = path.join(tempDir, 'project'); const projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath, { recursive: true }); await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile( await fs.writeFile(
@ -161,9 +182,9 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
queueQuietWindowMs: 500, queueQuietWindowMs: 500,
// Native Claude teammates are registered by the real lead process, but in this // Native Claude teammates are registered by the real lead process, but in this
// headless harness their bootstrap turn can finish before there is a durable // headless harness their bootstrap turn can finish before there is a durable
// member process to prompt. The live assertion below still uses a real Claude // member process to prompt. These live assertions still use a real Claude
// process, real MCP calls, and a real Stop hook payload; this seam keeps the // process, real MCP calls, and real Stop hook payloads; this seam keeps the
// test focused on hook ingestion instead of tmux liveness. // tests focused on hook ingestion instead of tmux liveness.
runtimeTurnSettledTargetResolver: { runtimeTurnSettledTargetResolver: {
resolve: async (event) => { resolve: async (event) => {
if (event.provider !== 'claude') { if (event.provider !== 'claude') {
@ -181,9 +202,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
}, },
}, },
}); });
activeService.setTeamChangeEmitter((event: TeamChangeEvent) => activeService.setTeamChangeEmitter((event: TeamChangeEvent) => feature!.noteTeamChange(event));
feature!.noteTeamChange(event)
);
activeService.setRuntimeTurnSettledHookSettingsProvider((input) => activeService.setRuntimeTurnSettledHookSettingsProvider((input) =>
feature!.buildRuntimeTurnSettledHookSettings(input) feature!.buildRuntimeTurnSettledHookSettings(input)
); );
@ -242,33 +261,19 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
) )
).resolves.toMatchObject({ mode: expect.any(Number) }); ).resolves.toMatchObject({ mode: expect.any(Number) });
const taskPromptContext: ClaudeStopHookLiveScenarioContext = {
marker,
memberName,
teamName,
controlUrl: controlServer.baseUrl,
};
const task = await teamDataService.createTask(teamName, { const task = await teamDataService.createTask(teamName, {
subject: `Member work sync Claude Stop hook live lease ${marker}`, subject: `${scenario.subjectPrefix} ${marker}`,
owner: memberName, owner: memberName,
startImmediately: true, startImmediately: true,
prompt: [ prompt: scenario.buildTaskPromptLines(taskPromptContext).join('\n'),
`This is a live member-work-sync validation task. Marker: ${marker}.`,
'Do not edit files and do not complete this task.',
'Call task_start for this task.',
`Add one task comment containing exactly: ${marker}:still-working.`,
`Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`,
`Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlServer.baseUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`,
'After that stop. Do not send a user-visible message.',
].join('\n'),
}); });
feature.noteTeamChange({ type: 'task', teamName, taskId: task.id }); feature.noteTeamChange({ type: 'task', teamName, taskId: task.id });
await activeService.sendMessageToTeam(
teamName,
[
`Live member-work-sync validation instruction. Marker: ${marker}.`,
`Use the board MCP tools as member "${memberName}" for this validation.`,
`Call task_get for taskId "${task.id}", then task_start.`,
`Add one task comment containing exactly: ${marker}:still-working.`,
`Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`,
`Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlServer.baseUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and taskIds ["${task.id}"].`,
'After that stop. Do not complete the task. Do not send a user-visible message.',
].join('\n')
);
await waitUntil(async () => { await waitUntil(async () => {
const status = await feature!.getStatus({ teamName: teamName!, memberName }); const status = await feature!.getStatus({ teamName: teamName!, memberName });
@ -280,18 +285,33 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
); );
}, 30_000); }, 30_000);
await activeService.sendMessageToTeam(
teamName,
scenario
.buildInstructionLines({
...taskPromptContext,
taskId: task.id,
})
.join('\n')
);
await waitUntil(async () => { await waitUntil(async () => {
await feature!.replayPendingReports([teamName!]); await feature!.replayPendingReports([teamName!]);
const status = await feature!.getStatus({ teamName: teamName!, memberName }); const [status, tasks] = await Promise.all([
if (status.report?.accepted && status.report.state === 'still_working') { feature!.getStatus({ teamName: teamName!, memberName }),
return true; new TeamTaskReader().getTasks(teamName!),
} ]);
const tasks = await new TeamTaskReader().getTasks(teamName!);
const currentTask = tasks.find((candidate) => candidate.id === task.id); const currentTask = tasks.find((candidate) => candidate.id === task.id);
const hasMarkerComment = currentTask?.comments?.some((comment) => const expectedMarker = scenario.expectedMarkerText(marker);
comment.text.includes(`${marker}:still-working`) const hasMarkerComment = currentTask?.comments?.some(
(comment) => comment.author === memberName && comment.text.includes(expectedMarker)
);
return Boolean(
hasMarkerComment &&
currentTask?.status === scenario.expectedTaskStatus &&
status.report?.accepted &&
status.report.state === scenario.expectedState
); );
return Boolean(hasMarkerComment && status.report?.accepted);
}, 300_000, 2_000, async () => }, 300_000, 2_000, async () =>
formatMemberWorkSyncDiagnostics({ formatMemberWorkSyncDiagnostics({
feature: feature!, feature: feature!,
@ -333,17 +353,84 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
feature.getStatus({ teamName, memberName }), feature.getStatus({ teamName, memberName }),
feature.getMetrics({ teamName }), feature.getMetrics({ teamName }),
]); ]);
expect(finalStatus.state).toBe('still_working'); expect(finalStatus.state).toBe(scenario.expectedState);
expect(finalStatus.report).toMatchObject({ expect(finalStatus.report).toMatchObject({
accepted: true, accepted: true,
state: 'still_working', state: scenario.expectedState,
}); });
if (scenario.expectedState === 'caught_up') {
expect(finalStatus.agenda.items).toHaveLength(0);
} else {
expect(finalStatus.agenda.items.some((item) => item.taskId === task.id)).toBe(true);
}
expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true); expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true);
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
claimed: 0, claimed: 0,
delivered: 0, delivered: 0,
}); });
}, }
it(
'launches a real Claude teammate, accepts a still-working report, and ingests its Stop hook event',
async () =>
runClaudeStopHookLiveScenario({
markerSuffix: 'lease',
subjectPrefix: 'Member work sync Claude Stop hook live lease',
expectedState: 'still_working',
expectedTaskStatus: 'in_progress',
expectedMarkerText: (marker) => `${marker}:still-working`,
buildTaskPromptLines: ({ marker, memberName, teamName, controlUrl }) => [
`This is a live member-work-sync validation task. Marker: ${marker}.`,
'Do not edit files and do not complete this task.',
'Call task_start for this task.',
`Add one task comment containing exactly: ${marker}:still-working.`,
`Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`,
`Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`,
'After that stop. Do not send a user-visible message.',
],
buildInstructionLines: ({ marker, memberName, teamName, controlUrl, taskId }) => [
`Live member-work-sync validation instruction. Marker: ${marker}.`,
`Use the board MCP tools as member "${memberName}" for this validation.`,
`Call task_get for taskId "${taskId}", then task_start.`,
`Add one task comment containing exactly: ${marker}:still-working.`,
`Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`,
`Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and taskIds ["${taskId}"].`,
'After that stop. Do not complete the task. Do not send a user-visible message.',
],
}),
420_000
);
it(
'launches a real Claude teammate, completes work, reports caught-up, and ingests its Stop hook event',
async () =>
runClaudeStopHookLiveScenario({
markerSuffix: 'caught-up',
subjectPrefix: 'Member work sync Claude Stop hook live caught-up',
expectedState: 'caught_up',
expectedTaskStatus: 'completed',
expectedMarkerText: (marker) => `${marker}:completed`,
buildTaskPromptLines: ({ marker, memberName, teamName, controlUrl }) => [
`This is a live member-work-sync caught-up validation task. Marker: ${marker}.`,
'Do not edit files.',
'Call task_start for this task.',
`Add one task comment containing exactly: ${marker}:completed.`,
'Then call task_complete for this task.',
`Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`,
`Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "caught_up", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and no taskIds.`,
'After that stop. Do not send a user-visible message.',
],
buildInstructionLines: ({ marker, memberName, teamName, controlUrl, taskId }) => [
`Live member-work-sync caught-up validation instruction. Marker: ${marker}.`,
`Use the board MCP tools as member "${memberName}" for this validation.`,
`Call task_get for taskId "${taskId}", then task_start.`,
`Add one task comment containing exactly: ${marker}:completed.`,
`Call task_complete for taskId "${taskId}".`,
`Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`,
`Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "caught_up", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and no taskIds.`,
'After that stop. Do not send a user-visible message.',
],
}),
420_000 420_000
); );
}); });