From 724202d38759748b245d3bea67d0800fb7a95dd2 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 1 Sep 2026 09:09:31 +0200 Subject: [PATCH 1/2] test: support Muse 1.0 session frames --- src/tests/continuity.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tests/continuity.test.ts b/src/tests/continuity.test.ts index beac63f..bdac14b 100644 --- a/src/tests/continuity.test.ts +++ b/src/tests/continuity.test.ts @@ -60,7 +60,11 @@ describe.skipIf(!museAvailable())("multi-turn continuity (live echo provider)", expect(dirs[0].endsWith(sessionId)).toBe(true); const log = readFileSync(join(dirs[0], "session.jsonl"), "utf8").trim().split("\n"); - const sequences = log.map((line) => JSON.parse(line).sequence as number); + const sequences = log.flatMap((line) => { + const sequence = (JSON.parse(line) as { sequence?: unknown }).sequence; + return typeof sequence === "number" ? [sequence] : []; + }); + expect(sequences.length).toBeGreaterThan(1); for (let i = 1; i < sequences.length; i++) { expect(sequences[i]).toBeGreaterThan(sequences[i - 1]); } From 43a5a392621bc789f3b4a0a36fa0f929c4adcb0c Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 1 Sep 2026 09:28:28 +0200 Subject: [PATCH 2/2] feat: support ACP prompt content in Muse --- README.md | 15 +- src/acp-agent.ts | 47 ++-- src/muse-exec.ts | 5 + src/prompt-content.ts | 117 +++++++++ src/tests/fixtures/cat-image.ts | 3 + src/tests/fixtures/fake-muse.cjs | 19 ++ src/tests/prompt-content.test.ts | 266 ++++++++++++++++++++ src/tests/real-provider.integration.test.ts | 35 +++ 8 files changed, 477 insertions(+), 30 deletions(-) create mode 100644 src/prompt-content.ts create mode 100644 src/tests/fixtures/cat-image.ts create mode 100644 src/tests/prompt-content.test.ts diff --git a/README.md b/README.md index 93ffccc..5c8bef2 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,11 @@ for the current settings shape.) ## How it works -Muse Code has no SDK or server mode; its headless surface is -`muse exec --json` — one process per prompt turn streaming JSONL events, with -conversation continuity through `--session-id` and muse's replay-exact session -log. This adapter translates that stream into ACP session updates: +Muse Code exposes Muse Session Protocol rather than ACP. This adapter uses its +headless `muse exec --json` surface: one process per prompt turn streaming JSONL +events, with conversation continuity through `--session-id` and Muse's +replay-exact session log. The adapter translates that stream into ACP session +updates: | Muse | ACP | | ----------------------------- | ---------------------------------------------------------------------------- | @@ -53,6 +54,9 @@ log. This adapter translates that stream into ACP session updates: | Surface | Status | | ------------------------------------------------------------ | -------------------------------------------------- | | Prompt turns with streamed text | ✅ | +| Resource links | ✅ (rendered as deterministic prompt context) | +| PNG, JPEG, GIF, and WebP prompt images | ✅ (private turn-scoped files) | +| Audio and embedded resource blocks | ❌ (rejected with an actionable ACP error) | | Tool calls with results, diffs, locations | ✅ (title upgraded at result time) | | Cancellation (`session/cancel` → SIGINT, safe resume) | ✅ | | Multi-turn sessions, `session/list`, `session/load` | ✅ | @@ -84,6 +88,9 @@ log. This adapter translates that stream into ACP session updates: - **Mode/config changes apply from the next prompt** (flags are per-spawn). - **Exit code 0 means the turn completed,** not that your tests pass. - **Per-turn spawn latency**: each prompt starts a fresh `muse exec`. +- **Images need accompanying text or a resource link.** Muse rejects an + image-only headless prompt, so the adapter reports that requirement before + starting the process. - ACP-client-provided stdio MCP servers are merged with Muse's user-configured servers in a private per-turn settings overlay. The user's settings file is never modified, and the overlay is removed after the turn. HTTP, SSE, and diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 6859b4e..1616fa4 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -49,6 +49,7 @@ import { Logger } from "./logger.js"; import { guardContext, isModeAvailable, MODES, modeState, MuseModeId } from "./modes.js"; import { MuseExecHandle, spawnMuseExec } from "./muse-exec.js"; import { createMuseMcpOverlay, MuseMcpOverlay } from "./mcp-overlay.js"; +import { compileMusePrompt } from "./prompt-content.js"; import { readMuseSettings } from "./muse-settings.js"; import { exportToUpdates, runMuseExport } from "./session-export.js"; import { listStoredSessions } from "./session-store.js"; @@ -88,6 +89,8 @@ export interface SessionState { museSessionId: string; /** Live `muse exec` child while a prompt turn is running. */ activeTurn: MuseExecHandle | null; + /** Reserves the session from prompt compilation through final cleanup. */ + turnInProgress: boolean; /** Set by `session/cancel`; forces the turn to settle with `cancelled`. */ cancelRequested: boolean; /** Active ACP session mode; decides the safety flags of the next spawn. */ @@ -126,7 +129,7 @@ export class MuseAcpAgent { // Only advertise what is actually implemented; capabilities grow with // the milestones that ship them. agentCapabilities: { - promptCapabilities: {}, + promptCapabilities: { image: true }, mcpCapabilities: {}, loadSession: true, sessionCapabilities: { list: {} }, @@ -189,6 +192,7 @@ export class MuseAcpAgent { cwd: params.cwd, museSessionId: sessionId, activeTurn: null, + turnInProgress: false, cancelRequested: false, modeId: "default", config, @@ -255,6 +259,7 @@ export class MuseAcpAgent { cwd: params.cwd, museSessionId: params.sessionId, activeTurn: null, + turnInProgress: false, cancelRequested: false, modeId: "default", config, @@ -299,27 +304,30 @@ export class MuseAcpAgent { async prompt(params: PromptRequest): Promise { const session = this.requireSession(params.sessionId); - if (session.activeTurn) { + if (session.turnInProgress) { throw RequestError.invalidRequest( undefined, `session ${params.sessionId} already has a prompt turn in flight`, ); } - const promptText = promptToText(params.prompt); - if (promptText.length === 0) { - throw RequestError.invalidParams(undefined, "prompt contains no text content"); - } - + session.turnInProgress = true; session.cancelRequested = false; const baseEnv = this.options.env ?? process.env; - const mcpOverlay = - session.mcpServers.length > 0 ? createMuseMcpOverlay(session.mcpServers, baseEnv) : null; - session.activeMcpOverlay = mcpOverlay; + let compiledPrompt: Awaited> | undefined; + let mcpOverlay: MuseMcpOverlay | null = null; try { + compiledPrompt = await compileMusePrompt(params.prompt); + if (session.cancelRequested) { + return { stopReason: "cancelled" }; + } + mcpOverlay = + session.mcpServers.length > 0 ? createMuseMcpOverlay(session.mcpServers, baseEnv) : null; + session.activeMcpOverlay = mcpOverlay; const translator = new TurnTranslator(params.sessionId, this.logger); const handle = spawnMuseExec({ - prompt: promptText, + prompt: compiledPrompt.prompt, + imagePaths: compiledPrompt.imagePaths, sessionId: session.museSessionId, cwd: session.cwd, museBinary: this.options.museBinary, @@ -379,9 +387,11 @@ export class MuseAcpAgent { } finally { session.activeTurn = null; mcpOverlay?.cleanup(); + await compiledPrompt?.cleanup(); if (session.activeMcpOverlay === mcpOverlay) { session.activeMcpOverlay = null; } + session.turnInProgress = false; } } @@ -436,21 +446,6 @@ export class MuseAcpAgent { } } -/** - * m1 supports text prompts only. Text blocks are joined; other block types - * (images, resources) arrive in later milestones and are ignored with a log - * so the turn still runs. - */ -function promptToText(blocks: PromptRequest["prompt"]): string { - const parts: string[] = []; - for (const block of blocks) { - if (block.type === "text") { - parts.push(block.text); - } - } - return parts.join("\n\n").trim(); -} - /** * Builds the ACP agent app and connects it to `target` (a transport stream in * production, a `ClientApp` for in-process tests). The handlers close over diff --git a/src/muse-exec.ts b/src/muse-exec.ts index 925420f..963abee 100644 --- a/src/muse-exec.ts +++ b/src/muse-exec.ts @@ -16,6 +16,8 @@ export interface MuseExecOptions { provider?: "meta" | "echo"; model?: string; reasoningEffort?: string; + /** Turn-scoped images forwarded through Muse's repeatable `--image` flag. */ + imagePaths?: string[]; /** Extra CLI flags appended verbatim (e.g. `--echo-delay-ms` in tests). */ extraArgs?: string[]; env?: Record; @@ -59,6 +61,9 @@ export function spawnMuseExec(options: MuseExecOptions): MuseExecHandle { if (options.reasoningEffort) { args.push("--reasoning-effort", options.reasoningEffort); } + for (const imagePath of options.imagePaths ?? []) { + args.push("--image", imagePath); + } // Note: no `--no-session-log` ever — muse rejects it alongside // `--session-id` ("a session id needs retained logging"), and session // continuity/resume depend on the retained log. Tests isolate the store via diff --git a/src/prompt-content.ts b/src/prompt-content.ts new file mode 100644 index 0000000..b7d9a1e --- /dev/null +++ b/src/prompt-content.ts @@ -0,0 +1,117 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RequestError, type ContentBlock, type ResourceLink } from "@agentclientprotocol/sdk"; + +const IMAGE_EXTENSIONS = new Map([ + ["image/gif", "gif"], + ["image/jpeg", "jpg"], + ["image/png", "png"], + ["image/webp", "webp"], +]); + +const SUPPORTED_IMAGE_TYPES = [...IMAGE_EXTENSIONS.keys()].join(", "); + +export type CompiledMusePrompt = { + prompt: string; + imagePaths: string[]; + cleanup(): Promise; +}; + +function unsupportedContent(type: string, detail?: string): RequestError { + const suffix = detail ? ` (${detail})` : ""; + return RequestError.invalidParams( + undefined, + `unsupported ACP prompt content: ${type}${suffix}. ` + + "Muse Code accepts text, resource links, and PNG/JPEG/GIF/WebP images; " + + "send embedded resources as resource_link blocks instead.", + ); +} + +function decodeImage(data: string): Buffer { + const normalized = data.replace(/\s/gu, ""); + if (!normalized || normalized.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(normalized)) { + throw unsupportedContent("image", "invalid base64 data"); + } + const decoded = Buffer.from(normalized, "base64"); + const canonical = normalized.replace(/=+$/u, ""); + if (!decoded.length || decoded.toString("base64").replace(/=+$/u, "") !== canonical) { + throw unsupportedContent("image", "invalid base64 data"); + } + return decoded; +} + +function renderResourceLink(link: ResourceLink): string { + const fields = [ + `name=${JSON.stringify(link.name)}`, + `uri=${JSON.stringify(link.uri)}`, + ...(link.mimeType ? [`mimeType=${JSON.stringify(link.mimeType)}`] : []), + ...(link.size == null ? [] : [`size=${String(link.size)}`]), + ...(link.title ? [`title=${JSON.stringify(link.title)}`] : []), + ...(link.description ? [`description=${JSON.stringify(link.description)}`] : []), + ]; + return `Resource link: ${fields.join("; ")}`; +} + +export async function compileMusePrompt(blocks: ContentBlock[]): Promise { + const promptParts: string[] = []; + const images: Array<{ bytes: Buffer; extension: string }> = []; + + for (const block of blocks) { + switch (block.type) { + case "text": + promptParts.push(block.text); + break; + case "resource_link": + promptParts.push(renderResourceLink(block)); + break; + case "image": { + const mimeType = block.mimeType.trim().toLowerCase(); + const extension = IMAGE_EXTENSIONS.get(mimeType); + if (!extension) { + throw unsupportedContent("image", `supported MIME types: ${SUPPORTED_IMAGE_TYPES}`); + } + images.push({ bytes: decodeImage(block.data), extension }); + break; + } + case "audio": + throw unsupportedContent("audio"); + case "resource": + throw unsupportedContent("embedded resource"); + default: + throw unsupportedContent("unknown"); + } + } + + const prompt = promptParts.join("\n\n").trim(); + if (!prompt) { + throw RequestError.invalidParams( + undefined, + images.length > 0 + ? "Muse Code requires text or a resource link alongside image content" + : "prompt contains no text or resource link content", + ); + } + if (images.length === 0) { + return { prompt, imagePaths: [], cleanup: async () => {} }; + } + + const directory = await mkdtemp(join(tmpdir(), "muse-code-acp-images-")); + await chmod(directory, 0o700); + try { + const imagePaths: string[] = []; + for (const [index, image] of images.entries()) { + const imagePath = join(directory, `image-${String(index + 1)}.${image.extension}`); + await writeFile(imagePath, image.bytes, { mode: 0o600 }); + imagePaths.push(imagePath); + } + return { + prompt, + imagePaths, + cleanup: async () => await rm(directory, { recursive: true, force: true }), + }; + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } +} diff --git a/src/tests/fixtures/cat-image.ts b/src/tests/fixtures/cat-image.ts new file mode 100644 index 0000000..cc4a878 --- /dev/null +++ b/src/tests/fixtures/cat-image.ts @@ -0,0 +1,3 @@ +/** PNG containing the black word CAT on a white background. */ +export const CAT_IMAGE_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAIAAABhdOiYAAAACXBIWXMAAAABAAAAAQBPJcTWAAAC8ElEQVR4nO2YMUhqURjHlUDMFMSlEDQ3EQIhpKYgwUVxMBoCh9xsaCjEFmsKgohocFMi3FwincUgHB2KBGsMESGQFAchI/F9vODjvnu17xo+3jvy/SY75/zvPefH6bv3XM2A+RbNv57A/w4LImBBBCyIgAURsCACFkTAgghYEAELImBBBCyIgAURsCCCvyKo2WxeXFwEg8HFxUWj0TgzM2OxWFZXV/f39+/u7kaldnZ2NH/y9PSEvS8vL5oxmchaJizo8/Pz6Ohodnb2m3kfHBwogx8fHyBRNjKRSOCAaRD0/v7u9XrJee/u7iqzuVxOOdJut+OAaRC0tbUlW97l5WW9Xu/1eq+vr6AgFAqNErS5uTl0kaVSadTtbm5upCMfHh4muBZkYoKKxaJ0um63++3tTTkMFnx2diZr7HQ6Op0Os/Pz8/g7Go2OuqNggvx+P85Vq9VWq1X1WdhomDWZTKlUCv80m82wAYemRBIE1Uev1+NcfT7fWPH19XXMhsNh2Hrw4MMWEDE0JZKgSqUinevx8bH6LBQp2HGYvb6+hkZpsd/Y2BgaFEnQ7e2tdK7pdFp99vT0FIOwDbvdLjQmk0lshPLUbreVQYEFQU1Rn11aWsIgPOa+GmFbSS8IVUkZFEnQj//FHh8fpcFMJoNdHo8H29fW1pRZkQT9uEjH43FMQWFutVrYdXJyIl1/rVaTZUUSNFA85p+fn8lIv9+3Wq0adYAvWVwwQbIXxeXl5aGVVfqiKIt8j8vlkl1KMEEDxVHD4XBcXV01Gg04iMJRI5/Py44akUhEvSDg/v5eejvxBI11WIXB8NIsa5QBj/y5uTkcE4vFpL3iCRr8/txxeHio5nNHNpuVNpbL5aEX3N7exjELCwtQtrBLSEFfNJvN8/PzQCBgs9kMBsPXB7OVlZW9vT38YBYMBnFtTqdz1KVkdapQKGCXwIKmCRZEwIIIWBABCyJgQQQsiIAFEbAgAhZEwIIIWBABCyJgQQQsiOAXJ2r353yEvnMAAAAASUVORK5CYII="; diff --git a/src/tests/fixtures/fake-muse.cjs b/src/tests/fixtures/fake-muse.cjs index efd8ea2..a3fdd10 100755 --- a/src/tests/fixtures/fake-muse.cjs +++ b/src/tests/fixtures/fake-muse.cjs @@ -46,6 +46,25 @@ if (process.argv.includes("exec") && process.env.FAKE_MUSE_SETTINGS_CAPTURE) { ); } +if (process.argv.includes("exec") && process.env.FAKE_MUSE_ARGV_CAPTURE) { + const fs = require("node:fs"); + const imagePaths = process.argv.flatMap((arg, index, argv) => + arg === "--image" && argv[index + 1] ? [argv[index + 1]] : [], + ); + fs.writeFileSync( + process.env.FAKE_MUSE_ARGV_CAPTURE, + JSON.stringify({ + argv: process.argv.slice(2), + directoryMode: imagePaths[0] ? fs.statSync(require("node:path").dirname(imagePaths[0])).mode & 0o777 : null, + images: imagePaths.map((imagePath) => ({ + path: imagePath, + data: fs.readFileSync(imagePath).toString("base64"), + mode: fs.statSync(imagePath).mode & 0o777, + })), + }), + ); +} + const mode = process.env.FAKE_MUSE_MODE ?? "block"; if (mode === "exit0") { process.exit(0); diff --git a/src/tests/prompt-content.test.ts b/src/tests/prompt-content.test.ts new file mode 100644 index 0000000..e770668 --- /dev/null +++ b/src/tests/prompt-content.test.ts @@ -0,0 +1,266 @@ +import { methods, PROTOCOL_VERSION, type ContentBlock } from "@agentclientprotocol/sdk"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { capturingLogger, connectTestClient, fakeMuseBinary, newTestSession } from "./helpers.js"; + +type MuseCapture = { + argv: string[]; + directoryMode: number | null; + images: Array<{ path: string; data: string; mode: number }>; +}; + +function capturePath(): string { + return join(mkdtempSync(join(tmpdir(), "muse-content-capture-")), "capture.json"); +} + +function fakeClient(capture: string, mode = "exit0") { + return connectTestClient({ + museBinary: fakeMuseBinary(), + env: { + ...process.env, + FAKE_MUSE_ARGV_CAPTURE: capture, + FAKE_MUSE_MODE: mode, + }, + }); +} + +function readCapture(path: string): MuseCapture { + return JSON.parse(readFileSync(path, "utf8")) as MuseCapture; +} + +describe("ACP prompt content", () => { + it("advertises image support without claiming audio or embedded resources", async () => { + const testClient = fakeClient(capturePath()); + const ctx = await testClient.connect(); + + const response = await ctx.request(methods.agent.initialize, { + protocolVersion: PROTOCOL_VERSION, + }); + + expect(response.agentCapabilities?.promptCapabilities).toEqual({ image: true }); + }); + + it("renders baseline resource links in a deterministic single line", async () => { + const capture = capturePath(); + const testClient = fakeClient(capture); + const { ctx, sessionId } = await newTestSession(testClient); + + await ctx.request(methods.agent.session.prompt, { + sessionId, + prompt: [ + { type: "text", text: "Review this input." }, + { + type: "resource_link", + name: 'config "prod"', + uri: "file:///workspace/config.json", + mimeType: "application/json", + size: 1284, + title: "Production config", + description: "Current settings;\nuri=spoof", + }, + ], + }); + + expect(readCapture(capture).argv.at(-1)).toBe( + 'Review this input.\n\nResource link: name="config \\"prod\\""; ' + + 'uri="file:///workspace/config.json"; mimeType="application/json"; size=1284; ' + + 'title="Production config"; description="Current settings;\\nuri=spoof"', + ); + }); + + it("omits absent resource-link metadata without placeholders", async () => { + const capture = capturePath(); + const testClient = fakeClient(capture); + const { ctx, sessionId } = await newTestSession(testClient); + + await ctx.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: "resource_link", name: "notes", uri: "file:///workspace/notes.txt" }], + }); + + expect(readCapture(capture).argv.at(-1)).toBe( + 'Resource link: name="notes"; uri="file:///workspace/notes.txt"', + ); + }); + + it("stages supported images privately and removes them after the turn", async () => { + const capture = capturePath(); + const testClient = fakeClient(capture); + const { ctx, sessionId } = await newTestSession(testClient); + const data = Buffer.from("image bytes").toString("base64"); + + await ctx.request(methods.agent.session.prompt, { + sessionId, + prompt: [ + { type: "image", data, mimeType: "image/png" }, + { type: "image", data, mimeType: "image/jpeg" }, + { type: "image", data, mimeType: "image/gif" }, + { type: "image", data, mimeType: "image/webp" }, + { type: "text", text: "Describe the images." }, + ], + }); + + const result = readCapture(capture); + expect(result.directoryMode).toBe(0o700); + expect(result.images.map((image) => image.mode)).toEqual([0o600, 0o600, 0o600, 0o600]); + expect(result.images.map((image) => image.data)).toEqual([data, data, data, data]); + expect(result.images.map((image) => image.path.slice(image.path.lastIndexOf(".")))).toEqual([ + ".png", + ".jpg", + ".gif", + ".webp", + ]); + expect(result.images.every((image) => !existsSync(image.path))).toBe(true); + expect(result.argv.at(-1)).toBe("Describe the images."); + }); + + it("reserves the session while image files are being staged", async () => { + const testClient = fakeClient(capturePath()); + const { sessionId } = await newTestSession(testClient); + const first = testClient.agent.prompt({ + sessionId, + prompt: [ + { type: "image", data: "YQ==", mimeType: "image/png" }, + { type: "text", text: "First prompt." }, + ], + }); + + await expect( + testClient.agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "Second prompt." }], + }), + ).rejects.toMatchObject({ + code: -32600, + message: expect.stringMatching(/already has a prompt/), + }); + await expect(first).resolves.toEqual({ stopReason: "end_turn" }); + }); + + it("honors cancellation while image files are being staged", async () => { + const capture = capturePath(); + const testClient = fakeClient(capture); + const { sessionId } = await newTestSession(testClient); + const prompt = testClient.agent.prompt({ + sessionId, + prompt: [ + { type: "image", data: "YQ==", mimeType: "image/png" }, + { type: "text", text: "Cancel before spawn." }, + ], + }); + + await testClient.agent.cancel({ sessionId }); + + await expect(prompt).resolves.toEqual({ stopReason: "cancelled" }); + expect(existsSync(capture)).toBe(false); + }); + + it.each([ + { + name: "audio", + block: { type: "audio", data: "YQ==", mimeType: "audio/wav" } satisfies ContentBlock, + message: /unsupported ACP prompt content: audio/, + }, + { + name: "embedded resource", + block: { + type: "resource", + resource: { uri: "file:///context.txt", text: "context" }, + } satisfies ContentBlock, + message: /send embedded resources as resource_link blocks/, + }, + { + name: "unsupported image MIME", + block: { type: "image", data: "YQ==", mimeType: "image/bmp" } satisfies ContentBlock, + message: /supported MIME types/, + }, + { + name: "invalid image base64", + block: { type: "image", data: "not base64!", mimeType: "image/png" } satisfies ContentBlock, + message: /invalid base64 data/, + }, + ])("rejects $name before spawning Muse", async ({ block, message }) => { + const lines: string[] = []; + const testClient = connectTestClient( + { museBinary: fakeMuseBinary(), env: { ...process.env, FAKE_MUSE_MODE: "exit0" } }, + capturingLogger(lines), + ); + const { ctx, sessionId } = await newTestSession(testClient); + + await expect( + ctx.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: "text", text: "Use this." }, block], + }), + ).rejects.toMatchObject({ code: -32602, message }); + expect(lines.some((line) => line.includes("muse-exec spawn"))).toBe(false); + }); + + it("rejects image-only prompts with the native Muse requirement", async () => { + const testClient = fakeClient(capturePath()); + const { ctx, sessionId } = await newTestSession(testClient); + + await expect( + ctx.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: "image", data: "YQ==", mimeType: "image/png" }], + }), + ).rejects.toMatchObject({ + code: -32602, + message: expect.stringMatching(/requires text or a resource link alongside image content/), + }); + }); + + it("removes staged images when the Muse child fails", async () => { + const capture = capturePath(); + const testClient = fakeClient(capture, "exit1"); + const { ctx, sessionId } = await newTestSession(testClient); + + await expect( + ctx.request(methods.agent.session.prompt, { + sessionId, + prompt: [ + { type: "image", data: "YQ==", mimeType: "image/png" }, + { type: "text", text: "Fail after staging." }, + ], + }), + ).rejects.toMatchObject({ code: -32603 }); + + const [image] = readCapture(capture).images; + if (!image) { + throw new Error("fake Muse did not capture the staged image"); + } + expect(existsSync(dirname(image.path))).toBe(false); + }); + + it("removes staged images when the Muse process cannot spawn", async () => { + const lines: string[] = []; + const testClient = connectTestClient( + { + museBinary: join(tmpdir(), "missing-muse-binary"), + env: { ...process.env }, + }, + capturingLogger(lines), + ); + const { ctx, sessionId } = await newTestSession(testClient); + + await expect( + ctx.request(methods.agent.session.prompt, { + sessionId, + prompt: [ + { type: "image", data: "YQ==", mimeType: "image/png" }, + { type: "text", text: "Fail before starting." }, + ], + }), + ).rejects.toMatchObject({ code: -32603 }); + + const spawnLine = lines.find((line) => line.includes("muse-exec spawn")); + const imagePath = spawnLine?.match(/--image (\S+)/u)?.[1]; + if (!imagePath) { + throw new Error("spawn diagnostics did not include the staged image path"); + } + expect(existsSync(dirname(imagePath))).toBe(false); + }); +}); diff --git a/src/tests/real-provider.integration.test.ts b/src/tests/real-provider.integration.test.ts index 5ee97bc..da89bc7 100644 --- a/src/tests/real-provider.integration.test.ts +++ b/src/tests/real-provider.integration.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { connectTestClient, museAvailable } from "./helpers.js"; import { initialized } from "./helpers.js"; +import { CAT_IMAGE_BASE64 } from "./fixtures/cat-image.js"; /** * The one real-model run in the suite (m2 definition of done). Gated behind @@ -50,4 +51,38 @@ describe.skipIf(!enabled)("real provider integration", () => { expect(JSON.stringify(toolUpdates)).toContain("marker.txt"); expect(JSON.stringify(toolUpdates)).toContain("beacon"); }, 300_000); + + it("forwards an ACP image through Muse's native vision path", async () => { + const xdg = mkdtempSync(join(tmpdir(), "muse-image-integration-xdg-")); + const testClient = connectTestClient({ + env: { ...process.env, XDG_DATA_HOME: xdg }, + }); + const ctx = await initialized(testClient); + const cwd = mkdtempSync(join(tmpdir(), "muse-image-integration-cwd-")); + const { sessionId } = await ctx.request(methods.agent.session.new, { cwd, mcpServers: [] }); + await ctx.request(methods.agent.session.setConfigOption, { + sessionId, + configId: "reasoningEffort", + value: "low", + }); + + const response = await ctx.request(methods.agent.session.prompt, { + sessionId, + prompt: [ + { type: "image", data: CAT_IMAGE_BASE64, mimeType: "image/png" }, + { + type: "text", + text: "Read the single uppercase word in the attached image. Reply with only that word.", + }, + ], + }); + + expect(response.stopReason).toBe("end_turn"); + const text = testClient.updates + .map((update) => update.update) + .filter((update) => update.sessionUpdate === "agent_message_chunk") + .map((update) => (update.content.type === "text" ? update.content.text : "")) + .join(""); + expect(text.toUpperCase()).toContain("CAT"); + }, 300_000); });