300 lines
9.7 KiB
TypeScript
300 lines
9.7 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { RuntimeTurnSettledIngestor } from '@features/member-work-sync/core/application';
|
|
import { ClaudeStopHookPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer';
|
|
import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter';
|
|
import { OpenCodeTurnSettledPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer';
|
|
|
|
import type {
|
|
MemberWorkSyncAuditEvent,
|
|
RuntimeTurnSettledClaimedPayload,
|
|
RuntimeTurnSettledEventStorePort,
|
|
RuntimeTurnSettledInvalidResult,
|
|
RuntimeTurnSettledProcessedResult,
|
|
RuntimeTurnSettledTargetResolverPort,
|
|
} from '@features/member-work-sync/core/application';
|
|
|
|
function makePayload(raw: string): RuntimeTurnSettledClaimedPayload {
|
|
return {
|
|
id: 'event-1.claude.json',
|
|
fileName: 'event-1.claude.json',
|
|
filePath: '/tmp/event-1.claude.json',
|
|
provider: 'claude',
|
|
raw,
|
|
claimedAt: '2026-04-29T12:00:00.000Z',
|
|
};
|
|
}
|
|
|
|
function makeOpenCodePayload(raw: string): RuntimeTurnSettledClaimedPayload {
|
|
return {
|
|
id: 'event-1.opencode.json',
|
|
fileName: 'event-1.opencode.json',
|
|
filePath: '/tmp/event-1.opencode.json',
|
|
provider: 'opencode',
|
|
raw,
|
|
claimedAt: '2026-04-29T12:00:00.000Z',
|
|
};
|
|
}
|
|
|
|
describe('RuntimeTurnSettledIngestor', () => {
|
|
it('normalizes Claude Stop payloads and enqueues resolved member reconcile', async () => {
|
|
const payload = makePayload(
|
|
JSON.stringify({
|
|
hook_event_name: 'Stop',
|
|
session_id: 'ses-1',
|
|
transcript_path: '/tmp/ses-1.jsonl',
|
|
})
|
|
);
|
|
const processed: RuntimeTurnSettledProcessedResult[] = [];
|
|
const store: RuntimeTurnSettledEventStorePort = {
|
|
claimPending: vi.fn(async () => [payload]),
|
|
markProcessed: vi.fn(async (_payload, result) => {
|
|
processed.push(result);
|
|
}),
|
|
markInvalid: vi.fn(),
|
|
};
|
|
const resolver: RuntimeTurnSettledTargetResolverPort = {
|
|
resolve: vi.fn(async () => ({ ok: true as const, teamName: 'team-a', memberName: 'alice' })),
|
|
};
|
|
const enqueueRuntimeTurnSettled = vi.fn();
|
|
const auditEvents: MemberWorkSyncAuditEvent[] = [];
|
|
|
|
const ingestor = new RuntimeTurnSettledIngestor({
|
|
eventStore: store,
|
|
normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()),
|
|
targetResolver: resolver,
|
|
reconcileQueue: { enqueueRuntimeTurnSettled },
|
|
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
|
|
auditJournal: {
|
|
append: async (event) => {
|
|
auditEvents.push(event);
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(ingestor.drainPending()).resolves.toEqual({
|
|
claimed: 1,
|
|
enqueued: 1,
|
|
unresolved: 0,
|
|
ignored: 0,
|
|
invalid: 0,
|
|
failed: 0,
|
|
});
|
|
expect(enqueueRuntimeTurnSettled).toHaveBeenCalledWith({
|
|
teamName: 'team-a',
|
|
memberName: 'alice',
|
|
event: expect.objectContaining({
|
|
provider: 'claude',
|
|
hookEventName: 'Stop',
|
|
sessionId: 'ses-1',
|
|
transcriptPath: '/tmp/ses-1.jsonl',
|
|
}),
|
|
});
|
|
expect(processed[0]).toMatchObject({
|
|
outcome: 'enqueued',
|
|
teamName: 'team-a',
|
|
memberName: 'alice',
|
|
});
|
|
});
|
|
|
|
it('quarantines malformed payloads without enqueueing reconcile', async () => {
|
|
const payload = makePayload('not-json');
|
|
const invalid: RuntimeTurnSettledInvalidResult[] = [];
|
|
const store: RuntimeTurnSettledEventStorePort = {
|
|
claimPending: vi.fn(async () => [payload]),
|
|
markProcessed: vi.fn(),
|
|
markInvalid: vi.fn(async (_payload, result) => {
|
|
invalid.push(result);
|
|
}),
|
|
};
|
|
|
|
const ingestor = new RuntimeTurnSettledIngestor({
|
|
eventStore: store,
|
|
normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()),
|
|
targetResolver: { resolve: vi.fn() },
|
|
reconcileQueue: { enqueueRuntimeTurnSettled: vi.fn() },
|
|
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
|
|
});
|
|
|
|
await expect(ingestor.drainPending()).resolves.toMatchObject({
|
|
claimed: 1,
|
|
invalid: 1,
|
|
enqueued: 0,
|
|
});
|
|
expect(invalid[0]).toMatchObject({ reason: 'invalid_json' });
|
|
});
|
|
|
|
it('records unresolved Stop events as processed diagnostics', async () => {
|
|
const payload = makePayload(
|
|
JSON.stringify({ hook_event_name: 'Stop', transcript_path: '/tmp/unknown.jsonl' })
|
|
);
|
|
const processed: RuntimeTurnSettledProcessedResult[] = [];
|
|
const store: RuntimeTurnSettledEventStorePort = {
|
|
claimPending: vi.fn(async () => [payload]),
|
|
markProcessed: vi.fn(async (_payload, result) => {
|
|
processed.push(result);
|
|
}),
|
|
markInvalid: vi.fn(),
|
|
};
|
|
|
|
const ingestor = new RuntimeTurnSettledIngestor({
|
|
eventStore: store,
|
|
normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()),
|
|
targetResolver: {
|
|
resolve: vi.fn(async () => ({
|
|
ok: false as const,
|
|
reason: 'no_matching_member_session',
|
|
})),
|
|
},
|
|
reconcileQueue: { enqueueRuntimeTurnSettled: vi.fn() },
|
|
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
|
|
});
|
|
|
|
await expect(ingestor.drainPending()).resolves.toMatchObject({
|
|
claimed: 1,
|
|
unresolved: 1,
|
|
ignored: 0,
|
|
enqueued: 0,
|
|
});
|
|
expect(processed[0]).toMatchObject({
|
|
outcome: 'unresolved',
|
|
reason: 'no_matching_member_session',
|
|
});
|
|
});
|
|
|
|
it('normalizes OpenCode turn-settled payloads and enqueues provider-owned member reconcile', async () => {
|
|
const payload = makeOpenCodePayload(
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
provider: 'opencode',
|
|
source: 'agent-teams-orchestrator-opencode',
|
|
eventName: 'runtime_turn_settled',
|
|
hookEventName: 'Stop',
|
|
sessionId: 'ses-opencode-1',
|
|
runtimePromptMessageId: 'msg_123',
|
|
laneId: 'secondary:opencode:jack',
|
|
memberName: 'jack',
|
|
teamName: 'team-a',
|
|
cwd: '/tmp/project',
|
|
outcome: 'success',
|
|
recordedAt: '2026-04-29T12:00:00.000Z',
|
|
})
|
|
);
|
|
const processed: RuntimeTurnSettledProcessedResult[] = [];
|
|
const store: RuntimeTurnSettledEventStorePort = {
|
|
claimPending: vi.fn(async () => [payload]),
|
|
markProcessed: vi.fn(async (_payload, result) => {
|
|
processed.push(result);
|
|
}),
|
|
markInvalid: vi.fn(),
|
|
};
|
|
const resolver: RuntimeTurnSettledTargetResolverPort = {
|
|
resolve: vi.fn(async () => ({ ok: true as const, teamName: 'team-a', memberName: 'jack' })),
|
|
};
|
|
const enqueueRuntimeTurnSettled = vi.fn();
|
|
const auditEvents: MemberWorkSyncAuditEvent[] = [];
|
|
|
|
const ingestor = new RuntimeTurnSettledIngestor({
|
|
eventStore: store,
|
|
normalizer: new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter()),
|
|
targetResolver: resolver,
|
|
reconcileQueue: { enqueueRuntimeTurnSettled },
|
|
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
|
|
auditJournal: {
|
|
append: async (event) => {
|
|
auditEvents.push(event);
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(ingestor.drainPending()).resolves.toMatchObject({
|
|
claimed: 1,
|
|
enqueued: 1,
|
|
invalid: 0,
|
|
unresolved: 0,
|
|
ignored: 0,
|
|
});
|
|
expect(enqueueRuntimeTurnSettled).toHaveBeenCalledWith({
|
|
teamName: 'team-a',
|
|
memberName: 'jack',
|
|
event: expect.objectContaining({
|
|
provider: 'opencode',
|
|
hookEventName: 'Stop',
|
|
sessionId: 'ses-opencode-1',
|
|
turnId: 'msg_123',
|
|
teamName: 'team-a',
|
|
memberName: 'jack',
|
|
agentId: 'secondary:opencode:jack',
|
|
outcome: 'success',
|
|
}),
|
|
});
|
|
expect(processed[0]).toMatchObject({
|
|
outcome: 'enqueued',
|
|
teamName: 'team-a',
|
|
memberName: 'jack',
|
|
});
|
|
expect(auditEvents.map((event) => event.event)).toEqual([
|
|
'turn_settled_claimed',
|
|
'turn_settled_resolved',
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
'timeout',
|
|
'stream_unavailable',
|
|
'prompt_rejected',
|
|
'idle_without_assistant_activity',
|
|
'unknown',
|
|
])('ignores non-terminal OpenCode outcome %s without enqueueing premature reconcile', async (outcome) => {
|
|
const payload = makeOpenCodePayload(
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
provider: 'opencode',
|
|
source: 'agent-teams-orchestrator-opencode',
|
|
eventName: 'runtime_turn_settled',
|
|
hookEventName: 'Stop',
|
|
sessionId: 'ses-opencode-1',
|
|
runtimePromptMessageId: 'msg_123',
|
|
laneId: 'secondary:opencode:jack',
|
|
memberName: 'jack',
|
|
teamName: 'team-a',
|
|
outcome,
|
|
recordedAt: '2026-04-29T12:00:00.000Z',
|
|
})
|
|
);
|
|
const processed: RuntimeTurnSettledProcessedResult[] = [];
|
|
const store: RuntimeTurnSettledEventStorePort = {
|
|
claimPending: vi.fn(async () => [payload]),
|
|
markProcessed: vi.fn(async (_payload, result) => {
|
|
processed.push(result);
|
|
}),
|
|
markInvalid: vi.fn(),
|
|
};
|
|
const resolver: RuntimeTurnSettledTargetResolverPort = {
|
|
resolve: vi.fn(),
|
|
};
|
|
const enqueueRuntimeTurnSettled = vi.fn();
|
|
|
|
const ingestor = new RuntimeTurnSettledIngestor({
|
|
eventStore: store,
|
|
normalizer: new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter()),
|
|
targetResolver: resolver,
|
|
reconcileQueue: { enqueueRuntimeTurnSettled },
|
|
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
|
|
});
|
|
|
|
await expect(ingestor.drainPending()).resolves.toMatchObject({
|
|
claimed: 1,
|
|
enqueued: 0,
|
|
unresolved: 0,
|
|
ignored: 1,
|
|
invalid: 0,
|
|
});
|
|
expect(resolver.resolve).not.toHaveBeenCalled();
|
|
expect(enqueueRuntimeTurnSettled).not.toHaveBeenCalled();
|
|
expect(processed[0]).toMatchObject({
|
|
outcome: 'ignored',
|
|
reason: `opencode_non_terminal_outcome:${outcome}`,
|
|
});
|
|
});
|
|
});
|