Merge remote-tracking branch 'origin/main' into codex/owner/ejclaw

This commit is contained in:
ejclaw
2026-05-02 19:53:41 +09:00
17 changed files with 565 additions and 127 deletions

View File

@@ -7,22 +7,10 @@ When working as a sub-agent or teammate, only use `send_message` if the main age
## Image attachments ## Image attachments
When a locally generated image or screenshot should appear in Discord, return an EJClaw structured output with `attachments` instead of only writing the file path in prose: When a locally generated image or screenshot should appear in Discord, include a Markdown image with an absolute local path:
```json ```text
{ ![screenshot](/absolute/path/screenshot.png)
"ejclaw": {
"visibility": "public",
"text": "스크린샷을 첨부했습니다.",
"attachments": [
{
"path": "/absolute/path/screenshot.png",
"name": "screenshot.png",
"mime": "image/png"
}
]
}
}
``` ```
Use absolute local paths only, and do not repeat the same path in the visible text. Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. You may also use `[Image: /absolute/path/screenshot.png]` when that is shorter. Use absolute local paths only, do not repeat the same path in the visible text, and only attach raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.

View File

@@ -30,27 +30,15 @@ Your output is sent directly to the Discord group.
## Image attachments ## Image attachments
When you need to show a locally generated image, screenshot, or other raster output in Discord, do not rely on a raw file path in prose. Emit an EJClaw structured output with `attachments`. When you need to show a locally generated image, screenshot, or other raster output in Discord, include a Markdown image with an absolute local path:
```json ```text
{ ![image](/absolute/path/image.png)
"ejclaw": {
"visibility": "public",
"text": "이미지를 생성했습니다.",
"verdict": "done",
"attachments": [
{
"path": "/absolute/path/image.png",
"name": "image.png",
"mime": "image/png"
}
]
}
}
``` ```
- `attachments.path` must be an absolute local path; URLs and relative paths are ignored - You may also use `[Image: /absolute/path/image.png]` when that is shorter
- Do not repeat the same file path in the visible text - URLs and relative paths are ignored
- Do not repeat the same file path elsewhere in the visible text
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. - Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
- Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file - Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file

View File

@@ -22,30 +22,16 @@ The group folder may contain a `conversations/` directory with searchable histor
## Image attachments ## Image attachments
For locally generated images or e2e screenshots that should appear in Discord, prefer EJClaw structured attachments over prose paths: For locally generated images or e2e screenshots that should appear in Discord, include a Markdown image with an absolute local path:
```json ```text
{ ![screenshot](/absolute/path/screenshot.png)
"ejclaw": {
"visibility": "public",
"text": "스크린샷을 첨부했습니다.",
"verdict": "done",
"attachments": [
{
"path": "/absolute/path/screenshot.png",
"name": "screenshot.png",
"mime": "image/png"
}
]
}
}
``` ```
- When emitting this as your final runner output, emit the JSON envelope directly. Do not wrap it in Markdown fences or add prose outside the JSON. - You may also use `[Image: /absolute/path/screenshot.png]` when that is shorter
- Use absolute local paths only - Do not duplicate the same path elsewhere in the visible text
- Do not duplicate the same path in the visible text
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. - Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
- The channel harness validates and uploads attachments; plain prose paths are not reliable - The channel harness validates and uploads attachments from these image markers
## CI monitoring (watch_ci) ## CI monitoring (watch_ci)

View File

