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 {
|
||||
EJCLAW_ENV,
|
||||
extractImageTagPaths,
|
||||
IPC_CLOSE_SENTINEL,
|
||||
IPC_INPUT_SUBDIR,
|
||||
IPC_POLL_MS,
|
||||
@@ -26,10 +25,8 @@ import {
|
||||
type RunnerStructuredOutput,
|
||||
} from 'ejclaw-runners-shared';
|
||||
|
||||
import {
|
||||
CodexAppServerClient,
|
||||
type AppServerInputItem,
|
||||
} from './app-server-client.js';
|
||||
import { CodexAppServerClient } from './app-server-client.js';
|
||||
import { parseAppServerInput } from './app-server-input.js';
|
||||
import {
|
||||
prependRoomRoleHeader,
|
||||
type RoomRoleContext,
|
||||
@@ -173,37 +170,6 @@ function drainIpcInput(): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function extractImagePaths(text: string): {
|
||||
cleanText: string;
|
||||
imagePaths: string[];
|
||||
} {
|
||||
return extractImageTagPaths(text);
|
||||
}
|
||||
|
||||
function parseAppServerInput(text: string): AppServerInputItem[] {
|
||||
const { cleanText, imagePaths } = extractImagePaths(text);
|
||||
const input: AppServerInputItem[] = [];
|
||||
|
||||
if (cleanText) {
|
||||
input.push({ type: 'text', text: cleanText });
|
||||
}
|
||||
|
||||
for (const imgPath of imagePaths) {
|
||||
if (fs.existsSync(imgPath)) {
|
||||
input.push({ type: 'localImage', path: imgPath });
|
||||
log(`Adding image input: ${imgPath}`);
|
||||
} else {
|
||||
log(`Image not found, skipping: ${imgPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.length === 0) {
|
||||
input.push({ type: 'text', text });
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function formatProgressElapsed(ms: number): string {
|
||||
const elapsedSeconds = Math.floor(ms / 10_000) * 10;
|
||||
const hours = Math.floor(elapsedSeconds / 3600);
|
||||
@@ -231,7 +197,7 @@ async function executeAppServerTurn(
|
||||
let lastProgressMessage: string | null = null;
|
||||
const activeTurn = await client.startTurn(
|
||||
threadId,
|
||||
parseAppServerInput(prompt),
|
||||
parseAppServerInput(prompt, log),
|
||||
{
|
||||
cwd: EFFECTIVE_CWD,
|
||||
model: CODEX_MODEL || undefined,
|
||||
@@ -277,7 +243,7 @@ async function executeAppServerTurn(
|
||||
const merged = messages.join('\n');
|
||||
log(`Steering active turn with ${messages.length} queued message(s)`);
|
||||
try {
|
||||
await activeTurn.steer(parseAppServerInput(merged));
|
||||
await activeTurn.steer(parseAppServerInput(merged, log));
|
||||
} catch (err) {
|
||||
log(
|
||||
`turn/steer failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
|
||||
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,
|
||||
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)
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
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 { 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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
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;
|
||||
role: PairedRoomRole;
|
||||
output_text: string;
|
||||
attachments?: OutboundAttachment[];
|
||||
verdict?: VisibleVerdict | null;
|
||||
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', () => {
|
||||
const message: NewMessage = {
|
||||
id: 'msg-markdown-image',
|
||||
|
||||
@@ -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 ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user