Compare commits

...

8 Commits

Author SHA1 Message Date
Aiden Cline 0b10d4384b fix(core): pin Figma OAuth callback port 2026-07-30 23:16:28 +00:00
Aiden Cline 02128f4254 fix(core): run Figma OAuth setup as plugin 2026-07-22 13:21:27 -05:00
Aiden Cline 456e6b5902 chore(core): clean up plugin test 2026-07-22 13:11:05 -05:00
Aiden Cline d3bb1859e0 fix(core): apply Figma OAuth config before startup 2026-07-22 13:10:15 -05:00
Aiden Cline 49be2b3a89 fix(core): configure Figma MCP OAuth client 2026-07-22 12:58:46 -05:00
Dax Raad 89e3141079 fix(core): preserve cache for session generation 2026-07-22 12:21:40 -04:00
Kit Langton 7a1f9764a2 feat(core): compact durable tool metadata (#38343) 2026-07-22 12:09:48 -04:00
opencode-agent[bot] b91dd78ab3 test(core): remove source text assertions (#38342)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-22 11:50:40 -04:00
24 changed files with 315 additions and 88 deletions
+6 -1
View File
@@ -12,7 +12,12 @@
"description": "Contains opencode logs and data",
},
},
"mcp": {},
"mcp": {
"figma": {
"type": "remote",
"url": "https://mcp.figma.com/mcp",
},
},
"tools": {
"github-triage": false,
"github-pr-search": false,
@@ -157,6 +157,53 @@ function failedTool(inputID: string): V2Event[] {
]
}
function successfulGrep(inputID: string): V2Event[] {
const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle"
return [
prompted(inputID),
{
id: "evt_grep_input",
created: 1,
type: "session.tool.input.started",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
name: "grep",
},
},
{
id: "evt_grep_called",
created: 2,
type: "session.tool.called",
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
input: { pattern: "needle" },
executed: true,
},
},
{
id: "evt_grep_success",
created: 3,
type: "session.tool.success",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
structured: { matches: 2 },
content: [{ type: "text", text }],
executed: false,
},
},
settled(),
]
}
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: {
@@ -269,6 +316,32 @@ afterEach(() => {
})
describe("runNonInteractivePrompt", () => {
test("keeps formatted tool output and compact structured metadata in JSON", async () => {
const output = await capture({ format: "json", turn: successfulGrep })
const events = output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
type: "tool_use",
part: {
tool: "grep",
state: {
status: "completed",
output: expect.stringContaining("Found 2 matches"),
metadata: {
structured: { matches: 2 },
content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }],
},
},
},
})
expect(events[0].part.state.metadata.structured).toEqual({ matches: 2 })
expect(events[0].part.state.metadata.result).toBeUndefined()
})
test("uses session.wait then reconciles projected output without a terminal event", async () => {
const idle = Promise.withResolvers<void>()
let done = false
+37
View File
@@ -0,0 +1,37 @@
export * as FigmaPlugin from "./figma"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect } from "effect"
import { Config } from "../config"
import { ConfigMCP } from "../config/mcp"
import { MCP } from "../mcp/index"
const CLIENT_ID = "3zVHNs9kINDDrk8loekLZV"
const CALLBACK_PORT = 19876
export function apply(server: typeof ConfigMCP.Server.Type) {
if (server.type !== "remote" || server.oauth === false) return server
if (!URL.canParse(server.url) || new URL(server.url).hostname !== "mcp.figma.com") return server
if (server.oauth) {
Object.assign(server.oauth, { client_id: server.oauth.client_id ?? CLIENT_ID, callback_port: CALLBACK_PORT })
return server
}
Object.assign(server, { oauth: { client_id: CLIENT_ID, callback_port: CALLBACK_PORT } })
return server
}
export const Plugin = define({
id: "opencode.figma",
effect: Effect.fn(function* () {
const config = yield* Config.Service
const mcp = yield* MCP.Service
const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
for (const entry of documents) {
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
if (server.type !== "remote" || !URL.canParse(server.url) || new URL(server.url).hostname !== "mcp.figma.com")
continue
yield* mcp.add(name, apply(server))
}
}
}),
})
+5
View File
@@ -24,6 +24,7 @@ import { Integration } from "../integration"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { MCP } from "../mcp/index"
import { Npm } from "@opencode-ai/util/npm"
import { PermissionV2 } from "../permission"
import { Reference } from "../reference"
@@ -48,6 +49,7 @@ import { WellKnown } from "../wellknown"
import { WriteTool } from "../tool/write"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { FigmaPlugin } from "./figma"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { PluginRuntime } from "./runtime"
@@ -73,6 +75,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const mcp = yield* MCP.Service
const npm = yield* Npm.Service
const permission = yield* PermissionV2.Service
const runtime = yield* PluginRuntime.Service
@@ -102,6 +105,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(MCP.Service, mcp),
Context.make(Npm.Service, npm),
Context.make(PermissionV2.Service, permission),
Context.make(PluginRuntime.Service, runtime),
@@ -126,6 +130,7 @@ export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
WellKnownPlugin.Plugin,
FigmaPlugin.Plugin,
AgentPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
+2
View File
@@ -23,6 +23,7 @@ import { Integration } from "../integration"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { MCP } from "../mcp/index"
import { Npm } from "@opencode-ai/util/npm"
import { PermissionV2 } from "../permission"
import { PluginV2 } from "../plugin"
@@ -292,6 +293,7 @@ export const node = makeLocationNode({
Location.node,
LocationMutation.node,
ModelsDev.node,
MCP.node,
Npm.node,
PermissionV2.node,
PluginRuntime.node,
+28 -5
View File
@@ -12,6 +12,7 @@ import { SessionGenerate } from "./generate"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model"
import { ToolRegistry } from "../tool/registry"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
@@ -23,6 +24,7 @@ export const layer = Layer.effect(
const hooks = yield* PluginHooks.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
const registry = yield* ToolRegistry.Service
const app = yield* App.Metadata
return SessionGenerate.Service.of({
@@ -34,6 +36,9 @@ export const layer = Layer.effect(
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
? selection.session.id.slice(4)
: selection.session.id
const executableTools = yield* registry.materialize(selection.agent.info.permissions)
const toolDefinitions = executableTools.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
const contextEvent = yield* hooks.trigger("session", "context", {
sessionID: selection.session.id,
agent: selection.agent.id,
@@ -46,24 +51,34 @@ export const layer = Layer.effect(
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
tools: {},
tools: Object.fromEntries(
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
})
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registered = toolsByName.get(name)
return registered
? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })]
: []
})
yield* Effect.logInfo("sending session generation request", {
sessionID: selection.session.id,
providerID: model.ref.providerID,
modelID: model.ref.id,
})
return (yield* llm.generate(
const response = yield* llm.generate(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session, app) },
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
tools: [],
tools: hookedTools,
toolChoice: "none",
}),
)).text
)
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
return response.text
}),
})
}),
@@ -72,5 +87,13 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: SessionGenerate.Service,
layer,
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
deps: [
SessionContext.node,
Database.node,
PluginHooks.node,
SessionRunnerModel.node,
ToolRegistry.node,
App.node,
llmClient,
],
})
+6 -1
View File
@@ -8,7 +8,7 @@ import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { NonNegativeInt, RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
@@ -25,6 +25,9 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Array(FileSystem.Entry)
const StructuredOutput = Schema.Struct({
count: NonNegativeInt,
})
type ModelOutput = typeof Output.Encoded
/** Format raw search results into the concise line-oriented output models expect. */
@@ -51,6 +54,8 @@ export const Plugin = {
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ count: output.length }),
toModelOutput: ({ output }) => [
{
type: "text",
+6 -1
View File
@@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { NonNegativeInt, RelativePath } from "../schema"
import { Tool } from "./tool"
export const name = "grep"
@@ -30,6 +30,9 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Array(FileSystem.Match)
const StructuredOutput = Schema.Struct({
matches: NonNegativeInt,
})
type ModelOutput = typeof Output.Encoded
/** Format raw search matches into the familiar concise model output. */
@@ -65,6 +68,8 @@ export const Plugin = {
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ matches: output.length }),
toModelOutput: ({ output }) => [
{
type: "text",
+6
View File
@@ -21,6 +21,10 @@ export const Output = Schema.Struct({
directory: Schema.String,
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
name: Output.fields.name,
directory: Output.fields.directory,
})
export const description = [
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
@@ -66,6 +70,8 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ name: output.name, directory: output.directory }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
+6
View File
@@ -31,6 +31,10 @@ export const Output = Schema.Struct({
status: Schema.Literals(["completed", "running"]),
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
sessionID: Output.fields.sessionID,
status: Output.fields.status,
})
export const description = [
"Spawn a subagent: a child session running a configured agent with fresh context.",
@@ -115,6 +119,8 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ sessionID: output.sessionID, status: output.status }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
+5
View File
@@ -37,6 +37,9 @@ const Output = Schema.Struct({
format: Input.fields.format,
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
contentType: Output.fields.contentType,
})
type Format = (typeof Input.Type)["format"]
@@ -126,6 +129,8 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ contentType: output.contentType }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
+5
View File
@@ -190,6 +190,9 @@ const Output = Schema.Struct({
provider: Provider,
text: Schema.String,
})
const StructuredOutput = Schema.Struct({
provider: Output.fields.provider,
})
export const Plugin = {
id: "opencode.tool.websearch",
@@ -206,6 +209,8 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ provider: output.provider }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test"
import { ConfigMCP } from "@opencode-ai/core/config/mcp"
import { FigmaPlugin } from "@opencode-ai/core/plugin/figma"
describe("plugin.figma", () => {
test("adds the OpenCode client ID to configured Figma servers", () => {
const server = new ConfigMCP.Remote({
type: "remote",
url: "https://mcp.figma.com/mcp",
oauth: new ConfigMCP.OAuth({ scope: "mcp:connect" }),
})
FigmaPlugin.apply(server)
expect(server.oauth).toEqual({
client_id: "3zVHNs9kINDDrk8loekLZV",
callback_port: 19876,
scope: "mcp:connect",
})
})
test("preserves an existing client ID", () => {
const server = new ConfigMCP.Remote({
type: "remote",
url: "https://mcp.figma.com/mcp",
oauth: new ConfigMCP.OAuth({ client_id: "configured-client-id", callback_port: 4321 }),
})
FigmaPlugin.apply(server)
expect(server.oauth).toEqual({ client_id: "configured-client-id", callback_port: 19876 })
})
test("does not enable OAuth or modify non-Figma servers", () => {
const disabled = new ConfigMCP.Remote({
type: "remote",
url: "https://mcp.figma.com/mcp",
oauth: false,
})
const other = new ConfigMCP.Remote({
type: "remote",
url: "https://mcp.example.com/mcp",
})
FigmaPlugin.apply(disabled)
FigmaPlugin.apply(other)
expect(disabled.oauth).toBe(false)
expect(other.oauth).toBeUndefined()
})
})
+14 -2
View File
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
@@ -38,6 +38,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { asc, eq } from "drizzle-orm"
import { Effect, Layer, Schema, Stream } from "effect"
import { testEffect } from "./lib/effect"
@@ -92,6 +93,15 @@ const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succee
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const tools = Layer.mock(ToolRegistry.Service, {
materialize: () =>
Effect.succeed({
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
settle: () => Effect.die(new Error("unused")),
}),
register: () => Effect.die(new Error("unused")),
registerBatch: () => Effect.die(new Error("unused")),
})
const it = testEffect(
AppNodeBuilder.build(
@@ -114,6 +124,7 @@ const it = testEffect(
[ReferenceInstructions.node, references],
[McpInstructions.node, mcp],
[PluginSupervisor.node, plugins],
[ToolRegistry.node, tools],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
],
),
@@ -259,6 +270,7 @@ it.effect("generates from fresh settled Session context without durable mutation
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system"), ...event.system]
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
}),
)
@@ -287,7 +299,7 @@ it.effect("generates from fresh settled Session context without durable mutation
: [],
),
).toEqual(["Settled partial answer"])
expect(requests[0]?.tools).toEqual([])
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
expect(requests[0]?.toolChoice).toMatchObject({ type: "none" })
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
+1 -24
View File
@@ -1,7 +1,6 @@
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -460,25 +459,3 @@ describe("EditTool", () => {
),
)
})
test("keeps the locked edit schema, semantics docstring, and deferred TODOs visible", async () => {
const source = (await fs.readFile(new URL("../src/tool/edit.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
const definition = await Effect.runPromise(
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
)
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["newString", "oldString", "path", "replaceAll"])
expect(source).toContain(
"absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.",
)
for (const todo of [
"Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.",
"Add formatter integration after V2 formatter runtime exists.",
"Publish watcher/file-edit events after V2 watcher integration exists.",
"Add snapshots / undo after design exists.",
"Add LSP notification and diagnostics after V2 LSP runtime exists.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})
+6 -2
View File
@@ -86,8 +86,12 @@ describe("search tools", () => {
const glob = yield* settleTool(registry, call("glob", { pattern: "*" }))
const grep = yield* settleTool(registry, call("grep", { pattern: "needle" }))
expect(glob.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grep.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.output?.content).toEqual([{ type: "text", text: glob.result.value }])
expect(grep.output?.content).toEqual([{ type: "text", text: grep.result.value }])
expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
}),
)
}),
+1 -18
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -643,20 +643,3 @@ describe("ShellTool", () => {
),
)
})
test("keeps locked deferred parity TODOs visible", async () => {
const source = await fs.readFile(new URL("../src/tool/shell.ts", import.meta.url), "utf8")
for (const todo of [
"Port tree-sitter bash / PowerShell parser-based approval reduction.",
"Port BashArity reusable command-prefix approvals.",
"Replace token-based command-argument external-directory advisories with parser-based detection.",
"Restore PowerShell and cmd-specific invocation/path handling on Windows.",
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
"Persist job status and define restart recovery before exposing remote observation.",
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.",
"Revisit binary output handling if stdout/stderr decoding is text-only.",
"Stream full shell output into managed storage while retaining only a bounded in-memory preview.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})
+5 -2
View File
@@ -119,9 +119,12 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } },
}),
).toMatchObject({
).toEqual({
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: { structured: { name: "Effect" } },
output: {
structured: { name: "Effect", directory },
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
},
})
expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
+24 -4
View File
@@ -35,7 +35,8 @@ const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID:
const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") })
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID
const outputSessionID = (value: unknown) =>
Schema.decodeUnknownSync(Schema.Struct({ sessionID: SessionV2.ID }))(value).sessionID
const executionNode = makeGlobalNode({
service: SessionExecution.Service,
@@ -229,7 +230,17 @@ describe("SubagentTool", () => {
},
})
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
expect(settled).toMatchObject({
result: { type: "text", value: childText },
output: {
structured: { status: "completed" },
content: [{ type: "text", text: childText }],
},
})
expect(settled.output?.structured).toEqual({
sessionID: outputSessionID(settled.output?.structured),
status: "completed",
})
expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id)
}),
),
@@ -264,8 +275,15 @@ describe("SubagentTool", () => {
},
})
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
expect(settled).toMatchObject({
result: { type: "text", value: childText },
output: {
structured: { status: "completed" },
content: [{ type: "text", text: childText }],
},
})
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
expect(settled.output?.structured).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({
parentID: parent.id,
@@ -361,8 +379,10 @@ describe("SubagentTool", () => {
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({
status: "running",
output: expect.stringContaining(`id: ${childID}`),
})
expect(settled.output?.structured).toEqual({ sessionID: childID, status: "running" })
expect(settled.result).toEqual({ type: "text", value: expect.stringContaining(`id: ${childID}`) })
expect(settled.output?.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
+1 -1
View File
@@ -96,7 +96,7 @@ describe("WebFetchTool registration", () => {
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
result: { type: "text", value: "hello" },
output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
structured: { contentType: "text/plain" },
content: [{ type: "text", text: "hello" }],
},
})
+1 -1
View File
@@ -244,7 +244,7 @@ describe("WebSearchTool registration", () => {
expect(settled).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel", text: "parallel results" },
structured: { provider: "parallel" },
content: [{ type: "text", text: "parallel results" }],
},
})
+1 -24
View File
@@ -1,7 +1,6 @@
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -363,25 +362,3 @@ describe("WriteTool", () => {
),
)
})
test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => {
const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
const definition = await Effect.runPromise(
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
)
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"])
expect(source).toContain(
"absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.",
)
for (const todo of [
"Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.",
"Add formatter integration after V2 formatter runtime exists.",
"Publish watcher/file-edit events after V2 watcher integration exists.",
"Add snapshots / undo after design exists.",
"Add LSP notification and diagnostics after V2 LSP runtime exists.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})
+1 -2
View File
@@ -2669,8 +2669,7 @@ function WebFetch(props: ToolProps) {
function WebSearch(props: ToolProps) {
return (
<InlineTool icon="◈" pending="Searching web..." complete={stringValue(props.input.query)} part={props.part}>
{webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"{" "}
<Show when={finiteNumber(props.metadata.numResults)}>({finiteNumber(props.metadata.numResults)} results)</Show>
{webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"
</InlineTool>
)
}
+24
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { normalizeTool, toolInlineInfo, toolOutputText, toolPath, toolScroll } from "../../src/mini/tool"
import { canonicalToolPart } from "./fixture/tool-part"
describe("Mini tool presentation", () => {
test("uses V2 shell output without the model-facing status", () => {
@@ -105,6 +106,29 @@ describe("Mini tool presentation", () => {
).toBe('→ Skill "effect"')
})
test("renders compact search metadata", () => {
expect(
toolInlineInfo(
canonicalToolPart("glob", {
status: "completed",
input: { pattern: "*.ts" },
structured: { count: 3 },
content: [],
}),
).description,
).toBe("3 matches")
expect(
toolInlineInfo(
canonicalToolPart("grep", {
status: "completed",
input: { pattern: "needle" },
structured: { matches: 1 },
content: [],
}),
).description,
).toBe("1 match")
})
test("keeps segment-safe contained tool paths relative", () => {
expect(toolPath("..cache/result.txt", { directory: "/work/project" })).toBe("..cache/result.txt")
expect(toolPath("../shared/result.txt", { directory: "/work/project" })).toBe("/work/shared/result.txt")