From 6eca648c47c686f47fbc73f4562c3cc45ebffee2 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 31 May 2026 14:49:56 +0900 Subject: [PATCH] fix: carry paired turn attachments into review context (#201) --- .../agent-runner/test/output-protocol.test.ts | 49 ++++++++++++ runners/codex-runner/src/app-server-input.ts | 32 ++++++++ runners/codex-runner/src/index.ts | 42 +--------- .../test/app-server-input.test.ts | 41 ++++++++++ src/db-paired-turn-outputs.test.ts | 49 ++++++++++++ src/db/base-schema.ts | 2 +- src/db/bootstrap.test.ts | 1 + .../018_paired-turn-output-attachments.ts | 20 +++++ src/db/migrations/index.ts | 2 + src/db/paired-turn-outputs.ts | 42 ++++++++-- src/db/runtime-paired.ts | 13 ++- src/db/work-items.ts | 4 +- src/message-agent-executor-attempt-runner.ts | 14 +++- src/message-agent-executor-paired.test.ts | 64 +++++++++++++++ src/message-agent-executor-paired.ts | 53 ++++++++++--- src/message-runtime-prompts.test.ts | 33 ++++++++ src/message-runtime-prompts.ts | 37 ++++++++- src/paired-turn-output-attachments.test.ts | 58 ++++++++++++++ src/paired-turn-output-attachments.ts | 79 +++++++++++++++++++ src/types.ts | 1 + src/web-dashboard-data-attachments.test.ts | 41 ++++++++++ src/web-dashboard-data.ts | 5 +- 22 files changed, 615 insertions(+), 67 deletions(-) create mode 100644 runners/agent-runner/test/output-protocol.test.ts create mode 100644 runners/codex-runner/src/app-server-input.ts create mode 100644 runners/codex-runner/test/app-server-input.test.ts create mode 100644 src/db-paired-turn-outputs.test.ts create mode 100644 src/db/migrations/018_paired-turn-output-attachments.ts create mode 100644 src/paired-turn-output-attachments.test.ts create mode 100644 src/paired-turn-output-attachments.ts diff --git a/runners/agent-runner/test/output-protocol.test.ts b/runners/agent-runner/test/output-protocol.test.ts new file mode 100644 index 0000000..57a5485 --- /dev/null +++ b/runners/agent-runner/test/output-protocol.test.ts @@ -0,0 +1,49 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildMultimodalContent } from '../src/output-protocol.js'; + +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +const cleanupDirs: string[] = []; + +afterEach(() => { + for (const dir of cleanupDirs.splice(0)) { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); + +describe('agent-runner multimodal prompts', () => { + it('loads labeled image tags as Claude image blocks', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-image-tag-')); + cleanupDirs.push(dir); + const imagePath = path.join(dir, 'settings-v0.1.92-deployed-390.png'); + fs.writeFileSync(imagePath, ONE_PIXEL_PNG); + const logs: string[] = []; + + const content = buildMultimodalContent( + `리뷰 증거\n[Image: settings-v0.1.92-deployed-390.png → ${imagePath}]`, + (message) => logs.push(message), + ); + + expect(Array.isArray(content)).toBe(true); + expect(content).toEqual([ + { type: 'text', text: '리뷰 증거' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: ONE_PIXEL_PNG.toString('base64'), + }, + }, + ]); + expect(logs).toContain(`Added image block: ${imagePath} (image/png)`); + }); +}); diff --git a/runners/codex-runner/src/app-server-input.ts b/runners/codex-runner/src/app-server-input.ts new file mode 100644 index 0000000..6646d00 --- /dev/null +++ b/runners/codex-runner/src/app-server-input.ts @@ -0,0 +1,32 @@ +import fs from 'fs'; + +import { extractImageTagPaths } from 'ejclaw-runners-shared'; + +import type { AppServerInputItem } from './app-server-client.js'; + +export function parseAppServerInput( + text: string, + log: (message: string) => void = () => undefined, +): AppServerInputItem[] { + const { cleanText, imagePaths } = extractImageTagPaths(text); + const input: AppServerInputItem[] = []; + + if (cleanText) { + input.push({ type: 'text', text: cleanText }); + } + + 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}`); + } + } + + if (input.length === 0) { + input.push({ type: 'text', text }); + } + + return input; +} diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts index 1c9d7f0..0db0b5e 100644 --- a/runners/codex-runner/src/index.ts +++ b/runners/codex-runner/src/index.ts @@ -17,7 +17,6 @@ import path from 'path'; import { EJCLAW_ENV, - extractImageTagPaths, IPC_CLOSE_SENTINEL, IPC_INPUT_SUBDIR, IPC_POLL_MS, @@ -26,10 +25,8 @@ import { type RunnerStructuredOutput, } from 'ejclaw-runners-shared'; -import { - CodexAppServerClient, - type AppServerInputItem, -} from './app-server-client.js'; +import { CodexAppServerClient } from './app-server-client.js'; +import { parseAppServerInput } from './app-server-input.js'; import { prependRoomRoleHeader, type RoomRoleContext, @@ -173,37 +170,6 @@ function drainIpcInput(): string[] { } } -function extractImagePaths(text: string): { - cleanText: string; - imagePaths: string[]; -} { - return extractImageTagPaths(text); -} - -function parseAppServerInput(text: string): AppServerInputItem[] { - const { cleanText, imagePaths } = extractImagePaths(text); - const input: AppServerInputItem[] = []; - - if (cleanText) { - input.push({ type: 'text', text: cleanText }); - } - - 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}`); - } - } - - if (input.length === 0) { - input.push({ type: 'text', text }); - } - - return input; -} - function formatProgressElapsed(ms: number): string { const elapsedSeconds = Math.floor(ms / 10_000) * 10; const hours = Math.floor(elapsedSeconds / 3600); @@ -231,7 +197,7 @@ async function executeAppServerTurn( let lastProgressMessage: string | null = null; const activeTurn = await client.startTurn( threadId, - parseAppServerInput(prompt), + parseAppServerInput(prompt, log), { cwd: EFFECTIVE_CWD, model: CODEX_MODEL || undefined, @@ -277,7 +243,7 @@ async function executeAppServerTurn( const merged = messages.join('\n'); log(`Steering active turn with ${messages.length} queued message(s)`); try { - await activeTurn.steer(parseAppServerInput(merged)); + await activeTurn.steer(parseAppServerInput(merged, log)); } catch (err) { log( `turn/steer failed: ${err instanceof Error ? err.message : String(err)}`, diff --git a/runners/codex-runner/test/app-server-input.test.ts b/runners/codex-runner/test/app-server-input.test.ts new file mode 100644 index 0000000..acca98a --- /dev/null +++ b/runners/codex-runner/test/app-server-input.test.ts @@ -0,0 +1,41 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { parseAppServerInput } from '../src/app-server-input.js'; + +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +const cleanupDirs: string[] = []; + +afterEach(() => { + for (const dir of cleanupDirs.splice(0)) { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); + +describe('codex app-server input', () => { + it('loads labeled image tags as local image input items', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-codex-image-')); + cleanupDirs.push(dir); + const imagePath = path.join(dir, 'settings-v0.1.92-deployed-390.png'); + fs.writeFileSync(imagePath, ONE_PIXEL_PNG); + const logs: string[] = []; + + const input = parseAppServerInput( + `리뷰 증거\n[Image: settings-v0.1.92-deployed-390.png → ${imagePath}]`, + (message) => logs.push(message), + ); + + expect(input).toEqual([ + { type: 'text', text: '리뷰 증거' }, + { type: 'localImage', path: imagePath }, + ]); + expect(logs).toContain(`Adding image input: ${imagePath}`); + }); +}); diff --git a/src/db-paired-turn-outputs.test.ts b/src/db-paired-turn-outputs.test.ts new file mode 100644 index 0000000..4e045ef --- /dev/null +++ b/src/db-paired-turn-outputs.test.ts @@ -0,0 +1,49 @@ +import { Database } from 'bun:sqlite'; +import { describe, expect, it } from 'vitest'; + +import { applyBaseSchema } from './db/base-schema.js'; +import { + getPairedTurnOutputsFromDatabase, + insertPairedTurnOutputInDatabase, +} from './db/paired-turn-outputs.js'; + +describe('paired turn output attachments', () => { + it('preserves attachments for reviewer evidence prompts', () => { + const database = new Database(':memory:'); + try { + applyBaseSchema(database); + + insertPairedTurnOutputInDatabase( + database, + 'paired-task-turn-output-attachments', + 1, + 'owner', + 'TASK_DONE\n새 스크린샷 첨부', + { + attachments: [ + { + path: '/tmp/settings-v0.1.92-deployed-390.png', + name: 'settings-v0.1.92-deployed-390.png', + mime: 'image/png', + }, + ], + }, + ); + + const outputs = getPairedTurnOutputsFromDatabase( + database, + 'paired-task-turn-output-attachments', + ); + + expect(outputs[0].attachments).toEqual([ + { + path: '/tmp/settings-v0.1.92-deployed-390.png', + name: 'settings-v0.1.92-deployed-390.png', + mime: 'image/png', + }, + ]); + } finally { + database.close(); + } + }); +}); diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts index 02bd003..5db1b72 100644 --- a/src/db/base-schema.ts +++ b/src/db/base-schema.ts @@ -180,7 +180,7 @@ export function applyBaseSchema(database: Database): void { task_id TEXT NOT NULL, turn_number INTEGER NOT NULL, role TEXT NOT NULL, - output_text TEXT NOT NULL, + output_text TEXT NOT NULL, attachment_payload TEXT, verdict TEXT, created_at TEXT NOT NULL, UNIQUE(task_id, turn_number, role) diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index c1caac2..47d8451 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -42,6 +42,7 @@ function getExpectedSchemaMigrations(): Array<{ { version: 15, name: 'turn_progress_text' }, { version: 16, name: 'room_skill_overrides' }, { version: 17, name: 'scheduled_task_room_role' }, + { version: 18, name: 'paired_turn_output_attachments' }, ]; } diff --git a/src/db/migrations/018_paired-turn-output-attachments.ts b/src/db/migrations/018_paired-turn-output-attachments.ts new file mode 100644 index 0000000..a717673 --- /dev/null +++ b/src/db/migrations/018_paired-turn-output-attachments.ts @@ -0,0 +1,20 @@ +import type { Database } from 'bun:sqlite'; + +import { tableHasColumn } from './helpers.js'; +import type { SchemaMigrationDefinition } from './types.js'; + +export const PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION: SchemaMigrationDefinition = + { + version: 18, + name: 'paired_turn_output_attachments', + apply(database: Database) { + if ( + !tableHasColumn(database, 'paired_turn_outputs', 'attachment_payload') + ) { + database.exec(` + ALTER TABLE paired_turn_outputs + ADD COLUMN attachment_payload TEXT + `); + } + }, + }; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index b7e93ca..38f8702 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -17,6 +17,7 @@ import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js' import { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js'; import { ROOM_SKILL_OVERRIDES_MIGRATION } from './016_room-skill-overrides.js'; import { SCHEDULED_TASK_ROOM_ROLE_MIGRATION } from './017_scheduled-task-room-role.js'; +import { PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION } from './018_paired-turn-output-attachments.js'; import type { SchemaMigrationArgs, SchemaMigrationDefinition, @@ -42,6 +43,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ TURN_PROGRESS_TEXT_MIGRATION, ROOM_SKILL_OVERRIDES_MIGRATION, SCHEDULED_TASK_ROOM_ROLE_MIGRATION, + PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION, ]; function ensureSchemaMigrationsTable(database: Database): void { diff --git a/src/db/paired-turn-outputs.ts b/src/db/paired-turn-outputs.ts index 2450fa2..5e33124 100644 --- a/src/db/paired-turn-outputs.ts +++ b/src/db/paired-turn-outputs.ts @@ -2,7 +2,15 @@ import { Database } from 'bun:sqlite'; import { logger } from '../logger.js'; import { parseVisibleVerdict } from '../paired-verdict.js'; -import { PairedRoomRole, PairedTurnOutput } from '../types.js'; +import { + OutboundAttachment, + PairedRoomRole, + PairedTurnOutput, +} from '../types.js'; +import { + parseAttachmentPayload, + serializeAttachmentPayload, +} from './work-items.js'; const MAX_TURN_OUTPUT_CHARS = 50_000; @@ -12,7 +20,10 @@ export function insertPairedTurnOutputInDatabase( turnNumber: number, role: PairedRoomRole, outputText: string, - createdAt?: string, + options: { + createdAt?: string; + attachments?: OutboundAttachment[]; + } = {}, ): void { if (outputText.length > MAX_TURN_OUTPUT_CHARS) { logger.warn( @@ -30,19 +41,33 @@ export function insertPairedTurnOutputInDatabase( database .prepare( `INSERT OR REPLACE INTO paired_turn_outputs - (task_id, turn_number, role, output_text, verdict, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, + (task_id, turn_number, role, output_text, attachment_payload, verdict, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run( taskId, turnNumber, role, outputText.slice(0, MAX_TURN_OUTPUT_CHARS), + serializeAttachmentPayload(options.attachments), parseVisibleVerdict(outputText), - createdAt ?? new Date().toISOString(), + options.createdAt ?? new Date().toISOString(), ); } +type StoredPairedTurnOutputRow = PairedTurnOutput & { + attachment_payload?: string | null; +}; + +function hydratePairedTurnOutputRow( + row: StoredPairedTurnOutputRow, +): PairedTurnOutput { + return { + ...row, + attachments: parseAttachmentPayload(row.attachment_payload), + }; +} + export function getPairedTurnOutputsFromDatabase( database: Database, taskId: string, @@ -53,7 +78,8 @@ export function getPairedTurnOutputsFromDatabase( WHERE task_id = ? ORDER BY turn_number ASC`, ) - .all(taskId) as PairedTurnOutput[]; + .all(taskId) + .map((row) => hydratePairedTurnOutputRow(row as StoredPairedTurnOutputRow)); } const recentOutputsForChatStmtCache = new WeakMap< @@ -78,8 +104,8 @@ export function getRecentPairedTurnOutputsForChatFromDatabase( `); recentOutputsForChatStmtCache.set(database, stmt); } - const rows = stmt.all(chatJid, limit) as PairedTurnOutput[]; - return rows.reverse(); + const rows = stmt.all(chatJid, limit) as StoredPairedTurnOutputRow[]; + return rows.reverse().map(hydratePairedTurnOutputRow); } export function getLatestTurnNumberFromDatabase( diff --git a/src/db/runtime-paired.ts b/src/db/runtime-paired.ts index 77f1bdb..d7bdeda 100644 --- a/src/db/runtime-paired.ts +++ b/src/db/runtime-paired.ts @@ -362,15 +362,24 @@ export function insertPairedTurnOutput( turnNumber: number, role: PairedRoomRole, outputText: string, - createdAt?: string, + createdAtOrOptions?: + | string + | { + createdAt?: string; + attachments?: import('../types.js').OutboundAttachment[]; + }, ): void { + const options = + typeof createdAtOrOptions === 'string' + ? { createdAt: createdAtOrOptions } + : (createdAtOrOptions ?? {}); insertPairedTurnOutputInDatabase( requireDatabase(), taskId, turnNumber, role, outputText, - createdAt, + options, ); } diff --git a/src/db/work-items.ts b/src/db/work-items.ts index 468998f..f21b477 100644 --- a/src/db/work-items.ts +++ b/src/db/work-items.ts @@ -105,7 +105,7 @@ function hydrateWorkItemRow(row: StoredWorkItemRow): WorkItem { }; } -function parseAttachmentPayload( +export function parseAttachmentPayload( payload: string | null | undefined, ): OutboundAttachment[] { if (!payload) return []; @@ -130,7 +130,7 @@ function parseAttachmentPayload( } } -function serializeAttachmentPayload( +export function serializeAttachmentPayload( attachments: OutboundAttachment[] | undefined, ): string | null { if (!attachments?.length) return null; diff --git a/src/message-agent-executor-attempt-runner.ts b/src/message-agent-executor-attempt-runner.ts index 0da06f4..344ce63 100644 --- a/src/message-agent-executor-attempt-runner.ts +++ b/src/message-agent-executor-attempt-runner.ts @@ -5,6 +5,7 @@ import { type EvaluatedAgentOutput, } from './agent-attempt.js'; import type { AttemptStreamedTrigger } from './agent-attempt-retry.js'; +import { getAgentOutputAttachments } from './agent-output.js'; import { runAgentProcess, type AgentOutput } from './agent-runner.js'; import { markCompactRefreshNeeded } from './compact-refresh.js'; import { getCodexAccountCount } from './codex-token-rotation.js'; @@ -13,7 +14,12 @@ import { shouldResetCodexSessionOnAgentFailure, shouldResetSessionOnAgentFailure, } from './session-recovery.js'; -import type { AgentType, RegisteredGroup, RoomRoleContext } from './types.js'; +import type { + AgentType, + OutboundAttachment, + RegisteredGroup, + RoomRoleContext, +} from './types.js'; export interface MessageAgentAttempt { output?: AgentOutput; @@ -100,7 +106,10 @@ interface RunMessageAgentAttemptArgs { outputText?: string | null; errorText?: string | null; }): void; - recordFinalOutputBeforeDelivery(outputText: string): boolean; + recordFinalOutputBeforeDelivery( + outputText: string, + attachments?: OutboundAttachment[], + ): boolean; }; log: Logger; } @@ -352,6 +361,7 @@ class MessageAgentAttemptRunner { try { return this.args.pairedExecutionLifecycle.recordFinalOutputBeforeDelivery( event.outputText, + getAgentOutputAttachments(event.output), ); } catch (err) { this.args.log.warn( diff --git a/src/message-agent-executor-paired.test.ts b/src/message-agent-executor-paired.test.ts index f159f01..d1b4718 100644 --- a/src/message-agent-executor-paired.test.ts +++ b/src/message-agent-executor-paired.test.ts @@ -38,6 +38,70 @@ describe('createPairedExecutionLifecycle', () => { vi.clearAllMocks(); }); + it('stores final output attachments with the paired turn output', () => { + const lifecycle = createPairedExecutionLifecycle({ + pairedExecutionContext: { + task: { + id: 'paired-task-output-attachment', + 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-output-attachment:2026-04-09T00:00:00.000Z:owner-turn', + taskId: 'paired-task-output-attachment', + taskUpdatedAt: '2026-04-09T00:00:00.000Z', + intentKind: 'owner-turn', + role: 'owner', + }, + completedRole: 'owner', + chatJid: 'group@test', + runId: 'run-output-attachment', + enqueueMessageCheck: vi.fn(), + log, + }); + + lifecycle.recordFinalOutputBeforeDelivery('TASK_DONE\n새 렌더 첨부', [ + { + path: '/tmp/settings-v0.1.92-deployed-390.png', + name: 'settings-v0.1.92-deployed-390.png', + mime: 'image/png', + }, + ]); + + expect(db.insertPairedTurnOutput).toHaveBeenCalledWith( + 'paired-task-output-attachment', + 1, + 'owner', + 'TASK_DONE\n새 렌더 첨부', + { + attachments: [ + { + path: '/tmp/settings-v0.1.92-deployed-390.png', + name: 'settings-v0.1.92-deployed-390.png', + mime: 'image/png', + }, + ], + }, + ); + }); + 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 2e58faa..4092433 100644 --- a/src/message-agent-executor-paired.ts +++ b/src/message-agent-executor-paired.ts @@ -20,7 +20,8 @@ import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js' import type { PairedTurnIdentity } from './paired-turn-identity.js'; import { resolvePairedTurnRunOwnership } from './paired-turn-run-ownership.js'; import { isHumanMessageCloseReason } from './message-close-reasons.js'; -import type { PairedRoomRole } from './types.js'; +import { persistPairedTurnOutputAttachments } from './paired-turn-output-attachments.js'; +import type { OutboundAttachment, PairedRoomRole } from './types.js'; type ExecutorLog = Pick; @@ -89,7 +90,10 @@ export interface PairedExecutionLifecycle { outputText?: string | null; errorText?: string | null; }): void; - recordFinalOutputBeforeDelivery(outputText: string): boolean; + recordFinalOutputBeforeDelivery( + outputText: string, + attachments?: OutboundAttachment[], + ): boolean; completeImmediately(args: { status: 'succeeded' | 'failed' }): void; markDelegated(): void; markStatus(status: 'succeeded' | 'failed'): void; @@ -124,6 +128,7 @@ class PairedExecutionLifecycleController implements PairedExecutionLifecycle { private pairedExecutionStatus: PairedExecutionStatus = 'failed'; private pairedExecutionSummary: string | null = null; private pairedFinalOutput: string | null = null; + private pairedFinalAttachments: OutboundAttachment[] = []; private pairedSummaryLocked = false; private pairedExecutionCompleted = false; private pairedExecutionDelegated = false; @@ -164,12 +169,15 @@ class PairedExecutionLifecycleController implements PairedExecutionLifecycle { } } - recordFinalOutputBeforeDelivery(outputText: string): boolean { + recordFinalOutputBeforeDelivery( + outputText: string, + attachments: OutboundAttachment[] = [], + ): boolean { if (this.wasInterruptedByHumanMessage()) return false; if (!this.currentRunOwnsActiveAttempt('streamed-final-output')) { return false; } - this.lockVisibleVerdict(outputText); + this.lockVisibleVerdict(outputText, attachments); this.completeSuccessfulOwnerTurnBeforeDeliveryIfNeeded(); this.persistPairedTurnOutputIfNeeded(); return true; @@ -348,12 +356,31 @@ class PairedExecutionLifecycleController implements PairedExecutionLifecycle { } const turnNumber = getLatestTurnNumber(pairedExecutionContext.task.id) + 1; - insertPairedTurnOutput( - pairedExecutionContext.task.id, - turnNumber, - completedRole, - this.pairedFinalOutput, - ); + const attachments = + this.pairedFinalAttachments.length > 0 + ? persistPairedTurnOutputAttachments({ + taskId: pairedExecutionContext.task.id, + turnNumber, + role: completedRole, + attachments: this.pairedFinalAttachments, + }) + : []; + if (attachments.length > 0) { + insertPairedTurnOutput( + pairedExecutionContext.task.id, + turnNumber, + completedRole, + this.pairedFinalOutput, + { attachments }, + ); + } else { + insertPairedTurnOutput( + pairedExecutionContext.task.id, + turnNumber, + completedRole, + this.pairedFinalOutput, + ); + } this.pairedTurnOutputPersisted = true; } @@ -383,12 +410,16 @@ class PairedExecutionLifecycleController implements PairedExecutionLifecycle { this.pairedExecutionCompleted = true; } - private lockVisibleVerdict(outputText: string): void { + private lockVisibleVerdict( + outputText: string, + attachments: OutboundAttachment[] = [], + ): void { if (outputText.length === 0) { return; } if (!this.pairedFinalOutput || this.pairedFinalOutput.length === 0) { this.pairedFinalOutput = outputText; + this.pairedFinalAttachments = attachments; } if (!this.pairedSummaryLocked) { this.pairedExecutionSummary = outputText.slice(0, 500); diff --git a/src/message-runtime-prompts.test.ts b/src/message-runtime-prompts.test.ts index 5a2335e..bfc8bc4 100644 --- a/src/message-runtime-prompts.test.ts +++ b/src/message-runtime-prompts.test.ts @@ -137,6 +137,39 @@ describe('message-runtime-prompts output-only context', () => { expect(prompt).not.toContain('과거 요청을 다시 기준으로 보면 안 됨'); }); + it('carries owner turn attachments into reviewer prompts as image inputs', () => { + const prompt = buildReviewerPendingPrompt({ + chatJid: 'group@test', + timezone: 'UTC', + turnOutputs: [ + makeTurnOutput('TASK_DONE\n새 렌더 증거 첨부', 'owner', { + turn_number: 1, + created_at: '2026-04-20T02:00:00.000Z', + attachments: [ + { + path: '/tmp/settings-v0.1.92-deployed-390.png', + name: 'settings-v0.1.92-deployed-390.png', + mime: 'image/png', + }, + ], + }), + ], + recentHumanMessages: [ + makeHumanMessage( + '원본 스크린샷\n[Image: old-phone.jpg → /tmp/old-phone.jpg]', + '2026-04-20T01:30:00.000Z', + ), + ], + lastHumanMessage: '원본 스크린샷', + taskCreatedAt: '2026-04-20T01:00:00.000Z', + }); + + expect(prompt).toContain('새 렌더 증거 첨부'); + expect(prompt).toContain( + '[Image: settings-v0.1.92-deployed-390.png → /tmp/settings-v0.1.92-deployed-390.png]', + ); + }); + it('includes current task user scope in reviewer pending prompts without pulling older human messages', () => { const prompt = buildReviewerPendingPrompt({ chatJid: 'group@test', diff --git a/src/message-runtime-prompts.ts b/src/message-runtime-prompts.ts index 1c8ae42..b57dea4 100644 --- a/src/message-runtime-prompts.ts +++ b/src/message-runtime-prompts.ts @@ -1,6 +1,11 @@ import { buildArbiterContextPrompt } from './arbiter-context.js'; import { formatMessages } from './router.js'; -import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js'; +import type { + NewMessage, + OutboundAttachment, + PairedTask, + PairedTurnOutput, +} from './types.js'; const CARRIED_FORWARD_OWNER_FINAL_MARKER = '[Carried forward context from the previous task: latest owner final]'; @@ -11,6 +16,34 @@ If you see a message beginning with "${CARRIED_FORWARD_OWNER_FINAL_MARKER}", tre const ARBITER_TURN_OUTPUT_CONTEXT_LIMIT = 6; const TASK_USER_CONTEXT_START_SKEW_MS = 5_000; +function isImageAttachment(attachment: OutboundAttachment): boolean { + if (attachment.mime?.toLowerCase().startsWith('image/')) return true; + return /\.(?:png|jpe?g|gif|webp|bmp)$/i.test(attachment.path); +} + +function attachmentLabel(attachment: OutboundAttachment): string { + return attachment.name?.trim() || attachment.path.split('/').at(-1) || 'file'; +} + +function formatTurnOutputAttachmentContext( + attachments: OutboundAttachment[] | undefined, +): string { + if (!attachments?.length) return ''; + const lines = attachments.map((attachment) => { + const label = attachmentLabel(attachment); + return isImageAttachment(attachment) + ? `[Image: ${label} → ${attachment.path}]` + : `[Attachment: ${label} → ${attachment.path}]`; + }); + return `\n\nAttached evidence from this turn:\n${lines.join('\n')}`; +} + +function formatTurnOutputMessageContent(turnOutput: PairedTurnOutput): string { + return `${turnOutput.output_text}${formatTurnOutputAttachmentContext( + turnOutput.attachments, + )}`; +} + function turnOutputsToMessages( outputs: PairedTurnOutput[], chatJid: string, @@ -20,7 +53,7 @@ function turnOutputsToMessages( chat_jid: chatJid, sender: turnOutput.role, sender_name: turnOutput.role, - content: turnOutput.output_text, + content: formatTurnOutputMessageContent(turnOutput), timestamp: turnOutput.created_at, is_bot_message: true as const, is_from_me: false as const, diff --git a/src/paired-turn-output-attachments.test.ts b/src/paired-turn-output-attachments.test.ts new file mode 100644 index 0000000..02ecc3a --- /dev/null +++ b/src/paired-turn-output-attachments.test.ts @@ -0,0 +1,58 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +const cleanupDirs: string[] = []; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + for (const dir of cleanupDirs.splice(0)) { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); + +describe('paired turn output attachments', () => { + it('copies attachments into durable data storage for reviewer access', async () => { + const dataDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-turn-output-data-'), + ); + const sourceDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-turn-output-source-'), + ); + cleanupDirs.push(dataDir, sourceDir); + vi.stubEnv('EJCLAW_DATA_DIR', dataDir); + + const sourcePath = path.join(sourceDir, 'render.png'); + fs.writeFileSync(sourcePath, ONE_PIXEL_PNG); + + const { persistPairedTurnOutputAttachments } = + await import('./paired-turn-output-attachments.js'); + const [attachment] = persistPairedTurnOutputAttachments({ + taskId: 'task:with/slashes', + turnNumber: 3, + role: 'owner', + attachments: [ + { + path: sourcePath, + name: '../settings-v0.1.92-deployed-390.png', + mime: 'image/png', + }, + ], + }); + + expect(attachment.path).toContain( + path.join('attachments', 'paired-turn-outputs', 'task-with-slashes'), + ); + expect(attachment.path).not.toBe(sourcePath); + expect(fs.readFileSync(attachment.path)).toEqual(ONE_PIXEL_PNG); + expect(attachment.name).toBe('settings-v0.1.92-deployed-390.png'); + }); +}); diff --git a/src/paired-turn-output-attachments.ts b/src/paired-turn-output-attachments.ts new file mode 100644 index 0000000..a680d5f --- /dev/null +++ b/src/paired-turn-output-attachments.ts @@ -0,0 +1,79 @@ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from './config.js'; +import type { OutboundAttachment, PairedRoomRole } from './types.js'; + +function sanitizePathSegment(value: string, fallback: string): string { + const sanitized = value.replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 96); + return sanitized.replace(/^[.-]+|[.-]+$/g, '') || fallback; +} + +function storedAttachmentName( + attachment: OutboundAttachment, + sourcePath: string, + index: number, +): string { + const sourceBasename = path.basename(sourcePath); + const displayBasename = attachment.name + ? path.basename(attachment.name) + : sourceBasename; + const ext = path.extname(displayBasename) || path.extname(sourceBasename); + const stem = + path.basename(displayBasename, path.extname(displayBasename)) || + `attachment-${index + 1}`; + return `${String(index + 1).padStart(2, '0')}-${sanitizePathSegment( + stem, + `attachment-${index + 1}`, + )}${ext}`; +} + +export function persistPairedTurnOutputAttachments(args: { + taskId: string; + turnNumber: number; + role: PairedRoomRole; + attachments: OutboundAttachment[]; +}): OutboundAttachment[] { + if (args.attachments.length === 0) return []; + + const safeTaskId = sanitizePathSegment(args.taskId, 'task'); + const targetDir = path.join( + DATA_DIR, + 'attachments', + 'paired-turn-outputs', + safeTaskId, + String(args.turnNumber), + args.role, + ); + + return args.attachments.map((attachment, index) => { + try { + if ( + !path.isAbsolute(attachment.path) || + !fs.existsSync(attachment.path) + ) { + return attachment; + } + const sourcePath = fs.realpathSync(attachment.path); + if (!fs.statSync(sourcePath).isFile()) return attachment; + + fs.mkdirSync(targetDir, { recursive: true }); + const targetPath = path.join( + targetDir, + storedAttachmentName(attachment, sourcePath, index), + ); + if (sourcePath !== path.resolve(targetPath)) { + fs.copyFileSync(sourcePath, targetPath); + } + return { + ...attachment, + path: targetPath, + name: attachment.name + ? path.basename(attachment.name) + : path.basename(targetPath), + }; + } catch { + return attachment; + } + }); +} diff --git a/src/types.ts b/src/types.ts index 7caa7ac..7c78c22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -146,6 +146,7 @@ export interface PairedTurnOutput { turn_number: number; role: PairedRoomRole; output_text: string; + attachments?: OutboundAttachment[]; verdict?: VisibleVerdict | null; created_at: string; } diff --git a/src/web-dashboard-data-attachments.test.ts b/src/web-dashboard-data-attachments.test.ts index abaf5e2..3ef6c05 100644 --- a/src/web-dashboard-data-attachments.test.ts +++ b/src/web-dashboard-data-attachments.test.ts @@ -153,6 +153,47 @@ describe('web dashboard attachment data', () => { }); }); + it('shows stored paired turn output attachments in room activity', () => { + const task = makePairedTask({ id: 'paired-output-attachments' }); + const output: PairedTurnOutput = { + id: 1, + task_id: task.id, + turn_number: 1, + role: 'owner', + output_text: 'TASK_DONE\n\n새 렌더 증거 첨부', + attachments: [ + { + path: '/tmp/settings-v0.1.92-deployed-390.png', + name: 'settings-v0.1.92-deployed-390.png', + mime: 'image/png', + }, + ], + verdict: 'task_done', + created_at: '2026-04-26T05:30:00.000Z', + }; + + const activity = buildWebDashboardRoomActivity({ + serviceId: 'codex-main', + entry: roomEntry, + pairedTask: task, + turns: [], + attempts: [], + outputs: [output], + messages: [], + }); + + expect(activity.pairedTask?.outputs[0]).toMatchObject({ + outputText: 'TASK_DONE\n\n새 렌더 증거 첨부', + attachments: [ + { + path: '/tmp/settings-v0.1.92-deployed-390.png', + name: 'settings-v0.1.92-deployed-390.png', + mime: 'image/png', + }, + ], + }); + }); + it('turns markdown image output into dashboard attachments', () => { const message: NewMessage = { id: 'msg-markdown-image', diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index b60bc99..edae145 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -560,7 +560,10 @@ function sanitizeRoomTurnOutput( verdict: output.verdict ?? null, createdAt: output.created_at, outputText: buildRoomBody(normalized.text), - attachments: normalized.attachments, + attachments: + normalized.attachments.length > 0 + ? normalized.attachments + : (output.attachments ?? []), }; }