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"', + ); + }); });