fix: ignore attachment syntax examples (#206)
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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 `` and fenced\n```md\n\n```\nreal ',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalized.output).toEqual({
|
||||||
|
visibility: 'public',
|
||||||
|
text: 'inline `` and fenced\n```md\n\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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user