refactor(tools): unify tool APIs and result handling (#38367)

This commit is contained in:
Kit Langton 2026-07-23 17:13:31 -04:00 committed by GitHub
commit 79c1544072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 3602 additions and 2770 deletions

View file

@ -32,6 +32,7 @@ import type { Plugin } from "@opencode-ai/plugin/v2/tui"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display"
import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
@ -605,7 +606,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
match.time.ran = event.created
match.executed = event.data.executed
match.providerState = event.data.state
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
match.state = { status: "running", input: event.data.input, metadata: {} }
})
break
case "session.tool.progress":
@ -615,8 +616,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
event.data.callID,
)
if (match?.state.status !== "running") return
match.state.structured = event.data.structured
match.state.content = [...event.data.content]
match.state.metadata = event.data.metadata
})
break
case "session.tool.success":
@ -629,9 +629,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
match.state = {
status: "completed",
input: match.state.input,
structured: event.data.structured,
metadata: event.data.metadata,
content: [...event.data.content],
result: event.data.result,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
@ -649,9 +648,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
status: "error",
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: event.data.metadata ?? (match.state.status === "running" ? match.state.structured : {}),
content: event.data.content ?? (match.state.status === "running" ? match.state.content : []),
result: event.data.result,
metadata: event.data.metadata,
content: event.data.content,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState

View file

@ -342,13 +342,13 @@ function make(state: State, tool: string, input: Record<string, JsonValue>): Ref
}
}
function startTool(state: State, ref: Ref, structured: Record<string, JsonValue> = {}): SessionMessageAssistantTool {
function startTool(state: State, ref: Ref, metadata: Record<string, JsonValue> = {}): SessionMessageAssistantTool {
state.started.add(ref.call)
const part = {
type: "tool" as const,
id: ref.call,
name: ref.tool,
state: { status: "running" as const, input: ref.input, structured, content: [] },
state: { status: "running" as const, input: ref.input, metadata },
time: { created: ref.start, ran: ref.start },
}
present(state, [toolCommit(part, ref.msg, "start")])
@ -395,8 +395,8 @@ function doneTool(
state: {
status: "completed",
input: ref.input,
content: output.output ? [{ type: "text", text: output.output }] : [],
structured: output.metadata ?? {},
content: [{ type: "text", text: output.output }],
metadata: output.metadata,
},
time: { created: ref.start, ran: ref.start, completed: Date.now() },
}
@ -415,8 +415,6 @@ function failTool(state: State, ref: Ref, error: string): void {
status: "error",
input: ref.input,
error: { type: "unknown", message: error },
structured: {},
content: [],
},
time: { created: ref.start, ran: ref.start, completed: Date.now() },
},
@ -527,8 +525,7 @@ function emitTask(state: State): void {
offset: 1,
limit: 200,
},
structured: {},
content: [],
metadata: {},
},
time: { created: Date.now(), ran: Date.now() },
} satisfies SessionMessageAssistantTool

View file

@ -53,7 +53,7 @@ export function permissionInfo(request: MiniPermissionRequest, directory?: strin
resources: request.resources,
metadata: request.metadata,
input: state?.status === "streaming" ? undefined : state?.input,
structured: state?.status === "streaming" ? undefined : state?.structured,
toolMetadata: state?.status === "streaming" ? undefined : state?.metadata,
},
(value) => toolPath(value, { home: true, directory }),
)

View file

