From 9e45534de0a8302dd58b19816d83c28f5f3db985 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sat, 2 May 2026 19:22:55 +0900 Subject: [PATCH 1/2] Support status-prefixed structured attachments Support status-prefixed structured attachment output and keep visible verdict text. --- runners/agent-runner/test/ipc-message.test.ts | 28 ++++++++++ runners/shared/src/agent-protocol.ts | 35 +++++++++++-- runners/shared/test/agent-protocol.test.ts | 39 ++++++++++++++ .../discord-structured-output.test.ts | 52 +++++++++++++++++++ 4 files changed, 151 insertions(+), 3 deletions(-) diff --git a/runners/agent-runner/test/ipc-message.test.ts b/runners/agent-runner/test/ipc-message.test.ts index 3d176ec..6664cef 100644 --- a/runners/agent-runner/test/ipc-message.test.ts +++ b/runners/agent-runner/test/ipc-message.test.ts @@ -75,6 +75,34 @@ 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('turns silent EJClaw envelopes into empty no-op messages', () => { expect( buildSendMessageIpcPayload({ diff --git a/runners/shared/src/agent-protocol.ts b/runners/shared/src/agent-protocol.ts index 6268d66..8ecf010 100644 --- a/runners/shared/src/agent-protocol.ts +++ b/runners/shared/src/agent-protocol.ts @@ -103,6 +103,8 @@ function isVisibleVerdict( const LEADING_STRUCTURED_OUTPUT_CONTROL_RE = /^[\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 { return value.replace(LEADING_STRUCTURED_OUTPUT_CONTROL_RE, '').trimStart(); @@ -142,6 +144,31 @@ function extractStructuredJsonCandidate(trimmed: string): string { 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( result: string | null, ): NormalizedRunnerOutput { @@ -150,7 +177,8 @@ export function normalizeEjclawStructuredOutput( } const trimmed = stripLeadingStructuredOutputControls(result.trim()); - const jsonCandidate = extractStructuredJsonCandidate(trimmed); + const { jsonCandidate, statusPrefix } = + extractStructuredCandidateWithOptionalStatus(trimmed); try { const parsed = JSON.parse(jsonCandidate) as { ejclaw?: { @@ -188,11 +216,12 @@ export function normalizeEjclawStructuredOutput( return normalizePublicTextOutput(result); } const attachments = normalizeAttachments(envelope.attachments); + const text = prefixStructuredText(envelope.text, statusPrefix); return { - result: envelope.text, + result: text, output: { visibility: 'public', - text: envelope.text, + text, verdict: isVisibleVerdict(envelope.verdict) ? envelope.verdict : undefined, diff --git a/runners/shared/test/agent-protocol.test.ts b/runners/shared/test/agent-protocol.test.ts index da4a26d..f79afaa 100644 --- a/runners/shared/test/agent-protocol.test.ts +++ b/runners/shared/test/agent-protocol.test.ts @@ -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', () => { const raw = JSON.stringify({ ejclaw: { diff --git a/src/channels/discord-structured-output.test.ts b/src/channels/discord-structured-output.test.ts index eae847a..9d2dcc4 100644 --- a/src/channels/discord-structured-output.test.ts +++ b/src/channels/discord-structured-output.test.ts @@ -173,4 +173,56 @@ describe('DiscordChannel structured output', () => { '"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"', + ); + }); }); From d3c02265e5c5bd25eeccb8bc41cf62b56d5090d0 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sat, 2 May 2026 19:50:32 +0900 Subject: [PATCH 2/2] Normalize agent attachment output (#122) --- prompts/claude-platform.md | 20 +-- prompts/codex-platform.md | 24 +--- prompts/owner-common-platform.md | 26 +--- runners/agent-runner/src/ipc-message.ts | 4 +- runners/agent-runner/src/output-protocol.ts | 4 +- runners/agent-runner/test/ipc-message.test.ts | 26 ++++ runners/codex-runner/src/index.ts | 4 +- runners/shared/src/agent-protocol.ts | 122 ++++++++++++++++++ runners/shared/src/index.ts | 3 + .../test/normalize-agent-output.test.ts | 121 +++++++++++++++++ src/agent-protocol.ts | 3 + src/channels/discord-outbound.ts | 76 +++-------- .../discord-structured-output.test.ts | 53 ++++++++ src/ipc-message-forwarding.ts | 4 +- src/web-dashboard-data-attachments.test.ts | 35 +++++ src/web-dashboard-data.ts | 13 +- 16 files changed, 414 insertions(+), 124 deletions(-) create mode 100644 runners/shared/test/normalize-agent-output.test.ts diff --git a/prompts/claude-platform.md b/prompts/claude-platform.md index 42a7abf..57e1727 100644 --- a/prompts/claude-platform.md +++ b/prompts/claude-platform.md @@ -7,22 +7,10 @@ When working as a sub-agent or teammate, only use `send_message` if the main age ## 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 -{ - "ejclaw": { - "visibility": "public", - "text": "스크린샷을 첨부했습니다.", - "attachments": [ - { - "path": "/absolute/path/screenshot.png", - "name": "screenshot.png", - "mime": "image/png" - } - ] - } -} +```text +![screenshot](/absolute/path/screenshot.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. diff --git a/prompts/codex-platform.md b/prompts/codex-platform.md index d2e07ca..cc5c132 100644 --- a/prompts/codex-platform.md +++ b/prompts/codex-platform.md @@ -30,27 +30,15 @@ Your output is sent directly to the Discord group. ## 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 -{ - "ejclaw": { - "visibility": "public", - "text": "이미지를 생성했습니다.", - "verdict": "done", - "attachments": [ - { - "path": "/absolute/path/image.png", - "name": "image.png", - "mime": "image/png" - } - ] - } -} +```text +![image](/absolute/path/image.png) ``` -- `attachments.path` must be an absolute local path; URLs and relative paths are ignored -- Do not repeat the same file path in the visible text +- You may also use `[Image: /absolute/path/image.png]` when that is shorter +- 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. - Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file diff --git a/prompts/owner-common-platform.md b/prompts/owner-common-platform.md index 618b41f..3758eae 100644 --- a/prompts/owner-common-platform.md +++ b/prompts/owner-common-platform.md @@ -22,30 +22,16 @@ The group folder may contain a `conversations/` directory with searchable histor ## 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 -{ - "ejclaw": { - "visibility": "public", - "text": "스크린샷을 첨부했습니다.", - "verdict": "done", - "attachments": [ - { - "path": "/absolute/path/screenshot.png", - "name": "screenshot.png", - "mime": "image/png" - } - ] - } -} +```text +![screenshot](/absolute/path/screenshot.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. -- Use absolute local paths only -- Do not duplicate the same path in the visible text +- You may also use `[Image: /absolute/path/screenshot.png]` when that is shorter +- Do not duplicate the same path elsewhere in the visible text - 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) diff --git a/runners/agent-runner/src/ipc-message.ts b/runners/agent-runner/src/ipc-message.ts index 5124925..ca10017 100644 --- a/runners/agent-runner/src/ipc-message.ts +++ b/runners/agent-runner/src/ipc-message.ts @@ -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 ?? ''; diff --git a/runners/agent-runner/src/output-protocol.ts b/runners/agent-runner/src/output-protocol.ts index f5ded31..3630137 100644 --- a/runners/agent-runner/src/output-protocol.ts +++ b/runners/agent-runner/src/output-protocol.ts @@ -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 { diff --git a/runners/agent-runner/test/ipc-message.test.ts b/runners/agent-runner/test/ipc-message.test.ts index 6664cef..5e1a495 100644 --- a/runners/agent-runner/test/ipc-message.test.ts +++ b/runners/agent-runner/test/ipc-message.test.ts @@ -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 + +스크린샷입니다. +![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', () => { expect( buildSendMessageIpcPayload({ diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts index 252e492..1762fc5 100644 --- a/runners/codex-runner/src/index.ts +++ b/runners/codex-runner/src/index.ts @@ -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 { diff --git a/runners/shared/src/agent-protocol.ts b/runners/shared/src/agent-protocol.ts index 8ecf010..b2531a0 100644 --- a/runners/shared/src/agent-protocol.ts +++ b/runners/shared/src/agent-protocol.ts @@ -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(); + 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, + }; +} diff --git a/runners/shared/src/index.ts b/runners/shared/src/index.ts index 8e432d1..d8701f4 100644 --- a/runners/shared/src/index.ts +++ b/runners/shared/src/index.ts @@ -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, diff --git a/runners/shared/test/normalize-agent-output.test.ts b/runners/shared/test/normalize-agent-output.test.ts new file mode 100644 index 0000000..ddad649 --- /dev/null +++ b/runners/shared/test/normalize-agent-output.test.ts @@ -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', + }); + }); +}); diff --git a/src/agent-protocol.ts b/src/agent-protocol.ts index 17daa32..d2eea53 100644 --- a/src/agent-protocol.ts +++ b/src/agent-protocol.ts @@ -1,15 +1,18 @@ 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 RunnerOutputAttachment, type RunnerOutputPhase, type RunnerOutputVerdict, diff --git a/src/channels/discord-outbound.ts b/src/channels/discord-outbound.ts index 9447b61..1f5fe74 100644 --- a/src/channels/discord-outbound.ts +++ b/src/channels/discord-outbound.ts @@ -1,64 +1,32 @@ import path from 'path'; -import { - extractImageTagPaths, - normalizeEjclawStructuredOutput, -} from '../agent-protocol.js'; +import { normalizeAgentOutput } from '../agent-protocol.js'; import type { OutboundAttachment } from '../types.js'; -const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i; -const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g; +const LOCAL_MARKDOWN_LINK_RE = /\[[^\]\n]*\]\((\/[^)\n]+)\)/g; export interface PreparedDiscordOutbound { text: string; cleanText: string; attachments: OutboundAttachment[]; - attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'none'; + attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'mixed' | 'none'; silent: boolean; } -function extractMarkdownImageAttachments(text: string): { - cleanText: string; - attachments: OutboundAttachment[]; -} { - const attachments: OutboundAttachment[] = []; - const seen = new Set(); - - const cleanText = text.replace(MD_LINK_RE, (_full, rawPath: string) => { +function sanitizeLocalMarkdownLinks(text: string): string { + return text.replace(LOCAL_MARKDOWN_LINK_RE, (_full, rawPath: string) => { 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 lineMatch = trimmed.match(/#L(\d+)/); 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( text: string, optionAttachments: OutboundAttachment[] | undefined, ): PreparedDiscordOutbound { - const normalized = normalizeEjclawStructuredOutput(text); + const normalized = normalizeAgentOutput(text); if (normalized.output?.visibility === 'silent') { return { text: '', @@ -72,30 +40,26 @@ export function prepareDiscordOutbound( const structuredOutput = normalized.output?.visibility === 'public' ? normalized.output : null; const outboundText = structuredOutput?.text ?? normalized.result ?? text; - const structuredAttachments = + const cleanText = sanitizeLocalMarkdownLinks(outboundText); + const attachments = optionAttachments && optionAttachments.length > 0 ? optionAttachments : (structuredOutput?.attachments ?? []); - const hasStructuredAttachments = structuredAttachments.length > 0; - const markdownExtracted = extractMarkdownImageAttachments(outboundText); - const imageTagExtracted = extractImageTagPaths(markdownExtracted.cleanText); - const legacyImageTagAttachments = imageTagPathsToAttachments( - imageTagExtracted.imagePaths, - ); + const hasAttachments = attachments.length > 0; + const attachmentSource = + optionAttachments && optionAttachments.length > 0 + ? 'structured' + : normalized.attachmentSource === 'legacy-ejclaw-json' + ? 'structured' + : normalized.attachmentSource === 'markdown-image' + ? 'md-link' + : (normalized.attachmentSource ?? 'none'); return { text: outboundText, - cleanText: imageTagExtracted.cleanText, - attachments: hasStructuredAttachments - ? structuredAttachments - : [...markdownExtracted.attachments, ...legacyImageTagAttachments], - attachmentSource: hasStructuredAttachments - ? 'structured' - : markdownExtracted.attachments.length > 0 - ? 'md-link' - : legacyImageTagAttachments.length > 0 - ? 'image-tag' - : 'none', + cleanText, + attachments, + attachmentSource: hasAttachments ? attachmentSource : 'none', silent: false, }; } diff --git a/src/channels/discord-structured-output.test.ts b/src/channels/discord-structured-output.test.ts index 9d2dcc4..0e935d9 100644 --- a/src/channels/discord-structured-output.test.ts +++ b/src/channels/discord-structured-output.test.ts @@ -225,4 +225,57 @@ ${JSON.stringify({ '"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, + }); + }); }); diff --git a/src/ipc-message-forwarding.ts b/src/ipc-message-forwarding.ts index 1a5c317..6efe6af 100644 --- a/src/ipc-message-forwarding.ts +++ b/src/ipc-message-forwarding.ts @@ -2,7 +2,7 @@ import type { IpcMessageForwardResult, IpcMessagePayload, } from './ipc-types.js'; -import { normalizeEjclawStructuredOutput } from './agent-protocol.js'; +import { normalizeAgentOutput } from './agent-protocol.js'; import type { RegisteredGroup } from './types.js'; 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') { return { outcome: 'sent', diff --git a/src/web-dashboard-data-attachments.test.ts b/src/web-dashboard-data-attachments.test.ts index d37ef04..abaf5e2 100644 --- a/src/web-dashboard-data-attachments.test.ts +++ b/src/web-dashboard-data-attachments.test.ts @@ -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', + }, + ], + }); + }); }); diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index abf4274..7580f6e 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -1,6 +1,6 @@ import { extractImageTagPaths, - normalizeEjclawStructuredOutput, + normalizeAgentOutput, } from './agent-protocol.js'; import { isWatchCiTask } from './task-watch-status.js'; import type { @@ -228,17 +228,18 @@ function normalizeStructuredVisibleContent(value: string): { text: string | null; attachments: OutboundAttachment[]; } { - if (!hasStructuredOutputHint(value)) return splitLegacyImageTags(value); - - const candidates = [value, decodeCommonHtmlEntities(value)]; + const candidates = hasStructuredOutputHint(value) + ? [value, decodeCommonHtmlEntities(value)] + : [value]; for (const candidate of candidates) { - const normalized = normalizeEjclawStructuredOutput(candidate); + const normalized = normalizeAgentOutput(candidate); if (normalized.output?.visibility === 'silent') { return { text: null, attachments: [] }; } if ( normalized.output?.visibility === 'public' && - normalized.output.text !== candidate + (normalized.output.text !== candidate || + (normalized.output.attachments?.length ?? 0) > 0) ) { return splitLegacyImageTags( normalized.output.text,