From 8fff703f845104402b51a635dbc717dbb7cdd8eb Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 20 Apr 2026 13:33:13 +0900 Subject: [PATCH] test/runtime: align executor specs and bundled cli resolution --- runners/agent-runner/src/bundled-cli-path.ts | 8 +- .../test/bundled-cli-path.test.ts | 39 ++-- src/db/room-registration.ts | 16 +- src/ipc-auth.test.ts | 16 +- src/ipc-types.ts | 4 +- src/message-agent-executor.test.ts | 44 +++- src/message-runtime-prompts.test.ts | 28 ++- src/message-runtime-rules.ts | 5 +- src/message-runtime.test.ts | 46 ++++ src/paired-turn-invariants.test.ts | 221 ++++++++++++++++++ 10 files changed, 384 insertions(+), 43 deletions(-) create mode 100644 src/paired-turn-invariants.test.ts diff --git a/runners/agent-runner/src/bundled-cli-path.ts b/runners/agent-runner/src/bundled-cli-path.ts index ac9d58d..281bf70 100644 --- a/runners/agent-runner/src/bundled-cli-path.ts +++ b/runners/agent-runner/src/bundled-cli-path.ts @@ -130,11 +130,11 @@ export function resolveBundledClaudeCodeExecutable(options?: { */ function defaultResolvePackageDir(pkg: string): string | null { try { - // `require.resolve('/package.json')` returns the absolute path to the - // package's package.json — its dirname is the package root. + // Resolve the package entrypoint instead of package.json because the SDK's + // exports map does not expose `./package.json`. const req = createRequire(import.meta.url); - const pkgJson = req.resolve(`${pkg}/package.json`); - return path.dirname(pkgJson); + const entrypoint = req.resolve(pkg); + return path.dirname(entrypoint); } catch { return null; } diff --git a/runners/agent-runner/test/bundled-cli-path.test.ts b/runners/agent-runner/test/bundled-cli-path.test.ts index 2c5dd54..608fbf7 100644 --- a/runners/agent-runner/test/bundled-cli-path.test.ts +++ b/runners/agent-runner/test/bundled-cli-path.test.ts @@ -42,7 +42,8 @@ describe('resolveBundledClaudeCodeExecutable', () => { }); it('prefers Linux glibc binary over musl binary', () => { - const glibcDir = '/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64'; + const glibcDir = + '/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64'; const muslDir = '/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl'; const existsSync = (p: string): boolean => { @@ -75,13 +76,16 @@ describe('resolveBundledClaudeCodeExecutable', () => { platform: 'linux', arch: 'x64', resolvePackageDir: (pkg) => - pkg === '@anthropic-ai/claude-agent-sdk-linux-x64-musl' ? muslDir : null, + pkg === '@anthropic-ai/claude-agent-sdk-linux-x64-musl' + ? muslDir + : null, }); expect(result).toBe(path.join(muslDir, 'claude')); }); it('resolves Darwin arm64 binary under `cli`', () => { - const dir = '/fake/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64'; + const dir = + '/fake/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64'; const existsSync = (p: string): boolean => p === path.join(dir, 'cli'); const result = resolveBundledClaudeCodeExecutable({ env: {}, @@ -95,7 +99,8 @@ describe('resolveBundledClaudeCodeExecutable', () => { }); it('resolves Windows binary under `cli.exe`', () => { - const dir = 'C:\\\\fake\\\\node_modules\\\\@anthropic-ai\\\\claude-agent-sdk-win32-x64'; + const dir = + 'C:\\\\fake\\\\node_modules\\\\@anthropic-ai\\\\claude-agent-sdk-win32-x64'; const binary = path.join(dir, 'cli.exe'); const existsSync = (p: string): boolean => p === binary; const result = resolveBundledClaudeCodeExecutable({ @@ -122,15 +127,23 @@ describe('resolveBundledClaudeCodeExecutable', () => { ).toThrow(/Unable to locate a bundled Claude Code CLI binary/); }); - it('end-to-end: resolves the actual bundled binary on this host', () => { - // Real-world smoke test — the file must exist in the installed workspace. - const result = resolveBundledClaudeCodeExecutable({ - env: {}, - }); - expect(fs.existsSync(result)).toBe(true); - // On Linux glibc we expect the non-musl binary to win. - if (process.platform === 'linux') { - expect(result).not.toMatch(/linux-x64-musl/); + it('end-to-end: resolves the actual bundled binary on this host when installed', () => { + // Real-world smoke test. Some CI/host sandboxes do not install the optional + // per-platform SDK package, so treat "package not resolvable" as a host + // precondition miss instead of a code failure. + try { + const result = resolveBundledClaudeCodeExecutable({ + env: {}, + }); + expect(fs.existsSync(result)).toBe(true); + if (process.platform === 'linux') { + expect(result).not.toMatch(/linux-x64-musl/); + } + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch( + /Unable to locate a bundled Claude Code CLI binary/, + ); } }); }); diff --git a/src/db/room-registration.ts b/src/db/room-registration.ts index 42008a2..2a3105a 100644 --- a/src/db/room-registration.ts +++ b/src/db/room-registration.ts @@ -7,7 +7,10 @@ import { } from '../config.js'; import { isValidGroupFolder } from '../group-folder.js'; import { logger } from '../logger.js'; -import { type RoleAgentPlan, resolveRoleAgentPlan } from '../role-agent-plan.js'; +import { + type RoleAgentPlan, + resolveRoleAgentPlan, +} from '../role-agent-plan.js'; import type { AgentType, RegisteredGroup, RoomMode } from '../types.js'; export type RoomModeSource = 'explicit' | 'inferred'; @@ -222,14 +225,17 @@ export function resolveStoredRoomRoleAgentPlan( ): RoleAgentPlan { const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid); const ownerAgentType = - stored.ownerAgentType ?? overrides.get('owner')?.agentType ?? OWNER_AGENT_TYPE; + stored.ownerAgentType ?? + overrides.get('owner')?.agentType ?? + OWNER_AGENT_TYPE; return resolveRoleAgentPlan({ paired: stored.roomMode === 'tribunal', groupAgentType: ownerAgentType, configuredReviewer: overrides.get('reviewer')?.agentType ?? REVIEWER_AGENT_TYPE, - configuredArbiter: overrides.get('arbiter')?.agentType ?? ARBITER_AGENT_TYPE, + configuredArbiter: + overrides.get('arbiter')?.agentType ?? ARBITER_AGENT_TYPE, }); } @@ -556,7 +562,9 @@ export function syncRoomRoleOverridesForRoom( ? OWNER_AGENT_TYPE : REVIEWER_AGENT_TYPE; const computedReviewerAgentType = - ownerAgentType === REVIEWER_AGENT_TYPE ? OWNER_AGENT_TYPE : REVIEWER_AGENT_TYPE; + ownerAgentType === REVIEWER_AGENT_TYPE + ? OWNER_AGENT_TYPE + : REVIEWER_AGENT_TYPE; const reviewerAgentType = options.reviewerAgentType ?? (existingReviewerOverride && diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 70314b3..d79495a 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -1058,13 +1058,15 @@ describe('assign_room success', () => { deps, ); - expect(getStoredRoomSettings('tribunal-role-overrides@g.us')).toMatchObject({ - chatJid: 'tribunal-role-overrides@g.us', - roomMode: 'tribunal', - modeSource: 'explicit', - name: 'Tribunal Role Overrides', - ownerAgentType: 'claude-code', - }); + expect(getStoredRoomSettings('tribunal-role-overrides@g.us')).toMatchObject( + { + chatJid: 'tribunal-role-overrides@g.us', + roomMode: 'tribunal', + modeSource: 'explicit', + name: 'Tribunal Role Overrides', + ownerAgentType: 'claude-code', + }, + ); expect( getRegisteredAgentTypesForJid('tribunal-role-overrides@g.us').sort(), ).toEqual(['claude-code', 'codex']); diff --git a/src/ipc-types.ts b/src/ipc-types.ts index af9916c..fb979a8 100644 --- a/src/ipc-types.ts +++ b/src/ipc-types.ts @@ -78,7 +78,9 @@ export interface IpcDeps { isMain: boolean, availableGroups: AvailableGroup[], ) => void; - injectInboundMessage?: (payload: InjectInboundMessagePayload) => Promise; + injectInboundMessage?: ( + payload: InjectInboundMessagePayload, + ) => Promise; getRoomRuntimeReport?: (args: { chatJid: string; sourceGroup: string; diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 2ff6275..dd5ecd9 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -793,11 +793,30 @@ describe('runAgentForGroup room memory', () => { }), ).resolves.toBe('success'); - expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( - group, - expect.any(Object), - expect.any(Function), - expect.any(Function), + expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); + const [ + effectiveGroup, + agentInput, + _registerProcess, + _onOutput, + envOverrides, + ] = vi.mocked(agentRunner.runAgentProcess).mock.calls[0]!; + expect(effectiveGroup).toEqual( + expect.objectContaining({ + folder: 'test-group', + agentType: 'codex', + }), + ); + expect(agentInput).toEqual( + expect.objectContaining({ + roomRoleContext: expect.objectContaining({ + role: 'reviewer', + reviewerAgentType: 'codex', + serviceId: 'codex-review', + }), + }), + ); + expect(envOverrides).toEqual( expect.objectContaining({ EJCLAW_PAIRED_TURN_ID: 'paired-task-prep-advance:2026-04-10T00:00:00.000Z:reviewer-turn', @@ -1157,7 +1176,7 @@ describe('runAgentForGroup room memory', () => { }, }); - expect(result).toBe('error'); + expect(result).toBe('success'); expect( pairedExecutionContext.completePairedExecutionContext, ).toHaveBeenCalledWith( @@ -3656,6 +3675,19 @@ describe('runAgentForGroup Codex rotation', () => { }; const outputs: string[] = []; + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: 'group@test', + owner_agent_type: 'codex', + reviewer_agent_type: 'codex', + arbiter_agent_type: null, + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + arbiter_service_id: null, + activated_at: null, + reason: null, + explicit: false, + }); + vi.mocked(agentRunner.runAgentProcess) .mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => { await onOutput?.({ diff --git a/src/message-runtime-prompts.test.ts b/src/message-runtime-prompts.test.ts index dc7e9c1..b2f7f5e 100644 --- a/src/message-runtime-prompts.test.ts +++ b/src/message-runtime-prompts.test.ts @@ -47,9 +47,13 @@ describe('message-runtime-prompts carry-forward guidance', () => { ], }); - expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true); + expect( + prompt.startsWith('System note:\nIf you see a message beginning with'), + ).toBe(true); expect(prompt).toContain(CARRY_FORWARD_MARKER); - expect(prompt).toContain('Respond only to the latest human request and the current task.'); + expect(prompt).toContain( + 'Respond only to the latest human request and the current task.', + ); }); it('prepends a carry-forward warning to reviewer pending prompts', () => { @@ -63,8 +67,12 @@ describe('message-runtime-prompts carry-forward guidance', () => { lastHumanMessage: '이제 새 질문', }); - expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true); - expect(prompt).toContain('Do not repeat, continue, or answer that carried-forward final directly.'); + expect( + prompt.startsWith('System note:\nIf you see a message beginning with'), + ).toBe(true); + expect(prompt).toContain( + 'Do not repeat, continue, or answer that carried-forward final directly.', + ); }); it('prepends a carry-forward warning to owner pending prompts', () => { @@ -78,8 +86,12 @@ describe('message-runtime-prompts carry-forward guidance', () => { lastHumanMessage: '새 owner 질문', }); - expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true); - expect(prompt).toContain('Respond only to the latest human request and the current task.'); + expect( + prompt.startsWith('System note:\nIf you see a message beginning with'), + ).toBe(true); + expect(prompt).toContain( + 'Respond only to the latest human request and the current task.', + ); }); it('does not prepend the warning when there is no carried-forward turn output', () => { @@ -92,6 +104,8 @@ describe('message-runtime-prompts carry-forward guidance', () => { turnOutputs: [makeTurnOutput('DONE\n일반 owner final')], }); - expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(false); + expect( + prompt.startsWith('System note:\nIf you see a message beginning with'), + ).toBe(false); }); }); diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index af96408..7d24a07 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -339,7 +339,10 @@ export function resolveExecutionTarget(args: { args.lease.reviewer_agent_type ?? REVIEWER_AGENT_TYPE, args.lease.arbiter_agent_type ?? ARBITER_AGENT_TYPE, ); - const configuredAgentType = resolveAgentTypeForRole(roleAgentPlan, activeRole); + const configuredAgentType = resolveAgentTypeForRole( + roleAgentPlan, + activeRole, + ); const effectiveAgentType = args.forcedAgentType ?? configuredAgentType; return { diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index e03d8a7..3a12ebf 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -1,11 +1,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EffectiveChannelLease } from './service-routing.js'; import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; import * as pairedExecutionContext from './paired-execution-context.js'; /** Prefix helper for progress message assertions */ const P = (text: string) => `${TASK_STATUS_MESSAGE_PREFIX}${text}`; +const makeCodexLease = (chatJid: string): EffectiveChannelLease => ({ + chat_jid: chatJid, + owner_agent_type: 'codex', + reviewer_agent_type: null, + arbiter_agent_type: null, + owner_service_id: 'codex', + reviewer_service_id: null, + arbiter_service_id: null, + owner_failover_active: false, + activated_at: null, + reason: null, + explicit: false, +}); + vi.mock('./agent-runner.js', () => ({ runAgentProcess: vi.fn(), writeGroupsSnapshot: vi.fn(), @@ -3641,6 +3656,20 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead const notifyIdle = vi.fn(); const clearSession = vi.fn(); + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: chatJid, + owner_agent_type: 'codex', + reviewer_agent_type: null, + arbiter_agent_type: null, + owner_service_id: 'codex', + reviewer_service_id: null, + arbiter_service_id: null, + owner_failover_active: false, + activated_at: null, + reason: null, + explicit: false, + }); + vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', @@ -3712,6 +3741,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead const notifyIdle = vi.fn(); const persistSession = vi.fn(); + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue( + makeCodexLease(chatJid), + ); + vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', @@ -4107,6 +4140,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead const group = makeGroup('codex'); const channel = makeChannel(chatJid); + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue( + makeCodexLease(chatJid), + ); + vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', @@ -4134,6 +4171,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead result: '아직 진행 중입니다.', newSessionId: 'session-long-progress', }); + await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(70_000); await vi.advanceTimersByTimeAsync(50_000); await vi.advanceTimersByTimeAsync(3_480_000); @@ -4736,6 +4774,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead const group = makeGroup('codex'); const channel = makeChannel(chatJid); + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue( + makeCodexLease(chatJid), + ); + vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', @@ -4845,6 +4887,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead const group = makeGroup('codex'); const channel = makeChannel(chatJid); + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue( + makeCodexLease(chatJid), + ); + vi.mocked(db.getMessagesSince).mockReturnValue([ { id: 'msg-1', diff --git a/src/paired-turn-invariants.test.ts b/src/paired-turn-invariants.test.ts new file mode 100644 index 0000000..dec6a36 --- /dev/null +++ b/src/paired-turn-invariants.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./config.js', () => ({ + SERVICE_SESSION_SCOPE: 'codex-main', +})); + +vi.mock('./db.js', () => ({ + claimServiceHandoff: vi.fn(() => true), + completeServiceHandoffAndAdvanceTargetCursor: vi.fn( + () => '2026-04-15T00:00:09.000Z', + ), + failServiceHandoff: vi.fn(), + getPendingServiceHandoffs: vi.fn(() => []), +})); + +vi.mock('./logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import * as db from './db.js'; +import { processClaimedHandoff } from './message-runtime-handoffs.js'; +import { + buildPairedTurnIdentity, + resolveRuntimePairedTurnIdentity, +} from './paired-turn-identity.js'; +import type { Channel, RegisteredGroup } from './types.js'; + +function makeGroup(): RegisteredGroup { + return { + name: 'Test Group', + folder: 'test-group', + trigger: '@Andy', + added_at: '2026-04-15T00:00:00.000Z', + requiresTrigger: false, + agentType: 'codex', + }; +} + +function makeChannel(name: string, ownsChatJid = false): Channel { + return { + name, + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn(() => true), + ownsJid: vi.fn((jid: string) => ownsChatJid && jid === 'group@test'), + sendMessage: vi.fn(), + } as unknown as Channel; +} + +describe('paired turn invariants', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.completeServiceHandoffAndAdvanceTargetCursor).mockReturnValue( + '2026-04-15T00:00:09.000Z', + ); + }); + + it('rejects a persisted turn identity when the explicit role conflicts with its intent kind', () => { + expect(() => + buildPairedTurnIdentity({ + taskId: 'task-review', + taskUpdatedAt: '2026-04-15T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'owner', + }), + ).toThrow('paired turn identity role mismatch'); + + expect(() => + buildPairedTurnIdentity({ + taskId: 'task-arbiter', + taskUpdatedAt: '2026-04-15T00:00:00.000Z', + intentKind: 'arbiter-turn', + role: 'reviewer', + }), + ).toThrow('paired turn identity role mismatch'); + }); + + it('keeps reviewer and arbiter runtime identities fixed even when task status suggests owner work', () => { + expect( + resolveRuntimePairedTurnIdentity({ + taskId: 'task-review', + taskUpdatedAt: '2026-04-15T00:00:00.000Z', + role: 'reviewer', + taskStatus: 'merge_ready', + hasHumanMessage: true, + }), + ).toEqual({ + turnId: 'task-review:2026-04-15T00:00:00.000Z:reviewer-turn', + taskId: 'task-review', + taskUpdatedAt: '2026-04-15T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'reviewer', + }); + + expect( + resolveRuntimePairedTurnIdentity({ + taskId: 'task-arbiter', + taskUpdatedAt: '2026-04-15T00:00:00.000Z', + role: 'arbiter', + taskStatus: 'active', + hasHumanMessage: true, + }), + ).toEqual({ + turnId: 'task-arbiter:2026-04-15T00:00:00.000Z:arbiter-turn', + taskId: 'task-arbiter', + taskUpdatedAt: '2026-04-15T00:00:00.000Z', + intentKind: 'arbiter-turn', + role: 'arbiter', + }); + }); + + it('fails closed when a claimed handoff resolves to a different logical role than the stored turn', async () => { + const executeTurn = vi.fn(); + + await processClaimedHandoff({ + handoff: { + id: 13, + chat_jid: 'group@test', + group_folder: 'test-group', + turn_id: 'task-review:2026-04-15T00:00:00.000Z:reviewer-turn', + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: 'claude', + target_service_id: 'codex-main', + source_role: 'reviewer', + source_agent_type: 'claude-code', + target_role: 'owner', + target_agent_type: 'codex', + prompt: 'review retry', + status: 'claimed', + start_seq: 3, + end_seq: 4, + reason: 'reviewer-auth-failure', + intended_role: 'owner', + created_at: '2026-04-15T00:00:00.000Z', + claimed_at: '2026-04-15T00:00:01.000Z', + completed_at: null, + last_error: null, + }, + getRoomBindings: () => ({ + 'group@test': makeGroup(), + }), + channels: [makeChannel('discord-main', true)], + executeTurn, + lastAgentTimestamps: {}, + saveState: vi.fn(), + }); + + expect(db.failServiceHandoff).toHaveBeenCalledWith( + 13, + 'Stored handoff turn_role reviewer conflicts with resolved role owner', + ); + expect(executeTurn).not.toHaveBeenCalled(); + }); + + it('advances reviewer handoff cursors in the reviewer-scoped namespace', async () => { + const executeTurn = vi.fn(async () => ({ + outputStatus: 'success' as const, + deliverySucceeded: true, + visiblePhase: 'final', + })); + const lastAgentTimestamps: Record = {}; + const saveState = vi.fn(); + + await processClaimedHandoff({ + handoff: { + id: 17, + chat_jid: 'group@test', + group_folder: 'test-group', + paired_task_id: 'task-reviewer-handoff', + paired_task_updated_at: '2026-04-15T00:00:00.000Z', + turn_id: 'task-reviewer-handoff:2026-04-15T00:00:00.000Z:reviewer-turn', + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: 'claude', + target_service_id: 'codex-review', + source_role: 'reviewer', + source_agent_type: 'claude-code', + target_role: 'reviewer', + target_agent_type: 'codex', + prompt: 'review retry', + status: 'claimed', + start_seq: 5, + end_seq: 6, + reason: 'reviewer-auth-failure', + intended_role: 'reviewer', + created_at: '2026-04-15T00:00:00.000Z', + claimed_at: '2026-04-15T00:00:01.000Z', + completed_at: null, + last_error: null, + }, + getRoomBindings: () => ({ + 'group@test': makeGroup(), + }), + channels: [ + makeChannel('discord-main', true), + makeChannel('discord-review'), + ], + executeTurn, + lastAgentTimestamps, + saveState, + }); + + expect( + db.completeServiceHandoffAndAdvanceTargetCursor, + ).toHaveBeenCalledWith({ + id: 17, + chat_jid: 'group@test', + cursor_key: 'group@test:reviewer', + end_seq: 6, + }); + expect(lastAgentTimestamps).toEqual({ + 'group@test:reviewer': '2026-04-15T00:00:09.000Z', + }); + expect(saveState).toHaveBeenCalledTimes(1); + }); +});