@ -1,7 +1,7 @@
// Current-native subagent (child Session) tracking for the mini transport.
//
// Discovers child Sessions of the active parent from four current sources:
// 1. projected subagent tool output (`structured.sessionID`) during hydration
// 1. projected subagent tool output (`metadata.sessionID`) during hydration
// 2. the current session list filtered by `parentID` during hydration
// 3. the process-local active-session map during hydration
// 4. live events from unknown sessions whose `parentID` matches the parent
@ -33,6 +33,7 @@ import type {
StreamCommit,
} from "./types"
import { canonicalToolName, normalizeTool, toolOutputText, toolView } from "./tool"
import { toolDisplayContent } from "../util/tool-display"
const CHILD_MESSAGE_LIMIT = 80
const CHILD_FRAME_LIMIT = 80
@ -55,7 +56,7 @@ export function toolCommit(
): StreamCommit {
const part = normalizeTool(input)
const status = part.state.status
const output = status === "streaming" ? "" : toolOutputText(part.name, part.state.content)
const output = status === "streaming" ? "" : toolOutputText(part.name, toolDisplayContent(part.state))
const partial = status === "error" && phase === "progress" && value !== undefined
const text =
status === "running" || partial
@ -178,10 +179,10 @@ function blockerCategory(event: V2Event): "permission" | "form" | undefined {
if (event.type === "form.created" || event.type === "form.replied" || event.type === "form.cancelled") return "form"
}
function childSessionID(structured: Record<string, unknown> | undefined) {
const sessionID = text(structured?.sessionID)
function childSessionID(metadata: Record<string, unknown> | undefined) {
const sessionID = text(metadata?.sessionID)
if (!sessionID || !sessionID.startsWith("ses")) return undefined
const status = structured?.status
const status = metadata?.status
if (status !== "running" && status !== "completed") return undefined
return { sessionID, running: status === "running" }
}
@ -199,7 +200,7 @@ function tab(child: ChildState): FooterSubagentTab {
export function createSubagentTracker(input: SubagentTrackerInput): SubagentTracker {
const children = new Map<string, ChildState>()
// Live subagent tool calls in the parent, so tool.success structured output
// Live subagent tool calls in the parent, so tool.success metadata
// can be joined with the call's input metadata.
const pendingCalls = new Map<string, Record<string, unknown>>()
// Recently resolved non-family sessions. Retention is bounded so unrelated
@ -309,7 +310,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return
}
const current = child.tools.get(key)
const output = toolOutputText(part.name, part.state.content)
const output = toolOutputText(part.name, toolDisplayContent(part.state))
if (part.state.status === "running") {
if (!current || current.part.state.status === "streaming")
setFrame(child, frame, toolCommit(part, messageID, "start", undefined, input.directory))
@ -779,7 +780,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
name: current?.part.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
state: { status: "running", input: event.data.input, metadata: {} },
time: { created: current?.part.time.created ?? event.created, ran: event.created },
},
event.data.assistantMessageID,
@ -804,8 +805,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
state: {
status: "running",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured: event.data.structured,
content: event.data.content,
metadata: event.data.metadata,
},
time: {
created: part?.time.created ?? event.created,
@ -837,18 +837,15 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
? {
status: "error",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured:
event.data.metadata ?? (part && part.state.status !== "streaming" ? part.state.structured : {}),
content: event.data.content ?? (part && part.state.status !== "streaming" ? part.state.content : []),
metadata: event.data.metadata,
content: event.data.content,
error: event.data.error,
result: event.data.result,
}
: {
status: "completed",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
},
time: {
created: part?.time.created ?? event.created,
@ -921,7 +918,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const mainTool = (item: SessionMessageAssistantTool, active?: Record<string, unknown>) => {
const tool = normalizeTool(item)
if (tool.name !== "subagent" || tool.state.status === "streaming") return
const found = childSessionID(record(tool.state.structured))
const found = childSessionID(record(tool.state.metadata))
if (!found) return
const child = admitChild(found.sessionID)
if (!child) return
@ -967,7 +964,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
const pending = pendingCalls.get(key)
if (event.type !== "session.tool.progress") pendingCalls.delete(key)
const found = childSessionID(record(event.type === "session.tool.failed" ? event.data.metadata : event.data.structured))
const found = childSessionID(record(event.data.metadata))
if (!found) return
const child = admitChild(found.sessionID)
if (!child) return
@ -1085,11 +1082,13 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
input.emit()
},
snapshot() {
const tabs = [...children.values()].toSorted((a, b) => {
const active = Number(b.status === "running") - Number(a.status === "running")
if (active !== 0) return active
return b.lastUpdatedAt - a.lastUpdatedAt
}).map(tab)
const tabs = [...children.values()]
.toSorted((a, b) => {
const active = Number(b.status === "running") - Number(a.status === "running")
if (active !== 0) return active
return b.lastUpdatedAt - a.lastUpdatedAt
})
.map(tab)
const child = selected ? children.get(selected) : undefined
const details: Record<string, FooterSubagentDetail> =
child && !child.detailStale ? { [child.sessionID]: { commits: child.frames.map((item) => item.commit) } } : {}

View file

@ -16,6 +16,7 @@ import { writeSessionOutput } from "./stream"
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent"
import { normalizeTool, toolOutputText } from "./tool"
import { toolDisplayContent } from "../util/tool-display"
import type {
FooterApi,
FooterView,
@ -558,7 +559,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
const current = state.tools.get(key)
const output = toolOutputText(part.name, part.state.content)
const output = toolOutputText(part.name, toolDisplayContent(part.state))
const prefix = current ? output.startsWith(current.output) : false
const version = current && !prefix ? current.version + 1 : (current?.version ?? 0)
const delta = current && prefix ? output.slice(current.output.length) : output
@ -670,8 +671,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
{
kind: "reasoning",
source: "reasoning",
text:
update.previous.length === 0 ? `Thinking: ${item.text}` : item.text.slice(update.previous.length),
text: update.previous.length === 0 ? `Thinking: ${item.text}` : item.text.slice(update.previous.length),
phase: "progress",
messageID: message.id,
partID: fragment.partID,
@ -1042,7 +1042,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
name: current?.part.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
state: { status: "running", input: event.data.input, metadata: {} },
time: { created: current?.part.time.created ?? event.created, ran: event.created },
}
renderTool(event.data.assistantMessageID, item)
@ -1062,8 +1062,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state: {
status: "running",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured: event.data.structured,
content: event.data.content,
metadata: event.data.metadata,
},
time: { created: part?.time.created ?? event.created, ran: part?.time.ran ?? event.created },
})
@ -1084,18 +1083,15 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
? {
status: "error",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured:
event.data.metadata ?? (part && part.state.status !== "streaming" ? part.state.structured : {}),
content: event.data.content ?? (part && part.state.status !== "streaming" ? part.state.content : []),
metadata: event.data.metadata,
content: event.data.content,
error: event.data.error,
result: event.data.result,
}
: {
status: "completed",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
},
time: { created: part?.time.created ?? event.created, ran: part?.time.ran, completed: event.created },
}

View file

@ -75,7 +75,7 @@ function traceCommit(commit: StreamCommit) {
state: {
status: commit.part.state.status,
input: summarize(commit.part.state.input),
structured: "structured" in commit.part.state ? summarize(commit.part.state.structured) : undefined,
metadata: "metadata" in commit.part.state ? summarize(commit.part.state.metadata) : undefined,
content: "content" in commit.part.state ? summarize(commit.part.state.content) : undefined,
error: "error" in commit.part.state ? summarize(commit.part.state.error) : undefined,
},

View file

@ -1,2 +1,3 @@
export { toolInlineInfo, toolOutputText } from "./tool"
export { readDisplayText, toolInlineInfo, toolOutputText } from "./tool"
export { nonEmptyToolContent } from "../util/tool-display"
export type { MiniToolPart } from "./types"

View file

@ -21,6 +21,7 @@ import {
canonicalToolName,
finiteNumber,
primitiveInputSummary,
toolDisplayContent,
toolDisplayMetadata,
webSearchProviderLabel,
} from "../util/tool-display"
@ -157,10 +158,36 @@ function text(v: unknown): string {
return typeof v === "string" ? v : ""
}
export function toolOutputText(name: string, content: ReadonlyArray<{ type: string; text?: string }>) {
export function toolOutputText(name: string, content: ReadonlyArray<{ type: string; text?: string }> | undefined) {
if (!content) return ""
// V2 shell content appends model-only status after the user-visible command output.
if (canonicalToolName(name) === "shell") return content.find((item) => item.type === "text")?.text ?? ""
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
const joined = content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
if (canonicalToolName(name) === "read") return readDisplayText(joined) ?? joined
return joined
}
/** Read's model content is a JSON page envelope; unwrap the human-facing text. */
export function readDisplayText(text: string): string | undefined {
if (!text.startsWith("{")) return undefined
const parsed = (() => {
try {
return JSON.parse(text) as unknown
} catch {
return undefined
}
})()
const envelope = dict(parsed)
if (typeof envelope.content === "string" && (envelope.type === "text-page" || envelope.encoding === "utf8"))
return envelope.content
if (!Array.isArray(envelope.entries)) return undefined
return envelope.entries
.flatMap((entry): string[] => {
if (typeof entry === "string") return [entry]
const path = dict(entry).path
return typeof path === "string" ? [path] : []
})
.join("\n")
}
function normalizeInput(name: string, value: unknown) {
@ -202,16 +229,16 @@ function normalizeFile(value: unknown): PatchFile | undefined {
}
}
function normalizeStructured(name: string, value: unknown) {
const structured = dict(value)
const files = list(structured.files).flatMap((item) => {
function normalizeMetadata(name: string, value: unknown) {
const metadata = dict(value)
const files = list(metadata.files).flatMap((item) => {
const file = normalizeFile(item)
return file ? [file] : []
})
const sessionID = text(structured.sessionID) || text(structured.sessionId)
const sessionID = text(metadata.sessionID) || text(metadata.sessionId)
return {
...structured,
...(["edit", "patch"].includes(name) && Array.isArray(structured.files) ? { files } : {}),
...metadata,
...(["edit", "patch"].includes(name) && Array.isArray(metadata.files) ? { files } : {}),
...(name === "subagent" && sessionID ? { sessionID } : {}),
}
}
@ -225,7 +252,7 @@ export function normalizeTool(tool: SessionMessageAssistantTool): SessionMessage
state: {
...tool.state,
input: normalizeInput(name, tool.state.input),
structured: normalizeStructured(name, toolDisplayMetadata(tool.state)),
metadata: normalizeMetadata(name, toolDisplayMetadata(tool.state)),
},
} as SessionMessageAssistantTool
}
@ -1089,13 +1116,13 @@ function frame(part: SessionMessageAssistantTool, directory?: string): ToolFrame
output: "",
time: { start: tool.time.created },
}
const output = toolOutputText(tool.name, tool.state.content)
const output = toolOutputText(tool.name, toolDisplayContent(tool.state))
return {
directory,
raw: output,
name: tool.name,
input: normalizeInput(tool.name, tool.state.input),
meta: normalizeStructured(tool.name, tool.state.structured),
meta: normalizeMetadata(tool.name, tool.state.metadata),
state: dict(tool.state),
status: tool.state.status,
error: tool.state.status === "error" ? tool.state.error.message : "",

View file

@ -42,6 +42,7 @@ import {
canonicalToolName,
finiteNumber,
primitiveInputSummary,
toolDisplayContent,
toolDisplayMetadata,
webSearchProviderLabel,
} from "../../util/tool-display"
@ -2146,7 +2147,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
},
get output() {
if (props.part.state.status === "streaming") return undefined
return props.part.state.content
return toolDisplayContent(props.part.state)
.flatMap((content) => (content.type === "text" ? [content.text] : [content.name ?? content.uri]))
.join("\n")
},
@ -2548,6 +2549,8 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
)
}
const SHELL_DISPLAY_LIMIT = 1024 * 1024
function Shell(props: ToolProps) {
const { themeV2 } = useTheme()
const ctx = use()
@ -2559,6 +2562,7 @@ function Shell(props: ToolProps) {
})
const color = createMemo(() => (permission() ? themeV2.text.feedback.warning.default : themeV2.text.default))
const shellID = createMemo(() => stringValue(props.metadata.shellID))
const background = createMemo(() => Boolean(shellID()) && props.part.state.status !== "running")
const backgroundRunning = createMemo(() => {
const id = shellID()
return Boolean(id && data.shell.get(id))
@ -2567,31 +2571,73 @@ function Shell(props: ToolProps) {
const command = createMemo(() => stringValue(props.input.command))
const [expanded, setExpanded] = createSignal(false)
const [backgroundOutput, setBackgroundOutput] = createSignal("")
const [outputTruncated, setOutputTruncated] = createSignal(false)
let loading = false
const loadBackgroundOutput = async () => {
let drainRequested = false
let cursor = 0
let wasRunning = false
const loadBackgroundOutput = async (drain = false) => {
const id = shellID()
if (!id || loading) return
if (!id) return
if (loading) {
if (drain) drainRequested = true
return
}
loading = true
const location = data.session.get(ctx.sessionID)?.location
await client.api.shell
.output({
id,
limit: 1024 * 1024,
location: location ? { directory: location.directory, workspace: location.workspaceID } : undefined,
})
.then((response) => setBackgroundOutput(stripAnsi(response.data.output.trim())))
.catch(() => undefined)
do {
const response = await client.api.shell
.output({
id,
cursor,
limit: SHELL_DISPLAY_LIMIT,
location: location ? { directory: location.directory, workspace: location.workspaceID } : undefined,
})
.catch(() => undefined)
if (!response) break
if (response.data.output)
setBackgroundOutput((output) => {
const next = stripAnsi(output + response.data.output)
if (next.length <= SHELL_DISPLAY_LIMIT) return next
setOutputTruncated(true)
return next.slice(-SHELL_DISPLAY_LIMIT)
})
if (response.data.cursor <= cursor) break
cursor = response.data.cursor
if (!drain || cursor >= response.data.size) break
const tail = Math.max(cursor, response.data.size - SHELL_DISPLAY_LIMIT)
if (tail > cursor) {
cursor = tail
setOutputTruncated(true)
}
} while (true)
loading = false
if (drainRequested) {
drainRequested = false
void loadBackgroundOutput(true)
}
}
createEffect(() => {
if (!expanded() || !backgroundRunning()) return
const running = backgroundRunning()
if (!running) {
if (wasRunning) void loadBackgroundOutput(true)
wasRunning = false
return
}
wasRunning = true
if (background() && !expanded()) return
void loadBackgroundOutput()
const interval = setInterval(() => void loadBackgroundOutput(), 1_000)
onCleanup(() => clearInterval(interval))
})
const output = createMemo(() => {
if (props.part.state.status === "streaming") return ""
if (shellID()) return expanded() ? backgroundOutput() : ""
const content = props.part.state.content[0]
if (shellID()) {
if (background() && !expanded()) return ""
const text = backgroundOutput().trim()
return outputTruncated() ? `[earlier output omitted]\n${text}` : text
}
const content = toolDisplayContent(props.part.state)[0]
return stripAnsi(content?.type === "text" ? content.text.trim() : "")
})
const maxLines = 10
@ -2607,7 +2653,7 @@ function Shell(props: ToolProps) {
const toggle = () => {
const next = !expanded()
setExpanded(next)
if (next) void loadBackgroundOutput()
if (next) void loadBackgroundOutput(!backgroundRunning())
}
return (
@ -2638,7 +2684,7 @@ function Shell(props: ToolProps) {
</Spinner>
</Show>
</Show>
<Show when={shellID()}>
<Show when={background()}>
<StatusBadge>Background</StatusBadge>
</Show>
</box>
@ -3154,7 +3200,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
? item.state.error.message
: item.state.status === "streaming"
? ""
: item.state.content
: toolDisplayContent(item.state)
.flatMap((entry) => (entry.type === "text" ? [entry.text] : [entry.name ?? entry.uri]))
.join("\n")
return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`]

View file

@ -118,14 +118,14 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
const source = createMemo(() => {
const tool = props.request.source
if (!tool) return { input: undefined, structured: undefined }
if (!tool) return { input: undefined, metadata: undefined }
const message = data.session.message.get(props.request.sessionID, tool.messageID)
if (message?.type !== "assistant") return { input: undefined, structured: undefined }
if (message?.type !== "assistant") return { input: undefined, metadata: undefined }
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
if (part?.type === "tool" && part.state.status !== "streaming") {
return { input: part.state.input, structured: part.state.structured }
return { input: part.state.input, metadata: part.state.metadata }
}
return { input: undefined, structured: undefined }
return { input: undefined, metadata: undefined }
})
const { themeV2 } = useTheme()
@ -182,7 +182,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
resources: props.request.resources,
metadata: props.request.metadata,
input: source().input,
structured: source().structured,
toolMetadata: source().metadata,
},
pathFormatter.format,
)

View file

@ -17,7 +17,7 @@ export type PermissionPresentationInput = {
resources: ReadonlyArray<unknown>
metadata?: unknown
input?: unknown
structured?: unknown
toolMetadata?: unknown
}
export function permissionPresentation(
@ -26,7 +26,7 @@ export function permissionPresentation(
): PermissionPresentation {
const action = canonicalToolName(source.action)
const input = normalizeInput(action, source.input)
const metadata = { ...dict(source.structured), ...dict(source.metadata) }
const metadata = { ...dict(source.toolMetadata), ...dict(source.metadata) }
const resources = source.resources.filter((item): item is string => typeof item === "string")
if (action === "edit") {

View file

@ -28,7 +28,19 @@ export function webSearchProviderLabel(provider: unknown) {
export function toolDisplayMetadata(state: unknown): Record<string, unknown> {
if (!state || typeof state !== "object" || Array.isArray(state)) return {}
if (!("status" in state) || state.status === "streaming") return {}
if (!("structured" in state) || !state.structured || typeof state.structured !== "object") return {}
if (Array.isArray(state.structured)) return {}
return state.structured as Record<string, unknown>
if (!("metadata" in state) || !state.metadata || typeof state.metadata !== "object") return {}
if (Array.isArray(state.metadata)) return {}
return state.metadata as Record<string, unknown>
}
export function toolDisplayContent(state: SessionMessageAssistantTool["state"]) {
if (state.status === "streaming" || state.status === "running") return []
return state.content ?? []
}
export function nonEmptyToolContent<T>(content: ReadonlyArray<T> | undefined): [T, ...T[]] | undefined {
if (!content) return undefined
const [first, ...rest] = content
return first === undefined ? undefined : [first, ...rest]
}
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"