refactor(tools): unify tool APIs and result handling (#38367)
This commit is contained in:
parent
8cac010bac
commit
79c1544072
133 changed files with 3602 additions and 2770 deletions
|
|
@ -28,7 +28,7 @@ export type TurnControl = {
|
|||
type ToolState = {
|
||||
readonly name: string
|
||||
input: ToolInput
|
||||
structured: Record<string, unknown>
|
||||
metadata: Record<string, unknown>
|
||||
content: ToolContent
|
||||
}
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ export type TurnStart =
|
|||
| { readonly type: "compaction"; readonly id: string }
|
||||
|
||||
function emptyToolState(): ToolState {
|
||||
return { name: "tool", input: {}, structured: {}, content: [] }
|
||||
return { name: "tool", input: {}, metadata: {}, content: [] }
|
||||
}
|
||||
|
||||
export async function streamTurn(input: {
|
||||
|
|
@ -120,7 +120,7 @@ export async function streamTurn(input: {
|
|||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
tools.set(event.data.callID, { name: event.data.name, input: {}, structured: {}, content: [] })
|
||||
tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
|
||||
await update({
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
|
|
@ -151,15 +151,13 @@ export async function streamTurn(input: {
|
|||
if (event.type === "session.tool.progress") {
|
||||
const current = tools.get(event.data.callID)
|
||||
if (!current) continue
|
||||
current.structured = event.data.structured
|
||||
current.content = event.data.content
|
||||
current.metadata = event.data.metadata
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
state: { input: current.input },
|
||||
content: current.content,
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
|
|
@ -175,7 +173,7 @@ export async function streamTurn(input: {
|
|||
cwd: input.cwd,
|
||||
toolName: current.name,
|
||||
toolInput: current.input,
|
||||
structured: event.data.structured,
|
||||
metadata: event.data.metadata ?? {},
|
||||
}).catch(() => {})
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
|
|
@ -183,9 +181,8 @@ export async function streamTurn(input: {
|
|||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
structured: event.data.structured,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
|
|
@ -199,7 +196,7 @@ export async function streamTurn(input: {
|
|||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
structured: event.data.metadata ?? current.structured,
|
||||
metadata: event.data.metadata ?? current.metadata,
|
||||
content: event.data.content ?? current.content,
|
||||
error: event.data.error.message,
|
||||
cwd: input.cwd,
|
||||
|
|
@ -342,9 +339,8 @@ async function replayMessage(
|
|||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
structured: part.state.structured,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
result: part.state.result,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
|
@ -358,7 +354,6 @@ async function replayMessage(
|
|||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
state: { input: part.state.input },
|
||||
content: part.state.content,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
|
|
@ -373,7 +368,7 @@ async function replayMessage(
|
|||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
structured: part.state.structured,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
error: part.state.error.message,
|
||||
cwd,
|
||||
|
|
|
|||
|
|
@ -58,11 +58,11 @@ export async function syncEditedFiles(input: {
|
|||
readonly cwd: string
|
||||
readonly toolName: string
|
||||
readonly toolInput: ToolInput
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
readonly metadata: Readonly<Record<string, unknown>>
|
||||
}) {
|
||||
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
|
||||
const files = Array.isArray(input.structured.files)
|
||||
? input.structured.files.flatMap((file): string[] => {
|
||||
const files = Array.isArray(input.metadata.files)
|
||||
? input.metadata.files.flatMap((file): string[] => {
|
||||
if (!file || typeof file !== "object") return []
|
||||
const path = Reflect.get(file, "file")
|
||||
return typeof path === "string" ? [path] : []
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { isAbsolute, resolve } from "node:path"
|
||||
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
|
||||
import { readDisplayText } from "@opencode-ai/tui/mini/tool"
|
||||
|
||||
export type ToolInput = Record<string, unknown>
|
||||
export type ToolContent = ReadonlyArray<
|
||||
|
|
@ -100,11 +101,12 @@ export function completedToolUpdate(input: {
|
|||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly content: ToolContent
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
readonly result?: unknown
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
}): ToolCallUpdate {
|
||||
const normalized = toolContent(input.content)
|
||||
const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined
|
||||
// Read's model content is a JSON page envelope; show the clean text instead.
|
||||
const firstText = input.content.find((part) => part.type === "text")
|
||||
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
|
||||
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
|
||||
const primary =
|
||||
read === undefined
|
||||
|
|
@ -128,8 +130,7 @@ export function completedToolUpdate(input: {
|
|||
status: "completed",
|
||||
content: [...primary, ...diff, ...images],
|
||||
rawOutput: {
|
||||
structured: input.structured,
|
||||
...(input.result === undefined ? {} : { result: input.result }),
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -138,8 +139,8 @@ export function errorToolUpdate(input: {
|
|||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly content: ToolContent
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
readonly content?: ToolContent
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly error: string
|
||||
readonly cwd?: string
|
||||
}): ToolCallUpdate {
|
||||
|
|
@ -150,8 +151,11 @@ export function errorToolUpdate(input: {
|
|||
title: toolTitle(input.toolName, input.input, undefined),
|
||||
locations: toLocations(input.toolName, input.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.input, input.cwd),
|
||||
content: [...toolContent(input.content), { type: "content", content: { type: "text", text: input.error } }],
|
||||
rawOutput: { structured: input.structured, error: input.error },
|
||||
content: [...toolContent(input.content ?? []), { type: "content", content: { type: "text", text: input.error } }],
|
||||
rawOutput: {
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
error: input.error,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -164,21 +168,6 @@ function toolContent(content: ToolContent): ToolCallContent[] {
|
|||
})
|
||||
}
|
||||
|
||||
function readDisplayText(structured: Readonly<Record<string, unknown>>) {
|
||||
if (typeof structured.content === "string") {
|
||||
if (structured.type === "text-page" || structured.encoding === "utf8") return structured.content
|
||||
}
|
||||
if (!Array.isArray(structured.entries)) return undefined
|
||||
return structured.entries
|
||||
.flatMap((entry): string[] => {
|
||||
if (typeof entry === "string") return [entry]
|
||||
if (!entry || typeof entry !== "object") return []
|
||||
const path = Reflect.get(entry, "path")
|
||||
return typeof path === "string" ? [path] : []
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
|
||||
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
|
||||
return fallback || toolName
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
Reference,
|
||||
Skill,
|
||||
} from "@opencode-ai/plugin/v2"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/tool"
|
||||
|
||||
const key = Symbol.for("opencode.plugin.v2.promise")
|
||||
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||
|
|
@ -23,4 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.promise")
|
|||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
Tool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import type {
|
|||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { EOL } from "node:os"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
|
||||
import { nonEmptyToolContent, toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
|
||||
import { UI } from "./ui"
|
||||
|
||||
type Model = {
|
||||
|
|
@ -55,7 +55,7 @@ type ToolState = StartedPart & {
|
|||
raw?: string
|
||||
provider?: unknown
|
||||
providerState?: SessionMessageAssistantTool["providerState"]
|
||||
structured: Record<string, JsonValue>
|
||||
metadata: Record<string, JsonValue>
|
||||
content: LLMToolContent[]
|
||||
}
|
||||
|
||||
|
|
@ -306,7 +306,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: event.data.name,
|
||||
input: {},
|
||||
structured: {},
|
||||
metadata: {},
|
||||
content: [],
|
||||
})
|
||||
continue
|
||||
|
|
@ -334,7 +334,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
raw: current?.raw,
|
||||
provider: { executed: event.data.executed, state: event.data.state },
|
||||
providerState: event.data.state,
|
||||
structured: {},
|
||||
metadata: {},
|
||||
content: [],
|
||||
})
|
||||
continue
|
||||
|
|
@ -342,8 +342,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
if (event.type === "session.tool.progress") {
|
||||
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
|
||||
if (current) {
|
||||
current.structured = event.data.structured
|
||||
current.content = event.data.content
|
||||
current.metadata = event.data.metadata
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -360,9 +359,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
state: {
|
||||
status: "completed",
|
||||
input: current.input,
|
||||
structured: event.data.structured,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
||||
}
|
||||
|
|
@ -379,9 +377,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
output: toolOutputText(current.tool, event.data.content),
|
||||
title: current.tool,
|
||||
metadata: {
|
||||
structured: event.data.structured,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||
rawInput: current.raw,
|
||||
|
|
@ -398,8 +395,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
||||
const current = tools.get(key) ?? fallbackTool(event)
|
||||
const error = event.data.error.message
|
||||
const structured = event.data.metadata ?? current.structured
|
||||
const content = event.data.content ?? current.content
|
||||
const metadata = event.data.metadata ?? current.metadata
|
||||
const content = event.data.content ?? nonEmptyToolContent(current.content)
|
||||
const tool: SessionMessageAssistantTool = {
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
|
|
@ -410,10 +407,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
state: {
|
||||
status: "error",
|
||||
input: current.input,
|
||||
structured,
|
||||
metadata,
|
||||
content,
|
||||
error: event.data.error,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
||||
}
|
||||
|
|
@ -429,7 +425,6 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
input: current.input,
|
||||
error,
|
||||
metadata: {
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||
rawInput: current.raw,
|
||||
|
|
@ -441,15 +436,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
renderedTools.add(key)
|
||||
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
|
||||
if (!emit("tool_use", time, { part })) {
|
||||
if (toolOutputText(current.tool, content).trim())
|
||||
if (content && toolOutputText(current.tool, content).trim())
|
||||
await input.renderTool({
|
||||
...tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: current.input,
|
||||
structured,
|
||||
metadata,
|
||||
content,
|
||||
result: event.data.result,
|
||||
},
|
||||
})
|
||||
await input.renderToolError(tool)
|
||||
|
|
@ -597,14 +591,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
input: item.state.input,
|
||||
output: toolOutputText(item.name, item.state.content),
|
||||
title: item.name,
|
||||
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
|
||||
metadata: { metadata: item.state.metadata, content: item.state.content },
|
||||
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
|
||||
}
|
||||
: {
|
||||
status: "error",
|
||||
input: item.state.input,
|
||||
error: item.state.error.message,
|
||||
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
|
||||
metadata: { metadata: item.state.metadata, content: item.state.content },
|
||||
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
|
||||
},
|
||||
}
|
||||
|
|
@ -614,8 +608,16 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
await input.renderTool(item)
|
||||
continue
|
||||
}
|
||||
if (toolOutputText(item.name, item.state.content).trim()) {
|
||||
await input.renderTool({ ...item, state: { ...item.state, status: "completed" } })
|
||||
if (item.state.content && toolOutputText(item.name, item.state.content).trim()) {
|
||||
await input.renderTool({
|
||||
...item,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: item.state.input,
|
||||
metadata: item.state.metadata,
|
||||
content: item.state.content,
|
||||
},
|
||||
})
|
||||
}
|
||||
await input.renderToolError(item)
|
||||
UI.error(item.state.error.message)
|
||||
|
|
@ -792,7 +794,7 @@ function fallbackTool(event: {
|
|||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: "tool",
|
||||
input: {},
|
||||
structured: {},
|
||||
metadata: {},
|
||||
content: [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,8 +218,7 @@ describe("acp event behavior", () => {
|
|||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_ok",
|
||||
structured: { phase: 1 },
|
||||
content: [{ type: "text", text: "working" }],
|
||||
metadata: { phase: 1 },
|
||||
}),
|
||||
)
|
||||
send(
|
||||
|
|
@ -227,9 +226,8 @@ describe("acp event behavior", () => {
|
|||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_ok",
|
||||
structured: { exit: 0 },
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
result: { code: 0 },
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
|
|
@ -255,8 +253,7 @@ describe("acp event behavior", () => {
|
|||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_fail",
|
||||
structured: { bytes: 0 },
|
||||
content: [{ type: "text", text: "opening" }],
|
||||
metadata: { bytes: 0 },
|
||||
}),
|
||||
)
|
||||
send(
|
||||
|
|
@ -313,12 +310,10 @@ describe("acp event behavior", () => {
|
|||
locations: [{ path: resolve("/workspace", "sub") }],
|
||||
rawInput: { command: "printf done", workdir: "sub" },
|
||||
})
|
||||
expect(updates[2]?.update).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "working" } }],
|
||||
})
|
||||
expect(updates[2]?.update).not.toHaveProperty("content")
|
||||
expect(updates[3]?.update).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(updates[7]?.update).toMatchObject({
|
||||
kind: "read",
|
||||
|
|
@ -327,7 +322,7 @@ describe("acp event behavior", () => {
|
|||
{ type: "content", content: { type: "text", text: "opening" } },
|
||||
{ type: "content", content: { type: "text", text: "not found" } },
|
||||
],
|
||||
rawOutput: { structured: { bytes: 0 }, error: "not found" },
|
||||
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
} finally {
|
||||
|
|
@ -379,7 +374,7 @@ describe("acp event behavior", () => {
|
|||
{ type: "content", content: { type: "text", text: "done" } },
|
||||
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
|
||||
],
|
||||
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(updates[8]?.update).toMatchObject({
|
||||
toolCallId: "call_running",
|
||||
|
|
@ -618,12 +613,11 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
|||
state: {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
structured: { exit: 0 },
|
||||
metadata: { exit: 0 },
|
||||
content: [
|
||||
{ type: "text", text: "done" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
|
||||
],
|
||||
result: { code: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -634,8 +628,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
|||
state: {
|
||||
status: "running",
|
||||
input: { command: "pwd" },
|
||||
structured: {},
|
||||
content: [{ type: "text", text: "/workspace" }],
|
||||
metadata: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -646,7 +639,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
|||
state: {
|
||||
status: "error",
|
||||
input: { filePath: "/workspace/missing.ts" },
|
||||
structured: { bytes: 0 },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
error: { type: "tool.error", message: "failed hard" },
|
||||
},
|
||||
|
|
@ -679,7 +672,7 @@ function replayToolMessage(id: string) {
|
|||
state: {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
structured: { exit: 0 },
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ describe("acp permission behavior", () => {
|
|||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
toolInput: { filePath: "/workspace/file.ts" },
|
||||
structured: {},
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
expect(writes).toEqual([])
|
||||
|
|
@ -193,7 +193,7 @@ describe("acp permission behavior", () => {
|
|||
sessionID: "ses_edit",
|
||||
assistantMessageID: "msg_edit",
|
||||
callID: "call_edit",
|
||||
structured: { files: [{ file: "file.ts" }], replacements: 1 },
|
||||
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
|
||||
content: [{ type: "text", text: "edited" }],
|
||||
executed: true,
|
||||
}),
|
||||
|
|
@ -286,7 +286,7 @@ describe("acp permission behavior", () => {
|
|||
sessionID: "ses_patch",
|
||||
assistantMessageID: "msg_patch",
|
||||
callID: "call_patch",
|
||||
structured: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
|
||||
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
|
||||
content: [{ type: "text", text: "patched" }],
|
||||
executed: true,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ describe("acp tools", () => {
|
|||
{ type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
|
||||
{ type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
|
||||
],
|
||||
structured: {},
|
||||
metadata: {},
|
||||
}).content,
|
||||
).toEqual([
|
||||
{
|
||||
|
|
@ -93,7 +93,7 @@ describe("acp tools", () => {
|
|||
content: "created",
|
||||
},
|
||||
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
|
||||
structured: {},
|
||||
metadata: {},
|
||||
}).content,
|
||||
).toEqual([
|
||||
{
|
||||
|
|
@ -103,20 +103,22 @@ describe("acp tools", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test("uses clean structured read content instead of model-facing formatting", () => {
|
||||
test("unwraps read's JSON page envelope instead of showing model-facing formatting", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-read",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/file.ts" },
|
||||
content: [{ type: "text", text: "<content>1: first\n2: second</content>" }],
|
||||
structured: {
|
||||
type: "text-page",
|
||||
content: "first\nsecond",
|
||||
mime: "text/plain",
|
||||
offset: 1,
|
||||
truncated: false,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{ type: "text-page", content: "first\nsecond", mime: "text/plain", offset: 1, truncated: false },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
}).content,
|
||||
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
|
||||
|
||||
|
|
@ -125,13 +127,17 @@ describe("acp tools", () => {
|
|||
toolCallId: "tool-list",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp" },
|
||||
content: [],
|
||||
structured: {
|
||||
entries: [
|
||||
{ path: "a.ts", type: "file" },
|
||||
{ path: "src", type: "directory" },
|
||||
],
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
entries: [
|
||||
{ path: "a.ts", type: "file" },
|
||||
{ path: "src", type: "directory" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}).content,
|
||||
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
|
||||
})
|
||||
|
|
@ -171,7 +177,7 @@ describe("acp tools", () => {
|
|||
newString: "after",
|
||||
},
|
||||
content: [{ type: "text", text: "Edit applied successfully." }],
|
||||
structured: { output: "Edit applied successfully." },
|
||||
metadata: { output: "Edit applied successfully." },
|
||||
}),
|
||||
).toEqual({
|
||||
toolCallId: "tool-1",
|
||||
|
|
@ -189,7 +195,7 @@ describe("acp tools", () => {
|
|||
},
|
||||
],
|
||||
rawOutput: {
|
||||
structured: { output: "Edit applied successfully." },
|
||||
metadata: { output: "Edit applied successfully." },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -209,7 +215,7 @@ describe("acp tools", () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("builds completed raw output with structured data and optional result", () => {
|
||||
test("builds completed raw output with optional metadata", () => {
|
||||
const attachments = [
|
||||
{
|
||||
type: "file",
|
||||
|
|
@ -225,12 +231,10 @@ describe("acp tools", () => {
|
|||
toolName: "read",
|
||||
input: {},
|
||||
content: [],
|
||||
structured: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
result: "done",
|
||||
metadata: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
}).rawOutput,
|
||||
).toEqual({
|
||||
structured: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
result: "done",
|
||||
metadata: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
})
|
||||
|
||||
expect(
|
||||
|
|
@ -239,9 +243,8 @@ describe("acp tools", () => {
|
|||
toolName: "read",
|
||||
input: {},
|
||||
content: [],
|
||||
structured: { output: "done" },
|
||||
}).rawOutput,
|
||||
).toEqual({ structured: { output: "done" } })
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
test("extracts image attachments only from data URLs", () => {
|
||||
|
|
@ -255,7 +258,7 @@ describe("acp tools", () => {
|
|||
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
|
||||
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
|
||||
],
|
||||
structured: {},
|
||||
metadata: {},
|
||||
}).content,
|
||||
).toEqual([
|
||||
{
|
||||
|
|
@ -272,7 +275,7 @@ describe("acp tools", () => {
|
|||
toolName: "read",
|
||||
input: { filePath: "/tmp/a" },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
structured: { path: "/tmp/a" },
|
||||
metadata: { path: "/tmp/a" },
|
||||
error: "failed",
|
||||
}),
|
||||
).toEqual({
|
||||
|
|
@ -286,7 +289,7 @@ describe("acp tools", () => {
|
|||
{ type: "content", content: { type: "text", text: "partial output" } },
|
||||
{ type: "content", content: { type: "text", text: "failed" } },
|
||||
],
|
||||
rawOutput: { structured: { path: "/tmp/a" }, error: "failed" },
|
||||
rawOutput: { metadata: { path: "/tmp/a" }, error: "failed" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,7 +23,12 @@ describe("CLI frontend import boundaries", () => {
|
|||
|
||||
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
||||
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
|
||||
expect(Object.keys(tool).sort()).toEqual(["toolInlineInfo", "toolOutputText"])
|
||||
expect(Object.keys(tool).sort()).toEqual([
|
||||
"nonEmptyToolContent",
|
||||
"readDisplayText",
|
||||
"toolInlineInfo",
|
||||
"toolOutputText",
|
||||
])
|
||||
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -134,15 +134,14 @@ function failedTool(inputID: string): V2Event[] {
|
|||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_failed_tool",
|
||||
callID: "call_failed_tool",
|
||||
structured: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
metadata: { checkpoint: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_failed_tool_terminal",
|
||||
created: 4,
|
||||
type: "session.tool.failed",
|
||||
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
|
||||
durable: { aggregateID: "ses_1", seq: 4, version: 2 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_failed_tool",
|
||||
|
|
@ -190,12 +189,12 @@ function successfulGrep(inputID: string): V2Event[] {
|
|||
id: "evt_grep_success",
|
||||
created: 3,
|
||||
type: "session.tool.success",
|
||||
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||
durable: { aggregateID: "ses_1", seq: 3, version: 2 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_grep",
|
||||
callID: "call_grep",
|
||||
structured: { matches: 2 },
|
||||
metadata: { matches: 2 },
|
||||
content: [{ type: "text", text }],
|
||||
executed: false,
|
||||
},
|
||||
|
|
@ -258,9 +257,7 @@ async function run(input: {
|
|||
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
|
||||
spyOn(sdk.message, "list").mockImplementation(() =>
|
||||
ok({
|
||||
data: input.messages?.(promptID) ?? [
|
||||
{ id: promptID, type: "user", text: "hello", time: { created: 1 } },
|
||||
],
|
||||
data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }],
|
||||
cursor: {},
|
||||
}),
|
||||
)
|
||||
|
|
@ -316,7 +313,7 @@ afterEach(() => {
|
|||
})
|
||||
|
||||
describe("runNonInteractivePrompt", () => {
|
||||
test("keeps formatted tool output and compact structured metadata in JSON", async () => {
|
||||
test("keeps formatted tool output and compact tool metadata in JSON", async () => {
|
||||
const output = await capture({ format: "json", turn: successfulGrep })
|
||||
const events = output.stdout
|
||||
.split("\n")
|
||||
|
|
@ -332,13 +329,13 @@ describe("runNonInteractivePrompt", () => {
|
|||
status: "completed",
|
||||
output: expect.stringContaining("Found 2 matches"),
|
||||
metadata: {
|
||||
structured: { matches: 2 },
|
||||
metadata: { 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.metadata).toEqual({ matches: 2 })
|
||||
expect(events[0].part.state.metadata.result).toBeUndefined()
|
||||
})
|
||||
|
||||
|
|
@ -534,7 +531,7 @@ describe("runNonInteractivePrompt", () => {
|
|||
id: "call_failed_tool",
|
||||
state: {
|
||||
status: "completed",
|
||||
structured: { checkpoint: 1 },
|
||||
metadata: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
},
|
||||
},
|
||||
|
|
@ -544,7 +541,7 @@ describe("runNonInteractivePrompt", () => {
|
|||
id: "call_failed_tool",
|
||||
state: {
|
||||
status: "error",
|
||||
structured: { checkpoint: 1 },
|
||||
metadata: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
error: { message: "tool failed" },
|
||||
},
|
||||
|
|
@ -574,7 +571,7 @@ describe("runNonInteractivePrompt", () => {
|
|||
},
|
||||
})
|
||||
expect(events[0].part.state.output).toBeUndefined()
|
||||
expect(events[0].part.state.metadata.structured).toBeUndefined()
|
||||
expect(events[0].part.state.metadata.metadata).toBeUndefined()
|
||||
expect(events[0].part.state.metadata.content).toBeUndefined()
|
||||
expect(output.stderr).toBe("")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -75,6 +75,10 @@ export const define = sdk.Plugin.define`
|
|||
const effectPluginModule = promisePluginModule
|
||||
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
|
||||
.replace("Promise plugin", "Effect plugin")
|
||||
const promiseToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
|
||||
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
|
||||
export const Tool = sdk.Tool
|
||||
export const make = sdk.Tool.make`
|
||||
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
|
||||
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
|
||||
export const Tool = sdk.Tool
|
||||
|
|
@ -83,10 +87,8 @@ export const RegistrationError = sdk.Tool.RegistrationError
|
|||
export const make = sdk.Tool.make
|
||||
export const validateName = sdk.Tool.validateName
|
||||
export const registrationEntries = sdk.Tool.registrationEntries
|
||||
export const withPermission = sdk.Tool.withPermission
|
||||
export const permission = sdk.Tool.permission
|
||||
export const definition = sdk.Tool.definition
|
||||
export const settle = sdk.Tool.settle`
|
||||
export const validateNamespace = sdk.Tool.validateNamespace
|
||||
export const toLLMDefinition = sdk.Tool.toLLMDefinition`
|
||||
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
|
||||
import __cjs_mod__ from "node:module"
|
||||
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
|
||||
|
|
@ -100,6 +102,7 @@ const require = __cjs_mod__.createRequire(import.meta.url)
|
|||
const __ocPluginModules = ${JSON.stringify({
|
||||
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
|
||||
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
|
||||
"@opencode-ai/plugin/v2/tool": "opencode:plugin-v2-tool",
|
||||
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
|
||||
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
|
||||
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
|
||||
|
|
@ -107,6 +110,7 @@ const __ocPluginModules = ${JSON.stringify({
|
|||
const __ocPluginSources = ${JSON.stringify({
|
||||
"opencode:plugin-v2": promiseModule,
|
||||
"opencode:plugin-v2-plugin": promisePluginModule,
|
||||
"opencode:plugin-v2-tool": promiseToolModule,
|
||||
"opencode:plugin-v2-effect": effectModule,
|
||||
"opencode:plugin-v2-effect-plugin": effectPluginModule,
|
||||
"opencode:plugin-v2-effect-tool": effectToolModule,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue