feat(opencode): JSDoc tags, Result<T> return hints, and opaque attachments

- describe/preview now surface each tool's return type as Result<T> (alias
  defined once in the prompt); the inline preview shows it too, so the model
  sees a tool's result shape without a describe round-trip. T is the declared
  outputSchema else unknown, with prose telling the model to inspect an unknown
  result before assuming fields.
- renderType pretty mode emits JSDoc tags a TS type can't express: @default,
  @format, @deprecated, @minItems/@maxItems (multi-line descriptions preserved).
- Attachments are now opaque handles: a tool's media becomes
  { type:'file', id, mime, filename?, bytes } with no inline bytes. Real bytes
  stay host-side in a per-execution attachmentTable; the program propagates or
  drops a handle (return it to surface the image to the model+user) but cannot
  read or leak the base64. Documents the divergence from prior art in rune.md.
- Records the throw/catch (not errors-as-values) decision in rune.md.
This commit is contained in:
Aiden Cline
2026-07-01 10:54:42 -05:00
parent 5085a13b0e
commit c16bba8bc0
4 changed files with 274 additions and 88 deletions
+149 -51
View File
@@ -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<Tool.ExecuteResult["attachments"]>[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<T>`: 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<T>` 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<Result<${resultType(outputSchema)}>>`
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<T>` — 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, CatalogEntry[]>): 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.<server>.<tool>(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.<server>.<tool>(input)`. Every call and your final `return` ",
"uses the same envelope: `type Result<T> = { 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<string, Attachment>()
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<string, unknown>).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<string, unknown>
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 },
+41 -6
View File
@@ -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<Result<unknown>>`,
where `type Result<T> = { 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<T>`, 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<T>`), 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
@@ -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<Result<{ sum: number }>>",
)
// 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<Result<unknown>>")
})
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 () => {
@@ -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<T>) so the model sees the result shape up front.
expect(description).toContain("tools.github.create_issue(input: { title: string; body?: string }): Result<unknown>")
expect(description).toContain("tools.linear.search(input: object): Result<unknown>")
// The Result<T> envelope alias is defined once in the prose.
expect(description).toContain("type Result<T> = { 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<Result<unknown>>",
)
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<Result<{ tempC: number; summary?: string }>>",
)
// 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 })