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 { 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,
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)

View File

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

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 { 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 {

View File

@@ -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(

View File

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

View File

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

View File

@@ -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(

View File

@@ -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[] = [];

View File

@@ -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<typeof logger, 'info' | 'warn'>;
@@ -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);

View File

@@ -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',

View File

@@ -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,

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;
role: PairedRoomRole;
output_text: string;
attachments?: OutboundAttachment[];
verdict?: VisibleVerdict | null;
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', () => {
const message: NewMessage = {
id: 'msg-markdown-image',

View File

@@ -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 ?? []),
};
}