Merge pull request #15 from phj1081/codex/parse-fenced-ejclaw-output

[codex] Parse fenced EJClaw output envelopes
This commit is contained in:
Eyejoker
2026-04-25 10:16:05 +09:00
committed by GitHub
3 changed files with 108 additions and 1 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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: {