From 1ff6b3434fa3fe6e7f62971d753e97eaf93f31cd Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 31 May 2026 22:44:42 +0900 Subject: [PATCH] fix: ignore attachment syntax examples (#206) --- runners/shared/src/agent-protocol.ts | 50 ++++++++++++++---- runners/shared/test/agent-protocol.test.ts | 60 +++++++++++++++++++++- 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/runners/shared/src/agent-protocol.ts b/runners/shared/src/agent-protocol.ts index ff78199..36b77e0 100644 --- a/runners/shared/src/agent-protocol.ts +++ b/runners/shared/src/agent-protocol.ts @@ -85,19 +85,26 @@ export function extractImageTagPaths(text: string): { cleanText: string; imagePaths: string[]; } { + const codeSpans = codeSyntaxSpans(text); const imagePattern = cloneImageTagPattern(); - const imagePaths = [...text.matchAll(imagePattern)].map((match) => - match[1].trim(), + const imagePaths: string[] = []; + const cleanText = text.replace( + imagePattern, + (full: string, rawPath: string, offset: number) => { + if (isInsideSpans(offset, codeSpans)) return full; + imagePaths.push(rawPath.trim()); + return ''; + }, ); return { - cleanText: text.replace(cloneImageTagPattern(), '').trim(), + cleanText: cleanText.trim(), imagePaths, }; } export function expandImagePromptReferences(text: string): string { - const codeSpans = fencedCodeSpans(text); + const codeSpans = codeSyntaxSpans(text); const withMediaImages = text.replace( MEDIA_TAG_RE, (full: string, doubleQuoted, singleQuoted, backticked, bare, offset) => { @@ -113,7 +120,7 @@ export function expandImagePromptReferences(text: string): string { }, ); - const markdownCodeSpans = fencedCodeSpans(withMediaImages); + const markdownCodeSpans = codeSyntaxSpans(withMediaImages); return withMediaImages.replace( MARKDOWN_IMAGE_ABSOLUTE_LINK_RE, (full: string, rawPath: string, offset: number) => { @@ -137,7 +144,7 @@ function promptReferenceTag(filePath: string): 'Image' | 'File' | null { } export function expandPromptAttachmentReferences(text: string): string { - const codeSpans = fencedCodeSpans(text); + const codeSpans = codeSyntaxSpans(text); const withMediaAttachments = text.replace( MEDIA_TAG_RE, (full: string, doubleQuoted, singleQuoted, backticked, bare, offset) => { @@ -153,7 +160,7 @@ export function expandPromptAttachmentReferences(text: string): string { }, ); - const markdownCodeSpans = fencedCodeSpans(withMediaAttachments); + const markdownCodeSpans = codeSyntaxSpans(withMediaAttachments); return withMediaAttachments.replace( MARKDOWN_IMAGE_ABSOLUTE_LINK_RE, (full: string, rawPath: string, offset: number) => { @@ -183,11 +190,13 @@ export type PromptAttachmentPart = export function splitImageTagPromptParts(text: string): ImageTagPromptPart[] { const imagePattern = cloneImageTagSegmentPattern(); + const codeSpans = codeSyntaxSpans(text); const parts: ImageTagPromptPart[] = []; let cursor = 0; for (const match of text.matchAll(imagePattern)) { const start = match.index ?? 0; + if (isInsideSpans(start, codeSpans)) continue; if (start > cursor) { parts.push({ type: 'text', text: text.slice(cursor, start) }); } @@ -213,11 +222,13 @@ export function splitPromptAttachmentParts( PROMPT_ATTACHMENT_TAG_SEGMENT_RE.source, PROMPT_ATTACHMENT_TAG_SEGMENT_RE.flags, ); + const codeSpans = codeSyntaxSpans(text); const parts: PromptAttachmentPart[] = []; let cursor = 0; for (const match of text.matchAll(pattern)) { const start = match.index ?? 0; + if (isInsideSpans(start, codeSpans)) continue; if (start > cursor) { parts.push({ type: 'text', text: text.slice(cursor, start) }); } @@ -312,10 +323,12 @@ export function extractMarkdownImageAttachments(text: string): { cleanText: string; attachments: RunnerOutputAttachment[]; } { + const codeSpans = codeSyntaxSpans(text); const attachments: RunnerOutputAttachment[] = []; const cleanText = text.replace( MARKDOWN_IMAGE_ABSOLUTE_LINK_RE, - (full: string, rawPath: string) => { + (full: string, rawPath: string, offset: number) => { + if (isInsideSpans(offset, codeSpans)) return full; const trimmed = rawPath.trim(); if (!IMAGE_EXT_RE.test(trimmed)) return full; @@ -340,6 +353,25 @@ function fencedCodeSpans(text: string): Array<[number, number]> { ]); } +function inlineCodeSpans( + text: string, + fencedSpans: Array<[number, number]>, +): Array<[number, number]> { + return [...text.matchAll(/`[^`\n]+`/g)] + .map((match): [number, number] => [ + match.index ?? 0, + (match.index ?? 0) + match[0].length, + ]) + .filter(([start]) => !isInsideSpans(start, fencedSpans)); +} + +function codeSyntaxSpans(text: string): Array<[number, number]> { + const fencedSpans = fencedCodeSpans(text); + return [...fencedSpans, ...inlineCodeSpans(text, fencedSpans)].sort( + ([a], [b]) => a - b, + ); +} + function isInsideSpans(index: number, spans: Array<[number, number]>): boolean { return spans.some(([start, end]) => index >= start && index < end); } @@ -372,7 +404,7 @@ export function extractMediaAttachments(text: string): { cleanText: string; attachments: RunnerOutputAttachment[]; } { - const codeSpans = fencedCodeSpans(text); + const codeSpans = codeSyntaxSpans(text); const attachments: RunnerOutputAttachment[] = []; const cleanText = text.replace( MEDIA_TAG_RE, diff --git a/runners/shared/test/agent-protocol.test.ts b/runners/shared/test/agent-protocol.test.ts index 4e5706c..b28b757 100644 --- a/runners/shared/test/agent-protocol.test.ts +++ b/runners/shared/test/agent-protocol.test.ts @@ -8,6 +8,7 @@ import { imageTagCaption, missingAttachmentCaption, missingImageTagCaption, + normalizeAgentOutput, normalizeEjclawStructuredOutput, normalizePublicTextOutput, splitPromptAttachmentParts, @@ -33,6 +34,17 @@ describe('shared agent protocol helpers', () => { }); }); + it('does not extract image tags from code examples', () => { + const text = + 'inline `[Image: x.png → /x.png]` and fenced\n```text\n[Image: y.png → /y.png]\n```\nreal [Image: z.png → /z.png]'; + + expect(extractImageTagPaths(text)).toEqual({ + cleanText: + 'inline `[Image: x.png → /x.png]` and fenced\n```text\n[Image: y.png → /y.png]\n```\nreal', + imagePaths: ['/z.png'], + }); + }); + it('splits labeled image tags for multimodal prompt builders', () => { expect( splitImageTagPromptParts( @@ -50,6 +62,21 @@ describe('shared agent protocol helpers', () => { ]); }); + it('does not split image tags from code examples into prompt images', () => { + const text = + '`[Image: inline.png → /tmp/inline.png]` [Image: real.png → /tmp/real.png]'; + + expect(splitImageTagPromptParts(text)).toEqual([ + { type: 'text', text: '`[Image: inline.png → /tmp/inline.png]` ' }, + { + type: 'image', + label: 'real.png', + path: '/tmp/real.png', + raw: '[Image: real.png → /tmp/real.png]', + }, + ]); + }); + it('formats image evidence captions and missing-image warnings', () => { const part = { type: 'image' as const, @@ -77,7 +104,8 @@ describe('shared agent protocol helpers', () => { }); it('keeps non-image media and fenced media text unchanged', () => { - const text = 'MEDIA:/tmp/demo.mp4\n```text\nMEDIA:/tmp/render.png\n```'; + const text = + 'MEDIA:/tmp/demo.mp4\n```text\nMEDIA:/tmp/render.png\n```\n`MEDIA:/tmp/inline.png`'; expect(expandImagePromptReferences(text)).toBe(text); }); @@ -115,6 +143,23 @@ describe('shared agent protocol helpers', () => { ]); }); + it('does not split prompt attachments from code examples', () => { + const text = + '`[File: inline.pdf → /tmp/inline.pdf]` [File: report.pdf → /tmp/report.pdf]'; + + expect(splitPromptAttachmentParts(text)).toEqual([ + { type: 'text', text: '`[File: inline.pdf → /tmp/inline.pdf]` ' }, + { + type: 'attachment', + kind: 'document', + label: 'report.pdf', + path: '/tmp/report.pdf', + raw: '[File: report.pdf → /tmp/report.pdf]', + tag: 'File', + }, + ]); + }); + it('formats document evidence captions and missing-document warnings', () => { const part = { type: 'attachment' as const, @@ -132,6 +177,19 @@ describe('shared agent protocol helpers', () => { '[Document unavailable: report.pdf → /tmp/report.pdf — file not found]', ); }); + + it('does not extract markdown image attachments from code examples', () => { + const normalized = normalizeAgentOutput( + 'inline `![x](/x.png)` and fenced\n```md\n![y](/y.png)\n```\nreal ![z](/z.png)', + ); + + expect(normalized.output).toEqual({ + visibility: 'public', + text: 'inline `![x](/x.png)` and fenced\n```md\n![y](/y.png)\n```\nreal', + attachments: [{ path: '/z.png', name: 'z.png' }], + }); + expect(normalized.attachmentSource).toBe('markdown-image'); + }); }); describe('shared agent output normalization', () => {