mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
fix(server): keep provider images separate from adjacent prose
Generated image Markdown was concatenated into the preceding paragraph and rendered at inline size. Preserve paragraph boundaries around image output while retaining intentional inline image behavior.
This commit is contained in:
@@ -140,11 +140,11 @@ function imageMessages(items: AgentTimelineItem[]): string[] {
|
||||
return items
|
||||
.filter((item) => item.type === "assistant_message")
|
||||
.map((item) => (item as { text: string }).text)
|
||||
.filter((text) => text.startsWith("!["));
|
||||
.filter((text) => text.trimStart().startsWith("!["));
|
||||
}
|
||||
|
||||
function markdownImageSource(markdown: string): string {
|
||||
const match = markdown.match(/^!\[[^\]]*]\((.*)\)$/);
|
||||
const match = markdown.trim().match(/^!\[[^\]]*]\((.*)\)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected markdown image, got: ${markdown}`);
|
||||
}
|
||||
|
||||
@@ -502,7 +502,7 @@ function asInternals(session: CodexTestSession): CodexSessionTestAccess {
|
||||
}
|
||||
|
||||
function markdownImageSource(markdown: string): string {
|
||||
const match = markdown.match(/^!\[[^\]]*]\((.*)\)$/);
|
||||
const match = markdown.trim().match(/^!\[[^\]]*]\((.*)\)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected markdown image, got: ${markdown}`);
|
||||
}
|
||||
@@ -6000,7 +6000,7 @@ describe("Codex app-server provider", () => {
|
||||
turnId: "test-turn",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "",
|
||||
text: "\n\n\n\n",
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -6032,7 +6032,7 @@ describe("Codex app-server provider", () => {
|
||||
turnId: "test-turn",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: ``,
|
||||
text: `\n\n\n\n`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -92,7 +92,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp
|
||||
},
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: expect.stringMatching(/^!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)$/),
|
||||
text: expect.stringMatching(/^\n\n!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)\n\n$/),
|
||||
},
|
||||
]);
|
||||
expect(timeline.messages([screenshot])).toEqual([]);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { projectTimelineRows } from "../timeline-projection.js";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -44,7 +46,7 @@ describe("isProviderImageMarkdown", () => {
|
||||
);
|
||||
|
||||
expect(markdown).toBe(
|
||||
``,
|
||||
`\n\n\n\n`,
|
||||
);
|
||||
expect(isProviderImageMarkdown(markdown)).toBe(true);
|
||||
});
|
||||
@@ -53,20 +55,20 @@ describe("isProviderImageMarkdown", () => {
|
||||
const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png");
|
||||
|
||||
expect(markdown).toBe(
|
||||
"",
|
||||
"\n\n\n\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("encodes URI-significant characters in POSIX file paths", () => {
|
||||
const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png");
|
||||
|
||||
expect(markdown).toBe("");
|
||||
expect(markdown).toBe("\n\n\n\n");
|
||||
});
|
||||
|
||||
test("preserves double-leading slashes in POSIX file paths", () => {
|
||||
const markdown = renderImageMarkdown("//tmp/screenshot#1.png");
|
||||
|
||||
expect(markdown).toBe("");
|
||||
expect(markdown).toBe("\n\n\n\n");
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -77,7 +79,7 @@ describe("isProviderImageMarkdown", () => {
|
||||
"file://server/share/shot%3Fdraft.png",
|
||||
],
|
||||
])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => {
|
||||
expect(renderImageMarkdown(imagePath)).toBe(``);
|
||||
expect(renderImageMarkdown(imagePath)).toBe(`\n\n\n\n`);
|
||||
});
|
||||
|
||||
test("rejects user-authored markdown that is not a materialized attachment", () => {
|
||||
@@ -113,3 +115,52 @@ describe("materializeProviderImage", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider image paragraph boundaries", () => {
|
||||
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", (ending) => {
|
||||
const image = renderProviderImageOutputAsAssistantMarkdown({
|
||||
url: "https://example.com/chart.png",
|
||||
});
|
||||
if (!image) throw new Error("Expected image output");
|
||||
const projected = projectTimelineRows({
|
||||
mode: "projected",
|
||||
rows: [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-10-16T15:08:23Z",
|
||||
item: { type: "assistant_message", text: `Before${ending}`, messageId: "commentary" },
|
||||
},
|
||||
{ seq: 2, timestamp: "2026-10-16T15:08:24Z", item: image },
|
||||
{
|
||||
seq: 3,
|
||||
timestamp: "2026-10-16T15:08:25Z",
|
||||
item: { type: "assistant_message", text: "After" },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(projected).toHaveLength(1);
|
||||
const item = projected[0]!.item;
|
||||
if (item.type !== "assistant_message") throw new Error("Expected assistant message");
|
||||
const paragraphs = new MarkdownIt()
|
||||
.parse(item.text, {})
|
||||
.filter((token) => token.type === "inline");
|
||||
expect(paragraphs.map((token) => token.content)).toEqual([
|
||||
"Before",
|
||||
"",
|
||||
"After",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("materialized image output retains paragraph boundaries and attachment recognition", () => {
|
||||
const item = renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data: "YWJjMTIz", mimeType: "image/png" },
|
||||
{ materialize: () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
|
||||
);
|
||||
expect(item).toEqual({
|
||||
type: "assistant_message",
|
||||
text: `\n\n\n\n`,
|
||||
});
|
||||
if (!item || item.type !== "assistant_message") throw new Error("Expected image output");
|
||||
expect(isProviderImageMarkdown(item.text)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -108,7 +108,7 @@ const PROVIDER_IMAGE_MARKDOWN = new RegExp(
|
||||
);
|
||||
|
||||
export function isProviderImageMarkdown(text: string): boolean {
|
||||
return PROVIDER_IMAGE_MARKDOWN.test(text);
|
||||
return PROVIDER_IMAGE_MARKDOWN.test(text.trimStart());
|
||||
}
|
||||
|
||||
interface RenderProviderImageOutputOptions {
|
||||
@@ -178,7 +178,8 @@ export function renderProviderImageOutputAsAssistantMarkdown(
|
||||
const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image");
|
||||
return {
|
||||
type: "assistant_message",
|
||||
text: `})`,
|
||||
// Image outputs remain standalone paragraphs when adjacent assistant chunks merge.
|
||||
text: `\n\n})\n\n`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,6 +209,6 @@ export function renderProviderImageOutputAsAssistantMarkdown(
|
||||
const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image");
|
||||
return {
|
||||
type: "assistant_message",
|
||||
text: `})`,
|
||||
text: `\n\n})\n\n`,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user