test/runtime: align executor specs and bundled cli resolution

This commit is contained in:
ejclaw
2026-04-20 13:33:13 +09:00
parent fc1c377cf1
commit 8fff703f84
10 changed files with 384 additions and 43 deletions

View File

@@ -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;
}

View File

@@ -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/,
);
}
});
});

View File

@@ -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 &&

View File

@@ -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']);

View File

@@ -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;

View File

@@ -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?.({

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -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',

View 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);
});
});