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 {
|
function defaultResolvePackageDir(pkg: string): string | null {
|
||||||
try {
|
try {
|
||||||
// `require.resolve('<pkg>/package.json')` returns the absolute path to the
|
// Resolve the package entrypoint instead of package.json because the SDK's
|
||||||
// package's package.json — its dirname is the package root.
|
// exports map does not expose `./package.json`.
|
||||||
const req = createRequire(import.meta.url);
|
const req = createRequire(import.meta.url);
|
||||||
const pkgJson = req.resolve(`${pkg}/package.json`);
|
const entrypoint = req.resolve(pkg);
|
||||||
return path.dirname(pkgJson);
|
return path.dirname(entrypoint);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ describe('resolveBundledClaudeCodeExecutable', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('prefers Linux glibc binary over musl binary', () => {
|
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 =
|
const muslDir =
|
||||||
'/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl';
|
'/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl';
|
||||||
const existsSync = (p: string): boolean => {
|
const existsSync = (p: string): boolean => {
|
||||||
@@ -75,13 +76,16 @@ describe('resolveBundledClaudeCodeExecutable', () => {
|
|||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
arch: 'x64',
|
arch: 'x64',
|
||||||
resolvePackageDir: (pkg) =>
|
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'));
|
expect(result).toBe(path.join(muslDir, 'claude'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves Darwin arm64 binary under `cli`', () => {
|
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 existsSync = (p: string): boolean => p === path.join(dir, 'cli');
|
||||||
const result = resolveBundledClaudeCodeExecutable({
|
const result = resolveBundledClaudeCodeExecutable({
|
||||||
env: {},
|
env: {},
|
||||||
@@ -95,7 +99,8 @@ describe('resolveBundledClaudeCodeExecutable', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('resolves Windows binary under `cli.exe`', () => {
|
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 binary = path.join(dir, 'cli.exe');
|
||||||
const existsSync = (p: string): boolean => p === binary;
|
const existsSync = (p: string): boolean => p === binary;
|
||||||
const result = resolveBundledClaudeCodeExecutable({
|
const result = resolveBundledClaudeCodeExecutable({
|
||||||
@@ -122,15 +127,23 @@ describe('resolveBundledClaudeCodeExecutable', () => {
|
|||||||
).toThrow(/Unable to locate a bundled Claude Code CLI binary/);
|
).toThrow(/Unable to locate a bundled Claude Code CLI binary/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('end-to-end: resolves the actual bundled binary on this host', () => {
|
it('end-to-end: resolves the actual bundled binary on this host when installed', () => {
|
||||||
// Real-world smoke test — the file must exist in the installed workspace.
|
// Real-world smoke test. Some CI/host sandboxes do not install the optional
|
||||||
const result = resolveBundledClaudeCodeExecutable({
|
// per-platform SDK package, so treat "package not resolvable" as a host
|
||||||
env: {},
|
// precondition miss instead of a code failure.
|
||||||
});
|
try {
|
||||||
expect(fs.existsSync(result)).toBe(true);
|
const result = resolveBundledClaudeCodeExecutable({
|
||||||
// On Linux glibc we expect the non-musl binary to win.
|
env: {},
|
||||||
if (process.platform === 'linux') {
|
});
|
||||||
expect(result).not.toMatch(/linux-x64-musl/);
|
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';
|
} from '../config.js';
|
||||||
import { isValidGroupFolder } from '../group-folder.js';
|
import { isValidGroupFolder } from '../group-folder.js';
|
||||||
import { logger } from '../logger.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';
|
import type { AgentType, RegisteredGroup, RoomMode } from '../types.js';
|
||||||
|
|
||||||
export type RoomModeSource = 'explicit' | 'inferred';
|
export type RoomModeSource = 'explicit' | 'inferred';
|
||||||
@@ -222,14 +225,17 @@ export function resolveStoredRoomRoleAgentPlan(
|
|||||||
): RoleAgentPlan {
|
): RoleAgentPlan {
|
||||||
const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid);
|
const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid);
|
||||||
const ownerAgentType =
|
const ownerAgentType =
|
||||||
stored.ownerAgentType ?? overrides.get('owner')?.agentType ?? OWNER_AGENT_TYPE;
|
stored.ownerAgentType ??
|
||||||
|
overrides.get('owner')?.agentType ??
|
||||||
|
OWNER_AGENT_TYPE;
|
||||||
|
|
||||||
return resolveRoleAgentPlan({
|
return resolveRoleAgentPlan({
|
||||||
paired: stored.roomMode === 'tribunal',
|
paired: stored.roomMode === 'tribunal',
|
||||||
groupAgentType: ownerAgentType,
|
groupAgentType: ownerAgentType,
|
||||||
configuredReviewer:
|
configuredReviewer:
|
||||||
overrides.get('reviewer')?.agentType ?? REVIEWER_AGENT_TYPE,
|
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
|
? OWNER_AGENT_TYPE
|
||||||
: REVIEWER_AGENT_TYPE;
|
: REVIEWER_AGENT_TYPE;
|
||||||
const computedReviewerAgentType =
|
const computedReviewerAgentType =
|
||||||
ownerAgentType === REVIEWER_AGENT_TYPE ? OWNER_AGENT_TYPE : REVIEWER_AGENT_TYPE;
|
ownerAgentType === REVIEWER_AGENT_TYPE
|
||||||
|
? OWNER_AGENT_TYPE
|
||||||
|
: REVIEWER_AGENT_TYPE;
|
||||||
const reviewerAgentType =
|
const reviewerAgentType =
|
||||||
options.reviewerAgentType ??
|
options.reviewerAgentType ??
|
||||||
(existingReviewerOverride &&
|
(existingReviewerOverride &&
|
||||||
|
|||||||
@@ -1058,13 +1058,15 @@ describe('assign_room success', () => {
|
|||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getStoredRoomSettings('tribunal-role-overrides@g.us')).toMatchObject({
|
expect(getStoredRoomSettings('tribunal-role-overrides@g.us')).toMatchObject(
|
||||||
chatJid: 'tribunal-role-overrides@g.us',
|
{
|
||||||
roomMode: 'tribunal',
|
chatJid: 'tribunal-role-overrides@g.us',
|
||||||
modeSource: 'explicit',
|
roomMode: 'tribunal',
|
||||||
name: 'Tribunal Role Overrides',
|
modeSource: 'explicit',
|
||||||
ownerAgentType: 'claude-code',
|
name: 'Tribunal Role Overrides',
|
||||||
});
|
ownerAgentType: 'claude-code',
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
getRegisteredAgentTypesForJid('tribunal-role-overrides@g.us').sort(),
|
getRegisteredAgentTypesForJid('tribunal-role-overrides@g.us').sort(),
|
||||||
).toEqual(['claude-code', 'codex']);
|
).toEqual(['claude-code', 'codex']);
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ export interface IpcDeps {
|
|||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
availableGroups: AvailableGroup[],
|
availableGroups: AvailableGroup[],
|
||||||
) => void;
|
) => void;
|
||||||
injectInboundMessage?: (payload: InjectInboundMessagePayload) => Promise<void>;
|
injectInboundMessage?: (
|
||||||
|
payload: InjectInboundMessagePayload,
|
||||||
|
) => Promise<void>;
|
||||||
getRoomRuntimeReport?: (args: {
|
getRoomRuntimeReport?: (args: {
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
sourceGroup: string;
|
sourceGroup: string;
|
||||||
|
|||||||
@@ -793,11 +793,30 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
}),
|
}),
|
||||||
).resolves.toBe('success');
|
).resolves.toBe('success');
|
||||||
|
|
||||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
|
||||||
group,
|
const [
|
||||||
expect.any(Object),
|
effectiveGroup,
|
||||||
expect.any(Function),
|
agentInput,
|
||||||
expect.any(Function),
|
_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({
|
expect.objectContaining({
|
||||||
EJCLAW_PAIRED_TURN_ID:
|
EJCLAW_PAIRED_TURN_ID:
|
||||||
'paired-task-prep-advance:2026-04-10T00:00:00.000Z:reviewer-turn',
|
'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(
|
expect(
|
||||||
pairedExecutionContext.completePairedExecutionContext,
|
pairedExecutionContext.completePairedExecutionContext,
|
||||||
).toHaveBeenCalledWith(
|
).toHaveBeenCalledWith(
|
||||||
@@ -3656,6 +3675,19 @@ describe('runAgentForGroup Codex rotation', () => {
|
|||||||
};
|
};
|
||||||
const outputs: string[] = [];
|
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)
|
vi.mocked(agentRunner.runAgentProcess)
|
||||||
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
|
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
|
||||||
await 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(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', () => {
|
it('prepends a carry-forward warning to reviewer pending prompts', () => {
|
||||||
@@ -63,8 +67,12 @@ describe('message-runtime-prompts carry-forward guidance', () => {
|
|||||||
lastHumanMessage: '이제 새 질문',
|
lastHumanMessage: '이제 새 질문',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true);
|
expect(
|
||||||
expect(prompt).toContain('Do not repeat, continue, or answer that carried-forward final directly.');
|
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', () => {
|
it('prepends a carry-forward warning to owner pending prompts', () => {
|
||||||
@@ -78,8 +86,12 @@ describe('message-runtime-prompts carry-forward guidance', () => {
|
|||||||
lastHumanMessage: '새 owner 질문',
|
lastHumanMessage: '새 owner 질문',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true);
|
expect(
|
||||||
expect(prompt).toContain('Respond only to the latest human request and the current task.');
|
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', () => {
|
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')],
|
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.reviewer_agent_type ?? REVIEWER_AGENT_TYPE,
|
||||||
args.lease.arbiter_agent_type ?? ARBITER_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;
|
const effectiveAgentType = args.forcedAgentType ?? configuredAgentType;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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 { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
|
||||||
import * as pairedExecutionContext from './paired-execution-context.js';
|
import * as pairedExecutionContext from './paired-execution-context.js';
|
||||||
|
|
||||||
/** Prefix helper for progress message assertions */
|
/** Prefix helper for progress message assertions */
|
||||||
const P = (text: string) => `${TASK_STATUS_MESSAGE_PREFIX}${text}`;
|
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', () => ({
|
vi.mock('./agent-runner.js', () => ({
|
||||||
runAgentProcess: vi.fn(),
|
runAgentProcess: vi.fn(),
|
||||||
writeGroupsSnapshot: 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 notifyIdle = vi.fn();
|
||||||
const clearSession = 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([
|
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||||
{
|
{
|
||||||
id: 'msg-1',
|
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 notifyIdle = vi.fn();
|
||||||
const persistSession = vi.fn();
|
const persistSession = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue(
|
||||||
|
makeCodexLease(chatJid),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mocked(db.getMessagesSince).mockReturnValue([
|
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||||
{
|
{
|
||||||
id: 'msg-1',
|
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 group = makeGroup('codex');
|
||||||
const channel = makeChannel(chatJid);
|
const channel = makeChannel(chatJid);
|
||||||
|
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue(
|
||||||
|
makeCodexLease(chatJid),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mocked(db.getMessagesSince).mockReturnValue([
|
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||||
{
|
{
|
||||||
id: 'msg-1',
|
id: 'msg-1',
|
||||||
@@ -4134,6 +4171,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
result: '아직 진행 중입니다.',
|
result: '아직 진행 중입니다.',
|
||||||
newSessionId: 'session-long-progress',
|
newSessionId: 'session-long-progress',
|
||||||
});
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
await vi.advanceTimersByTimeAsync(70_000);
|
await vi.advanceTimersByTimeAsync(70_000);
|
||||||
await vi.advanceTimersByTimeAsync(50_000);
|
await vi.advanceTimersByTimeAsync(50_000);
|
||||||
await vi.advanceTimersByTimeAsync(3_480_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 group = makeGroup('codex');
|
||||||
const channel = makeChannel(chatJid);
|
const channel = makeChannel(chatJid);
|
||||||
|
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue(
|
||||||
|
makeCodexLease(chatJid),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mocked(db.getMessagesSince).mockReturnValue([
|
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||||
{
|
{
|
||||||
id: 'msg-1',
|
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 group = makeGroup('codex');
|
||||||
const channel = makeChannel(chatJid);
|
const channel = makeChannel(chatJid);
|
||||||
|
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue(
|
||||||
|
makeCodexLease(chatJid),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mocked(db.getMessagesSince).mockReturnValue([
|
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||||
{
|
{
|
||||||
id: 'msg-1',
|
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