@@ -1,5 +1,5 @@
import { import {
normalizeEjclawStructuredOutput, normalizeAgentOutput,
type RunnerOutputAttachment, type RunnerOutputAttachment,
} from 'ejclaw-runners-shared'; } from 'ejclaw-runners-shared';
@@ -28,7 +28,7 @@ export interface SendMessageIpcPayload {
export function buildSendMessageIpcPayload( export function buildSendMessageIpcPayload(
input: SendMessageIpcPayloadInput, input: SendMessageIpcPayloadInput,
): SendMessageIpcPayload { ): SendMessageIpcPayload {
const normalized = normalizeEjclawStructuredOutput(input.text); const normalized = normalizeAgentOutput(input.text);
const output = const output =
normalized.output?.visibility === 'public' ? normalized.output : null; normalized.output?.visibility === 'public' ? normalized.output : null;
const text = output?.text ?? normalized.result ?? ''; const text = output?.text ?? normalized.result ?? '';

View File

@@ -3,7 +3,7 @@ import path from 'path';
import { import {
extractImageTagPaths, extractImageTagPaths,
normalizeEjclawStructuredOutput, normalizeAgentOutput,
type RunnerStructuredOutput, type RunnerStructuredOutput,
writeProtocolOutput, writeProtocolOutput,
} from 'ejclaw-runners-shared'; } from 'ejclaw-runners-shared';
@@ -151,7 +151,7 @@ export function normalizeStructuredOutput(result: string | null): {
result: string | null; result: string | null;
output?: RunnerOutput['output']; output?: RunnerOutput['output'];
} { } {
return normalizeEjclawStructuredOutput(result); return normalizeAgentOutput(result);
} }
export function extractAssistantText(message: unknown): string | null { export function extractAssistantText(message: unknown): string | null {

View File

@@ -75,6 +75,60 @@ describe('agent runner IPC message payload', () => {
}); });
}); });
it('normalizes status-prefixed EJClaw envelopes and preserves attachments', () => {
expect(
buildSendMessageIpcPayload({
chatJid: 'dc:123',
text: `TASK_DONE
\`\`\`json
{"ejclaw":{"visibility":"public","text":"첨부했습니다.","verdict":"done","attachments":[{"path":"/tmp/ejclaw-status.png","name":"status.png","mime":"image/png"}]}}
\`\`\``,
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-status.png',
name: 'status.png',
mime: 'image/png',
},
],
});
});
it('normalizes markdown image output and preserves attachments', () => {
expect(
buildSendMessageIpcPayload({
chatJid: 'dc:123',
text: `TASK_DONE
스크린샷입니다.
![screenshot](/tmp/ejclaw-markdown.png)`,
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', () => { it('turns silent EJClaw envelopes into empty no-op messages', () => {
expect( expect(
buildSendMessageIpcPayload({ buildSendMessageIpcPayload({

View File

@@ -20,7 +20,7 @@ import {
IPC_CLOSE_SENTINEL, IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR, IPC_INPUT_SUBDIR,
IPC_POLL_MS, IPC_POLL_MS,
normalizeEjclawStructuredOutput, normalizeAgentOutput,
writeProtocolOutput, writeProtocolOutput,
type RunnerStructuredOutput, type RunnerStructuredOutput,
} from 'ejclaw-runners-shared'; } from 'ejclaw-runners-shared';
@@ -89,7 +89,7 @@ function normalizeStructuredOutput(result: string | null): {
result: string | null; result: string | null;
output?: RunnerOutput['output']; output?: RunnerOutput['output'];
} { } {
return normalizeEjclawStructuredOutput(result); return normalizeAgentOutput(result);
} }
function log(message: string): void { function log(message: string): void {

View File

@@ -3,6 +3,8 @@ export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
export const IMAGE_TAG_RE = export const IMAGE_TAG_RE =
/\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g; /\[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_POLL_MS = 500;
export const IPC_INPUT_SUBDIR = 'input'; export const IPC_INPUT_SUBDIR = 'input';
@@ -44,8 +46,16 @@ export type RunnerStructuredOutput =
export interface NormalizedRunnerOutput { export interface NormalizedRunnerOutput {
result: string | null; result: string | null;
output?: RunnerStructuredOutput; output?: RunnerStructuredOutput;
attachmentSource?:
| 'legacy-ejclaw-json'
| 'markdown-image'
| 'image-tag'
| 'mixed'
| 'none';
} }
export type NormalizedAgentOutput = NormalizedRunnerOutput;
function cloneImageTagPattern(): RegExp { function cloneImageTagPattern(): RegExp {
return new RegExp(IMAGE_TAG_RE.source, IMAGE_TAG_RE.flags); 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( export function normalizePublicTextOutput(
result: string | null, result: string | null,
): NormalizedRunnerOutput { ): NormalizedRunnerOutput {
@@ -103,6 +166,8 @@ function isVisibleVerdict(
const LEADING_STRUCTURED_OUTPUT_CONTROL_RE = const LEADING_STRUCTURED_OUTPUT_CONTROL_RE =
/^[\u0000-\u001F\u007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]+/u; /^[\u0000-\u001F\u007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]+/u;
const STRUCTURED_STATUS_PREFIX_RE =
/^(STEP_DONE|TASK_DONE|DONE|DONE_WITH_CONCERNS|BLOCKED|NEEDS_CONTEXT)[ \t]*(?:\r?\n)+([\s\S]+)$/;
function stripLeadingStructuredOutputControls(value: string): string { function stripLeadingStructuredOutputControls(value: string): string {
return value.replace(LEADING_STRUCTURED_OUTPUT_CONTROL_RE, '').trimStart(); return value.replace(LEADING_STRUCTURED_OUTPUT_CONTROL_RE, '').trimStart();
@@ -142,6 +207,31 @@ function extractStructuredJsonCandidate(trimmed: string): string {
return fencedJson?.[1]?.trim() ?? trimmed; return fencedJson?.[1]?.trim() ?? trimmed;
} }
function extractStructuredCandidateWithOptionalStatus(trimmed: string): {
jsonCandidate: string;
statusPrefix: string | null;
} {
const statusMatch = trimmed.match(STRUCTURED_STATUS_PREFIX_RE);
if (!statusMatch) {
return {
jsonCandidate: extractStructuredJsonCandidate(trimmed),
statusPrefix: null,
};
}
return {
jsonCandidate: extractStructuredJsonCandidate(statusMatch[2].trim()),
statusPrefix: statusMatch[1],
};
}
function prefixStructuredText(
text: string,
statusPrefix: string | null,
): string {
return statusPrefix ? `${statusPrefix}\n\n${text}` : text;
}
export function normalizeEjclawStructuredOutput( export function normalizeEjclawStructuredOutput(
result: string | null, result: string | null,
): NormalizedRunnerOutput { ): NormalizedRunnerOutput {
@@ -150,7 +240,8 @@ export function normalizeEjclawStructuredOutput(
} }
const trimmed = stripLeadingStructuredOutputControls(result.trim()); const trimmed = stripLeadingStructuredOutputControls(result.trim());
const jsonCandidate = extractStructuredJsonCandidate(trimmed); const { jsonCandidate, statusPrefix } =
extractStructuredCandidateWithOptionalStatus(trimmed);
try { try {
const parsed = JSON.parse(jsonCandidate) as { const parsed = JSON.parse(jsonCandidate) as {
ejclaw?: { ejclaw?: {
@@ -188,11 +279,12 @@ export function normalizeEjclawStructuredOutput(
return normalizePublicTextOutput(result); return normalizePublicTextOutput(result);
} }
const attachments = normalizeAttachments(envelope.attachments); const attachments = normalizeAttachments(envelope.attachments);
const text = prefixStructuredText(envelope.text, statusPrefix);
return { return {
result: envelope.text, result: text,
output: { output: {
visibility: 'public', visibility: 'public',
text: envelope.text, text,
verdict: isVisibleVerdict(envelope.verdict) verdict: isVisibleVerdict(envelope.verdict)
? envelope.verdict ? envelope.verdict
: undefined, : undefined,
@@ -207,3 +299,62 @@ export function normalizeEjclawStructuredOutput(
return normalizePublicTextOutput(result); 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,
};
}

View File

@@ -3,17 +3,20 @@ export {
type RoomRoleContext, type RoomRoleContext,
} from './room-role-context.js'; } from './room-role-context.js';
export { export {
extractMarkdownImageAttachments,
extractImageTagPaths, extractImageTagPaths,
IMAGE_TAG_RE, IMAGE_TAG_RE,
IPC_CLOSE_SENTINEL, IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR, IPC_INPUT_SUBDIR,
IPC_POLL_MS, IPC_POLL_MS,
normalizeAgentOutput,
normalizeEjclawStructuredOutput, normalizeEjclawStructuredOutput,
normalizePublicTextOutput, normalizePublicTextOutput,
OUTPUT_END_MARKER, OUTPUT_END_MARKER,
OUTPUT_START_MARKER, OUTPUT_START_MARKER,
writeProtocolOutput, writeProtocolOutput,
type NormalizedRunnerOutput, type NormalizedRunnerOutput,
type NormalizedAgentOutput,
type RunnerOutputPhase, type RunnerOutputPhase,
type RunnerOutputAttachment, type RunnerOutputAttachment,
type RunnerOutputVerdict, type RunnerOutputVerdict,

View File

@@ -156,6 +156,45 @@ describe('shared agent protocol helpers', () => {
}); });
}); });
it('parses status-prefixed fenced public ejclaw attachments', () => {
expect(
normalizeEjclawStructuredOutput(`TASK_DONE
\`\`\`json
{
"ejclaw": {
"visibility": "public",
"text": "이미지를 첨부했습니다.",
"verdict": "done",
"attachments": [
{
"path": "/tmp/ejclaw-discord-image-status.png",
"name": "status.png",
"mime": "image/png"
}
]
}
}
\`\`\``),
).toEqual({
result: 'TASK_DONE\n\n이미지를 첨부했습니다.',
output: {
visibility: 'public',
text: 'TASK_DONE\n\n이미지를 첨부했습니다.',
verdict: 'done',
attachments: [
{
path: '/tmp/ejclaw-discord-image-status.png',
name: 'status.png',
mime: 'image/png',
},
],
},
});
});
});
describe('shared agent protocol fallback behavior', () => {
it('parses in_progress public envelopes instead of leaking raw structured JSON', () => { it('parses in_progress public envelopes instead of leaking raw structured JSON', () => {
const raw = JSON.stringify({ const raw = JSON.stringify({
ejclaw: { ejclaw: {

View 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![screenshot](/tmp/result.png)\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![screenshot](/tmp/screenshot.png)',
),
).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',
});
});
});

View File

@@ -1,15 +1,18 @@
export { export {
extractMarkdownImageAttachments,
extractImageTagPaths, extractImageTagPaths,
IMAGE_TAG_RE, IMAGE_TAG_RE,
IPC_CLOSE_SENTINEL, IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR, IPC_INPUT_SUBDIR,
IPC_POLL_MS, IPC_POLL_MS,
normalizeAgentOutput,
normalizeEjclawStructuredOutput, normalizeEjclawStructuredOutput,
normalizePublicTextOutput, normalizePublicTextOutput,
OUTPUT_END_MARKER, OUTPUT_END_MARKER,
OUTPUT_START_MARKER, OUTPUT_START_MARKER,
writeProtocolOutput, writeProtocolOutput,
type NormalizedRunnerOutput, type NormalizedRunnerOutput,
type NormalizedAgentOutput,
type RunnerOutputAttachment, type RunnerOutputAttachment,
type RunnerOutputPhase, type RunnerOutputPhase,
type RunnerOutputVerdict, type RunnerOutputVerdict,

View File

@@ -1,64 +1,32 @@
import path from 'path'; import path from 'path';
import { import { normalizeAgentOutput } from '../agent-protocol.js';
extractImageTagPaths,
normalizeEjclawStructuredOutput,
} from '../agent-protocol.js';
import type { OutboundAttachment } from '../types.js'; import type { OutboundAttachment } from '../types.js';
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i; const LOCAL_MARKDOWN_LINK_RE = /\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g;
export interface PreparedDiscordOutbound { export interface PreparedDiscordOutbound {
text: string; text: string;
cleanText: string; cleanText: string;
attachments: OutboundAttachment[]; attachments: OutboundAttachment[];
attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'none'; attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'mixed' | 'none';
silent: boolean; silent: boolean;
} }
function extractMarkdownImageAttachments(text: string): { function sanitizeLocalMarkdownLinks(text: string): string {
cleanText: string; return text.replace(LOCAL_MARKDOWN_LINK_RE, (_full, rawPath: string) => {
attachments: OutboundAttachment[];
} {
const attachments: OutboundAttachment[] = [];
const seen = new Set<string>();
const cleanText = text.replace(MD_LINK_RE, (_full, rawPath: string) => {
const trimmed = rawPath.trim(); const trimmed = rawPath.trim();
if (IMAGE_EXTS.test(trimmed)) {
if (!seen.has(trimmed)) {
attachments.push({
path: trimmed,
name: path.basename(trimmed),
});
seen.add(trimmed);
}
return '';
}
const basename = path.basename(trimmed.replace(/#.*$/, '')); const basename = path.basename(trimmed.replace(/#.*$/, ''));
const lineMatch = trimmed.match(/#L(\d+)/); const lineMatch = trimmed.match(/#L(\d+)/);
return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``; return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``;
}); });
return { cleanText, attachments };
}
function imageTagPathsToAttachments(paths: string[]): OutboundAttachment[] {
return paths
.filter((filePath) => IMAGE_EXTS.test(filePath))
.map((filePath) => ({
path: filePath,
name: path.basename(filePath),
}));
} }
export function prepareDiscordOutbound( export function prepareDiscordOutbound(
text: string, text: string,
optionAttachments: OutboundAttachment[] | undefined, optionAttachments: OutboundAttachment[] | undefined,
): PreparedDiscordOutbound { ): PreparedDiscordOutbound {
const normalized = normalizeEjclawStructuredOutput(text); const normalized = normalizeAgentOutput(text);
if (normalized.output?.visibility === 'silent') { if (normalized.output?.visibility === 'silent') {
return { return {
text: '', text: '',
@@ -72,30 +40,26 @@ export function prepareDiscordOutbound(
const structuredOutput = const structuredOutput =
normalized.output?.visibility === 'public' ? normalized.output : null; normalized.output?.visibility === 'public' ? normalized.output : null;
const outboundText = structuredOutput?.text ?? normalized.result ?? text; const outboundText = structuredOutput?.text ?? normalized.result ?? text;
const structuredAttachments = const cleanText = sanitizeLocalMarkdownLinks(outboundText);
const attachments =
optionAttachments && optionAttachments.length > 0 optionAttachments && optionAttachments.length > 0
? optionAttachments ? optionAttachments
: (structuredOutput?.attachments ?? []); : (structuredOutput?.attachments ?? []);
const hasStructuredAttachments = structuredAttachments.length > 0; const hasAttachments = attachments.length > 0;
const markdownExtracted = extractMarkdownImageAttachments(outboundText); const attachmentSource =
const imageTagExtracted = extractImageTagPaths(markdownExtracted.cleanText); optionAttachments && optionAttachments.length > 0
const legacyImageTagAttachments = imageTagPathsToAttachments( ? 'structured'
imageTagExtracted.imagePaths, : normalized.attachmentSource === 'legacy-ejclaw-json'
); ? 'structured'
: normalized.attachmentSource === 'markdown-image'
? 'md-link'
: (normalized.attachmentSource ?? 'none');
return { return {
text: outboundText, text: outboundText,
cleanText: imageTagExtracted.cleanText, cleanText,
attachments: hasStructuredAttachments attachments,
? structuredAttachments attachmentSource: hasAttachments ? attachmentSource : 'none',
: [...markdownExtracted.attachments, ...legacyImageTagAttachments],
attachmentSource: hasStructuredAttachments
? 'structured'
: markdownExtracted.attachments.length > 0
? 'md-link'
: legacyImageTagAttachments.length > 0
? 'image-tag'
: 'none',
silent: false, silent: false,
}; };
} }

View File

@@ -173,4 +173,109 @@ describe('DiscordChannel structured output', () => {
'"ejclaw"', '"ejclaw"',
); );
}); });
it('normalizes status-prefixed EJClaw JSON and keeps the status line visible', async () => {
const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect();
const filePath = path.join(
os.tmpdir(),
`ejclaw-discord-image-status-${Date.now()}.png`,
);
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
tempFiles.push(filePath);
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-2' }),
sendTyping: vi.fn(),
};
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage(
'dc:1234567890123456',
`TASK_DONE
\`\`\`json
${JSON.stringify({
ejclaw: {
visibility: 'public',
text: '첨부 테스트입니다.',
verdict: 'done',
attachments: [
{
path: filePath,
name: 'status.png',
mime: 'image/png',
},
],
},
})}
\`\`\``,
);
expect(mockChannel.send).toHaveBeenCalledWith({
content: 'TASK_DONE\n\n첨부 테스트입니다.',
files: [
{
attachment: fs.realpathSync(filePath),
name: 'status.png',
},
],
flags: 1 << 2,
});
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain(
'"ejclaw"',
);
});
it('normalizes markdown images and sends them as files', async () => {
const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect();
const filePath = path.join(
os.tmpdir(),
`ejclaw-discord-markdown-image-${Date.now()}.png`,
);
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
tempFiles.push(filePath);
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-3' }),
sendTyping: vi.fn(),
};
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage(
'dc:1234567890123456',
`스크린샷입니다.\n![screenshot](${filePath})`,
);
expect(mockChannel.send).toHaveBeenCalledWith({
content: '스크린샷입니다.',
files: [
{
attachment: fs.realpathSync(filePath),
name: path.basename(filePath),
},
],
flags: 1 << 2,
});
});
it('keeps non-image local markdown links readable without uploading files', async () => {
const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect();
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-4' }),
sendTyping: vi.fn(),
};
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage(
'dc:1234567890123456',
'수정 위치: [source](/tmp/project/src/index.ts#L42)',
);
expect(mockChannel.send).toHaveBeenCalledWith({
content: '수정 위치: `index.ts:42`',
files: undefined,
flags: 1 << 2,
});
});
}); });

View File

@@ -2,7 +2,7 @@ import type {
IpcMessageForwardResult, IpcMessageForwardResult,
IpcMessagePayload, IpcMessagePayload,
} from './ipc-types.js'; } from './ipc-types.js';
import { normalizeEjclawStructuredOutput } from './agent-protocol.js'; import { normalizeAgentOutput } from './agent-protocol.js';
import type { RegisteredGroup } from './types.js'; import type { RegisteredGroup } from './types.js';
export async function forwardAuthorizedIpcMessage( export async function forwardAuthorizedIpcMessage(
@@ -81,7 +81,7 @@ export async function forwardAuthorizedIpcMessage(
}; };
} }
const normalized = normalizeEjclawStructuredOutput(msg.text); const normalized = normalizeAgentOutput(msg.text);
if (normalized.output?.visibility === 'silent') { if (normalized.output?.visibility === 'silent') {
return { return {
outcome: 'sent', outcome: 'sent',

View File

@@ -152,4 +152,39 @@ describe('web dashboard attachment data', () => {
], ],
}); });
}); });
it('turns markdown image output into dashboard attachments', () => {
const message: NewMessage = {
id: 'msg-markdown-image',
chat_jid: 'dc:ops',
sender: 'bot-1',
sender_name: 'owner',
content:
'라벨 좌측 클리핑 회귀 수정했습니다.\n![screenshot](/tmp/bar-chart-label-fit-playwright.png)',
timestamp: '2026-04-26T05:31:00.000Z',
is_from_me: true,
is_bot_message: true,
message_source_kind: 'bot',
};
const activity = buildWebDashboardRoomActivity({
serviceId: 'codex-main',
entry: roomEntry,
pairedTask: null,
turns: [],
attempts: [],
outputs: [],
messages: [message],
});
expect(activity.messages[0]).toMatchObject({
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
attachments: [
{
path: '/tmp/bar-chart-label-fit-playwright.png',
name: 'bar-chart-label-fit-playwright.png',
},
],
});
});
}); });

View File

@@ -1,6 +1,6 @@
import { import {
extractImageTagPaths, extractImageTagPaths,
normalizeEjclawStructuredOutput, normalizeAgentOutput,
} from './agent-protocol.js'; } from './agent-protocol.js';
import { isWatchCiTask } from './task-watch-status.js'; import { isWatchCiTask } from './task-watch-status.js';
import type { import type {
@@ -228,17 +228,18 @@ function normalizeStructuredVisibleContent(value: string): {
text: string | null; text: string | null;
attachments: OutboundAttachment[]; attachments: OutboundAttachment[];
} { } {
if (!hasStructuredOutputHint(value)) return splitLegacyImageTags(value); const candidates = hasStructuredOutputHint(value)
? [value, decodeCommonHtmlEntities(value)]
const candidates = [value, decodeCommonHtmlEntities(value)]; : [value];
for (const candidate of candidates) { for (const candidate of candidates) {
const normalized = normalizeEjclawStructuredOutput(candidate); const normalized = normalizeAgentOutput(candidate);
if (normalized.output?.visibility === 'silent') { if (normalized.output?.visibility === 'silent') {
return { text: null, attachments: [] }; return { text: null, attachments: [] };
} }
if ( if (
normalized.output?.visibility === 'public' && normalized.output?.visibility === 'public' &&
normalized.output.text !== candidate (normalized.output.text !== candidate ||
(normalized.output.attachments?.length ?? 0) > 0)
) { ) {
return splitLegacyImageTags( return splitLegacyImageTags(
normalized.output.text, normalized.output.text,