Normalize agent attachment output (#122)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
normalizeEjclawStructuredOutput,
|
||||
normalizeAgentOutput,
|
||||
type RunnerOutputAttachment,
|
||||
} from 'ejclaw-runners-shared';
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface SendMessageIpcPayload {
|
||||
export function buildSendMessageIpcPayload(
|
||||
input: SendMessageIpcPayloadInput,
|
||||
): SendMessageIpcPayload {
|
||||
const normalized = normalizeEjclawStructuredOutput(input.text);
|
||||
const normalized = normalizeAgentOutput(input.text);
|
||||
const output =
|
||||
normalized.output?.visibility === 'public' ? normalized.output : null;
|
||||
const text = output?.text ?? normalized.result ?? '';
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'path';
|
||||
|
||||
import {
|
||||
extractImageTagPaths,
|
||||
normalizeEjclawStructuredOutput,
|
||||
normalizeAgentOutput,
|
||||
type RunnerStructuredOutput,
|
||||
writeProtocolOutput,
|
||||
} from 'ejclaw-runners-shared';
|
||||
@@ -151,7 +151,7 @@ export function normalizeStructuredOutput(result: string | null): {
|
||||
result: string | null;
|
||||
output?: RunnerOutput['output'];
|
||||
} {
|
||||
return normalizeEjclawStructuredOutput(result);
|
||||
return normalizeAgentOutput(result);
|
||||
}
|
||||
|
||||
export function extractAssistantText(message: unknown): string | null {
|
||||
|
||||
@@ -103,6 +103,32 @@ describe('agent runner IPC message payload', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes markdown image output and preserves attachments', () => {
|
||||
expect(
|
||||
buildSendMessageIpcPayload({
|
||||
chatJid: 'dc:123',
|
||||
text: `TASK_DONE
|
||||
|
||||
스크린샷입니다.
|
||||
`,
|
||||
groupFolder: 'discord-review',
|
||||
timestamp: '2026-04-04T13:45:00.000Z',
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'message',
|
||||
chatJid: 'dc:123',
|
||||
text: 'TASK_DONE\n\n스크린샷입니다.',
|
||||
groupFolder: 'discord-review',
|
||||
timestamp: '2026-04-04T13:45:00.000Z',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/ejclaw-markdown.png',
|
||||
name: 'ejclaw-markdown.png',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('turns silent EJClaw envelopes into empty no-op messages', () => {
|
||||
expect(
|
||||
buildSendMessageIpcPayload({
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
IPC_CLOSE_SENTINEL,
|
||||
IPC_INPUT_SUBDIR,
|
||||
IPC_POLL_MS,
|
||||
normalizeEjclawStructuredOutput,
|
||||
normalizeAgentOutput,
|
||||
writeProtocolOutput,
|
||||
type RunnerStructuredOutput,
|
||||
} from 'ejclaw-runners-shared';
|
||||
@@ -89,7 +89,7 @@ function normalizeStructuredOutput(result: string | null): {
|
||||
result: string | null;
|
||||
output?: RunnerOutput['output'];
|
||||
} {
|
||||
return normalizeEjclawStructuredOutput(result);
|
||||
return normalizeAgentOutput(result);
|
||||
}
|
||||
|
||||
function log(message: string): void {
|
||||
|
||||
@@ -3,6 +3,8 @@ export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
|
||||
|
||||
export const IMAGE_TAG_RE =
|
||||
/\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g;
|
||||
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
||||
const MARKDOWN_ABSOLUTE_LINK_RE = /!?\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
|
||||
|
||||
export const IPC_POLL_MS = 500;
|
||||
export const IPC_INPUT_SUBDIR = 'input';
|
||||
@@ -44,8 +46,16 @@ export type RunnerStructuredOutput =
|
||||
export interface NormalizedRunnerOutput {
|
||||
result: string | null;
|
||||
output?: RunnerStructuredOutput;
|
||||
attachmentSource?:
|
||||
| 'legacy-ejclaw-json'
|
||||
| 'markdown-image'
|
||||
| 'image-tag'
|
||||
| 'mixed'
|
||||
| 'none';
|
||||
}
|
||||
|
||||
export type NormalizedAgentOutput = NormalizedRunnerOutput;
|
||||
|
||||
function cloneImageTagPattern(): RegExp {
|
||||
return new RegExp(IMAGE_TAG_RE.source, IMAGE_TAG_RE.flags);
|
||||
}
|
||||
@@ -74,6 +84,59 @@ export function extractImageTagPaths(text: string): {
|
||||
};
|
||||
}
|
||||
|
||||
function attachmentName(filePath: string): string | undefined {
|
||||
return filePath.split(/[\\/]/).at(-1) || undefined;
|
||||
}
|
||||
|
||||
function uniqueAttachments(
|
||||
attachments: RunnerOutputAttachment[],
|
||||
): RunnerOutputAttachment[] {
|
||||
const seen = new Set<string>();
|
||||
return attachments.filter((attachment) => {
|
||||
if (seen.has(attachment.path)) return false;
|
||||
seen.add(attachment.path);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function extractMarkdownImageAttachments(text: string): {
|
||||
cleanText: string;
|
||||
attachments: RunnerOutputAttachment[];
|
||||
} {
|
||||
const attachments: RunnerOutputAttachment[] = [];
|
||||
const cleanText = text.replace(
|
||||
MARKDOWN_ABSOLUTE_LINK_RE,
|
||||
(full: string, rawPath: string) => {
|
||||
const trimmed = rawPath.trim();
|
||||
if (!IMAGE_EXT_RE.test(trimmed)) return full;
|
||||
|
||||
attachments.push({
|
||||
path: trimmed,
|
||||
name: attachmentName(trimmed),
|
||||
});
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
cleanText: cleanText.trim(),
|
||||
attachments: uniqueAttachments(attachments),
|
||||
};
|
||||
}
|
||||
|
||||
function imageTagPathsToAttachments(
|
||||
imagePaths: string[],
|
||||
): RunnerOutputAttachment[] {
|
||||
return uniqueAttachments(
|
||||
imagePaths
|
||||
.filter((filePath) => IMAGE_EXT_RE.test(filePath))
|
||||
.map((filePath) => ({
|
||||
path: filePath,
|
||||
name: attachmentName(filePath),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePublicTextOutput(
|
||||
result: string | null,
|
||||
): NormalizedRunnerOutput {
|
||||
@@ -236,3 +299,62 @@ export function normalizeEjclawStructuredOutput(
|
||||
|
||||
return normalizePublicTextOutput(result);
|
||||
}
|
||||
|
||||
export function normalizeAgentOutput(
|
||||
result: string | null,
|
||||
): NormalizedRunnerOutput {
|
||||
const normalized = normalizeEjclawStructuredOutput(result);
|
||||
if (
|
||||
normalized.output?.visibility !== 'public' ||
|
||||
typeof normalized.output.text !== 'string'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const explicitAttachments = normalized.output.attachments ?? [];
|
||||
if (explicitAttachments.length > 0) {
|
||||
return {
|
||||
...normalized,
|
||||
attachmentSource: 'legacy-ejclaw-json',
|
||||
};
|
||||
}
|
||||
|
||||
const markdownExtracted = extractMarkdownImageAttachments(
|
||||
normalized.output.text,
|
||||
);
|
||||
const imageTagExtracted = extractImageTagPaths(markdownExtracted.cleanText);
|
||||
const imageTagAttachments = imageTagPathsToAttachments(
|
||||
imageTagExtracted.imagePaths,
|
||||
);
|
||||
const attachments = uniqueAttachments([
|
||||
...markdownExtracted.attachments,
|
||||
...imageTagAttachments,
|
||||
]);
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return {
|
||||
...normalized,
|
||||
attachmentSource: normalized.attachmentSource ?? 'none',
|
||||
};
|
||||
}
|
||||
|
||||
const attachmentSource =
|
||||
markdownExtracted.attachments.length > 0 && imageTagAttachments.length > 0
|
||||
? 'mixed'
|
||||
: markdownExtracted.attachments.length > 0
|
||||
? 'markdown-image'
|
||||
: 'image-tag';
|
||||
|
||||
return {
|
||||
result: imageTagExtracted.cleanText,
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: imageTagExtracted.cleanText,
|
||||
...(normalized.output.verdict
|
||||
? { verdict: normalized.output.verdict }
|
||||
: {}),
|
||||
attachments,
|
||||
},
|
||||
attachmentSource,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,17 +3,20 @@ export {
|
||||
type RoomRoleContext,
|
||||
} from './room-role-context.js';
|
||||
export {
|
||||
extractMarkdownImageAttachments,
|
||||
extractImageTagPaths,
|
||||
IMAGE_TAG_RE,
|
||||
IPC_CLOSE_SENTINEL,
|
||||
IPC_INPUT_SUBDIR,
|
||||
IPC_POLL_MS,
|
||||
normalizeAgentOutput,
|
||||
normalizeEjclawStructuredOutput,
|
||||
normalizePublicTextOutput,
|
||||
OUTPUT_END_MARKER,
|
||||
OUTPUT_START_MARKER,
|
||||
writeProtocolOutput,
|
||||
type NormalizedRunnerOutput,
|
||||
type NormalizedAgentOutput,
|
||||
type RunnerOutputPhase,
|
||||
type RunnerOutputAttachment,
|
||||
type RunnerOutputVerdict,
|
||||
|
||||
121
runners/shared/test/normalize-agent-output.test.ts
Normal file
121
runners/shared/test/normalize-agent-output.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
extractMarkdownImageAttachments,
|
||||
normalizeAgentOutput,
|
||||
} from '../src/agent-protocol.js';
|
||||
|
||||
describe('normalizeAgentOutput', () => {
|
||||
it('extracts markdown image attachments without rewriting normal links', () => {
|
||||
expect(
|
||||
extractMarkdownImageAttachments(
|
||||
'결과입니다.\n\n[code](/tmp/source.ts#L10)',
|
||||
),
|
||||
).toEqual({
|
||||
cleanText: '결과입니다.\n\n[code](/tmp/source.ts#L10)',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/result.png',
|
||||
name: 'result.png',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes markdown image output into internal attachments', () => {
|
||||
expect(
|
||||
normalizeAgentOutput(
|
||||
'TASK_DONE\n\n스크린샷입니다.\n',
|
||||
),
|
||||
).toEqual({
|
||||
result: 'TASK_DONE\n\n스크린샷입니다.',
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: 'TASK_DONE\n\n스크린샷입니다.',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/screenshot.png',
|
||||
name: 'screenshot.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
attachmentSource: 'markdown-image',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes legacy image tags into internal attachments', () => {
|
||||
expect(
|
||||
normalizeAgentOutput(
|
||||
'TASK_DONE\n\n스크린샷입니다.\n[Image: screenshot.png → /tmp/legacy.png]',
|
||||
),
|
||||
).toEqual({
|
||||
result: 'TASK_DONE\n\n스크린샷입니다.',
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: 'TASK_DONE\n\n스크린샷입니다.',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/legacy.png',
|
||||
name: 'legacy.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
attachmentSource: 'image-tag',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes short image tags documented in prompts', () => {
|
||||
expect(
|
||||
normalizeAgentOutput('TASK_DONE\n\n[Image: /tmp/short-form.png]'),
|
||||
).toEqual({
|
||||
result: 'TASK_DONE',
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: 'TASK_DONE',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/short-form.png',
|
||||
name: 'short-form.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
attachmentSource: 'image-tag',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps legacy ejclaw JSON as compatibility input', () => {
|
||||
expect(
|
||||
normalizeAgentOutput(
|
||||
JSON.stringify({
|
||||
ejclaw: {
|
||||
visibility: 'public',
|
||||
text: '이미지를 첨부했습니다.',
|
||||
verdict: 'done',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/compat.png',
|
||||
name: 'compat.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
result: '이미지를 첨부했습니다.',
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: '이미지를 첨부했습니다.',
|
||||
verdict: 'done',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/compat.png',
|
||||
name: 'compat.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
attachmentSource: 'legacy-ejclaw-json',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user