emit() sends content to the user, not back to the model, so a script that only emits returns a null value. The result envelope gave no signal that emit had produced anything, so an emit-without-return looked indistinguishable from a script that did nothing. formatExecuteResult now reports an emitted count in the structured envelope and a "(no return value; N items emitted to the user)" text preview, and the execute tool docs spell out that emit is user-facing while return is what the caller reads back.
Unified · Split
| @@ -0,0 +1,53 @@ | ||
| 1 | +// Cloud: the `execute` sandbox reports emitted output in its result envelope. | |
| 2 | +// | |
| 3 | +// `emit()` sends content to the USER, not back to the model, so a script that | |
| 4 | +// only emits returns a null value. Without a signal, an emit-without-return | |
| 5 | +// looks to the caller like nothing happened at all (the exact confusion this | |
| 6 | +// pins against). The envelope now carries an `emitted` count, and the text | |
| 7 | +// preview says output went to the user instead of a bare "(no result)". | |
| 8 | +import { expect } from "@effect/vitest"; | |
| 9 | +import { Effect } from "effect"; | |
| 10 | + | |
| 11 | +import { scenario } from "../src/scenario"; | |
| 12 | +import { Mcp, Target } from "../src/services"; | |
| 13 | + | |
| 14 | +scenario( | |
| 15 | + "Execute · an emit-only script reports its emitted output in the result envelope", | |
| 16 | + {}, | |
| 17 | + Effect.gen(function* () { | |
| 18 | + const target = yield* Target; | |
| 19 | + const mcp = yield* Mcp; | |
| 20 | + const identity = yield* target.newIdentity(); | |
| 21 | + const session = mcp.session(identity); | |
| 22 | + | |
| 23 | + // A script that emits twice and returns nothing. Pre-change this came back | |
| 24 | + // as `{ result: null }` with no hint the emits landed. | |
| 25 | + const result = yield* session.call("execute", { | |
| 26 | + code: `emit("first line for the user"); emit("second line"); return undefined;`, | |
| 27 | + }); | |
| 28 | + expect(result.ok, `execute completed (got: ${result.text.slice(0, 300)})`).toBe(true); | |
| 29 | + | |
| 30 | + // The envelope (what the model-facing MCP client reads) now reports the | |
| 31 | + // emit count, so an emit-only script is no longer an indistinguishable | |
| 32 | + // `{ result: null }`. | |
| 33 | + const structured = (result.raw as { structuredContent?: Record<string, unknown> }) | |
| 34 | + .structuredContent; | |
| 35 | + expect(structured?.status, "the run completed").toBe("completed"); | |
| 36 | + expect(structured?.result, "an emit-only script has no return value").toBeNull(); | |
| 37 | + expect(structured?.emitted, "the envelope counts the emitted items").toBe(2); | |
| 38 | + | |
| 39 | + // The emitted text itself flows to the user-facing content blocks. | |
| 40 | + expect(result.text, "the emitted lines reach the user-facing content").toContain( | |
| 41 | + "first line for the user", | |
| 42 | + ); | |
| 43 | + | |
| 44 | + // A script that returns a value keeps the plain envelope (no emit noise). | |
| 45 | + const returned = yield* session.call("execute", { code: `return 6 * 7;` }); | |
| 46 | + const returnedStructured = (returned.raw as { structuredContent?: Record<string, unknown> }) | |
| 47 | + .structuredContent; | |
| 48 | + expect(returnedStructured?.result, "the return value comes back to the caller").toBe(42); | |
| 49 | + expect("emitted" in (returnedStructured ?? {}), "no emitted key when nothing was emitted").toBe( | |
| 50 | + false, | |
| 51 | + ); | |
| 52 | + }), | |
| 53 | +); | |
| @@ -115,7 +115,7 @@ const formatDescription = (connectionEntries: readonly ConnectionInventoryEntry[ · 114 unmodified lines | ||
| 115 | 115 | "- `tools.executor.coreTools.connections.list({})` returns saved connections with `{ address, integration, owner, name, ... }`. The `address` field includes the leading `tools.` root.", |
| 116 | 116 | "- Tool calls return a value union: `{ ok: true, data }` for success or `{ ok: false, error: { code, message, status?, details?, retryable? } }` for expected tool/domain failures. Branch on `result.ok`.", |
| 117 | 117 | "- `data` is the upstream payload itself. HTTP-backed tools (OpenAPI) also set `http: { status, headers }` beside `data` — read `result.http?.headers` for pagination (Link) or rate-limit headers.", |
| 118 | − "- Use `emit(value)` to append user-visible output and return `undefined`. Plain values become MCP text content. MCP content blocks are forwarded as-is. `ToolFile` values are rendered by MIME.", | |
| 118 | + "- Use `emit(value)` to append user-visible output and return `undefined`. Plain values become MCP text content. MCP content blocks are forwarded as-is. `ToolFile` values are rendered by MIME. Emitted output goes to the user, not back to you; the result envelope reports an `emitted` count so you can confirm it landed, but to read a value yourself, `return` it.", | |
| 119 | 119 | '- File-returning tools may return `ToolFile` values: `{ _tag: "ToolFile", name?, mimeType, encoding: "base64", data, byteLength }`. Emit any attachment with `emit(result.data)`.', |
| 120 | 120 | '- To emit MCP-native content directly, pass an MCP content block to `emit(...)`, such as `{ type: "image", data, mimeType }`, `{ type: "audio", data, mimeType }`, `{ type: "text", text }`, `{ type: "resource", resource }`, or `{ type: "resource_link", uri, name, ... }`.', |
| 121 | 121 | "- `emit(ToolFile)` is MIME-based: `image/*` becomes MCP image content, `audio/*` becomes MCP audio content, text-like files become decoded text, and other binary files become embedded MCP resources.", |
| @@ -80,22 +80,43 @@ export const formatExecuteResult = ( · 79 unmodified lines | ||
| 80 | 80 | |
| 81 | 81 | const logText = result.logs && result.logs.length > 0 ? result.logs.join("\n") : null; |
| 82 | 82 | |
| 83 | + // `emit()` output is shown to the user, not returned to the model, so a | |
| 84 | + // script that only emits comes back with a null result. Acknowledge the | |
| 85 | + // emitted items in the envelope so an emit-without-return reads as "output | |
| 86 | + // went to the user" rather than a silent void. | |
| 87 | + const emitted = result.output?.length ?? 0; | |
| 88 | + const emittedNote = | |
| 89 | + emitted > 0 ? `${emitted} item${emitted === 1 ? "" : "s"} emitted to the user` : null; | |
| 90 | + const emittedField = emitted > 0 ? { emitted } : {}; | |
| 91 | + | |
| 83 | 92 | if (result.error) { |
| 84 | 93 | const parts = [`Error: ${result.error}`, ...(logText ? [`\nLogs:\n${logText}`] : [])]; |
| 85 | 94 | return { |
| 86 | 95 | text: truncate(parts.join("\n"), MAX_PREVIEW_CHARS), |
| 87 | − structured: { status: "error", error: result.error, logs: result.logs ?? [] }, | |
| 96 | + structured: { | |
| 97 | + status: "error", | |
| 98 | + error: result.error, | |
| 99 | + ...emittedField, | |
| 100 | + logs: result.logs ?? [], | |
| 101 | + }, | |
| 88 | 102 | isError: true, |
| 89 | 103 | }; |
| 90 | 104 | } |
| 91 | 105 | |
| 92 | − const parts = [ | |
| 93 | − ...(resultText ? [truncate(resultText, MAX_PREVIEW_CHARS)] : ["(no result)"]), | |
| 94 | − ...(logText ? [`\nLogs:\n${logText}`] : []), | |
| 95 | − ]; | |
| 106 | + const resultPart = resultText | |
| 107 | + ? truncate(resultText, MAX_PREVIEW_CHARS) | |
| 108 | + : emittedNote | |
| 109 | + ? `(no return value; ${emittedNote})` | |
| 110 | + : "(no result)"; | |
| 111 | + const parts = [resultPart, ...(logText ? [`\nLogs:\n${logText}`] : [])]; | |
| 96 | 112 | return { |
| 97 | 113 | text: parts.join("\n"), |
| 98 | − structured: { status: "completed", result: result.result ?? null, logs: result.logs ?? [] }, | |
| 114 | + structured: { | |
| 115 | + status: "completed", | |
| 116 | + result: result.result ?? null, | |
| 117 | + ...emittedField, | |
| 118 | + logs: result.logs ?? [], | |
| 119 | + }, | |
| 99 | 120 | isError: false, |
| 100 | 121 | }; |
| 101 | 122 | }; |