test/runtime: align executor specs and bundled cli resolution
This commit is contained in:
@@ -130,11 +130,11 @@ export function resolveBundledClaudeCodeExecutable(options?: {
|
||||
*/
|
||||
function defaultResolvePackageDir(pkg: string): string | null {
|
||||
try {
|
||||
// `require.resolve('<pkg>/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;
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -78,7 +78,9 @@ export interface IpcDeps {
|
||||
isMain: boolean,
|
||||
availableGroups: AvailableGroup[],
|
||||
) => void;
|
||||
injectInboundMessage?: (payload: InjectInboundMessagePayload) => Promise<void>;
|
||||
injectInboundMessage?: (
|
||||
payload: InjectInboundMessagePayload,
|
||||
) => Promise<void>;
|
||||
getRoomRuntimeReport?: (args: {
|
||||
chatJid: string;
|
||||
sourceGroup: string;
|
||||
|
||||
@@ -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?.({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
221
src/paired-turn-invariants.test.ts
Normal file
221
src/paired-turn-invariants.test.ts
Normal file
@@ -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<string, string> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user