fix: carry paired turn attachments into review context (#201)
This commit is contained in:
49
runners/agent-runner/test/output-protocol.test.ts
Normal file
49
runners/agent-runner/test/output-protocol.test.ts
Normal 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)`);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
runners/codex-runner/src/app-server-input.ts
Normal file
32
runners/codex-runner/src/app-server-input.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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)}`,
|
||||||
|
|||||||
41
runners/codex-runner/test/app-server-input.test.ts
Normal file
41
runners/codex-runner/test/app-server-input.test.ts
Normal 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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/db-paired-turn-outputs.test.ts
Normal file
49
src/db-paired-turn-outputs.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
src/db/migrations/018_paired-turn-output-attachments.ts
Normal file
20
src/db/migrations/018_paired-turn-output-attachments.ts
Normal 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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
58
src/paired-turn-output-attachments.test.ts
Normal file
58
src/paired-turn-output-attachments.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
79
src/paired-turn-output-attachments.ts
Normal file
79
src/paired-turn-output-attachments.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 ?? []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user