diff --git a/prompts/owner-common-platform.md b/prompts/owner-common-platform.md index 67204b8..618b41f 100644 --- a/prompts/owner-common-platform.md +++ b/prompts/owner-common-platform.md @@ -41,6 +41,7 @@ For locally generated images or e2e screenshots that should appear in Discord, p } ``` +- 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 - Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. diff --git a/runners/shared/src/agent-protocol.ts b/runners/shared/src/agent-protocol.ts index 20a1494..de2e6cc 100644 --- a/runners/shared/src/agent-protocol.ts +++ b/runners/shared/src/agent-protocol.ts @@ -124,6 +124,13 @@ function normalizeAttachments(value: unknown): RunnerOutputAttachment[] { return attachments; } +function extractStructuredJsonCandidate(trimmed: string): string { + const fencedJson = trimmed.match( + /^```(?:json|JSON)?[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*$/, + ); + return fencedJson?.[1]?.trim() ?? trimmed; +} + export function normalizeEjclawStructuredOutput( result: string | null, ): NormalizedRunnerOutput { @@ -132,8 +139,9 @@ export function normalizeEjclawStructuredOutput( } const trimmed = result.trim(); + const jsonCandidate = extractStructuredJsonCandidate(trimmed); try { - const parsed = JSON.parse(trimmed) as { + const parsed = JSON.parse(jsonCandidate) as { ejclaw?: { visibility?: unknown; text?: unknown; diff --git a/runners/shared/test/agent-protocol.test.ts b/runners/shared/test/agent-protocol.test.ts index 74e631f..7b166f6 100644 --- a/runners/shared/test/agent-protocol.test.ts +++ b/runners/shared/test/agent-protocol.test.ts @@ -86,6 +86,104 @@ describe('shared agent protocol helpers', () => { }); }); + it('parses fenced public ejclaw attachments', () => { + expect( + normalizeEjclawStructuredOutput(`\`\`\`json +{ + "ejclaw": { + "visibility": "public", + "text": "검 아이콘을 생성했습니다.", + "verdict": "done", + "attachments": [ + { + "path": "/tmp/imagegen-sword.png", + "name": "imagegen-sword.png", + "mime": "image/png" + } + ] + } +} +\`\`\``), + ).toEqual({ + result: '검 아이콘을 생성했습니다.', + output: { + visibility: 'public', + text: '검 아이콘을 생성했습니다.', + verdict: 'done', + attachments: [ + { + path: '/tmp/imagegen-sword.png', + name: 'imagegen-sword.png', + mime: 'image/png', + }, + ], + }, + }); + }); + + it('parses unlabeled fenced ejclaw envelopes', () => { + expect( + normalizeEjclawStructuredOutput(`\`\`\` +{"ejclaw":{"visibility":"public","text":"스크린샷입니다.","verdict":"done"}} +\`\`\``), + ).toEqual({ + result: '스크린샷입니다.', + output: { + visibility: 'public', + text: '스크린샷입니다.', + verdict: 'done', + }, + }); + }); + + it('does not parse mixed prose and fenced ejclaw JSON as structured output', () => { + const raw = `설명입니다. + +\`\`\`json +{"ejclaw":{"visibility":"public","text":"첨부입니다.","verdict":"done"}} +\`\`\``; + + expect(normalizeEjclawStructuredOutput(raw)).toEqual({ + result: raw, + output: { + visibility: 'public', + text: raw, + }, + }); + }); + + it('falls back when fenced JSON has no ejclaw envelope', () => { + const raw = `\`\`\`json +{"text":"plain json"} +\`\`\``; + + expect(normalizeEjclawStructuredOutput(raw)).toEqual({ + result: raw, + output: { + visibility: 'public', + text: raw, + }, + }); + }); + + it('falls back when multiple fenced JSON blocks are present', () => { + const raw = `\`\`\`json +{"ejclaw":{"visibility":"public","text":"첫 번째","verdict":"done"}} +\`\`\` + +\`\`\`json +{"ejclaw":{"visibility":"public","text":"두 번째","verdict":"done"}} +\`\`\``; + + expect(normalizeEjclawStructuredOutput(raw)).toEqual({ + result: raw, + output: { + visibility: 'public', + text: raw, + }, + }); + }); + it('falls back to visible raw text on invalid public verdicts', () => { const raw = JSON.stringify({ ejclaw: {