History

e2e: add self-host coverage for emit() content blocks (#1592)

Rhys Sullivan committed 2 months ago

Commit de81e191 parent ac2dec61 file · +94 −0Browse files

Unified · Split

e2e/selfhost/execute-emit-envelope.test.ts+94−0
@@ -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+);