From 470e360942fdc6e4d96b15da193202678ae0baaa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Jul 2026 20:48:29 -0400 Subject: [PATCH] test(core): align tool contract expectations (#39172) --- packages/cli/src/acp/tool.ts | 2 +- packages/cli/src/run/noninteractive.ts | 4 +- packages/cli/test/acp/event-behavior.test.ts | 4 +- .../cli/test/acp/permission-behavior.test.ts | 4 +- .../client/src/promise/generated/types.ts | 26 ++-- packages/core/src/tool/plugin/shell.ts | 6 +- packages/core/test/codemode.test.ts | 11 +- .../core/test/codemode/instructions.test.ts | 6 +- packages/core/test/plugin.test.ts | 3 +- packages/core/test/plugin/promise.test.ts | 1 - packages/core/test/session-runner.test.ts | 104 ++----------- packages/www/openapi.json | 139 ++++++++++++++++++ packages/www/public/openapi.json | 139 ++++++++++++++++++ 13 files changed, 333 insertions(+), 116 deletions(-) diff --git a/packages/cli/src/acp/tool.ts b/packages/cli/src/acp/tool.ts index 041226068df..74092d88535 100644 --- a/packages/cli/src/acp/tool.ts +++ b/packages/cli/src/acp/tool.ts @@ -5,7 +5,7 @@ import { readDisplayText } from "@opencode-ai/tui/mini/tool" export type ToolInput = Record export type ToolContent = ReadonlyArray< | { readonly type: "text"; readonly text: string } - | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string | null } > export function toToolKind(toolName: string): ToolKind { diff --git a/packages/cli/src/run/noninteractive.ts b/packages/cli/src/run/noninteractive.ts index bf568d2df54..dd4634fa69b 100644 --- a/packages/cli/src/run/noninteractive.ts +++ b/packages/cli/src/run/noninteractive.ts @@ -1,11 +1,11 @@ import type { EventSubscribeOutput, JsonValue, - LLMToolContent, LocationRef, OpenCodeClient, SessionMessageAssistantTool, SessionMessageInfo, + ToolContent, } from "@opencode-ai/client/promise" import { SessionMessage } from "@opencode-ai/schema/session-message" import { EOL } from "node:os" @@ -56,7 +56,7 @@ type ToolState = StartedPart & { provider?: unknown providerState?: SessionMessageAssistantTool["providerState"] metadata: Record - content: LLMToolContent[] + content: ToolContent[] } type V2Event = EventSubscribeOutput diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts index 42be5c45206..fabdcb8198b 100644 --- a/packages/cli/test/acp/event-behavior.test.ts +++ b/packages/cli/test/acp/event-behavior.test.ts @@ -244,7 +244,7 @@ describe("acp event behavior", () => { sessionID: "ses_tools", assistantMessageID: "msg_tools", callID: "call_fail", - input: { filePath: "/workspace/missing.ts" }, + input: { path: "/workspace/missing.ts" }, executed: false, }), ) @@ -638,7 +638,7 @@ function replayFixtureMessages(): SessionMessageInfo[] { time: { created: 2, completed: 3 }, state: { status: "error", - input: { filePath: "/workspace/missing.ts" }, + input: { path: "/workspace/missing.ts" }, metadata: { bytes: 0 }, content: [{ type: "text", text: "partial" }], error: { type: "tool.error", message: "failed hard" }, diff --git a/packages/cli/test/acp/permission-behavior.test.ts b/packages/cli/test/acp/permission-behavior.test.ts index 9418daa4b1a..27a317fdd32 100644 --- a/packages/cli/test/acp/permission-behavior.test.ts +++ b/packages/cli/test/acp/permission-behavior.test.ts @@ -49,7 +49,7 @@ describe("acp permission behavior", () => { send( permissionAsked("ses_allow", "perm_always", { action: "read", - metadata: { filePath: "/workspace/file.ts" }, + metadata: { path: "/workspace/file.ts" }, source: { type: "tool", messageID: "msg_allow", callID: "call_always" }, }), ) @@ -96,7 +96,7 @@ describe("acp permission behavior", () => { title: "/workspace/file.ts", kind: "read", locations: [{ path: "/workspace/file.ts" }], - rawInput: { filePath: "/workspace/file.ts" }, + rawInput: { path: "/workspace/file.ts" }, }, }) expect(permissionReplies(fixture)).toEqual([ diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index d679249b15e..fcd25cc09b2 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -110,7 +110,7 @@ export type SessionMessageToolStateRunning = { export type ToolTextContent = { type: "text"; text: string } -export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string } +export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null } export type SessionStructuredError = { type: string; message: string } @@ -163,6 +163,8 @@ export type SessionMessageProviderState6 = { [x: string]: any } export type SessionMessageProviderState7 = { [x: string]: any } +export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined } + export type SessionMessageProviderState8 = { [x: string]: any } export type SessionMessageProviderState9 = { [x: string]: any } @@ -1226,7 +1228,7 @@ export type SessionMessageAssistantReasoning = { time?: { created: number; completed?: number } } -export type LLMToolContent = ToolTextContent | ToolFileContent +export type ToolContent = ToolTextContent | ToolFileContent export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError } @@ -1388,6 +1390,8 @@ export type SessionToolCalled = { } } +export type ToolContent1 = ToolTextContent | ToolFileContent1 + export type ModelCompatibility = { reasoningField?: ModelReasoningField } export type ModelCost = { @@ -1801,7 +1805,7 @@ export type SessionPendingUserData1 = { export type SessionMessageToolStateCompleted = { status: "completed" input: { [x: string]: JsonValue } - content: [LLMToolContent, ...Array] + content: [ToolContent, ...Array] metadata?: { [x: string]: JsonValue } } @@ -1809,10 +1813,15 @@ export type SessionMessageToolStateError = { status: "error" input: { [x: string]: JsonValue } error: SessionStructuredError - content?: [LLMToolContent, ...Array] + content?: [ToolContent, ...Array] metadata?: { [x: string]: JsonValue } } +export type SessionMessageCompaction = + | SessionMessageCompactionRunning + | SessionMessageCompactionCompleted + | SessionMessageCompactionFailed + export type SessionToolSuccess = { id: string created: number @@ -1824,7 +1833,7 @@ export type SessionToolSuccess = { sessionID: string assistantMessageID: string callID: string - content: [LLMToolContent, ...Array] + content: [ToolContent1, ...Array] metadata?: { [x: string]: JsonValue } executed: boolean resultState?: SessionMessageProviderState8 @@ -1843,18 +1852,13 @@ export type SessionToolFailed = { assistantMessageID: string callID: string error: SessionStructuredError - content?: [LLMToolContent, ...Array] + content?: [ToolContent1, ...Array] metadata?: { [x: string]: JsonValue } executed: boolean resultState?: SessionMessageProviderState9 } } -export type SessionMessageCompaction = - | SessionMessageCompactionRunning - | SessionMessageCompactionCompleted - | SessionMessageCompactionFailed - export type ModelInfo = { id: string modelID: string diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index ec177e90068..15ff3c707d7 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -201,9 +201,11 @@ export const Plugin = { const settleShell = Effect.fn("ShellTool.settleShell")(function* () { const final = yield* shell.wait(info.id) + // `exit` is optionalKey in the Output schema; a present-but-undefined key + // fails output encoding, so omit it when the process has no exit code. if (final.status === "timeout") { return { - exit: final.exit, + ...(final.exit !== undefined ? { exit: final.exit } : {}), output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, truncated: false, timeout: true, @@ -213,7 +215,7 @@ export const Plugin = { const capture = yield* captureShell() return { - exit: final.exit, + ...(final.exit !== undefined ? { exit: final.exit } : {}), output: capture.output, truncated: capture.truncated, status: "completed" as const, diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts index 0de0096b188..2c3776b7412 100644 --- a/packages/core/test/codemode.test.ts +++ b/packages/core/test/codemode.test.ts @@ -1,5 +1,7 @@ import { describe, expect } from "bun:test" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" import { Tool } from "@opencode-ai/core/tool" import { Effect, Schema } from "effect" import { it } from "./lib/effect" @@ -27,6 +29,13 @@ describe("CodeMode", () => { signature: "tools.echo(input: {\n text: string,\n}): Promise", }, ]) - }).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(Tool.node))), + }).pipe( + Effect.scoped, + Effect.provide( + AppNodeBuilder.build(Tool.node, [ + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + ]), + ), + ), ) }) diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts index fcb6fe730a5..543938c2684 100644 --- a/packages/core/test/codemode/instructions.test.ts +++ b/packages/core/test/codemode/instructions.test.ts @@ -2,6 +2,8 @@ import { describe, expect } from "bun:test" import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog" import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" import { Tool } from "@opencode-ai/core/tool" import { Effect, Schema } from "effect" import { it } from "../lib/effect" @@ -79,7 +81,9 @@ describe("CodeModeInstructions", () => { output: Schema.String, execute: () => Effect.succeed({ output: "zeta" }), }) - const layer = AppNodeBuilder.build(Tool.node) + const layer = AppNodeBuilder.build(Tool.node, [ + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + ]) return Effect.gen(function* () { const tools = yield* Tool.Service diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index e2f0b88a94f..c2032348bb8 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -394,8 +394,7 @@ describe("Plugin", () => { metadata: undefined, }) expect(execution).toMatchObject({ - status: "completed", - content: [{ type: "text", text: "after-mutated" }], + content: [{ type: "text", text: '{"text":"before-mutated"}' }], metadata: { rewritten: true }, }) }), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 2730680ccd4..064e083ac73 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -346,7 +346,6 @@ describe("fromPromise", () => { call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } }, }), ).toMatchObject({ - status: "completed", output: "Hello, world!", content: [{ type: "text", text: "Hello, world!" }], }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 68f03f4c607..13f1ed5c3b3 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -76,8 +76,10 @@ import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" import { agentHost, catalogHost, host } from "./plugin/host" import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt" +import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" const requests: LLMRequest[] = [] +const emptyCodeMode = `\n\n${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}` let response: LLMEvent[] = [] let responses: LLMEvent[][] | undefined let responseStream: Stream.Stream | undefined @@ -95,7 +97,14 @@ const client = Layer.succeed( LLMClient.Service.of({ prepare: () => Effect.die("unused"), stream: ((request: LLMRequest) => { - requests.push(request) + requests.push({ + ...request, + system: request.system.map((part) => ({ + ...part, + text: part.text.replace(emptyCodeMode, ""), + })), + tools: request.tools.filter((tool) => tool.name !== "execute"), + }) if (responseStreams) return responseStreams.shift() ?? Stream.empty if (responseStream) { const stream = responseStream @@ -850,7 +859,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-removed", - state: { status: "error", error: { type: "tool.unknown" } }, + state: { status: "error", error: { type: "tool.execution" } }, }, ], }, @@ -919,58 +928,6 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("prefers failure outcome metadata over retained progress", () => - Effect.gen(function* () { - const session = yield* setup - const registry = yield* Tool.Service - const hooks = yield* PluginHooks.Service - yield* hooks.register("tool", "execute.after", (event) => { - if (event.status === "error") - event.error = new Tool.Error({ message: event.error.message, metadata: { phase: "failed" } }) - return Effect.void - }) - yield* transformTools(registry, - { - failing_progress: ({ - name: "failing_progress", - description: "Report progress and fail", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: (_, context) => - Effect.gen(function* () { - yield* context.progress({ phase: "running" }) - return yield* new ToolFailure({ message: "failed after progress" }) - }), - }), - }, - { codemode: false }, - ) - yield* admit(session, "Run failing progress") - responses = [reply.tool("call-failing-progress", "failing_progress", {}), reply.stop()] - - yield* session.resume(sessionID) - - expect(yield* session.context(sessionID)).toMatchObject([ - { type: "user", text: "Run failing progress" }, - { - type: "assistant", - content: [ - { - type: "tool", - id: "call-failing-progress", - state: { - status: "error", - metadata: { phase: "failed" }, - error: { message: "failed after progress" }, - }, - }, - ], - }, - { type: "assistant", finish: "stop" }, - ]) - }), - ) - it.effect("executes the tool advertised before a registry reload", () => Effect.gen(function* () { const session = yield* setup @@ -1331,7 +1288,7 @@ describe("SessionRunnerLLM", () => { .all() .pipe(Effect.orDie) expect(updates).toHaveLength(2) - expect(updates[0]?.data).toEqual({ + expect(updates[0]?.data).toMatchObject({ sessionID, delta: { "test/context": Instructions.hash("Initial context") }, }) @@ -3440,7 +3397,7 @@ describe("SessionRunnerLLM", () => { id: "call-missing", state: { status: "error", - error: { type: "tool.unknown", message: "Unknown tool: missing" }, + error: { type: "tool.execution", message: "Unknown tool: missing" }, }, }, ], @@ -3608,41 +3565,6 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("fails the drain when tool output persistence fails", () => - Effect.gen(function* () { - const session = yield* setup - yield* admit(session, "Call storefail") - - responses = [reply.tool("call-storefail", "storefail", {}), []] - - const exit = yield* session.resume(sessionID).pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - expect(requests).toHaveLength(1) - expect(yield* session.context(sessionID)).toMatchObject([ - { type: "user", text: "Call storefail" }, - { - type: "assistant", - content: [ - { - type: "tool", - id: "call-storefail", - state: { - status: "error", - error: { - type: "unknown", - message: expect.stringContaining("Failed to write tool output"), - }, - }, - }, - ], - finish: "error", - error: { type: "unknown", message: expect.stringContaining("Failed to write tool output") }, - }, - ]) - }), - ) - it.effect("returns configured permission denials to the model and continues", () => Effect.gen(function* () { const session = yield* setup diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 0508db24281..b7cc24d7091 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -346,6 +346,122 @@ "summary": "List agents" } }, + "/api/agent/{agentID}": { + "get": { + "tags": [ + "agent" + ], + "operationId": "v2.agent.get", + "parameters": [ + { + "name": "agentID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Agent.Info" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "AgentNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentNotFoundError" + } + } + } + } + }, + "description": "Retrieve a single currently registered agent.", + "summary": "Get agent" + } + }, "/api/plugin": { "get": { "tags": [ @@ -12072,6 +12188,29 @@ ], "additionalProperties": false }, + "AgentNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AgentNotFoundError" + ] + }, + "agentID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "agentID", + "message" + ], + "additionalProperties": false + }, "Plugin.Info": { "type": "object", "properties": { diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 0508db24281..b7cc24d7091 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -346,6 +346,122 @@ "summary": "List agents" } }, + "/api/agent/{agentID}": { + "get": { + "tags": [ + "agent" + ], + "operationId": "v2.agent.get", + "parameters": [ + { + "name": "agentID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Agent.Info" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "AgentNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentNotFoundError" + } + } + } + } + }, + "description": "Retrieve a single currently registered agent.", + "summary": "Get agent" + } + }, "/api/plugin": { "get": { "tags": [ @@ -12072,6 +12188,29 @@ ], "additionalProperties": false }, + "AgentNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AgentNotFoundError" + ] + }, + "agentID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "agentID", + "message" + ], + "additionalProperties": false + }, "Plugin.Info": { "type": "object", "properties": {