diff --git a/runners/agent-runner/src/output-protocol.ts b/runners/agent-runner/src/output-protocol.ts index faebaf3..f96819e 100644 --- a/runners/agent-runner/src/output-protocol.ts +++ b/runners/agent-runner/src/output-protocol.ts @@ -3,7 +3,10 @@ import path from 'path'; import { extractImageTagPaths, + imageTagCaption, + missingImageTagCaption, normalizeAgentOutput, + splitImageTagPromptParts, type RunnerStructuredOutput, writeProtocolOutput, } from 'ejclaw-runners-shared'; @@ -76,36 +79,55 @@ export function buildMultimodalContent( text: string, log: LogFn, ): StreamContent { - const { cleanText, imagePaths } = extractImageTagPaths(text); + const { imagePaths } = extractImageTagPaths(text); if (imagePaths.length === 0) return text; const blocks: ContentBlock[] = []; - if (cleanText) { - blocks.push({ type: 'text', text: cleanText }); - } + const pushText = (value: string) => { + const trimmed = value.trim(); + if (trimmed) blocks.push({ type: 'text', text: trimmed }); + }; + + for (const part of splitImageTagPromptParts(text)) { + if (part.type === 'text') { + pushText(part.text); + continue; + } - for (const imgPath of imagePaths) { try { - if (!fs.existsSync(imgPath)) { - log(`Image not found, skipping: ${imgPath}`); + if (!fs.existsSync(part.path)) { + log(`Image not found, skipping: ${part.path}`); + pushText(missingImageTagCaption(part, 'file not found')); continue; } - const data = fs.readFileSync(imgPath).toString('base64'); - const ext = path.extname(imgPath).toLowerCase(); - const mediaType = (MIME_TYPES[ext] || 'image/png') as + const ext = path.extname(part.path).toLowerCase(); + const mediaType = MIME_TYPES[ext] as | 'image/jpeg' | 'image/png' | 'image/gif' - | 'image/webp'; + | 'image/webp' + | undefined; + if (!mediaType) { + log(`Unsupported image type, skipping: ${part.path}`); + pushText( + missingImageTagCaption( + part, + `unsupported image type ${ext || 'unknown'}`, + ), + ); + continue; + } + const data = fs.readFileSync(part.path).toString('base64'); + pushText(imageTagCaption(part)); blocks.push({ type: 'image', source: { type: 'base64', media_type: mediaType, data }, }); - log(`Added image block: ${imgPath} (${mediaType})`); + log(`Added image block: ${part.path} (${mediaType})`); } catch (err) { - log( - `Failed to read image ${imgPath}: ${err instanceof Error ? err.message : String(err)}`, - ); + const reason = err instanceof Error ? err.message : String(err); + log(`Failed to read image ${part.path}: ${reason}`); + pushText(missingImageTagCaption(part, `read failed: ${reason}`)); } } diff --git a/runners/agent-runner/test/output-protocol.test.ts b/runners/agent-runner/test/output-protocol.test.ts index 57a5485..aa33582 100644 --- a/runners/agent-runner/test/output-protocol.test.ts +++ b/runners/agent-runner/test/output-protocol.test.ts @@ -35,6 +35,10 @@ describe('agent-runner multimodal prompts', () => { expect(Array.isArray(content)).toBe(true); expect(content).toEqual([ { type: 'text', text: '리뷰 증거' }, + { + type: 'text', + text: 'Image evidence: settings-v0.1.92-deployed-390.png', + }, { type: 'image', source: { @@ -46,4 +50,48 @@ describe('agent-runner multimodal prompts', () => { ]); expect(logs).toContain(`Added image block: ${imagePath} (image/png)`); }); + + it('keeps missing image evidence visible in Claude prompts', () => { + const missingPath = path.join( + os.tmpdir(), + 'ejclaw-missing-image-evidence.png', + ); + const logs: string[] = []; + + const content = buildMultimodalContent( + `리뷰 증거\n[Image: expected-render.png → ${missingPath}]`, + (message) => logs.push(message), + ); + + expect(content).toEqual([ + { type: 'text', text: '리뷰 증거' }, + { + type: 'text', + text: `[Image unavailable: expected-render.png → ${missingPath} — file not found]`, + }, + ]); + expect(logs).toContain(`Image not found, skipping: ${missingPath}`); + }); + + it('keeps unsupported image evidence visible instead of mislabeled', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-bmp-image-')); + cleanupDirs.push(dir); + const imagePath = path.join(dir, 'settings.bmp'); + fs.writeFileSync(imagePath, Buffer.from('BMunsupported')); + const logs: string[] = []; + + const content = buildMultimodalContent( + `리뷰 증거\n[Image: settings.bmp → ${imagePath}]`, + (message) => logs.push(message), + ); + + expect(content).toEqual([ + { type: 'text', text: '리뷰 증거' }, + { + type: 'text', + text: `[Image unavailable: settings.bmp → ${imagePath} — unsupported image type .bmp]`, + }, + ]); + expect(logs).toContain(`Unsupported image type, skipping: ${imagePath}`); + }); }); diff --git a/runners/codex-runner/src/app-server-input.ts b/runners/codex-runner/src/app-server-input.ts index 6646d00..aee6eb2 100644 --- a/runners/codex-runner/src/app-server-input.ts +++ b/runners/codex-runner/src/app-server-input.ts @@ -1,27 +1,64 @@ import fs from 'fs'; +import path from 'path'; -import { extractImageTagPaths } from 'ejclaw-runners-shared'; +import { + extractImageTagPaths, + imageTagCaption, + missingImageTagCaption, + splitImageTagPromptParts, +} from 'ejclaw-runners-shared'; import type { AppServerInputItem } from './app-server-client.js'; +const SUPPORTED_LOCAL_IMAGE_EXTENSIONS = new Set([ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', +]); + export function parseAppServerInput( text: string, log: (message: string) => void = () => undefined, ): AppServerInputItem[] { - const { cleanText, imagePaths } = extractImageTagPaths(text); + const { imagePaths } = extractImageTagPaths(text); const input: AppServerInputItem[] = []; + const pushText = (value: string) => { + const trimmed = value.trim(); + if (trimmed) input.push({ type: 'text', text: trimmed }); + }; - if (cleanText) { - input.push({ type: 'text', text: cleanText }); - } + if (imagePaths.length > 0) { + for (const part of splitImageTagPromptParts(text)) { + if (part.type === 'text') { + pushText(part.text); + continue; + } + if (!fs.existsSync(part.path)) { + log(`Image not found, skipping: ${part.path}`); + pushText(missingImageTagCaption(part, 'file not found')); + continue; + } - for (const imgPath of imagePaths) { - if (fs.existsSync(imgPath)) { - input.push({ type: 'localImage', path: imgPath }); - log(`Adding image input: ${imgPath}`); - } else { - log(`Image not found, skipping: ${imgPath}`); + const ext = path.extname(part.path).toLowerCase(); + if (!SUPPORTED_LOCAL_IMAGE_EXTENSIONS.has(ext)) { + log(`Unsupported image type, skipping: ${part.path}`); + pushText( + missingImageTagCaption( + part, + `unsupported image type ${ext || 'unknown'}`, + ), + ); + continue; + } + + pushText(imageTagCaption(part)); + input.push({ type: 'localImage', path: part.path }); + log(`Adding image input: ${part.path}`); } + } else if (text) { + input.push({ type: 'text', text }); } if (input.length === 0) { diff --git a/runners/codex-runner/test/app-server-input.test.ts b/runners/codex-runner/test/app-server-input.test.ts index acca98a..b5434d7 100644 --- a/runners/codex-runner/test/app-server-input.test.ts +++ b/runners/codex-runner/test/app-server-input.test.ts @@ -34,8 +34,56 @@ describe('codex app-server input', () => { expect(input).toEqual([ { type: 'text', text: '리뷰 증거' }, + { + type: 'text', + text: 'Image evidence: settings-v0.1.92-deployed-390.png', + }, { type: 'localImage', path: imagePath }, ]); expect(logs).toContain(`Adding image input: ${imagePath}`); }); + + it('keeps missing image evidence visible in Codex prompts', () => { + const missingPath = path.join( + os.tmpdir(), + 'ejclaw-missing-codex-image-evidence.png', + ); + const logs: string[] = []; + + const input = parseAppServerInput( + `리뷰 증거\n[Image: expected-render.png → ${missingPath}]`, + (message) => logs.push(message), + ); + + expect(input).toEqual([ + { type: 'text', text: '리뷰 증거' }, + { + type: 'text', + text: `[Image unavailable: expected-render.png → ${missingPath} — file not found]`, + }, + ]); + expect(logs).toContain(`Image not found, skipping: ${missingPath}`); + }); + + it('keeps unsupported image evidence visible in Codex prompts', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-codex-bmp-')); + cleanupDirs.push(dir); + const imagePath = path.join(dir, 'settings.bmp'); + fs.writeFileSync(imagePath, Buffer.from('BMunsupported')); + const logs: string[] = []; + + const input = parseAppServerInput( + `리뷰 증거\n[Image: settings.bmp → ${imagePath}]`, + (message) => logs.push(message), + ); + + expect(input).toEqual([ + { type: 'text', text: '리뷰 증거' }, + { + type: 'text', + text: `[Image unavailable: settings.bmp → ${imagePath} — unsupported image type .bmp]`, + }, + ]); + expect(logs).toContain(`Unsupported image type, skipping: ${imagePath}`); + }); }); diff --git a/runners/shared/src/agent-protocol.ts b/runners/shared/src/agent-protocol.ts index 0950f69..bb4bf8f 100644 --- a/runners/shared/src/agent-protocol.ts +++ b/runners/shared/src/agent-protocol.ts @@ -3,6 +3,7 @@ export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---'; export const IMAGE_TAG_RE = /\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g; +const IMAGE_TAG_SEGMENT_RE = /\[Image:\s*(?:(.*?)\s*→\s*)?(\/[^\]\n]+)\]/g; const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp)$/i; const MARKDOWN_ABSOLUTE_LINK_RE = /!?\[[^\]\n]*\]\((\/[^)\n]+)\)/g; const MEDIA_TAG_RE = @@ -63,6 +64,10 @@ function cloneImageTagPattern(): RegExp { return new RegExp(IMAGE_TAG_RE.source, IMAGE_TAG_RE.flags); } +function cloneImageTagSegmentPattern(): RegExp { + return new RegExp(IMAGE_TAG_SEGMENT_RE.source, IMAGE_TAG_SEGMENT_RE.flags); +} + export function writeProtocolOutput( output: T, writeLine: (line: string) => void = console.log, @@ -87,6 +92,51 @@ export function extractImageTagPaths(text: string): { }; } +export type ImageTagPromptPart = + | { type: 'text'; text: string } + | { type: 'image'; label: string | null; path: string; raw: string }; + +export function splitImageTagPromptParts(text: string): ImageTagPromptPart[] { + const imagePattern = cloneImageTagSegmentPattern(); + const parts: ImageTagPromptPart[] = []; + let cursor = 0; + + for (const match of text.matchAll(imagePattern)) { + const start = match.index ?? 0; + if (start > cursor) { + parts.push({ type: 'text', text: text.slice(cursor, start) }); + } + + const raw = match[0]; + const label = match[1]?.trim() || null; + const imagePath = match[2].trim(); + parts.push({ type: 'image', label, path: imagePath, raw }); + cursor = start + raw.length; + } + + if (cursor < text.length) { + parts.push({ type: 'text', text: text.slice(cursor) }); + } + + return parts.length > 0 ? parts : [{ type: 'text', text }]; +} + +export function imageTagCaption( + part: Extract, +): string { + return part.label + ? `Image evidence: ${part.label}` + : `Image evidence: ${part.path}`; +} + +export function missingImageTagCaption( + part: Extract, + reason: string, +): string { + const label = part.label ? `${part.label} → ` : ''; + return `[Image unavailable: ${label}${part.path} — ${reason}]`; +} + function attachmentName(filePath: string): string | undefined { return filePath.split(/[\\/]/).at(-1) || undefined; } diff --git a/runners/shared/src/index.ts b/runners/shared/src/index.ts index e9a1eef..d5ac68a 100644 --- a/runners/shared/src/index.ts +++ b/runners/shared/src/index.ts @@ -41,15 +41,18 @@ export { extractMarkdownImageAttachments, extractMediaAttachments, extractImageTagPaths, + imageTagCaption, IMAGE_TAG_RE, IPC_CLOSE_SENTINEL, IPC_INPUT_SUBDIR, IPC_POLL_MS, + missingImageTagCaption, normalizeAgentOutput, normalizeEjclawStructuredOutput, normalizePublicTextOutput, OUTPUT_END_MARKER, OUTPUT_START_MARKER, + splitImageTagPromptParts, writeProtocolOutput, type NormalizedRunnerOutput, type NormalizedAgentOutput, diff --git a/runners/shared/test/agent-protocol.test.ts b/runners/shared/test/agent-protocol.test.ts index f79afaa..dd0f752 100644 --- a/runners/shared/test/agent-protocol.test.ts +++ b/runners/shared/test/agent-protocol.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest'; import { extractImageTagPaths, + imageTagCaption, + missingImageTagCaption, normalizeEjclawStructuredOutput, normalizePublicTextOutput, + splitImageTagPromptParts, writeProtocolOutput, } from '../src/agent-protocol.js'; @@ -25,6 +28,37 @@ describe('shared agent protocol helpers', () => { }); }); + it('splits labeled image tags for multimodal prompt builders', () => { + expect( + splitImageTagPromptParts( + 'before [Image: screenshot.png → /tmp/a.png] after', + ), + ).toEqual([ + { type: 'text', text: 'before ' }, + { + type: 'image', + label: 'screenshot.png', + path: '/tmp/a.png', + raw: '[Image: screenshot.png → /tmp/a.png]', + }, + { type: 'text', text: ' after' }, + ]); + }); + + it('formats image evidence captions and missing-image warnings', () => { + const part = { + type: 'image' as const, + label: 'screenshot.png', + path: '/tmp/a.png', + raw: '[Image: screenshot.png → /tmp/a.png]', + }; + + expect(imageTagCaption(part)).toBe('Image evidence: screenshot.png'); + expect(missingImageTagCaption(part, 'file not found')).toBe( + '[Image unavailable: screenshot.png → /tmp/a.png — file not found]', + ); + }); + it('normalizes plain text runner output as public text', () => { expect(normalizePublicTextOutput('DONE')).toEqual({ result: 'DONE', diff --git a/src/db-paired-turn-outputs.test.ts b/src/db-paired-turn-outputs.test.ts index 4e045ef..3b23ca8 100644 --- a/src/db-paired-turn-outputs.test.ts +++ b/src/db-paired-turn-outputs.test.ts @@ -8,6 +8,33 @@ import { } from './db/paired-turn-outputs.js'; describe('paired turn output attachments', () => { + it('marks truncated output text so reviewers know evidence was clipped', () => { + const database = new Database(':memory:'); + try { + applyBaseSchema(database); + + insertPairedTurnOutputInDatabase( + database, + 'paired-task-turn-output-truncated', + 1, + 'owner', + 'x'.repeat(60_000), + ); + + const [output] = getPairedTurnOutputsFromDatabase( + database, + 'paired-task-turn-output-truncated', + ); + + expect(output.output_text).toHaveLength(50_000); + expect(output.output_text).toMatch( + /\[Output truncated: 60000 > 50000 chars\]/, + ); + } finally { + database.close(); + } + }); + it('preserves attachments for reviewer evidence prompts', () => { const database = new Database(':memory:'); try { diff --git a/src/db/paired-turn-outputs.ts b/src/db/paired-turn-outputs.ts index 5e33124..58f7562 100644 --- a/src/db/paired-turn-outputs.ts +++ b/src/db/paired-turn-outputs.ts @@ -14,6 +14,15 @@ import { const MAX_TURN_OUTPUT_CHARS = 50_000; +function storedOutputText(outputText: string): string { + if (outputText.length <= MAX_TURN_OUTPUT_CHARS) { + return outputText; + } + + const notice = `\n\n[Output truncated: ${outputText.length} > ${MAX_TURN_OUTPUT_CHARS} chars]`; + return `${outputText.slice(0, MAX_TURN_OUTPUT_CHARS - notice.length)}${notice}`; +} + export function insertPairedTurnOutputInDatabase( database: Database, taskId: string, @@ -48,7 +57,7 @@ export function insertPairedTurnOutputInDatabase( taskId, turnNumber, role, - outputText.slice(0, MAX_TURN_OUTPUT_CHARS), + storedOutputText(outputText), serializeAttachmentPayload(options.attachments), parseVisibleVerdict(outputText), options.createdAt ?? new Date().toISOString(), diff --git a/src/message-agent-executor-paired.test.ts b/src/message-agent-executor-paired.test.ts index d1b4718..07e0c8c 100644 --- a/src/message-agent-executor-paired.test.ts +++ b/src/message-agent-executor-paired.test.ts @@ -33,11 +33,11 @@ const log = { warn: vi.fn(), }; -describe('createPairedExecutionLifecycle', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +beforeEach(() => { + vi.clearAllMocks(); +}); +describe('createPairedExecutionLifecycle output persistence', () => { it('stores final output attachments with the paired turn output', () => { const lifecycle = createPairedExecutionLifecycle({ pairedExecutionContext: { @@ -101,7 +101,67 @@ describe('createPairedExecutionLifecycle', () => { }, ); }); +}); +describe('createPairedExecutionLifecycle verdict routing', () => { + it('uses the full final output for paired verdict routing', () => { + const lifecycle = createPairedExecutionLifecycle({ + pairedExecutionContext: { + task: { + id: 'paired-task-long-final', + 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: null, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-09T00:00:00.000Z', + updated_at: '2026-04-09T00:00:00.000Z', + }, + workspace: null, + envOverrides: {}, + }, + pairedTurnIdentity: { + turnId: 'paired-task-long-final:2026-04-09T00:00:00.000Z:owner-turn', + taskId: 'paired-task-long-final', + taskUpdatedAt: '2026-04-09T00:00:00.000Z', + intentKind: 'owner-turn', + role: 'owner', + }, + completedRole: 'owner', + chatJid: 'group@test', + runId: 'run-long-final', + enqueueMessageCheck: vi.fn(), + log, + }); + const longPreface = '검증 증거 '.repeat(100); + const finalOutput = `${longPreface}\nTASK_DONE\n뒤쪽 상태줄도 라우팅에 반영되어야 합니다.`; + + lifecycle.recordFinalOutputBeforeDelivery(finalOutput); + + expect( + pairedExecutionContextModule.completePairedExecutionContext, + ).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: 'paired-task-long-final', + role: 'owner', + status: 'succeeded', + runId: 'run-long-final', + summary: finalOutput, + }), + ); + expect(finalOutput.indexOf('TASK_DONE')).toBeGreaterThan(500); + }); +}); + +describe('createPairedExecutionLifecycle completion handling', () => { it('does not emit a second public notification after arbiter ESCALATE', async () => { const outputs: AgentOutput[] = []; diff --git a/src/message-agent-executor-paired.ts b/src/message-agent-executor-paired.ts index 4092433..d91d08f 100644 --- a/src/message-agent-executor-paired.ts +++ b/src/message-agent-executor-paired.ts @@ -422,7 +422,7 @@ class PairedExecutionLifecycleController implements PairedExecutionLifecycle { this.pairedFinalAttachments = attachments; } if (!this.pairedSummaryLocked) { - this.pairedExecutionSummary = outputText.slice(0, 500); + this.pairedExecutionSummary = outputText; this.pairedSummaryLocked = true; } this.pairedSawOutput = true; @@ -451,7 +451,7 @@ class PairedExecutionLifecycleController implements PairedExecutionLifecycle { 'Adopted direct terminal delivery as paired final output', ); } else if (!this.pairedSummaryLocked) { - this.pairedExecutionSummary = this.pairedFinalOutput.slice(0, 500); + this.pairedExecutionSummary = this.pairedFinalOutput; this.pairedSummaryLocked = true; } return outputText; diff --git a/src/message-runtime-prompts.test.ts b/src/message-runtime-prompts.test.ts index bfc8bc4..e2cbdc7 100644 --- a/src/message-runtime-prompts.test.ts +++ b/src/message-runtime-prompts.test.ts @@ -323,6 +323,9 @@ describe('message-runtime-prompts arbiter output context', () => { expect(prompt).not.toContain('arbiter-context-output-01'); expect(prompt).not.toContain('arbiter-context-output-02'); + expect(prompt).toContain( + '[Earlier paired turn outputs omitted: 2 older outputs not shown]', + ); expect(prompt).toContain('arbiter-context-output-03'); expect(prompt).toContain('arbiter-context-output-08'); expect(prompt).not.toContain('예전 유저 지시'); @@ -362,6 +365,9 @@ describe('message-runtime-prompts arbiter output context', () => { expect(prompt).not.toContain('이전 작업의 유저 지시'); expect(prompt).not.toContain('arbiter-context-output-01'); expect(prompt).not.toContain('arbiter-context-output-02'); + expect(prompt).toContain( + '[Earlier paired turn outputs omitted: 2 older outputs not shown]', + ); expect(prompt).toContain('arbiter-context-output-03'); expect(prompt).toContain('arbiter-context-output-08'); }); diff --git a/src/message-runtime-prompts.ts b/src/message-runtime-prompts.ts index b57dea4..21675a5 100644 --- a/src/message-runtime-prompts.ts +++ b/src/message-runtime-prompts.ts @@ -60,6 +60,45 @@ function turnOutputsToMessages( })); } +function omittedTurnOutputsMessage(args: { + chatJid: string; + omittedCount: number; + firstVisibleOutput?: PairedTurnOutput; +}): NewMessage { + return { + id: `turn-output-omitted-${args.firstVisibleOutput?.task_id ?? 'unknown'}-${args.omittedCount}`, + chat_jid: args.chatJid, + sender: 'system', + sender_name: 'system', + content: `[Earlier paired turn outputs omitted: ${args.omittedCount} older outputs not shown]`, + timestamp: + args.firstVisibleOutput?.created_at ?? '1970-01-01T00:00:00.000Z', + is_bot_message: true as const, + is_from_me: false as const, + }; +} + +function latestTurnOutputMessagesWithOmissionMarker( + outputs: PairedTurnOutput[], + chatJid: string, + limit: number, +): NewMessage[] { + const latestOutputs = latestTurnOutputs(outputs, limit); + const messages = turnOutputsToMessages(latestOutputs, chatJid); + const omittedCount = outputs.length - latestOutputs.length; + if (omittedCount <= 0) { + return messages; + } + return [ + omittedTurnOutputsMessage({ + chatJid, + omittedCount, + firstVisibleOutput: latestOutputs[0], + }), + ...messages, + ]; +} + function mergeHumanAndTurnOutputMessages( chatJid: string, humanMessages: NewMessage[], @@ -221,12 +260,10 @@ export function buildArbiterPromptForTask(args: { args.turnOutputs.length > 0 ? [ ...taskHumanMessages, - ...turnOutputsToMessages( - latestTurnOutputs( - args.turnOutputs, - ARBITER_TURN_OUTPUT_CONTEXT_LIMIT, - ), + ...latestTurnOutputMessagesWithOmissionMarker( + args.turnOutputs, args.chatJid, + ARBITER_TURN_OUTPUT_CONTEXT_LIMIT, ), ] : args.labeledRecentMessages; diff --git a/src/paired-execution-context-arbiter.ts b/src/paired-execution-context-arbiter.ts index 863b23e..c204816 100644 --- a/src/paired-execution-context-arbiter.ts +++ b/src/paired-execution-context-arbiter.ts @@ -113,22 +113,17 @@ export function handleArbiterCompletion(args: { transitionPairedTaskStatus({ taskId, currentStatus: 'in_arbitration', - nextStatus: 'review_ready', + nextStatus: 'completed', expectedUpdatedAt: task.updated_at, updatedAt: now, patch: { - round_trip_count: ARBITER_RESOLUTION_ROUND_TRIP_COUNT, - owner_failure_count: 0, - owner_step_done_streak: 0, - finalize_step_done_count: 0, - empty_step_done_streak: 0, arbiter_verdict: 'unknown', - arbiter_requested_at: null, + completion_reason: 'arbiter_escalated', }, }); logger.warn( { taskId, summary: summary?.slice(0, 200) }, - 'Arbiter verdict unrecognized — returning task to reviewer as proceed fallback', + 'Arbiter verdict unrecognized — escalating instead of treating it as approval', ); return; } diff --git a/src/paired-execution-context-carry-forward.test.ts b/src/paired-execution-context-carry-forward.test.ts new file mode 100644 index 0000000..38d3679 --- /dev/null +++ b/src/paired-execution-context-carry-forward.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('./config.js', async () => { + const actual = + await vi.importActual('./config.js'); + return { + ...actual, + PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL: true, + }; +}); + +vi.mock('./db.js', () => { + const updatePairedTask = vi.fn(); + return { + cancelPairedTurn: vi.fn(), + createPairedTask: vi.fn(), + getLatestPairedTaskForChat: vi.fn(), + getLatestOpenPairedTaskForChat: vi.fn(), + getPairedTaskById: vi.fn(), + getPairedTurnById: vi.fn(), + getPairedTurnOutputs: vi.fn(() => []), + getPairedWorkspace: vi.fn(), + insertPairedTurnOutput: vi.fn(), + updatePairedTask, + updatePairedTaskIfUnchanged: vi.fn((id, _expectedUpdatedAt, updates) => { + updatePairedTask(id, updates); + return true; + }), + upsertPairedProject: vi.fn(), + hasActiveCiWatcherForChat: vi.fn(() => false), + releasePairedTaskExecutionLease: vi.fn(), + }; +}); + +vi.mock('./paired-workspace-manager.js', () => ({ + isOwnerWorkspaceRepairNeededError: vi.fn(() => false), + markPairedTaskReviewReady: vi.fn(), + prepareReviewerWorkspaceForExecution: vi.fn(() => ({ + workspace: null, + autoRefreshed: false, + })), + provisionOwnerWorkspaceForPairedTask: vi.fn(() => ({ + id: 'task-new:owner', + task_id: 'task-new', + role: 'owner', + workspace_dir: '/tmp/paired/task-new/owner', + snapshot_source_dir: null, + snapshot_ref: null, + status: 'ready', + snapshot_refreshed_at: null, + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + })), +})); + +vi.mock('./logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import * as db from './db.js'; +import { resolveOwnerTaskForHumanMessage } from './paired-execution-context.js'; +import type { PairedTask, RegisteredGroup, RoomRoleContext } from './types.js'; + +const group: RegisteredGroup = { + name: 'Paired Room', + folder: 'paired-room', + trigger: '@codex', + added_at: '2026-03-28T00:00:00.000Z', + agentType: 'codex', + workDir: '/repo/canonical', +}; + +const ownerContext: RoomRoleContext = { + serviceId: 'codex-main', + role: 'owner', + ownerServiceId: 'codex-main', + reviewerServiceId: 'claude-review', + failoverOwner: false, +}; + +function buildTask(overrides: Partial = {}): PairedTask { + return { + id: 'task-superseded', + chat_jid: 'dc:test', + group_folder: group.folder, + owner_service_id: 'codex-main', + reviewer_service_id: 'claude-review', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'codex', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: null, + round_trip_count: 0, + owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: 0, + task_done_then_user_reopen_count: 0, + empty_step_done_streak: 0, + status: 'merge_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:05:00.000Z', + ...overrides, + }; +} + +describe('paired execution carry-forward attachments', () => { + it('preserves latest owner final attachments when carrying context into a superseding task', () => { + const supersededTask = buildTask(); + const attachments = [ + { + path: '/data/attachments/paired-turn-outputs/task/1/owner/result.png', + name: 'result.png', + mime: 'image/png', + }, + ]; + vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ + { + id: 1, + task_id: supersededTask.id, + turn_number: 1, + role: 'owner', + output_text: 'TASK_DONE\n새 렌더 이미지입니다.', + attachments, + created_at: '2026-03-28T00:01:00.000Z', + }, + { + id: 2, + task_id: supersededTask.id, + turn_number: 2, + role: 'reviewer', + output_text: 'TASK_DONE\nreview approved', + created_at: '2026-03-28T00:02:00.000Z', + }, + ]); + + resolveOwnerTaskForHumanMessage({ + group, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + existingTask: supersededTask, + }); + + expect(db.insertPairedTurnOutput).toHaveBeenCalledWith( + expect.any(String), + 0, + 'owner', + expect.stringContaining('TASK_DONE\n새 렌더 이미지입니다.'), + { + createdAt: '2026-03-28T00:01:00.000Z', + attachments, + }, + ); + }); +}); diff --git a/src/paired-execution-context-routing.test.ts b/src/paired-execution-context-routing.test.ts index 8f53ca8..a058868 100644 --- a/src/paired-execution-context-routing.test.ts +++ b/src/paired-execution-context-routing.test.ts @@ -169,7 +169,7 @@ describe('paired execution routing loop guards', () => { ); }); - it('routes unknown arbiter verdicts to reviewer as a proceed fallback', () => { + it('escalates unknown arbiter verdicts instead of treating them as approval', () => { vi.mocked(db.getPairedTaskById).mockReturnValue( buildPairedTask({ status: 'in_arbitration', @@ -190,12 +190,9 @@ describe('paired execution routing loop guards', () => { expect(db.updatePairedTask).toHaveBeenCalledWith( 'task-1', expect.objectContaining({ - status: 'review_ready', - round_trip_count: 0, - owner_step_done_streak: 0, - empty_step_done_streak: 0, + status: 'completed', arbiter_verdict: 'unknown', - arbiter_requested_at: null, + completion_reason: 'arbiter_escalated', }), ); }); diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index b371891..216f56e 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -259,13 +259,17 @@ function carryForwardLatestOwnerFinal(args: { 0, 'owner', `[Carried forward context from the previous task: latest owner final]\n${latestOwnerFinal.output_text}`, - latestOwnerFinal.created_at, + { + createdAt: latestOwnerFinal.created_at, + attachments: latestOwnerFinal.attachments, + }, ); logger.info( { sourceTaskId: args.sourceTask.id, targetTaskId: args.targetTask.id, carriedChars: latestOwnerFinal.output_text.length, + attachmentCount: latestOwnerFinal.attachments?.length ?? 0, }, 'Carried forward latest owner final into superseding paired task', ); diff --git a/src/paired-verdict.test.ts b/src/paired-verdict.test.ts index aa2f221..210c9dd 100644 --- a/src/paired-verdict.test.ts +++ b/src/paired-verdict.test.ts @@ -43,7 +43,7 @@ describe('paired verdict parser', () => { ).toBe('continue'); }); - it('does not scan beyond the leading visible line window', () => { + it('accepts status tokens after a longer evidence preface', () => { expect( parseVisibleVerdict( [ @@ -55,14 +55,41 @@ describe('paired verdict parser', () => { 'TASK_DONE', ].join('\n'), ), + ).toBe('task_done'); + }); + + it('does not scan beyond the leading visible line window', () => { + expect( + parseVisibleVerdict( + [ + '01', + '02', + '03', + '04', + '05', + '06', + '07', + '08', + '09', + '10', + '11', + '12', + 'TASK_DONE', + ].join('\n'), + ), ).toBe('continue'); }); - it('classifies arbiter verdicts from the first visible line', () => { + it('classifies arbiter verdicts from leading visible lines', () => { expect(classifyArbiterVerdict('PROCEED\ncontinue')).toBe('proceed'); expect(classifyArbiterVerdict('**VERDICT: REVISE**\nfix it')).toBe( 'revise', ); + expect( + classifyArbiterVerdict( + ['중재 판단을 정리합니다.', 'VERDICT: REVISE', 'fix it'].join('\n'), + ), + ).toBe('revise'); expect( classifyArbiterVerdict( 'private notes\nVERDICT — RESET\nrestart', diff --git a/src/paired-verdict.ts b/src/paired-verdict.ts index c6c71e9..6cac2f0 100644 --- a/src/paired-verdict.ts +++ b/src/paired-verdict.ts @@ -11,7 +11,8 @@ export type VisibleVerdict = export type ArbiterVerdictResult = ArbiterVerdict | 'unknown'; -const VISIBLE_VERDICT_SCAN_LINE_LIMIT = 5; +const VISIBLE_VERDICT_SCAN_LINE_LIMIT = 12; +const ARBITER_VERDICT_SCAN_LINE_LIMIT = 12; function leadingVisibleLines(text: string, limit: number): string[] { const lines: string[] = []; @@ -35,6 +36,10 @@ function leadingVisibleLines(text: string, limit: number): string[] { return lines; } +function stripInternalBlocks(text: string): string { + return text.replace(/[\s\S]*?(?:<\/internal>|$)/g, ''); +} + function parseVisibleVerdictLine(line: string): VisibleVerdict | null { if (/^\*{0,2}BLOCKED(?:\*{0,2})?\b/i.test(line)) return 'blocked'; if (/^\*{0,2}NEEDS_CONTEXT(?:\*{0,2})?\b/i.test(line)) return 'needs_context'; @@ -52,7 +57,7 @@ export function parseVisibleVerdict( summary: string | null | undefined, ): VisibleVerdict { if (!summary) return 'continue'; - const cleaned = summary.replace(/[\s\S]*?<\/internal>/g, '').trim(); + const cleaned = stripInternalBlocks(summary).trim(); if (!cleaned) return 'continue'; for (const line of leadingVisibleLines( cleaned, @@ -68,17 +73,21 @@ export function classifyArbiterVerdict( summary: string | null | undefined, ): ArbiterVerdictResult { if (!summary) return 'unknown'; - const cleaned = summary.replace(/[\s\S]*?<\/internal>/g, '').trim(); + const cleaned = stripInternalBlocks(summary).trim(); if (!cleaned) return 'unknown'; - const firstLine = cleaned.split('\n')[0].trim(); - const verdictMatch = firstLine.match( - /\*{0,2}(?:VERDICT\s*[:—-]\s*)?(PROCEED|REVISE|RESET|ESCALATE|CONTINUE)\*{0,2}/i, - ); - if (verdictMatch) { - const normalized = verdictMatch[1].toLowerCase(); - return normalized === 'continue' - ? 'proceed' - : (normalized as ArbiterVerdict); + for (const line of leadingVisibleLines( + cleaned, + ARBITER_VERDICT_SCAN_LINE_LIMIT, + )) { + const verdictMatch = line.match( + /\*{0,2}(?:VERDICT\s*[:—-]\s*)?(PROCEED|REVISE|RESET|ESCALATE|CONTINUE)\*{0,2}/i, + ); + if (verdictMatch) { + const normalized = verdictMatch[1].toLowerCase(); + return normalized === 'continue' + ? 'proceed' + : (normalized as ArbiterVerdict); + } } return 'unknown'; }