fix: ignore attachment syntax examples (#206)

This commit is contained in:
Eyejoker
2026-05-31 22:44:42 +09:00
committed by GitHub
parent b6e7e060cc
commit 1ff6b3434f
2 changed files with 100 additions and 10 deletions

View File

@@ -85,19 +85,26 @@ export function extractImageTagPaths(text: string): {
cleanText: string; cleanText: string;
imagePaths: string[]; imagePaths: string[];
} { } {
const codeSpans = codeSyntaxSpans(text);
const imagePattern = cloneImageTagPattern(); const imagePattern = cloneImageTagPattern();
const imagePaths = [...text.matchAll(imagePattern)].map((match) => const imagePaths: string[] = [];
match[1].trim(), const cleanText = text.replace(
imagePattern,
(full: string, rawPath: string, offset: number) => {
if (isInsideSpans(offset, codeSpans)) return full;
imagePaths.push(rawPath.trim());
return '';
},
); );
return { return {
cleanText: text.replace(cloneImageTagPattern(), '').trim(), cleanText: cleanText.trim(),
imagePaths, imagePaths,
}; };
} }
export function expandImagePromptReferences(text: string): string { export function expandImagePromptReferences(text: string): string {
const codeSpans = fencedCodeSpans(text); const codeSpans = codeSyntaxSpans(text);
const withMediaImages = text.replace( const withMediaImages = text.replace(
MEDIA_TAG_RE, MEDIA_TAG_RE,
(full: string, doubleQuoted, singleQuoted, backticked, bare, offset) => { (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( return withMediaImages.replace(
MARKDOWN_IMAGE_ABSOLUTE_LINK_RE, MARKDOWN_IMAGE_ABSOLUTE_LINK_RE,
(full: string, rawPath: string, offset: number) => { (full: string, rawPath: string, offset: number) => {
@@ -137,7 +144,7 @@ function promptReferenceTag(filePath: string): 'Image' | 'File' | null {
} }
export function expandPromptAttachmentReferences(text: string): string { export function expandPromptAttachmentReferences(text: string): string {
const codeSpans = fencedCodeSpans(text); const codeSpans = codeSyntaxSpans(text);
const withMediaAttachments = text.replace( const withMediaAttachments = text.replace(
MEDIA_TAG_RE, MEDIA_TAG_RE,
(full: string, doubleQuoted, singleQuoted, backticked, bare, offset) => { (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( return withMediaAttachments.replace(
MARKDOWN_IMAGE_ABSOLUTE_LINK_RE, MARKDOWN_IMAGE_ABSOLUTE_LINK_RE,
(full: string, rawPath: string, offset: number) => { (full: string, rawPath: string, offset: number) => {
@@ -183,11 +190,13 @@ export type PromptAttachmentPart =
export function splitImageTagPromptParts(text: string): ImageTagPromptPart[] { export function splitImageTagPromptParts(text: string): ImageTagPromptPart[] {
const imagePattern = cloneImageTagSegmentPattern(); const imagePattern = cloneImageTagSegmentPattern();
const codeSpans = codeSyntaxSpans(text);
const parts: ImageTagPromptPart[] = []; const parts: ImageTagPromptPart[] = [];
let cursor = 0; let cursor = 0;
for (const match of text.matchAll(imagePattern)) { for (const match of text.matchAll(imagePattern)) {
const start = match.index ?? 0; const start = match.index ?? 0;
if (isInsideSpans(start, codeSpans)) continue;
if (start > cursor) { if (start > cursor) {
parts.push({ type: 'text', text: text.slice(cursor, start) }); 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.source,
PROMPT_ATTACHMENT_TAG_SEGMENT_RE.flags, PROMPT_ATTACHMENT_TAG_SEGMENT_RE.flags,
); );
const codeSpans = codeSyntaxSpans(text);
const parts: PromptAttachmentPart[] = []; const parts: PromptAttachmentPart[] = [];
let cursor = 0; let cursor = 0;
for (const match of text.matchAll(pattern)) { for (const match of text.matchAll(pattern)) {
const start = match.index ?? 0; const start = match.index ?? 0;
if (isInsideSpans(start, codeSpans)) continue;
if (start > cursor) { if (start > cursor) {
parts.push({ type: 'text', text: text.slice(cursor, start) }); parts.push({ type: 'text', text: text.slice(cursor, start) });
} }
@@ -312,10 +323,12 @@ export function extractMarkdownImageAttachments(text: string): {
cleanText: string; cleanText: string;
attachments: RunnerOutputAttachment[]; attachments: RunnerOutputAttachment[];
} { } {
const codeSpans = codeSyntaxSpans(text);
const attachments: RunnerOutputAttachment[] = []; const attachments: RunnerOutputAttachment[] = [];
const cleanText = text.replace( const cleanText = text.replace(
MARKDOWN_IMAGE_ABSOLUTE_LINK_RE, 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(); const trimmed = rawPath.trim();
if (!IMAGE_EXT_RE.test(trimmed)) return full; 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 { function isInsideSpans(index: number, spans: Array<[number, number]>): boolean {
return spans.some(([start, end]) => index >= start && index < end); return spans.some(([start, end]) => index >= start && index < end);
} }
@@ -372,7 +404,7 @@ export function extractMediaAttachments(text: string): {
cleanText: string; cleanText: string;
attachments: RunnerOutputAttachment[]; attachments: RunnerOutputAttachment[];
} { } {
const codeSpans = fencedCodeSpans(text); const codeSpans = codeSyntaxSpans(text);
const attachments: RunnerOutputAttachment[] = []; const attachments: RunnerOutputAttachment[] = [];
const cleanText = text.replace( const cleanText = text.replace(
MEDIA_TAG_RE, MEDIA_TAG_RE,

View File

@@ -8,6 +8,7 @@ import {
imageTagCaption, imageTagCaption,
missingAttachmentCaption, missingAttachmentCaption,
missingImageTagCaption, missingImageTagCaption,
normalizeAgentOutput,
normalizeEjclawStructuredOutput, normalizeEjclawStructuredOutput,
normalizePublicTextOutput, normalizePublicTextOutput,
splitPromptAttachmentParts, 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', () => { it('splits labeled image tags for multimodal prompt builders', () => {
expect( expect(
splitImageTagPromptParts( 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', () => { it('formats image evidence captions and missing-image warnings', () => {
const part = { const part = {
type: 'image' as const, type: 'image' as const,
@@ -77,7 +104,8 @@ describe('shared agent protocol helpers', () => {
}); });
it('keeps non-image media and fenced media text unchanged', () => { 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); 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', () => { it('formats document evidence captions and missing-document warnings', () => {
const part = { const part = {
type: 'attachment' as const, type: 'attachment' as const,
@@ -132,6 +177,19 @@ describe('shared agent protocol helpers', () => {
'[Document unavailable: report.pdf → /tmp/report.pdf — file not found]', '[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', () => { describe('shared agent output normalization', () => {