Unified · Split
| @@ -0,0 +1,94 @@ | |||
| 1 | // Selfhost: emit() output actually reaches the MCP client's content blocks, | ||
| 2 | // not merely the `emitted` count in the envelope. Regression coverage for | ||
| 3 | // issue #1592 — a self-hosted user saw a non-zero `emitted` count but no | ||
| 4 | // content blocks in the tool result, only `logs` and the return value. There | ||
| 5 | // is cloud coverage for the envelope count | ||
| 6 | // (`e2e/cloud/execute-emit-envelope.test.ts`), but nothing that asserts on | ||
| 7 | // the wire-level `content` array on self-host, and nothing on either target | ||
| 8 | // that covers all three emit shapes from the report: a plain string, a | ||
| 9 | // ToolFile, and a structured/object value. | ||
| 10 | import { expect } from "@effect/vitest"; | ||
| 11 | import { Effect } from "effect"; | ||
| 12 | |||
| 13 | import { scenario } from "../src/scenario"; | ||
| 14 | import { Mcp, Target } from "../src/services"; | ||
| 15 | |||
| 16 | interface ContentBlock { | ||
| 17 | readonly type: string; | ||
| 18 | readonly text?: string; | ||
| 19 | } | ||
| 20 | |||
| 21 | // Mirrors the issue's repro exactly: a plain string emit, a ToolFile emit, a | ||
| 22 | // structured/object emit, then a return. | ||
| 23 | const EXECUTE_CODE = ` | ||
| 24 | const attachment = { | ||
| 25 | _tag: "ToolFile", | ||
| 26 | name: "report.txt", | ||
| 27 | mimeType: "text/plain", | ||
| 28 | encoding: "base64", | ||
| 29 | data: "aGVsbG8gZmlsZQ==", | ||
| 30 | byteLength: 10, | ||
| 31 | }; | ||
| 32 | emit("plain string emit"); | ||
| 33 | emit(attachment); | ||
| 34 | emit({ hello: "object emit" }); | ||
| 35 | return { returnedValue: "this is the return" }; | ||
| 36 | `; | ||
| 37 | |||
| 38 | scenario( | ||
| 39 | "Execute · emit() output reaches the MCP client's content blocks", | ||
| 40 | {}, | ||
| 41 | Effect.gen(function* () { | ||
| 42 | const target = yield* Target; | ||
| 43 | const mcp = yield* Mcp; | ||
| 44 | const identity = yield* target.newIdentity(); | ||
| 45 | const session = mcp.session(identity); | ||
| 46 | |||
| 47 | const result = yield* session.call("execute", { code: EXECUTE_CODE }); | ||
| 48 | expect(result.ok, `execute completed (got: ${result.text.slice(0, 300)})`).toBe(true); | ||
| 49 | |||
| 50 | const structured = (result.raw as { structuredContent?: Record<string, unknown> }) | ||
| 51 | .structuredContent; | ||
| 52 | expect(structured?.status, "the run completed").toBe("completed"); | ||
| 53 | expect(structured?.emitted, "the envelope counts all three emitted items").toBe(3); | ||
| 54 | expect(structured?.result, "the return value still comes back to the caller").toEqual({ | ||
| 55 | returnedValue: "this is the return", | ||
| 56 | }); | ||
| 57 | |||
| 58 | // The real assertion: the JSON-RPC tool result's `content` array — what | ||
| 59 | // an MCP client actually renders — carries all three emitted items, not | ||
| 60 | // just the `emitted` count. | ||
| 61 | const content = (result.raw as { content?: ContentBlock[] }).content ?? []; | ||
| 62 | expect(Array.isArray(content) && content.length > 0, "the wire result carries content").toBe( | ||
| 63 | true, | ||
| 64 | ); | ||
| 65 | |||
| 66 | expect( | ||
| 67 | content.some((block) => block.type === "text" && block.text === "plain string emit"), | ||
| 68 | `content carries the plain string emit; got: ${JSON.stringify(content)}`, | ||
| 69 | ).toBe(true); | ||
| 70 | |||
| 71 | // A text-mimetype ToolFile renders as a summary line plus its decoded | ||
| 72 | // text content (see outputFileContent/toolFileContent in tool-server.ts). | ||
| 73 | expect( | ||
| 74 | content.some( | ||
| 75 | (block) => | ||
| 76 | block.type === "text" && | ||
| 77 | typeof block.text === "string" && | ||
| 78 | block.text.includes("report.txt"), | ||
| 79 | ), | ||
| 80 | `content carries the ToolFile emit's summary line; got: ${JSON.stringify(content)}`, | ||
| 81 | ).toBe(true); | ||
| 82 | expect( | ||
| 83 | content.some((block) => block.type === "text" && block.text === "hello file"), | ||
| 84 | `content carries the ToolFile emit's decoded text; got: ${JSON.stringify(content)}`, | ||
| 85 | ).toBe(true); | ||
| 86 | |||
| 87 | // A plain object emit is not a ToolFile or an MCP content block, so it | ||
| 88 | // falls back to a JSON text block. | ||
| 89 | expect( | ||
| 90 | content.some((block) => block.type === "text" && block.text === '{"hello":"object emit"}'), | ||
| 91 | `content carries the structured/object emit; got: ${JSON.stringify(content)}`, | ||
| 92 | ).toBe(true); | ||
| 93 | }), | ||
| 94 | ); | ||