diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 42e45b8d56..880a4f013a 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -34,14 +34,23 @@ type Metadata = { } /** - * A model-facing attachment: the same shape used for both child tool results and - * the program's final `return`, and identical to a session `FilePart` (minus the - * ids), so it lowers 1:1 into `Tool.ExecuteResult.attachments`. + * A real attachment: identical to a session `FilePart` (minus the ids) and carrying + * the actual bytes (`url`, often a base64 `data:` URL), so it lowers 1:1 into + * `Tool.ExecuteResult.attachments`. This never crosses into the sandbox — the program + * only ever sees the opaque {@link AttachmentHandle}. */ export type Attachment = NonNullable[number] +/** + * The opaque, model-facing view of an attachment: metadata only, no bytes. A program + * can inspect `mime`/`filename`/`bytes`, propagate the handle (return it to show the + * user) or drop it, but can NOT read or leak the contents — so a stray `return`/log + * can never dump a base64 blob back into the conversation. + */ +export type AttachmentHandle = { type: "file"; id: string; mime: string; filename?: string; bytes?: number } + /** The envelope every tool call resolves to, and the shape a program should `return`. */ -export type Envelope = { result: unknown; attachments?: Attachment[] } +export type Envelope = { result: unknown; attachments?: AttachmentHandle[] } const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ const SEARCH = "search" @@ -220,19 +229,38 @@ export function renderType( return "any" } +/** Schema constraints that a TypeScript type can't express natively but a model + * benefits from, surfaced as JSDoc tags (`@default`, `@format`, `@deprecated`, …). */ +function docTags(schema: JSONSchema7 | boolean | undefined): string[] { + if (!schema || typeof schema === "boolean") return [] + // `deprecated` is a later JSON-Schema draft than the `ai` JSONSchema7 type models. + const s = schema as JSONSchema7 & { deprecated?: boolean } + const tags: string[] = [] + if (s.deprecated === true) tags.push("@deprecated") + if (s.default !== undefined) { + try { + tags.push(`@default ${JSON.stringify(s.default)}`) + } catch { + // unserializable default: skip rather than emit a broken tag + } + } + if (typeof s.format === "string") tags.push(`@format ${s.format}`) + if (typeof s.minItems === "number") tags.push(`@minItems ${s.minItems}`) + if (typeof s.maxItems === "number") tags.push(`@maxItems ${s.maxItems}`) + return tags +} + /** - * Format a schema `description` as a JSDoc comment at the given indent, preserving - * multi-line text (a single line stays `/** … *\/`; multiple lines become a `*`-prefixed - * block). `*\/` is neutralized so a description can't close the comment early, and blank - * leading/trailing/edge lines are trimmed. Returns "" (with a trailing newline when - * non-empty) so callers can prepend it directly to the field line. + * Format a schema `description` plus `tags` as a JSDoc comment at the given indent, + * preserving multi-line text (a single line stays `/** … *\/`; multiple lines become a + * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and + * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so + * callers can prepend it directly to the field line. */ -function jsdoc(description: string | undefined, pad: string): string { - if (!description) return "" - const lines = description - .replaceAll("*/", "* /") - .split("\n") - .map((line) => line.replace(/\s+$/, "")) +function jsdoc(description: string | undefined, tags: string[], pad: string): string { + const lines = [...(description ? description.split("\n") : []), ...tags].map((line) => + line.replaceAll("*/", "* /").replace(/\s+$/, ""), + ) while (lines.length > 0 && lines[0]!.trim() === "") lines.shift() while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop() if (lines.length === 0) return "" @@ -265,7 +293,7 @@ function renderObject( } const pad = " ".repeat(depth + 1) - const lines = names.map((name) => `${jsdoc(props[name]?.description, pad)}${pad}${field(name)}`) + const lines = names.map((name) => `${jsdoc(props[name]?.description, docTags(props[name]), pad)}${pad}${field(name)}`) if (indexType) lines.push(`${pad}[key: string]: ${indexType}`) return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` } @@ -280,19 +308,23 @@ function inputType(tool: AITool): string { } } -/** The return type the model sees for any tool: the structured `outputSchema` (when - * the MCP server declares one) wrapped in the result envelope, else `unknown`. */ -const returnType = (outputSchema: JSONSchema7 | undefined) => - `Promise<{ result: ${outputSchema ? renderType(outputSchema) : "unknown"}; attachments?: Attachment[] }>` +/** The `T` in `Result`: the structured `outputSchema` (when the MCP server declares + * one), else `unknown` — an untyped result has no guaranteed shape and must be inspected, + * not assumed. `Result` itself is defined once in the tool description prose. */ +const resultType = (outputSchema: JSONSchema7 | undefined) => (outputSchema ? renderType(outputSchema) : "unknown") + +/** The full, awaited call type shown by `tools.$rune.describe`. */ +const returnType = (outputSchema: JSONSchema7 | undefined) => `Promise>` const signatureFor = (entry: CatalogEntry) => `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): ${returnType(entry.outputSchema)}` -/** The compact, directly-callable signature for the inline preview: the call path - * plus its input type, but without the (uniform) `Promise<{ result, attachments? }>` - * return — that full typed form is reserved for `tools.$rune.describe`. */ +/** The directly-callable signature for the inline preview. Unlike the full `describe` + * form it drops the uniform `Promise<…>` wrapper (calls are always awaited) but DOES + * show the awaited `Result` — so the model sees each tool's return shape without a + * discovery round-trip. */ const previewSignature = (entry: CatalogEntry) => - `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)})` + `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): Result<${resultType(entry.outputSchema)}>` /** * Character budget for the inline signature preview in the tool description. All @@ -317,16 +349,24 @@ export function describe(groups: Map): string { "from your MCP servers):", "- `await tools.$rune.search(query, { namespace?, limit? })` -> `{ items: [{ path, description }], total }`", "- `await tools.$rune.describe(path)` -> `{ path, description, signature, input, output? }` (types as TypeScript)", - "- Call a tool by its path: `await tools..(input)`. Each resolves to `{ result, attachments? }`.", "", - "Every tool call and your final `return` use the same envelope: `{ result, attachments? }`.", - "`result` is the structured data; `attachments` are media as `{ type: 'file', mime, url }` — ordinary", - "values you can read and route (e.g. feed one tool's attachment into another tool's input). Whichever", - "attachments you return are shown to the user as media; only `result` becomes text, so nothing in the", - "sandbox (attachment bytes included) re-enters the conversation unless you put it in `result`.", + "Call a tool by its path: `await tools..(input)`. Every call — and your final `return` —", + "uses the same envelope: `type Result = { result: T; attachments?: Attachment[] }`. The signatures", + "below (and `tools.$rune.describe`) show each tool's `T` as its return type.", "", - "Compose multiple calls in one program and `return` the final value — intermediate results stay in the", - "sandbox and never re-enter the conversation. Use `tools.$rune.search('', { namespace })` to list a namespace.", + "`result` (the `T`) is the tool's own payload. It is typed `unknown` unless the server declares an output", + "schema — an `unknown` result has NO guaranteed shape, so inspect it (e.g. `return` it to see it, or read", + "it defensively) before assuming any fields.", + "", + "`attachments` are files a tool produced (an image, a document, …), given to you as references you hold", + "but don't read inline: `type Attachment = { type: 'file'; mime: string; filename?: string; bytes?: number }`.", + "To actually SEE a file — e.g. look at a screenshot before deciding your next step — include it in what you", + "`return` (e.g. `return { result: summary, attachments: shot.attachments }`): returned attachments come back", + "into the conversation as real viewable images/files, so both YOU (on your next turn) and the user can see", + "them. Omit an attachment to discard it. You route whole attachment handles; you don't read their raw bytes.", + "", + "Only what you `return` re-enters the conversation — `result` becomes text; everything else in the sandbox", + "stays there. Compose multiple calls in one program and `return` the final value. Use `tools.$rune.search('', { namespace })` to list a namespace.", ] if (groups.size === 0) { lines.push("", "No MCP servers are currently connected.") @@ -388,15 +428,69 @@ const lastSegment = (uri: string) => { const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}` +/** Decoded byte length of a `data:` URL's base64 payload, or undefined for a + * non-data URL (e.g. an external `resource_link`) whose size we don't know. */ +function dataUrlBytes(url: string): number | undefined { + if (!url.startsWith("data:")) return undefined + const comma = url.indexOf(",") + if (comma === -1) return undefined + const base64 = url.slice(comma + 1) + if (base64.length === 0) return 0 + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0 + return Math.max(0, Math.floor((base64.length * 3) / 4) - padding) +} + +/** Functions for converting between real attachments and the opaque handles the + * sandbox sees. See {@link attachmentTable}. */ +export type AttachmentTable = { + /** Register a real attachment, returning the opaque handle to hand to the program. */ + seal: (attachment: Attachment) => AttachmentHandle + /** Resolve a handle the program returned back to its real attachment, or undefined + * if it isn't one this table issued (a fabricated or stale handle is dropped). */ + resolve: (handle: unknown) => Attachment | undefined +} + +/** + * A per-execution table that keeps real attachment bytes host-side and only ever + * exposes opaque handles to the sandbox. The bytes never enter the program's context, + * so a program cannot read or accidentally re-emit them; on `return`, a propagated + * handle is looked up here to recover the real attachment for the user. + */ +export function attachmentTable(): AttachmentTable { + const real = new Map() + let seq = 0 + return { + seal(attachment) { + const id = `att_${++seq}` + real.set(id, attachment) + const bytes = dataUrlBytes(attachment.url) + return { + type: "file", + id, + mime: attachment.mime, + ...(attachment.filename ? { filename: attachment.filename } : {}), + ...(bytes !== undefined ? { bytes } : {}), + } + }, + resolve(handle) { + if (!handle || typeof handle !== "object") return undefined + const id = (handle as Record).id + return typeof id === "string" ? real.get(id) : undefined + }, + } +} + /** * Reduce an MCP tool result to the `{ result, attachments? }` envelope. `result` * is the structured content (or joined text); media blocks (image/audio/resource) - * become attachments. Lenient — never throws on unexpected shapes. + * become opaque attachment handles via `seal` (the bytes stay host-side). Lenient — + * never throws on unexpected shapes. */ -export function toEnvelope(result: unknown): Envelope { +export function toEnvelope(result: unknown, seal: AttachmentTable["seal"]): Envelope { if (result === null || typeof result !== "object") return { result } const record = result as { structuredContent?: unknown; content?: unknown } - const attachments: Attachment[] = [] + const attachments: AttachmentHandle[] = [] + const push = (attachment: Attachment) => attachments.push(seal(attachment)) const text: string[] = [] const content = Array.isArray(record.content) ? record.content : [] for (const item of content) { @@ -409,7 +503,7 @@ export function toEnvelope(result: unknown): Envelope { case "image": case "audio": if (typeof block.data === "string" && typeof block.mimeType === "string") { - attachments.push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) }) + push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) }) } break case "resource": { @@ -418,7 +512,7 @@ export function toEnvelope(result: unknown): Envelope { const mime = typeof res.mimeType === "string" ? res.mimeType : "application/octet-stream" const uri = typeof res.uri === "string" ? res.uri : undefined if (typeof res.blob === "string") { - attachments.push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined }) + push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined }) } else if (typeof res.text === "string") { text.push(res.text) } @@ -427,7 +521,7 @@ export function toEnvelope(result: unknown): Envelope { } case "resource_link": if (typeof block.uri === "string") { - attachments.push({ + push({ type: "file", mime: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream", url: block.uri, @@ -461,21 +555,22 @@ export function formatValue(value: unknown): string { } } -const isAttachment = (value: unknown): value is Attachment => { - if (!value || typeof value !== "object") return false - const a = value as Record - return a.type === "file" && typeof a.mime === "string" && typeof a.url === "string" -} - /** - * Lower the program's return value into model-facing output + attachments. The - * value is treated as a `{ result, attachments? }` envelope when it has a `result` - * key; otherwise the whole value is the result. Attachments are model-curated. + * Lower the program's return value into model-facing output + attachments. The value + * is treated as a `{ result, attachments? }` envelope when it has a `result` key; + * otherwise the whole value is the result. Attachments are model-curated: each returned + * handle is resolved back to its real bytes via `resolve`; anything that isn't a handle + * this run issued is dropped. */ -export function fromReturn(value: unknown): { output: string; attachments?: Attachment[] } { +export function fromReturn( + value: unknown, + resolve: AttachmentTable["resolve"], +): { output: string; attachments?: Attachment[] } { if (value !== null && typeof value === "object" && "result" in value) { const env = value as { result: unknown; attachments?: unknown } - const attachments = Array.isArray(env.attachments) ? env.attachments.filter(isAttachment) : [] + const attachments = Array.isArray(env.attachments) + ? env.attachments.map(resolve).filter((a): a is Attachment => a !== undefined) + : [] return attachments.length > 0 ? { output: formatValue(env.result), attachments } : { output: formatValue(env.result) } @@ -623,6 +718,9 @@ export function define( parameters: Parameters, execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { const calls: CallEntry[] = [] + // Real attachment bytes stay in this table for the life of the call; the sandbox + // only ever handles opaque references to them (see attachmentTable). + const files = attachmentTable() // Stream the current call list to the UI. Sent on every status change so the // tool part shows each child call appearing and resolving while the program runs. const publish = (error?: boolean) => @@ -662,7 +760,7 @@ export function define( ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }).pipe( - Effect.map(toEnvelope), + Effect.map((raw) => toEnvelope(raw, files.seal)), )) }) @@ -698,7 +796,7 @@ export function define( }) if (result.ok) { - const { output, attachments } = fromReturn(result.value) + const { output, attachments } = fromReturn(result.value, files.resolve) return { title: "execute", metadata: { toolCalls: calls }, diff --git a/packages/opencode/src/session/rune/rune.md b/packages/opencode/src/session/rune/rune.md index 04268e00b7..a25dc84f25 100644 --- a/packages/opencode/src/session/rune/rune.md +++ b/packages/opencode/src/session/rune/rune.md @@ -120,7 +120,9 @@ Returns `{ path, description, signature, input, output? }` for one tool. **Every TypeScript — no raw JSON Schema is ever surfaced to the model.** - **Compact signature + detailed TS types.** `signature` is the one-line call form, e.g. - `tools.github.create_issue(input: { title: string; body?: string }): Promise<{ result: unknown; attachments?: Attachment[] }>`. + `tools.github.create_issue(input: { title: string; body?: string }): Promise>`, + where `type Result = { result: T; attachments?: Attachment[] }` is defined once in the tool + description prose so signatures stay short. `input` (and `output`, when the server declares an `outputSchema`) are the *detailed* types rendered by `renderType` in pretty mode: an indented block with `/** … */` JSDoc on described fields and literal unions for enums. This carries everything the raw JSON Schema used to — @@ -138,10 +140,32 @@ TypeScript — no raw JSON Schema is ever surfaced to the model.** `any`/`object`, and depth is capped. (Rune's own Effect-schema renderer in `rune/tool.ts`, `renderSchema`, is separate and has known gaps — no `$ref` cycle guard, and unions containing a number collapse to `number`; code mode does not use it.) -- **Return type shown ahead of the call.** Every tool resolves to the uniform envelope - `{ result, attachments? }`, surfaced in `signature`. `result` is typed `unknown` unless the - server declares a structured `outputSchema` (many MCP servers return text, in which case - `result` is a plain string). +- **Return type shown ahead of the call — in the preview too.** Every tool resolves to + `Result`, where `T` is the structured `outputSchema` when the server declares one, else + `unknown`. The `T` is surfaced not just by `describe` but in the budgeted inline preview in the + tool description (`tools.x.y(input: …): Result`), so the model sees a tool's result shape + without a discovery round-trip. `unknown` is deliberate — an untyped result has no guaranteed + shape (many MCP servers return plain text), so the prose directs the model to inspect it (e.g. + `return` it) before assuming fields, rather than pretending a shape we can't verify. + +## Attachments are opaque handles — bytes never enter the sandbox + +A tool's media (image/audio/resource content blocks) becomes an `attachments` array on the +result envelope, but the program only ever sees an **opaque handle**, not the bytes: +`type Attachment = { type: 'file'; id: string; mime: string; filename?: string; bytes?: number }`. +The real bytes (a base64 `data:` URL) are kept host-side in a per-execution `attachmentTable` +keyed by `id` (`code-mode.ts`); the handle carries only metadata. The program can inspect +`mime`/`bytes`, **propagate** a handle (return it under `attachments` to show the user), or +**drop** it — but it cannot read or re-emit the contents. On `return`, each propagated handle is +resolved back to its real attachment via the table; a fabricated or stale handle resolves to +nothing and is dropped. + +This is a deliberate divergence from the prior art we studied (both expose the base64 directly +and lean on prompt guidance plus output truncation). Making the handle opaque means a careless +`return`/log **cannot** dump a base64 blob back into the conversation — the leak is structurally +impossible rather than merely discouraged. The trade-off: a program can no longer read attachment +bytes to route them into another tool's input; if that need arises it would be an explicit host +call (e.g. a `readAttachment(handle)`), not the always-on default. Not implemented yet. ## Path handling — separator-tolerant @@ -152,7 +176,18 @@ resolve to the same tool. A slash-vs-dot mismatch previously made `describe` sil (returning a soft error the model then destructured into `{}`); normalizing the separator removes that whole failure mode. -## Errors are soft, with "did you mean" — never thrown +## Tool-call errors throw; discovery errors are soft + +A **tool call** that fails (an MCP `isError`, a transport failure, or a call to a path that +doesn't exist) throws inside the program, so the model uses ordinary `try`/`catch` — the same +control flow it already writes for any async call. We deliberately do *not* wrap results in an +`{ ok, error }` value: a uniform success envelope (`{ result, attachments? }`) plus normal +exceptions is simpler to reason about and to type than forcing every call site to branch on a +discriminated union. An uncaught tool error fails the whole `execute` run and is reported back. + +The **discovery helpers** are the exception — see below. + +## Discovery errors are soft, with "did you mean" — never thrown `tools.$rune.search` and `tools.$rune.describe` never throw. An unknown `describe(path)` returns `{ error: { code: 'tool_not_found', message, suggestions } }`. Suggestions come from a diff --git a/packages/opencode/test/session/code-mode-integration.test.ts b/packages/opencode/test/session/code-mode-integration.test.ts index 41a24a577c..d23ab1ab58 100644 --- a/packages/opencode/test/session/code-mode-integration.test.ts +++ b/packages/opencode/test/session/code-mode-integration.test.ts @@ -118,7 +118,7 @@ describe("code mode integration (real MCP server)", () => { const desc = JSON.parse(out.output) expect(desc.path).toBe("fixtures.add") expect(desc.signature).toBe( - "tools.fixtures.add(input: { a: number; b: number }): Promise<{ result: { sum: number }; attachments?: Attachment[] }>", + "tools.fixtures.add(input: { a: number; b: number }): Promise>", ) // describe returns TypeScript for the input/output types, not raw JSON Schema. expect(desc.input).toBe("{\n a: number\n b: number\n}") @@ -129,7 +129,7 @@ describe("code mode integration (real MCP server)", () => { test("describe falls back to result: unknown when no outputSchema is declared", async () => { const out = await run("return await tools.$rune.describe('fixtures.get_text')") const desc = JSON.parse(out.output) - expect(desc.signature).toContain("Promise<{ result: unknown; attachments?: Attachment[] }>") + expect(desc.signature).toContain("Promise>") }) test("search finds a tool by keyword", async () => { @@ -169,20 +169,24 @@ describe("code mode integration (real MCP server)", () => { expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: `data:image/png;base64,${PNG}` }]) }) - test("an attachment's bytes are readable and routable in code, not opaque", async () => { - // The data: URL carrying the base64 payload is an ordinary string in the - // sandbox: the program can inspect it (and thus route it into another tool). + test("an attachment is an opaque handle: metadata only, no readable bytes", async () => { + // The program sees mime/bytes but NOT the data — a stray return can't leak base64. const out = await run(` const shot = await tools.fixtures.screenshot({}) - const url = shot.attachments[0].url - return { result: { mime: shot.attachments[0].mime, isDataUrl: url.startsWith('data:'), bytes: url.length } } + const a = shot.attachments[0] + return { result: { mime: a.mime, hasUrl: 'url' in a, hasData: 'data' in a, bytes: a.bytes, keys: Object.keys(a).sort() } } `) expect(JSON.parse(out.output)).toEqual({ mime: "image/png", - isDataUrl: true, - bytes: `data:image/png;base64,${PNG}`.length, + hasUrl: false, + hasData: false, + bytes: Buffer.from(PNG, "base64").byteLength, + keys: ["bytes", "id", "mime", "type"], }) + // Returning the handle inside `.result` (not as an attachment) surfaces no media + // and — crucially — carries no base64, so nothing large re-enters the conversation. expect(out.attachments).toBeUndefined() + expect(out.output).not.toContain(PNG) }) test("drops media when only .result is returned", async () => { diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 0d3fc1ca9e..9b0e449edd 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { Parameters, + attachmentTable, define, describe as describeTools, formatValue, @@ -87,10 +88,13 @@ describe("code mode execute", () => { expect(description).toContain("This is the COMPLETE list") expect(description).toContain("- github (2 tools)") expect(description).toContain("- linear (1 tool)") - // Tools are previewed inline as compact, directly-callable input signatures. - expect(description).toContain("tools.github.create_issue(input: { title: string; body?: string })") - expect(description).toContain("tools.linear.search(input: object)") - // ...but not the full Promise return — that comes from tools.$rune.describe. + // Tools are previewed inline as directly-callable signatures that now include the + // awaited return type (Result) so the model sees the result shape up front. + expect(description).toContain("tools.github.create_issue(input: { title: string; body?: string }): Result") + expect(description).toContain("tools.linear.search(input: object): Result") + // The Result envelope alias is defined once in the prose. + expect(description).toContain("type Result = { result: T; attachments?: Attachment[] }") + // ...but the preview drops the uniform Promise<…> wrapper — that full form comes from describe. expect(description).not.toContain("): Promise<") }) @@ -143,7 +147,7 @@ describe("code mode execute", () => { const desc = JSON.parse(described.output) expect(desc.path).toBe("github.create_issue") expect(desc.signature).toBe( - "tools.github.create_issue(input: { title: string; body?: string }): Promise<{ result: unknown; attachments?: Attachment[] }>", + "tools.github.create_issue(input: { title: string; body?: string }): Promise>", ) expect(described.metadata.toolCalls).toEqual([ { tool: "$rune.describe", status: "completed", input: { path: "github.create_issue" } }, @@ -357,29 +361,43 @@ describe("code mode execute", () => { expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error", input: { reason: "test" } }] }) }) - test("unit: toEnvelope wraps result and extracts media as attachments", () => { - expect(toEnvelope({ structuredContent: { x: 1 }, content: [] })).toEqual({ result: { x: 1 } }) - expect(toEnvelope({ content: [{ type: "text", text: "hi" }] })).toEqual({ result: "hi" }) - expect(toEnvelope("raw")).toEqual({ result: "raw" }) + test("unit: toEnvelope wraps result and extracts media as opaque attachment handles", () => { + const table = attachmentTable() + expect(toEnvelope({ structuredContent: { x: 1 }, content: [] }, table.seal)).toEqual({ result: { x: 1 } }) + expect(toEnvelope({ content: [{ type: "text", text: "hi" }] }, table.seal)).toEqual({ result: "hi" }) + expect(toEnvelope("raw", table.seal)).toEqual({ result: "raw" }) - // image/audio blocks become data-URL file attachments; text stays in result - expect( - toEnvelope({ + // image/audio blocks become OPAQUE handles (mime/bytes, NO url/data); text stays in result + const withImage = toEnvelope( + { content: [ { type: "text", text: "see image" }, { type: "image", data: "AAAA", mimeType: "image/png" }, ], - }), - ).toEqual({ - result: "see image", - attachments: [{ type: "file", mime: "image/png", url: "data:image/png;base64,AAAA" }], + }, + table.seal, + ) + expect(withImage.result).toBe("see image") + expect(withImage.attachments).toEqual([{ type: "file", id: "att_1", mime: "image/png", bytes: 3 }]) + // The handle exposes no bytes, but resolves back to the real attachment host-side. + expect((withImage.attachments![0] as any).url).toBeUndefined() + expect(table.resolve(withImage.attachments![0])).toEqual({ + type: "file", + mime: "image/png", + url: "data:image/png;base64,AAAA", }) - // media-only result has an undefined result but still surfaces the attachment - expect(toEnvelope({ content: [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }] })).toEqual({ - result: undefined, - attachments: [{ type: "file", mime: "image/jpeg", url: "data:image/jpeg;base64,BBBB" }], - }) + // media-only result: undefined result, still surfaces the handle + const mediaOnly = toEnvelope({ content: [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }] }, table.seal) + expect(mediaOnly.result).toBeUndefined() + expect(mediaOnly.attachments).toEqual([{ type: "file", id: "att_2", mime: "image/jpeg", bytes: 3 }]) + }) + + test("unit: attachmentTable resolve drops fabricated or stale handles", () => { + const table = attachmentTable() + expect(table.resolve({ type: "file", id: "att_999", mime: "image/png" })).toBeUndefined() + expect(table.resolve({ type: "file" })).toBeUndefined() + expect(table.resolve("nope")).toBeUndefined() }) test("unit: formatValue", () => { @@ -414,7 +432,7 @@ describe("code mode execute", () => { const described = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.describe('weather.current')" }, ctx)) const desc = JSON.parse(described.output) expect(desc.signature).toBe( - "tools.weather.current(input: { city?: string }): Promise<{ result: { tempC: number; summary?: string }; attachments?: Attachment[] }>", + "tools.weather.current(input: { city?: string }): Promise>", ) // describe now returns the return shape as pretty TypeScript, not raw JSON Schema. expect(desc.output).toBe("{\n tempC: number\n summary?: string\n}") @@ -665,6 +683,37 @@ describe("renderType", () => { ) }) + test("emits JSDoc tags for schema constraints TypeScript can't express", () => { + const schema = { + type: "object", + properties: { + when: { type: "string", format: "date-time", default: "now", description: "start time" }, + legacy: { type: "boolean", deprecated: true }, + tags: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 }, + }, + required: ["when"], + } as any + expect(renderType(schema, { pretty: true })).toBe( + [ + "{", + " /**", + " * start time", + ' * @default "now"', + " * @format date-time", + " */", + " when: string", + " /** @deprecated */", + " legacy?: boolean", + " /**", + " * @minItems 1", + " * @maxItems 5", + " */", + " tags?: string[]", + "}", + ].join("\n"), + ) + }) + test("neutralizes a comment terminator inside a JSDoc description", () => { const schema = { type: "object", properties: { x: { type: "string", description: "danger */ oops" } } } as any const out = renderType(schema, { pretty: true })