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 | "- `tools.executor.coreTools.connections.list({})` returns saved connections with `{ address, integration, owner, name, ... }`. The `address` field includes the leading `tools.` root.", | 115 | "- `tools.executor.coreTools.connections.list({})` returns saved connections with `{ address, integration, owner, name, ... }`. The `address` field includes the leading `tools.` root.", |
| 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`.", | 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 | "- `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.", | 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 | '- File-returning tools may return `ToolFile` values: `{ _tag: "ToolFile", name?, mimeType, encoding: "base64", data, byteLength }`. Emit any attachment with `emit(result.data)`.', | 119 | '- File-returning tools may return `ToolFile` values: `{ _tag: "ToolFile", name?, mimeType, encoding: "base64", data, byteLength }`. Emit any attachment with `emit(result.data)`.', |
| 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, ... }`.', | 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 | "- `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.", | 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 | const logText = result.logs && result.logs.length > 0 ? result.logs.join("\n") : null; | 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 | if (result.error) { | 92 | if (result.error) { |
| 84 | const parts = [`Error: ${result.error}`, ...(logText ? [`\nLogs:\n${logText}`] : [])]; | 93 | const parts = [`Error: ${result.error}`, ...(logText ? [`\nLogs:\n${logText}`] : [])]; |
| 85 | return { | 94 | return { |
| 86 | text: truncate(parts.join("\n"), MAX_PREVIEW_CHARS), | 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 | isError: true, | 102 | isError: true, |
| 89 | }; | 103 | }; |
| 90 | } | 104 | } |
| 91 | 105 | ||
| 92 | const parts = [ | 106 | const resultPart = resultText |
| 93 | ...(resultText ? [truncate(resultText, MAX_PREVIEW_CHARS)] : ["(no result)"]), | 107 | ? truncate(resultText, MAX_PREVIEW_CHARS) |
| 94 | ...(logText ? [`\nLogs:\n${logText}`] : []), | 108 | : emittedNote |
| 95 | ]; | 109 | ? `(no return value; ${emittedNote})` |
| 110 | : "(no result)"; | ||
| 111 | const parts = [resultPart, ...(logText ? [`\nLogs:\n${logText}`] : [])]; | ||
| 96 | return { | 112 | return { |
| 97 | text: parts.join("\n"), | 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 | isError: false, | 120 | isError: false, |
| 100 | }; | 121 | }; |
| 101 | }; | 122 | }; |