fix: carry paired turn attachments into review context (#201)

This commit is contained in:
Eyejoker
2026-05-31 14:49:56 +09:00
committed by GitHub
parent 37b57b20bb
commit 6eca648c47
22 changed files with 615 additions and 67 deletions

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ import path from 'path';
import { import {
EJCLAW_ENV, EJCLAW_ENV,
extractImageTagPaths,
IPC_CLOSE_SENTINEL, IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR, IPC_INPUT_SUBDIR,
IPC_POLL_MS, IPC_POLL_MS,
@@ -26,10 +25,8 @@ import {
type RunnerStructuredOutput, type RunnerStructuredOutput,
} from 'ejclaw-runners-shared'; } from 'ejclaw-runners-shared';
import { import { CodexAppServerClient } from './app-server-client.js';
CodexAppServerClient, import { parseAppServerInput } from './app-server-input.js';
type AppServerInputItem,
} from './app-server-client.js';
import { import {
prependRoomRoleHeader, prependRoomRoleHeader,
type RoomRoleContext, 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 { function formatProgressElapsed(ms: number): string {
const elapsedSeconds = Math.floor(ms / 10_000) * 10; const elapsedSeconds = Math.floor(ms / 10_000) * 10;
const hours = Math.floor(elapsedSeconds / 3600); const hours = Math.floor(elapsedSeconds / 3600);
@@ -231,7 +197,7 @@ async function executeAppServerTurn(
let lastProgressMessage: string | null = null; let lastProgressMessage: string | null = null;
const activeTurn = await client.startTurn( const activeTurn = await client.startTurn(
threadId, threadId,
parseAppServerInput(prompt), parseAppServerInput(prompt, log),
{ {
cwd: EFFECTIVE_CWD, cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined, model: CODEX_MODEL || undefined,
@@ -277,7 +243,7 @@ async function executeAppServerTurn(
const merged = messages.join('\n'); const merged = messages.join('\n');
log(`Steering active turn with ${messages.length} queued message(s)`); log(`Steering active turn with ${messages.length} queued message(s)`);
try { try {
await activeTurn.steer(parseAppServerInput(merged)); await activeTurn.steer(parseAppServerInput(merged, log));
} catch (err) { } catch (err) {
log( log(
`turn/steer failed: ${err instanceof Error ? err.message : String(err)}`, `turn/steer failed: ${err instanceof Error ? err.message : String(err)}`,

View File

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

View File

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

View File

@@ -180,7 +180,7 @@ export function applyBaseSchema(database: Database): void {
task_id TEXT NOT NULL, task_id TEXT NOT NULL,
turn_number INTEGER NOT NULL, turn_number INTEGER NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
output_text TEXT NOT NULL, output_text TEXT NOT NULL, attachment_payload TEXT,
verdict TEXT, verdict TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
UNIQUE(task_id, turn_number, role) UNIQUE(task_id, turn_number, role)

View File

@@ -42,6 +42,7 @@ function getExpectedSchemaMigrations(): Array<{
{ version: 15, name: 'turn_progress_text' }, { version: 15, name: 'turn_progress_text' },
{ version: 16, name: 'room_skill_overrides' }, { version: 16, name: 'room_skill_overrides' },
{ version: 17, name: 'scheduled_task_room_role' }, { version: 17, name: 'scheduled_task_room_role' },
{ version: 18, name: 'paired_turn_output_attachments' },
]; ];
} }

View File

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

View File

@@ -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 { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js';
import { ROOM_SKILL_OVERRIDES_MIGRATION } from './016_room-skill-overrides.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 { 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 { import type {
SchemaMigrationArgs, SchemaMigrationArgs,
SchemaMigrationDefinition, SchemaMigrationDefinition,
@@ -42,6 +43,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
TURN_PROGRESS_TEXT_MIGRATION, TURN_PROGRESS_TEXT_MIGRATION,
ROOM_SKILL_OVERRIDES_MIGRATION, ROOM_SKILL_OVERRIDES_MIGRATION,
SCHEDULED_TASK_ROOM_ROLE_MIGRATION, SCHEDULED_TASK_ROOM_ROLE_MIGRATION,
PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION,
]; ];
function ensureSchemaMigrationsTable(database: Database): void { function ensureSchemaMigrationsTable(database: Database): void {

View File

@@ -2,7 +2,15 @@ import { Database } from 'bun:sqlite';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { parseVisibleVerdict } from '../paired-verdict.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; const MAX_TURN_OUTPUT_CHARS = 50_000;
@@ -12,7 +20,10 @@ export function insertPairedTurnOutputInDatabase(
turnNumber: number, turnNumber: number,
role: PairedRoomRole, role: PairedRoomRole,
outputText: string, outputText: string,
createdAt?: string, options: {
createdAt?: string;
attachments?: OutboundAttachment[];
} = {},
): void { ): void {
if (outputText.length > MAX_TURN_OUTPUT_CHARS) { if (outputText.length > MAX_TURN_OUTPUT_CHARS) {
logger.warn( logger.warn(
@@ -30,19 +41,33 @@ export function insertPairedTurnOutputInDatabase(
database database
.prepare( .prepare(
`INSERT OR REPLACE INTO paired_turn_outputs `INSERT OR REPLACE INTO paired_turn_outputs
(task_id, turn_number, role, output_text, verdict, created_at) (task_id, turn_number, role, output_text, attachment_payload, verdict, created_at)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
) )
.run( .run(
taskId, taskId,
turnNumber, turnNumber,
role, role,
outputText.slice(0, MAX_TURN_OUTPUT_CHARS), outputText.slice(0, MAX_TURN_OUTPUT_CHARS),
serializeAttachmentPayload(options.attachments),
parseVisibleVerdict(outputText), 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( export function getPairedTurnOutputsFromDatabase(
database: Database, database: Database,
taskId: string, taskId: string,
@@ -53,7 +78,8 @@ export function getPairedTurnOutputsFromDatabase(
WHERE task_id = ? WHERE task_id = ?
ORDER BY turn_number ASC`, ORDER BY turn_number ASC`,
) )
.all(taskId) as PairedTurnOutput[]; .all(taskId)
.map((row) => hydratePairedTurnOutputRow(row as StoredPairedTurnOutputRow));
} }
const recentOutputsForChatStmtCache = new WeakMap< const recentOutputsForChatStmtCache = new WeakMap<
@@ -78,8 +104,8 @@ export function getRecentPairedTurnOutputsForChatFromDatabase(
`); `);
recentOutputsForChatStmtCache.set(database, stmt); recentOutputsForChatStmtCache.set(database, stmt);
} }
const rows = stmt.all(chatJid, limit) as PairedTurnOutput[]; const rows = stmt.all(chatJid, limit) as StoredPairedTurnOutputRow[];
return rows.reverse(); return rows.reverse().map(hydratePairedTurnOutputRow);
} }
export function getLatestTurnNumberFromDatabase( export function getLatestTurnNumberFromDatabase(

View File

@@ -362,15 +362,24 @@ export function insertPairedTurnOutput(
turnNumber: number, turnNumber: number,
role: PairedRoomRole, role: PairedRoomRole,
outputText: string, outputText: string,
createdAt?: string, createdAtOrOptions?:
| string
| {
createdAt?: string;
attachments?: import('../types.js').OutboundAttachment[];
},
): void { ): void {
const options =
typeof createdAtOrOptions === 'string'
? { createdAt: createdAtOrOptions }
: (createdAtOrOptions ?? {});
insertPairedTurnOutputInDatabase( insertPairedTurnOutputInDatabase(
requireDatabase(), requireDatabase(),
taskId, taskId,
turnNumber, turnNumber,
role, role,
outputText, outputText,
createdAt, options,
); );
} }

View File

@@ -105,7 +105,7 @@ function hydrateWorkItemRow(row: StoredWorkItemRow): WorkItem {
}; };
} }
function parseAttachmentPayload( export function parseAttachmentPayload(
payload: string | null | undefined, payload: string | null | undefined,
): OutboundAttachment[] { ): OutboundAttachment[] {
if (!payload) return []; if (!payload) return [];
@@ -130,7 +130,7 @@ function parseAttachmentPayload(
} }
} }
function serializeAttachmentPayload( export function serializeAttachmentPayload(
attachments: OutboundAttachment[] | undefined, attachments: OutboundAttachment[] | undefined,
): string | null { ): string | null {
if (!attachments?.length) return null; if (!attachments?.length) return null;

View File

@@ -5,6 +5,7 @@ import {
type EvaluatedAgentOutput, type EvaluatedAgentOutput,
} from './agent-attempt.js'; } from './agent-attempt.js';
import type { AttemptStreamedTrigger } from './agent-attempt-retry.js'; import type { AttemptStreamedTrigger } from './agent-attempt-retry.js';
import { getAgentOutputAttachments } from './agent-output.js';
import { runAgentProcess, type AgentOutput } from './agent-runner.js'; import { runAgentProcess, type AgentOutput } from './agent-runner.js';
import { markCompactRefreshNeeded } from './compact-refresh.js'; import { markCompactRefreshNeeded } from './compact-refresh.js';
import { getCodexAccountCount } from './codex-token-rotation.js'; import { getCodexAccountCount } from './codex-token-rotation.js';
@@ -13,7 +14,12 @@ import {
shouldResetCodexSessionOnAgentFailure, shouldResetCodexSessionOnAgentFailure,
shouldResetSessionOnAgentFailure, shouldResetSessionOnAgentFailure,
} from './session-recovery.js'; } from './session-recovery.js';
import type { AgentType, RegisteredGroup, RoomRoleContext } from './types.js'; import type {
AgentType,
OutboundAttachment,
RegisteredGroup,
RoomRoleContext,
} from './types.js';
export interface MessageAgentAttempt { export interface MessageAgentAttempt {
output?: AgentOutput; output?: AgentOutput;
@@ -100,7 +106,10 @@ interface RunMessageAgentAttemptArgs {
outputText?: string | null; outputText?: string | null;
errorText?: string | null; errorText?: string | null;
}): void; }): void;
recordFinalOutputBeforeDelivery(outputText: string): boolean; recordFinalOutputBeforeDelivery(
outputText: string,
attachments?: OutboundAttachment[],
): boolean;
}; };
log: Logger; log: Logger;
} }
@@ -352,6 +361,7 @@ class MessageAgentAttemptRunner {
try { try {
return this.args.pairedExecutionLifecycle.recordFinalOutputBeforeDelivery( return this.args.pairedExecutionLifecycle.recordFinalOutputBeforeDelivery(
event.outputText, event.outputText,
getAgentOutputAttachments(event.output),
); );
} catch (err) { } catch (err) {
this.args.log.warn( this.args.log.warn(

View File

@@ -38,6 +38,70 @@ describe('createPairedExecutionLifecycle', () => {
vi.clearAllMocks(); 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 () => { it('does not emit a second public notification after arbiter ESCALATE', async () => {
const outputs: AgentOutput[] = []; const outputs: AgentOutput[] = [];

View File

@@ -20,7 +20,8 @@ import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js'
import type { PairedTurnIdentity } from './paired-turn-identity.js'; import type { PairedTurnIdentity } from './paired-turn-identity.js';
import { resolvePairedTurnRunOwnership } from './paired-turn-run-ownership.js'; import { resolvePairedTurnRunOwnership } from './paired-turn-run-ownership.js';
import { isHumanMessageCloseReason } from './message-close-reasons.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<typeof logger, 'info' | 'warn'>; type ExecutorLog = Pick<typeof logger, 'info' | 'warn'>;
@@ -89,7 +90,10 @@ export interface PairedExecutionLifecycle {
outputText?: string | null; outputText?: string | null;
errorText?: string | null; errorText?: string | null;
}): void; }): void;
recordFinalOutputBeforeDelivery(outputText: string): boolean; recordFinalOutputBeforeDelivery(
outputText: string,
attachments?: OutboundAttachment[],
): boolean;
completeImmediately(args: { status: 'succeeded' | 'failed' }): void; completeImmediately(args: { status: 'succeeded' | 'failed' }): void;
markDelegated(): void; markDelegated(): void;
markStatus(status: 'succeeded' | 'failed'): void; markStatus(status: 'succeeded' | 'failed'): void;
@@ -124,6 +128,7 @@ class PairedExecutionLifecycleController implements PairedExecutionLifecycle {
private pairedExecutionStatus: PairedExecutionStatus = 'failed'; private pairedExecutionStatus: PairedExecutionStatus = 'failed';
private pairedExecutionSummary: string | null = null; private pairedExecutionSummary: string | null = null;
private pairedFinalOutput: string | null = null; private pairedFinalOutput: string | null = null;
private pairedFinalAttachments: OutboundAttachment[] = [];
private pairedSummaryLocked = false; private pairedSummaryLocked = false;
private pairedExecutionCompleted = false; private pairedExecutionCompleted = false;
private pairedExecutionDelegated = 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.wasInterruptedByHumanMessage()) return false;
if (!this.currentRunOwnsActiveAttempt('streamed-final-output')) { if (!this.currentRunOwnsActiveAttempt('streamed-final-output')) {
return false; return false;
} }
this.lockVisibleVerdict(outputText); this.lockVisibleVerdict(outputText, attachments);
this.completeSuccessfulOwnerTurnBeforeDeliveryIfNeeded(); this.completeSuccessfulOwnerTurnBeforeDeliveryIfNeeded();
this.persistPairedTurnOutputIfNeeded(); this.persistPairedTurnOutputIfNeeded();
return true; return true;
@@ -348,12 +356,31 @@ class PairedExecutionLifecycleController implements PairedExecutionLifecycle {
} }
const turnNumber = getLatestTurnNumber(pairedExecutionContext.task.id) + 1; const turnNumber = getLatestTurnNumber(pairedExecutionContext.task.id) + 1;
insertPairedTurnOutput( const attachments =
pairedExecutionContext.task.id, this.pairedFinalAttachments.length > 0
turnNumber, ? persistPairedTurnOutputAttachments({
completedRole, taskId: pairedExecutionContext.task.id,
this.pairedFinalOutput, 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; this.pairedTurnOutputPersisted = true;
} }
@@ -383,12 +410,16 @@ class PairedExecutionLifecycleController implements PairedExecutionLifecycle {
this.pairedExecutionCompleted = true; this.pairedExecutionCompleted = true;
} }
private lockVisibleVerdict(outputText: string): void { private lockVisibleVerdict(
outputText: string,
attachments: OutboundAttachment[] = [],
): void {
if (outputText.length === 0) { if (outputText.length === 0) {
return; return;
} }
if (!this.pairedFinalOutput || this.pairedFinalOutput.length === 0) { if (!this.pairedFinalOutput || this.pairedFinalOutput.length === 0) {
this.pairedFinalOutput = outputText; this.pairedFinalOutput = outputText;
this.pairedFinalAttachments = attachments;
} }
if (!this.pairedSummaryLocked) { if (!this.pairedSummaryLocked) {
this.pairedExecutionSummary = outputText.slice(0, 500); this.pairedExecutionSummary = outputText.slice(0, 500);

View File

@@ -137,6 +137,39 @@ describe('message-runtime-prompts output-only context', () => {
expect(prompt).not.toContain('과거 요청을 다시 기준으로 보면 안 됨'); 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', () => { it('includes current task user scope in reviewer pending prompts without pulling older human messages', () => {
const prompt = buildReviewerPendingPrompt({ const prompt = buildReviewerPendingPrompt({
chatJid: 'group@test', chatJid: 'group@test',

View File

@@ -1,6 +1,11 @@
import { buildArbiterContextPrompt } from './arbiter-context.js'; import { buildArbiterContextPrompt } from './arbiter-context.js';
import { formatMessages } from './router.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 = const CARRIED_FORWARD_OWNER_FINAL_MARKER =
'[Carried forward context from the previous task: latest owner final]'; '[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 ARBITER_TURN_OUTPUT_CONTEXT_LIMIT = 6;
const TASK_USER_CONTEXT_START_SKEW_MS = 5_000; 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( function turnOutputsToMessages(
outputs: PairedTurnOutput[], outputs: PairedTurnOutput[],
chatJid: string, chatJid: string,
@@ -20,7 +53,7 @@ function turnOutputsToMessages(
chat_jid: chatJid, chat_jid: chatJid,
sender: turnOutput.role, sender: turnOutput.role,
sender_name: turnOutput.role, sender_name: turnOutput.role,
content: turnOutput.output_text, content: formatTurnOutputMessageContent(turnOutput),
timestamp: turnOutput.created_at, timestamp: turnOutput.created_at,
is_bot_message: true as const, is_bot_message: true as const,
is_from_me: false as const, is_from_me: false as const,

View File

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

View File

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

View File

@@ -146,6 +146,7 @@ export interface PairedTurnOutput {
turn_number: number; turn_number: number;
role: PairedRoomRole; role: PairedRoomRole;
output_text: string; output_text: string;
attachments?: OutboundAttachment[];
verdict?: VisibleVerdict | null; verdict?: VisibleVerdict | null;
created_at: string; created_at: string;
} }

View File

@@ -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', () => { it('turns markdown image output into dashboard attachments', () => {
const message: NewMessage = { const message: NewMessage = {
id: 'msg-markdown-image', id: 'msg-markdown-image',

View File

@@ -560,7 +560,10 @@ function sanitizeRoomTurnOutput(
verdict: output.verdict ?? null, verdict: output.verdict ?? null,
createdAt: output.created_at, createdAt: output.created_at,
outputText: buildRoomBody(normalized.text), outputText: buildRoomBody(normalized.text),
attachments: normalized.attachments, attachments:
normalized.attachments.length > 0
? normalized.attachments
: (output.attachments ?? []),
}; };
} }