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

@ -25,6 +25,7 @@ import addSessionForkMigration from "@opencode-ai/core/database/migration/202607
import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended"
import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events"
import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
@ -583,6 +584,208 @@ describe("DatabaseMigration", () => {
)
})
test("rewrites projected tool rows into the canonical result shape", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
)
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, type text NOT NULL, data text NOT NULL)`)
const assistant = {
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "text", text: "before" },
{
type: "tool",
id: "call_content",
name: "grep",
state: {
status: "completed",
input: { pattern: "TODO" },
content: [{ type: "text", text: "src/a.ts:1: TODO" }],
structured: { value: [{ file: "src/a.ts", line: 1 }] },
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_structured_only",
name: "read",
state: {
status: "completed",
input: { path: "README.md" },
content: [],
structured: { text: "hello" },
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_hosted",
name: "web_search",
executed: true,
providerResultState: { blockType: "web_search_tool_result" },
state: {
status: "completed",
input: { query: "effect" },
content: [],
structured: {},
result: { type: "json", value: [{ url: "https://example.com" }] },
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_failed",
name: "shell",
state: {
status: "error",
input: { command: "sleep 99" },
error: { type: "tool.execution", message: "timed out" },
content: [{ type: "text", text: "partial output" }],
structured: { truncated: false },
result: { type: "error", value: "timed out" },
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_running",
name: "shell",
state: {
status: "running",
input: { command: "sleep 1" },
structured: { truncated: false },
content: [{ type: "text", text: "tick" }],
},
time: { created: 1, ran: 2 },
},
],
time: { created: 1 },
}
yield* db.run(
sql`INSERT INTO session_message VALUES ('msg_tools', 'ses_test', 'assistant', 1, 10, 11, ${JSON.stringify(assistant)})`,
)
yield* db.run(
sql`INSERT INTO session_message VALUES ('msg_user', 'ses_test', 'user', 2, 12, 13, '{"text":"hi","time":{"created":1}}')`,
)
// A row that never decoded must be skipped, not fail the migration.
yield* db.run(
sql`INSERT INTO session_message VALUES ('msg_corrupt', 'ses_test', 'assistant', 3, 14, 15, 'not json')`,
)
yield* db.run(
sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_hosted",
structured: {},
content: [],
result: { type: "json", value: [{ url: "https://example.com" }] },
executed: true,
})})`,
)
yield* db.run(
sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_failed",
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false },
executed: false,
})})`,
)
yield* DatabaseMigration.applyOnly(db, [canonicalToolResultsMigration])
const row = yield* db.get<{ data: string }>(sql`SELECT data FROM session_message WHERE id = 'msg_tools'`)
const migrated = JSON.parse(row!.data)
// Every migrated row must decode with the current schema; reload hard-fails otherwise.
Schema.decodeUnknownSync(SessionMessage.Info)({ ...migrated, id: "msg_tools", type: "assistant" })
const states = new Map(
migrated.content.flatMap((part: { type: string; id?: string }) =>
part.type === "tool" ? [[part.id, part]] : [],
),
)
expect(states.get("call_content")).toMatchObject({
state: {
status: "completed",
input: { pattern: "TODO" },
content: [{ type: "text", text: "src/a.ts:1: TODO" }],
// Old generic structured payloads survive as canonical metadata.
metadata: { value: [{ file: "src/a.ts", line: 1 }] },
},
})
expect((states.get("call_content") as { state: Record<string, unknown> }).state).not.toHaveProperty(
"structured",
)
expect(states.get("call_structured_only")).toMatchObject({
state: {
status: "completed",
content: [{ type: "text", text: JSON.stringify({ text: "hello" }, null, 2) }],
metadata: { text: "hello" },
},
})
expect(states.get("call_hosted")).toMatchObject({
executed: true,
providerResultState: {
blockType: "web_search_tool_result",
result: [{ url: "https://example.com" }],
},
state: {
status: "completed",
content: [{ type: "text", text: JSON.stringify([{ url: "https://example.com" }], null, 2) }],
},
})
expect(states.get("call_failed")).toMatchObject({
state: {
status: "error",
error: { type: "tool.execution", message: "timed out" },
content: [{ type: "text", text: "partial output" }],
metadata: { truncated: false },
},
})
const failedState = (states.get("call_failed") as { state: Record<string, unknown> }).state
expect(failedState).not.toHaveProperty("result")
expect(failedState).not.toHaveProperty("structured")
expect(states.get("call_running")).toMatchObject({
state: {
status: "running",
metadata: { truncated: false },
},
})
const event = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_success'`)
expect(event!.type).toBe("session.tool.success.1")
expect(JSON.parse(event!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_hosted",
structured: {},
content: [],
result: { type: "json", value: [{ url: "https://example.com" }] },
executed: true,
})
const failedEvent = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_failed'`)
expect(failedEvent!.type).toBe("session.tool.failed.1")
expect(JSON.parse(failedEvent!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_failed",
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false },
executed: false,
})
expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_user'`)).toEqual({
data: '{"text":"hi","time":{"created":1}}',
})
expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_corrupt'`)).toEqual({
data: "not json",
})
}),
)
})
test("records the authoritative parent sequence on existing forks", async () => {
await run(
Effect.gen(function* () {