feat(core): finalize session event lifecycle (#35272)

This commit is contained in:
Kit Langton 2026-07-06 18:14:06 -04:00 committed by GitHub
commit bf01264661
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 5200 additions and 3711 deletions

View file

@ -75,12 +75,12 @@ export const Plugin = {
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string) => {
const fail = (path: string, error?: unknown) => {
const prefix =
applied.length === 0
? `Unable to apply patch at ${path}`
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
return new ToolFailure({ message: prefix })
return new ToolFailure({ message: prefix, error })
}
return Effect.gen(function* () {
const source = {
@ -152,7 +152,7 @@ export const Plugin = {
before,
after: update.content,
})
}).pipe(Effect.mapError(() => fail(hunk.path)))
}).pipe(Effect.mapError((error) => fail(hunk.path, error)))
}
const patchFiles = prepared.map(patchFile)
@ -182,11 +182,11 @@ export const Plugin = {
content: change.content,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.mapError(() => fail(change.path))),
}).pipe(Effect.mapError((error) => fail(change.path, error))),
{ discard: true },
)
return { applied, files: patchFiles }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))))
},
}),
"edit",

View file

@ -113,8 +113,9 @@ export const Plugin = {
error instanceof FileMutation.StaleContentError
? new ToolFailure({
message: "File changed after permission approval. Read it again before editing.",
error,
})
: new ToolFailure({ message: `Unable to edit ${input.path}` }),
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
),
)

View file

@ -102,7 +102,7 @@ export const Plugin = {
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }),
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
),
),
}),

View file

@ -133,7 +133,7 @@ export const Plugin = {
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to grep for ${input.pattern}` }),
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
),
),
}),

View file

@ -76,7 +76,7 @@ export const Plugin = {
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
.pipe(
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
Effect.andThen(
forms
.ask({

View file

@ -134,7 +134,7 @@ export const Plugin = {
error instanceof Image.SizeError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message })
return new ToolFailure({ message, error })
}),
)
},

View file

@ -14,6 +14,8 @@ import { definition, permission, registrationEntries, RegistrationError, settle,
import { Tools } from "./tools"
import { ToolHooks } from "./hooks"
import { makeLocationNode } from "../effect/app-node"
import { SessionError } from "@opencode-ai/schema/session-error"
import { toSessionError } from "../session/to-session-error"
export type ExecuteInput = {
readonly sessionID: SessionSchema.ID
@ -45,6 +47,7 @@ export interface Settlement {
readonly result: ToolResultValue
readonly output?: ToolOutput
readonly outputPaths?: ReadonlyArray<string>
readonly error?: SessionError.Error
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
@ -86,7 +89,10 @@ const registryLayer = Layer.effect(
).pipe(
Effect.map((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
Effect.succeed({
result: { type: "error" as const, value: failure.message },
error: toSessionError(failure),
}),
),
)
let settlement: Settlement
@ -124,20 +130,19 @@ const registryLayer = Layer.effect(
result: afterEvent.result,
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
...(settlement.error !== undefined ? { error: settlement.error } : {}),
}
})
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised: object) {
const registration = local.get(input.call.name)?.at(-1)?.registration
if (!registration)
if (!registration || registration.identity !== advertised) {
const message = `Stale tool call: ${input.call.name}`
return {
result: {
type: "error" as const,
value: `Stale tool call: ${input.call.name}`,
},
result: { type: "error" as const, value: message },
error: { type: "tool.stale" as const, message },
}
if (registration.identity !== advertised)
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
}
return yield* settleTool(input, registration.tool)
})
@ -215,7 +220,10 @@ const registryLayer = Layer.effect(
if (input.call.name === "execute" && execute) return settleTool(input, execute)
const registration = direct.get(input.call.name)
if (registration) return settleWith(input, registration.identity)
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
return Effect.succeed({
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
})
},
}
}),

View file

@ -270,7 +270,9 @@ export const Plugin = {
...(warnings.length ? { warnings } : {}),
}
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
),
}),
),

View file

@ -107,7 +107,7 @@ export const Plugin = {
.get(context.sessionID)
.pipe(
Effect.mapError(
() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }),
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
)
const agent = yield* agents.resolve(input.agent)
@ -128,7 +128,7 @@ export const Plugin = {
})
.pipe(
Effect.mapError(
() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }),
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
)

View file

@ -48,7 +48,7 @@ export const Plugin = {
})
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
return { todos: input.todos }
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
}).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to update todos", error }))),
}),
),
)

View file

@ -172,7 +172,7 @@ export const Plugin = {
format: input.format,
output,
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
}),
),
)

View file

@ -246,7 +246,9 @@ export const Plugin = {
text: text ?? NO_RESULTS,
}
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
)
},
}),

View file

@ -85,7 +85,9 @@ export const Plugin = {
source,
})
return yield* files.writeTextPreservingBom({ target, content: input.content })
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
}).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
"edit",
),