@@ -1,6 +1,6 @@
|
||||
# EJClaw
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ejclaw",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"description": "Discord tribunal multi-agent assistant with owner, reviewer, and arbiter roles.",
|
||||
"packageManager": "bun@1.3.11",
|
||||
"type": "module",
|
||||
|
||||
@@ -29,6 +29,8 @@ describe('config/env loading', () => {
|
||||
delete process.env.DISCORD_CODEX_MAIN_BOT_TOKEN;
|
||||
delete process.env.DISCORD_REVIEW_BOT_TOKEN;
|
||||
delete process.env.DISCORD_CODEX_REVIEW_BOT_TOKEN;
|
||||
delete process.env.PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL;
|
||||
delete process.env.PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION;
|
||||
delete process.env.SESSION_COMMAND_USER_IDS;
|
||||
vi.resetModules();
|
||||
});
|
||||
@@ -102,6 +104,26 @@ describe('config/env loading', () => {
|
||||
expect(config.TIMEZONE).toBe('UTC');
|
||||
});
|
||||
|
||||
it('defaults latest-owner-final carry-forward to disabled and honors explicit opt-in', async () => {
|
||||
let config = await import('./config.js');
|
||||
expect(config.PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL).toBe(false);
|
||||
|
||||
vi.resetModules();
|
||||
process.env.PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL = 'true';
|
||||
config = await import('./config.js');
|
||||
expect(config.PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults unsafe-host Claude reviewer fresh-session forcing to disabled and honors explicit opt-in', async () => {
|
||||
let config = await import('./config.js');
|
||||
expect(config.PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION).toBe(false);
|
||||
|
||||
vi.resetModules();
|
||||
process.env.PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION = 'true';
|
||||
config = await import('./config.js');
|
||||
expect(config.PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION).toBe(true);
|
||||
});
|
||||
|
||||
it('fails fast when a legacy Discord token alias is configured', async () => {
|
||||
process.env.DISCORD_BOT_TOKEN = 'legacy-owner-token';
|
||||
|
||||
|
||||
@@ -76,6 +76,18 @@ export const ARBITER_AGENT_TYPE = CONFIG.paired.arbiterAgentType;
|
||||
/** Service ID for the arbiter. Defaults to codex-review for internal routing when arbiter is enabled. */
|
||||
export const ARBITER_SERVICE_ID = CONFIG.paired.arbiterServiceId;
|
||||
|
||||
/** Whether to re-inject the previous task's latest owner final into a superseding task. Default: false. */
|
||||
export const PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL =
|
||||
CONFIG.paired.carryForwardLatestOwnerFinal;
|
||||
|
||||
/** Whether unsafe-host Claude reviewers must always start on a fresh SDK session. Default: false. */
|
||||
export const PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION =
|
||||
CONFIG.paired.forceFreshClaudeReviewerSessionInUnsafeHostMode;
|
||||
|
||||
export function shouldForceFreshClaudeReviewerSessionInUnsafeHostMode(): boolean {
|
||||
return PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION;
|
||||
}
|
||||
|
||||
/** Language for agent responses. When set, a language instruction is appended to all paired room prompts. */
|
||||
export const AGENT_LANGUAGE = CONFIG.paired.agentLanguage;
|
||||
|
||||
|
||||
@@ -223,6 +223,14 @@ export function loadConfig(): AppConfig {
|
||||
arbiterServiceId: arbiterAgentType
|
||||
? (readText('ARBITER_SERVICE_ID') ?? codexReviewServiceId)
|
||||
: null,
|
||||
carryForwardLatestOwnerFinal: readBoolean(
|
||||
'PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL',
|
||||
false,
|
||||
),
|
||||
forceFreshClaudeReviewerSessionInUnsafeHostMode: readBoolean(
|
||||
'PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION',
|
||||
false,
|
||||
),
|
||||
agentLanguage: readText('AGENT_LANGUAGE') ?? '',
|
||||
arbiterDeadlockThreshold: readInteger('ARBITER_DEADLOCK_THRESHOLD', 2),
|
||||
maxRoundTrips,
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface AppConfig {
|
||||
reviewerServiceIdForType: string;
|
||||
arbiterAgentType?: AgentType;
|
||||
arbiterServiceId: string | null;
|
||||
carryForwardLatestOwnerFinal: boolean;
|
||||
forceFreshClaudeReviewerSessionInUnsafeHostMode: boolean;
|
||||
agentLanguage: string;
|
||||
arbiterDeadlockThreshold: number;
|
||||
maxRoundTrips: number;
|
||||
|
||||
@@ -92,6 +92,7 @@ export {
|
||||
getLastBotFinalMessage,
|
||||
getLatestOpenPairedTaskForChat,
|
||||
getLatestPairedTaskForChat,
|
||||
getLatestPreviousPairedTaskForChat,
|
||||
getLatestTurnNumber,
|
||||
getPairedProject,
|
||||
getPairedTaskById,
|
||||
|
||||
@@ -254,6 +254,26 @@ export function getLatestOpenPairedTaskForChatFromDatabase(
|
||||
return row ? hydratePairedTaskRow(database, row) : undefined;
|
||||
}
|
||||
|
||||
export function getLatestPreviousPairedTaskForChatFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
currentTaskId: string,
|
||||
): PairedTask | undefined {
|
||||
const row = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT *
|
||||
FROM paired_tasks
|
||||
WHERE chat_jid = ?
|
||||
AND id != ?
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(chatJid, currentTaskId) as StoredPairedTaskRow | undefined;
|
||||
return row ? hydratePairedTaskRow(database, row) : undefined;
|
||||
}
|
||||
|
||||
export function updatePairedTaskInDatabase(
|
||||
database: Database,
|
||||
id: string,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
createPairedTaskInDatabase,
|
||||
getLastBotFinalMessageFromDatabase,
|
||||
getLatestOpenPairedTaskForChatFromDatabase,
|
||||
getLatestPreviousPairedTaskForChatFromDatabase,
|
||||
getLatestPairedTaskForChatFromDatabase,
|
||||
getPairedProjectFromDatabase,
|
||||
getPairedTaskByIdFromDatabase,
|
||||
@@ -101,6 +102,17 @@ export function getLatestOpenPairedTaskForChat(
|
||||
return getLatestOpenPairedTaskForChatFromDatabase(requireDatabase(), chatJid);
|
||||
}
|
||||
|
||||
export function getLatestPreviousPairedTaskForChat(
|
||||
chatJid: string,
|
||||
currentTaskId: string,
|
||||
): PairedTask | undefined {
|
||||
return getLatestPreviousPairedTaskForChatFromDatabase(
|
||||
requireDatabase(),
|
||||
chatJid,
|
||||
currentTaskId,
|
||||
);
|
||||
}
|
||||
|
||||
export function updatePairedTask(id: string, updates: PairedTaskUpdates): void {
|
||||
updatePairedTaskInDatabase(requireDatabase(), id, updates);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ARBITER_AGENT_TYPE,
|
||||
REVIEWER_AGENT_TYPE,
|
||||
getRoleModelConfig,
|
||||
shouldForceFreshClaudeReviewerSessionInUnsafeHostMode,
|
||||
} from './config.js';
|
||||
import {
|
||||
getAllTasks,
|
||||
@@ -119,7 +120,10 @@ export async function prepareMessageAgentExecutionTarget(
|
||||
const isClaudeCodeAgent = effectiveAgentType === 'claude-code';
|
||||
const unsafeHostPairedMode = isUnsafeHostPairedModeEnabled();
|
||||
const forceFreshClaudeReviewerSession =
|
||||
reviewerMode && isClaudeCodeAgent && unsafeHostPairedMode;
|
||||
reviewerMode &&
|
||||
isClaudeCodeAgent &&
|
||||
unsafeHostPairedMode &&
|
||||
shouldForceFreshClaudeReviewerSessionInUnsafeHostMode();
|
||||
const shouldPersistSession =
|
||||
activeRole !== 'arbiter' &&
|
||||
!args.forcedAgentType &&
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as config from './config.js';
|
||||
|
||||
vi.mock('./agent-runner.js', () => ({
|
||||
runAgentProcess: vi.fn(),
|
||||
@@ -16,9 +17,11 @@ vi.mock('./config.js', () => ({
|
||||
CODEX_MAIN_SERVICE_ID: 'codex-main',
|
||||
CODEX_REVIEW_SERVICE_ID: 'codex-review',
|
||||
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||
PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION: false,
|
||||
REVIEWER_AGENT_TYPE: 'claude-code',
|
||||
ARBITER_AGENT_TYPE: undefined,
|
||||
SERVICE_SESSION_SCOPE: 'claude',
|
||||
shouldForceFreshClaudeReviewerSessionInUnsafeHostMode: vi.fn(() => false),
|
||||
isClaudeService: vi.fn(() => true),
|
||||
normalizeServiceId: vi.fn((serviceId: string) =>
|
||||
serviceId === 'codex' ? 'codex-main' : serviceId,
|
||||
@@ -290,6 +293,9 @@ describe('runAgentForGroup room memory', () => {
|
||||
vi.resetAllMocks();
|
||||
resetPairedFollowUpScheduleState();
|
||||
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
|
||||
vi.mocked(
|
||||
config.shouldForceFreshClaudeReviewerSessionInUnsafeHostMode,
|
||||
).mockReturnValue(false);
|
||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||
async (_group, _input, _onProcess, onOutput) => {
|
||||
await onOutput?.({
|
||||
@@ -1862,7 +1868,7 @@ describe('runAgentForGroup room memory', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('starts reviewer Claude fresh in unsafe host mode instead of resuming a stored session', async () => {
|
||||
it('keeps reviewer Claude session in unsafe host mode by default', async () => {
|
||||
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1';
|
||||
const group = {
|
||||
...makeGroup(),
|
||||
@@ -1946,6 +1952,110 @@ describe('runAgentForGroup room memory', () => {
|
||||
runId: 'run-review-plan-unsafe-host',
|
||||
});
|
||||
|
||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
folder: 'test-group',
|
||||
agentType: 'claude-code',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sessionId: 'reviewer-session',
|
||||
}),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
|
||||
}),
|
||||
);
|
||||
expect(deps.clearSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts reviewer Claude fresh in unsafe host mode when explicitly forced', async () => {
|
||||
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1';
|
||||
vi.mocked(
|
||||
config.shouldForceFreshClaudeReviewerSessionInUnsafeHostMode,
|
||||
).mockReturnValue(true);
|
||||
const group = {
|
||||
...makeGroup(),
|
||||
folder: 'test-group',
|
||||
agentType: 'codex' as const,
|
||||
};
|
||||
const deps = {
|
||||
...makeDeps(),
|
||||
getSessions: () => ({ 'test-group:reviewer': 'reviewer-session' }),
|
||||
};
|
||||
|
||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||
chat_jid: 'group@test',
|
||||
owner_agent_type: 'codex',
|
||||
reviewer_agent_type: 'claude-code',
|
||||
arbiter_agent_type: null,
|
||||
owner_service_id: 'codex-main',
|
||||
reviewer_service_id: 'claude',
|
||||
arbiter_service_id: null,
|
||||
activated_at: null,
|
||||
reason: null,
|
||||
explicit: false,
|
||||
});
|
||||
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||
id: 'paired-task-review',
|
||||
chat_jid: 'group@test',
|
||||
group_folder: 'test-group',
|
||||
owner_service_id: 'codex-main',
|
||||
reviewer_service_id: 'claude',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
plan_notes: null,
|
||||
round_trip_count: 0,
|
||||
review_requested_at: '2026-03-31T00:00:00.000Z',
|
||||
status: 'review_ready',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-03-31T00:00:00.000Z',
|
||||
updated_at: '2026-03-31T00:00:00.000Z',
|
||||
});
|
||||
vi.mocked(
|
||||
pairedExecutionContext.preparePairedExecutionContext,
|
||||
).mockReturnValue({
|
||||
task: {
|
||||
id: 'paired-task-review',
|
||||
chat_jid: 'group@test',
|
||||
group_folder: 'test-group',
|
||||
owner_service_id: 'codex-main',
|
||||
reviewer_service_id: 'claude',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
plan_notes: null,
|
||||
round_trip_count: 0,
|
||||
review_requested_at: '2026-03-31T00:00:00.000Z',
|
||||
status: 'review_ready',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-03-31T00:00:00.000Z',
|
||||
updated_at: '2026-03-31T00:00:00.000Z',
|
||||
},
|
||||
workspace: null,
|
||||
envOverrides: {
|
||||
EJCLAW_PAIRED_TASK_ID: 'paired-task-review',
|
||||
EJCLAW_PAIRED_ROLE: 'reviewer',
|
||||
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
|
||||
CLAUDE_CONFIG_DIR: '/tmp/test-group-reviewer',
|
||||
},
|
||||
});
|
||||
vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({
|
||||
status: 'success',
|
||||
result: 'review ok',
|
||||
newSessionId: 'review-session-new',
|
||||
});
|
||||
|
||||
await runAgentForGroup(deps, {
|
||||
group,
|
||||
prompt: 'please review',
|
||||
chatJid: 'group@test',
|
||||
runId: 'run-review-plan-unsafe-host-fresh',
|
||||
});
|
||||
|
||||
expect(deps.clearSession).toHaveBeenCalledWith('test-group:reviewer');
|
||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
getLastHumanMessageContent,
|
||||
getLatestPreviousPairedTaskForChat,
|
||||
getPairedTurnOutputs,
|
||||
getRecentChatMessages,
|
||||
} from './db.js';
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
buildOwnerPendingPrompt,
|
||||
buildPairedTurnPrompt,
|
||||
buildReviewerPendingPrompt,
|
||||
type PriorTaskPromptContext,
|
||||
} from './message-runtime-prompts.js';
|
||||
import {
|
||||
requeuePendingPairedTurn,
|
||||
@@ -32,6 +34,7 @@ import type {
|
||||
NewMessage,
|
||||
PairedTask,
|
||||
PairedRoomRole,
|
||||
PairedTurnOutput,
|
||||
PairedTurnReservationIntentKind,
|
||||
RegisteredGroup,
|
||||
} from './types.js';
|
||||
@@ -115,6 +118,11 @@ export function buildPendingPairedTurn(args: {
|
||||
const cursor = lastRaw?.seq ?? lastRaw?.timestamp ?? null;
|
||||
const taskStatus = task.status;
|
||||
const turnOutputs = getPairedTurnOutputs(task.id);
|
||||
const priorTaskContext = resolvePriorTaskPromptContext(
|
||||
chatJid,
|
||||
task,
|
||||
turnOutputs,
|
||||
);
|
||||
const lastTurnOutput = turnOutputs[turnOutputs.length - 1];
|
||||
const nextTurnAction = resolveNextTurnAction({
|
||||
taskStatus,
|
||||
@@ -131,6 +139,7 @@ export function buildPendingPairedTurn(args: {
|
||||
turnOutputs,
|
||||
recentHumanMessages,
|
||||
lastHumanMessage,
|
||||
priorTaskContext,
|
||||
}),
|
||||
channel: resolveChannel(taskStatus),
|
||||
cursor,
|
||||
@@ -182,6 +191,7 @@ export function buildPendingPairedTurn(args: {
|
||||
turnOutputs,
|
||||
recentHumanMessages,
|
||||
lastHumanMessage,
|
||||
priorTaskContext,
|
||||
}),
|
||||
channel: resolveChannel(taskStatus),
|
||||
cursor,
|
||||
@@ -195,6 +205,38 @@ export function buildPendingPairedTurn(args: {
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePriorTaskPromptContext(
|
||||
chatJid: string,
|
||||
task: PairedTask,
|
||||
turnOutputs: PairedTurnOutput[],
|
||||
): PriorTaskPromptContext | null {
|
||||
if (turnOutputs.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousTask = getLatestPreviousPairedTaskForChat(chatJid, task.id);
|
||||
if (!previousTask || previousTask.status !== 'completed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousOutputs = getPairedTurnOutputs(previousTask.id);
|
||||
const ownerFinal = [...previousOutputs]
|
||||
.reverse()
|
||||
.find((output) => output.role === 'owner')?.output_text;
|
||||
const reviewerFinal = [...previousOutputs]
|
||||
.reverse()
|
||||
.find((output) => output.role === 'reviewer')?.output_text;
|
||||
|
||||
if (!ownerFinal && !reviewerFinal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
ownerFinal: ownerFinal ?? null,
|
||||
reviewerFinal: reviewerFinal ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executePendingPairedTurn(args: {
|
||||
pendingTurn: Exclude<PendingPairedTurn, null>;
|
||||
chatJid: string;
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('message-runtime-prompts carry-forward guidance', () => {
|
||||
],
|
||||
recentHumanMessages: [makeHumanMessage('이제 새 질문')],
|
||||
lastHumanMessage: '이제 새 질문',
|
||||
priorTaskContext: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -84,6 +85,7 @@ describe('message-runtime-prompts carry-forward guidance', () => {
|
||||
],
|
||||
recentHumanMessages: [makeHumanMessage('새 owner 질문')],
|
||||
lastHumanMessage: '새 owner 질문',
|
||||
priorTaskContext: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -108,4 +110,47 @@ describe('message-runtime-prompts carry-forward guidance', () => {
|
||||
prompt.startsWith('System note:\nIf you see a message beginning with'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('includes previous owner and reviewer finals in reviewer pending prompts when the current task has no outputs', () => {
|
||||
const prompt = buildReviewerPendingPrompt({
|
||||
chatJid: 'group@test',
|
||||
timezone: 'UTC',
|
||||
turnOutputs: [],
|
||||
recentHumanMessages: [makeHumanMessage('추가 질문')],
|
||||
lastHumanMessage: '추가 질문',
|
||||
priorTaskContext: {
|
||||
ownerFinal: 'DONE\n이전 owner 결론',
|
||||
reviewerFinal: 'DONE_WITH_CONCERNS\n이전 reviewer 결론',
|
||||
},
|
||||
});
|
||||
|
||||
expect(prompt).toContain(
|
||||
'Background from the previous completed paired task:',
|
||||
);
|
||||
expect(prompt).toContain('Previous task owner final:');
|
||||
expect(prompt).toContain('이전 owner 결론');
|
||||
expect(prompt).toContain('Previous task reviewer final:');
|
||||
expect(prompt).toContain('이전 reviewer 결론');
|
||||
});
|
||||
|
||||
it('includes previous owner and reviewer finals in owner pending prompts when the current task has no outputs', () => {
|
||||
const prompt = buildOwnerPendingPrompt({
|
||||
chatJid: 'group@test',
|
||||
timezone: 'UTC',
|
||||
turnOutputs: [],
|
||||
recentHumanMessages: [makeHumanMessage('추가 owner 질문')],
|
||||
lastHumanMessage: '추가 owner 질문',
|
||||
priorTaskContext: {
|
||||
ownerFinal: 'DONE\n이전 owner 결론',
|
||||
reviewerFinal: 'DONE_WITH_CONCERNS\n이전 reviewer 결론',
|
||||
},
|
||||
});
|
||||
|
||||
expect(prompt).toContain(
|
||||
'Background from the previous completed paired task:',
|
||||
);
|
||||
expect(prompt).toContain('Previous task owner final:');
|
||||
expect(prompt).toContain('Previous task reviewer final:');
|
||||
expect(prompt).toContain('추가 owner 질문');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,11 @@ const CARRIED_FORWARD_OWNER_FINAL_MARKER =
|
||||
const CARRIED_FORWARD_OWNER_FINAL_GUIDANCE = `System note:
|
||||
If you see a message beginning with "${CARRIED_FORWARD_OWNER_FINAL_MARKER}", treat it as background only. Do not repeat, continue, or answer that carried-forward final directly. Respond only to the latest human request and the current task.`;
|
||||
|
||||
export interface PriorTaskPromptContext {
|
||||
ownerFinal?: string | null;
|
||||
reviewerFinal?: string | null;
|
||||
}
|
||||
|
||||
function turnOutputsToMessages(
|
||||
outputs: PairedTurnOutput[],
|
||||
chatJid: string,
|
||||
@@ -51,6 +56,40 @@ function prependCarriedForwardGuidance(
|
||||
return `${CARRIED_FORWARD_OWNER_FINAL_GUIDANCE}\n\n${prompt}`;
|
||||
}
|
||||
|
||||
function truncatePriorTaskFinal(text: string, maxChars = 1200): string {
|
||||
const normalized = text.trim();
|
||||
if (normalized.length <= maxChars) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, maxChars)}...`;
|
||||
}
|
||||
|
||||
function formatPriorTaskPromptContext(
|
||||
priorTaskContext?: PriorTaskPromptContext | null,
|
||||
): string {
|
||||
if (!priorTaskContext) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
if (priorTaskContext.ownerFinal?.trim()) {
|
||||
sections.push(
|
||||
`Previous task owner final:\n---\n${truncatePriorTaskFinal(priorTaskContext.ownerFinal)}\n---`,
|
||||
);
|
||||
}
|
||||
if (priorTaskContext.reviewerFinal?.trim()) {
|
||||
sections.push(
|
||||
`Previous task reviewer final:\n---\n${truncatePriorTaskFinal(priorTaskContext.reviewerFinal)}\n---`,
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `Background from the previous completed paired task:\n${sections.join('\n\n')}\n\n`;
|
||||
}
|
||||
|
||||
export function buildPairedTurnPrompt(args: {
|
||||
taskId: string;
|
||||
chatJid: string;
|
||||
@@ -85,6 +124,7 @@ export function buildReviewerPendingPrompt(args: {
|
||||
turnOutputs: PairedTurnOutput[];
|
||||
recentHumanMessages: NewMessage[];
|
||||
lastHumanMessage: string | null | undefined;
|
||||
priorTaskContext?: PriorTaskPromptContext | null;
|
||||
}): string {
|
||||
if (args.turnOutputs.length > 0) {
|
||||
return prependCarriedForwardGuidance(
|
||||
@@ -101,10 +141,10 @@ export function buildReviewerPendingPrompt(args: {
|
||||
}
|
||||
|
||||
if (!args.lastHumanMessage) {
|
||||
return 'Review the latest owner changes in the workspace.';
|
||||
return `${formatPriorTaskPromptContext(args.priorTaskContext)}Review the latest owner changes in the workspace.`;
|
||||
}
|
||||
|
||||
return `User request:\n---\n${args.lastHumanMessage}\n---\n\nReview the latest owner changes in the workspace.`;
|
||||
return `${formatPriorTaskPromptContext(args.priorTaskContext)}User request:\n---\n${args.lastHumanMessage}\n---\n\nReview the latest owner changes in the workspace.`;
|
||||
}
|
||||
|
||||
export function buildOwnerPendingPrompt(args: {
|
||||
@@ -113,6 +153,7 @@ export function buildOwnerPendingPrompt(args: {
|
||||
turnOutputs: PairedTurnOutput[];
|
||||
recentHumanMessages: NewMessage[];
|
||||
lastHumanMessage: string | null | undefined;
|
||||
priorTaskContext?: PriorTaskPromptContext | null;
|
||||
}): string {
|
||||
if (args.turnOutputs.length > 0) {
|
||||
return prependCarriedForwardGuidance(
|
||||
@@ -129,10 +170,10 @@ export function buildOwnerPendingPrompt(args: {
|
||||
}
|
||||
|
||||
if (!args.lastHumanMessage) {
|
||||
return 'Continue the owner turn using the latest reviewer or arbiter feedback.';
|
||||
return `${formatPriorTaskPromptContext(args.priorTaskContext)}Continue the owner turn using the latest reviewer or arbiter feedback.`;
|
||||
}
|
||||
|
||||
return `User request:\n---\n${args.lastHumanMessage}\n---\n\nContinue the owner turn using the latest reviewer or arbiter feedback.`;
|
||||
return `${formatPriorTaskPromptContext(args.priorTaskContext)}User request:\n---\n${args.lastHumanMessage}\n---\n\nContinue the owner turn using the latest reviewer or arbiter feedback.`;
|
||||
}
|
||||
|
||||
export function buildArbiterPromptForTask(args: {
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock('./config.js', () => ({
|
||||
CODEX_REVIEW_SERVICE_ID: 'codex-review',
|
||||
REVIEWER_AGENT_TYPE: 'claude-code',
|
||||
ARBITER_AGENT_TYPE: undefined,
|
||||
shouldForceFreshClaudeReviewerSessionInUnsafeHostMode: vi.fn(() => false),
|
||||
normalizeServiceId: vi.fn((serviceId: string) => serviceId),
|
||||
isClaudeService: vi.fn(() => true),
|
||||
isReviewService: vi.fn(() => false),
|
||||
@@ -161,6 +162,7 @@ vi.mock('./db.js', () => {
|
||||
),
|
||||
hasActiveCiWatcherForChat: vi.fn(() => false),
|
||||
getLatestOpenPairedTaskForChat: vi.fn(() => undefined),
|
||||
getLatestPreviousPairedTaskForChat: vi.fn(() => undefined),
|
||||
getPairedTaskById: vi.fn(() => undefined),
|
||||
getPairedTurnOutputs: vi.fn(() => []),
|
||||
getRecentChatMessages: vi.fn(() => []),
|
||||
@@ -1921,6 +1923,87 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
||||
});
|
||||
});
|
||||
|
||||
it('includes previous task owner and reviewer finals in a new reviewer prompt when the current task has no outputs', () => {
|
||||
const chatJid = 'group@test';
|
||||
const group = makeGroup('claude-code');
|
||||
const currentTask = {
|
||||
id: 'task-current-reviewer',
|
||||
chat_jid: chatJid,
|
||||
group_folder: group.folder,
|
||||
owner_service_id: 'claude',
|
||||
reviewer_service_id: 'codex-main',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
plan_notes: null,
|
||||
review_requested_at: '2026-03-30T00:00:10.000Z',
|
||||
round_trip_count: 1,
|
||||
status: 'review_ready',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-03-30T00:00:10.000Z',
|
||||
updated_at: '2026-03-30T00:00:10.000Z',
|
||||
} as any;
|
||||
const previousTask = {
|
||||
...currentTask,
|
||||
id: 'task-previous-reviewer',
|
||||
status: 'completed',
|
||||
completion_reason: 'superseded',
|
||||
created_at: '2026-03-30T00:00:00.000Z',
|
||||
updated_at: '2026-03-30T00:00:09.000Z',
|
||||
} as any;
|
||||
|
||||
vi.mocked(db.getLatestPreviousPairedTaskForChat).mockReturnValue(
|
||||
previousTask,
|
||||
);
|
||||
vi.mocked(db.getPairedTurnOutputs).mockImplementation((taskId: string) => {
|
||||
if (taskId === currentTask.id) {
|
||||
return [];
|
||||
}
|
||||
if (taskId === previousTask.id) {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
task_id: previousTask.id,
|
||||
turn_number: 1,
|
||||
role: 'owner',
|
||||
output_text: 'DONE\n이전 owner 답변',
|
||||
created_at: '2026-03-30T00:00:05.000Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
task_id: previousTask.id,
|
||||
turn_number: 2,
|
||||
role: 'reviewer',
|
||||
output_text: 'DONE_WITH_CONCERNS\n이전 reviewer 피드백',
|
||||
created_at: '2026-03-30T00:00:06.000Z',
|
||||
},
|
||||
] as any;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
vi.mocked(db.getLastHumanMessageContent).mockReturnValue('추가 질문');
|
||||
|
||||
const pending = buildPendingPairedTurn({
|
||||
chatJid,
|
||||
timezone: 'UTC',
|
||||
task: currentTask,
|
||||
rawMissedMessages: [{ seq: 42, timestamp: '2026-03-30T00:00:11.000Z' }],
|
||||
recentHumanMessages: [],
|
||||
labeledRecentMessages: [],
|
||||
resolveChannel: () => makeChannel(chatJid, 'discord-review', false),
|
||||
});
|
||||
|
||||
expect(pending).not.toBeNull();
|
||||
expect(pending?.prompt).toContain(
|
||||
'Background from the previous completed paired task:',
|
||||
);
|
||||
expect(pending?.prompt).toContain('Previous task owner final:');
|
||||
expect(pending?.prompt).toContain('이전 owner 답변');
|
||||
expect(pending?.prompt).toContain('Previous task reviewer final:');
|
||||
expect(pending?.prompt).toContain('이전 reviewer 피드백');
|
||||
});
|
||||
|
||||
it('re-enqueues reviewer after a successful owner delivery moves the task to review_ready', async () => {
|
||||
const chatJid = 'group@test';
|
||||
const group = makeGroup('codex');
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('paired execution context', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('carries forward the latest owner final when a merge_ready task is superseded by new human input', () => {
|
||||
it('does not carry forward the latest owner final by default when a merge_ready task is superseded by new human input', () => {
|
||||
const supersededTask = buildPairedTask({
|
||||
id: 'task-superseded',
|
||||
status: 'merge_ready',
|
||||
@@ -290,13 +290,7 @@ describe('paired execution context', () => {
|
||||
|
||||
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
|
||||
expect(result.supersededTask).toEqual(supersededTask);
|
||||
expect(db.insertPairedTurnOutput).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
0,
|
||||
'owner',
|
||||
'[Carried forward context from the previous task: latest owner final]\nDONE_WITH_CONCERNS\n이전 task owner final',
|
||||
'2026-03-28T00:01:00.000Z',
|
||||
);
|
||||
expect(db.insertPairedTurnOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses room role context agent overrides when creating a paired task', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CODEX_MAIN_SERVICE_ID,
|
||||
CODEX_REVIEW_SERVICE_ID,
|
||||
DATA_DIR,
|
||||
PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL,
|
||||
PAIRED_MAX_ROUND_TRIPS,
|
||||
REVIEWER_AGENT_TYPE,
|
||||
} from './config.js';
|
||||
@@ -207,6 +208,10 @@ function carryForwardLatestOwnerFinal(args: {
|
||||
sourceTask: PairedTask;
|
||||
targetTask: PairedTask;
|
||||
}): void {
|
||||
if (!PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latestOwnerFinal = getLatestTurnOutputByRole(
|
||||
args.sourceTask.id,
|
||||
'owner',
|
||||
|
||||
Reference in New Issue
Block a user