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:
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user