Compare commits

..

5 Commits

Author SHA1 Message Date
Hona 3ab166579f docs(core): clarify Node compatibility guidance 2026-08-29 03:20:33 +00:00
Hona 370acf9070 docs(core): require Node-compatible runtime APIs 2026-08-29 02:59:44 +00:00
Kit Langton e3bda5e2d0 feat(tui): add branch review scopes 2026-08-28 22:46:23 -04:00
Kit Langton ad9117b107 fix(session): run user shells immediately in the background
Run user shell commands concurrently with model execution, retain their output in one shell entry, and admit non-waking completion messages. Share result capture and notification formatting across shell callers.
2026-08-28 22:46:20 -04:00
Kit Langton 6b7a1d419c fix(core): preserve Responses tool identities (#45658) 2026-08-28 22:14:13 -04:00
106 changed files with 4615 additions and 1531 deletions
+8
View File
@@ -1950,6 +1950,12 @@ export type VcsGetInput = {
export type VcsGetOutput = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: VcsGetInput) => Effect.Effect<VcsGetOutput, E>
export type VcsBaseInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type VcsBaseOutput = { readonly location: Location.Info; readonly data: Vcs.Base | null }
export type VcsBaseOperation<E = never> = (input?: VcsBaseInput) => Effect.Effect<VcsBaseOutput, E>
export type VcsStatusInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1967,6 +1973,7 @@ export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effe
export type VcsDiffInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
readonly base?: string | undefined
readonly context?: number | undefined
}
export type VcsDiffOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
@@ -1974,6 +1981,7 @@ export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly base: VcsBaseOperation<E>
readonly status: VcsStatusOperation<E>
readonly branches: VcsBranchesOperation<E>
readonly diff: VcsDiffOperation<E>
+11 -3
View File
@@ -244,6 +244,8 @@ import type {
WorkspaceDestroyOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
VcsBaseOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
@@ -1468,6 +1470,11 @@ const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsBase = (raw: RawClient["server.vcs"]) => (input?: VcsBaseInput) =>
preserveEffect<VcsBaseOutput>()(
raw["vcs.base"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusInput) =>
preserveEffect<VcsStatusOutput>()(
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1482,13 +1489,14 @@ const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranch
const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput) =>
preserveEffect<VcsDiffOutput>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError),
),
raw["vcs.diff"]({
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
get: EndpointVcsGet(raw),
base: EndpointVcsBase(raw),
status: EndpointVcsStatus(raw),
branches: EndpointVcsBranches(raw),
diff: EndpointVcsDiff(raw),
+1
View File
@@ -34,6 +34,7 @@ export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { Worktree } from "@opencode-ai/schema/worktree"
export { Vcs } from "@opencode-ai/schema/vcs"
export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
@@ -240,6 +240,8 @@ import type {
WorkspaceDestroyOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
VcsBaseOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
@@ -1996,6 +1998,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
base: (input?: VcsBaseInput, requestOptions?: RequestOptions) =>
request<VcsBaseOutput>(
{
method: "GET",
path: `/api/vcs/base`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
request<VcsStatusOutput>(
{
@@ -2025,9 +2039,9 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/vcs/diff`,
query: { location: input["location"], mode: input["mode"], context: input["context"] },
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
+25 -3
View File
@@ -419,6 +419,8 @@ export type WorkspaceDestroyResult = { destroyed: boolean }
export type VcsBranch = { current?: string; default?: string }
export type VcsBase = { name: string; ref: string; source: "reflog" | "default" }
export type VcsFileStatus = {
file: string
additions: number
@@ -6073,6 +6075,17 @@ export type VcsGetOutput = {
data: VcsInfo
}
export type VcsBaseInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type VcsBaseOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: VcsBase | null
}
export type VcsStatusInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -6110,17 +6123,26 @@ export type VcsBranchesOutput = {
export type VcsDiffInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly mode: "working" | "branch" | "committed"
readonly base?: string | undefined
readonly context?: number | undefined
}["location"]
readonly mode: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly mode: "working" | "branch" | "committed"
readonly base?: string | undefined
readonly context?: number | undefined
}["mode"]
readonly base?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch" | "committed"
readonly base?: string | undefined
readonly context?: number | undefined
}["base"]
readonly context?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly mode: "working" | "branch" | "committed"
readonly base?: string | undefined
readonly context?: number | undefined
}["context"]
}
+2 -1
View File
@@ -750,7 +750,8 @@ export function createData(config: CreateDataInput) {
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
metadata: event.metadata,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
time: { created: event.created },
})
})
@@ -6,6 +6,7 @@ import { Model } from "@opencode-ai/schema/model"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Vcs } from "@opencode-ai/schema/vcs"
const Client = await import("../src/effect")
@@ -14,6 +15,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
expect(Client.Config).toBe(Config)
expect(Client.Model).toBe(Model)
expect(Client.Session).toBe(Session)
expect(Client.Vcs.Base).toBe(Vcs.Base)
})
test("generated Effect API names canonical and composed outputs", async () => {
+20
View File
@@ -27,6 +27,26 @@ test("health.get decodes the readiness response", async () => {
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
})
test("vcs.base decodes nullable review-base metadata", async () => {
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
const base = {
name: "release",
ref: "refs/remotes/origin/release",
source: "reflog",
}
for (const data of [base, null]) {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ location, data }))),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.vcs.base({ location: { directory: AbsolutePath.make("/repo") } })
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result.data).toEqual(data)
expect(result.location.directory).toBe("/repo")
}
})
test("session.get returns the decoded Effect projection", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
+41 -1
View File
@@ -47,7 +47,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
expect(Object.keys(client.vcs)).toEqual(["get", "base", "status", "branches", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
expect(Object.keys(client.pty.connect)).toEqual(["token"])
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
@@ -84,6 +84,46 @@ test("config.get returns ordered config entries for a location", async () => {
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
})
test("vcs.base and committed diffs preserve location and explicit base on the wire", async () => {
const requests: Request[] = []
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
const base = { name: "release", ref: "refs/remotes/origin/release", source: "reflog" }
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
return Response.json({ location, data: new URL(request.url).pathname.endsWith("/base") ? base : [] })
},
})
expect(await client.vcs.base({ location: { directory: "/repo" } })).toEqual({ location, data: base })
expect(
await client.vcs.diff({ location: { directory: "/repo" }, mode: "committed", base: base.ref, context: 1 }),
).toEqual({ location, data: [] })
expect(new URL(requests[0].url).pathname).toBe("/api/vcs/base")
const query = new URL(requests[1].url).searchParams
expect(query.get("location[directory]")).toBe("/repo")
expect(query.get("mode")).toBe("committed")
expect(query.get("base")).toBe(base.ref)
expect(query.get("context")).toBe("1")
})
test("vcs.diff exposes unavailable comparisons as errors, not empty diffs", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json(
{ _tag: "ServiceUnavailableError", service: "vcs", message: "No review base available" },
{ status: 503 },
),
})
await expect(client.vcs.diff({ mode: "committed" })).rejects.toMatchObject({
_tag: "ServiceUnavailableError",
service: "vcs",
message: "No review base available",
})
})
test("project.update uses the global project contract", async () => {
let request: Request | undefined
const project = {
+30
View File
@@ -666,6 +666,36 @@ test("ignores activity snapshots from an older connection", async () => {
}
})
test("projects background user shell metadata from durable shell data", () => {
const setup = activityFixture(() => Response.json({ data: {} }))
try {
setup.emit({
id: "evt_user_shell",
created: 1,
type: "session.shell.started",
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
data: {
sessionID: "ses_refresh",
shell: {
id: "sh_user",
status: "running",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/project/shell.out",
metadata: { sessionID: "ses_refresh", background: true },
time: { started: 1 },
},
},
})
expect(setup.data.session.message.list("ses_refresh")).toMatchObject([
{ type: "shell", shellID: "sh_user", status: "running", metadata: { background: true } },
])
} finally {
setup.dispose()
}
})
function activityFixture(read: () => Response | Promise<Response>) {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
+1
View File
@@ -0,0 +1 @@
Core must run on Node.js. Never use Bun globals or Bun-only imports in shared runtime code. Bun-specific adapters must have Node equivalents. This overrides the root Bun preference; tests and build scripts are exempt.
+5 -13
View File
@@ -1,8 +1,8 @@
export * as CodeModeTool from "./tool.js"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { Content, Context, Declined, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Deferred, Effect, Ref, Schema, Semaphore } from "effect"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
const ExecuteFile = Schema.Struct({
@@ -43,7 +43,7 @@ const description = [
export const create = (
registrations: ReadonlyMap<string, Info>,
executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error | Declined>,
executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error>,
) => {
return {
name: "execute",
@@ -52,7 +52,6 @@ export const create = (
output: ExecuteOutput,
execute: ({ code }, context) =>
Effect.gen(function* () {
const declined = yield* Deferred.make<never, Declined>()
const callIndex = yield* Ref.make(0)
const files = yield* Ref.make<Array<CollectedFiles>>([])
const calls = yield* Ref.make<Array<ExecuteCall>>([])
@@ -61,17 +60,12 @@ export const create = (
lock.withPermit(
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
)
const execution = runtime(
const result = yield* runtime(
registrations,
(name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
// A decline stops the host execution; never expose it as a catchable JavaScript rejection.
const executed = yield* executeTool(name, tool, input, context).pipe(
Effect.catchTag("Tool.Declined", (error) =>
Deferred.fail(declined, error).pipe(Effect.andThen(Effect.never)),
),
)
const executed = yield* executeTool(name, tool, input, context)
const content =
typeof executed.content === "string"
? [{ type: "text" as const, text: executed.content }]
@@ -105,8 +99,6 @@ export const create = (
},
},
).execute(code)
// Register the interpreter fiber with the race before a synchronous tool can signal a decline.
const result = yield* Effect.raceFirst(Deferred.await(declined), Effect.andThen(Effect.yieldNow, execution))
const toolCalls = yield* Ref.get(calls)
const collected = (yield* Ref.get(files))
.toSorted((left, right) => left.index - right.index)
+1 -1
View File
@@ -207,7 +207,7 @@ export const layer = Layer.effect(
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) => Deferred.interrupt(entry.deferred).pipe(Effect.andThen(cancel(entry.form.id)), Effect.ignore),
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
{ discard: true },
),
),
@@ -4,7 +4,6 @@ import {
type LanguageModelV3,
type LanguageModelV3CallOptions,
type LanguageModelV3Content,
type LanguageModelV3ProviderTool,
type LanguageModelV3StreamPart,
type SharedV3ProviderMetadata,
type SharedV3Warning,
@@ -27,7 +26,7 @@ import { imageGenerationOutputSchema } from "./tool/image-generation.js"
import { convertToOpenAIResponsesInput } from "./convert-to-openai-responses-input.js"
import { mapOpenAIResponseFinishReason } from "./map-openai-responses-finish-reason.js"
import type { OpenAIResponsesIncludeOptions, OpenAIResponsesIncludeValue } from "./openai-responses-api-types.js"
import { prepareResponsesTools } from "./openai-responses-prepare-tools.js"
import { prepareResponsesTools, type ResponsesHostedTool } from "./openai-responses-prepare-tools.js"
import type { OpenAIResponsesModelId } from "./openai-responses-settings.js"
const webSearchCallItem = z.object({
@@ -221,15 +220,23 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
addInclude("message.output_text.logprobs")
}
// when a web search tool is present, automatically include the sources:
const webSearchToolName = (
tools?.find(
(tool) =>
tool.type === "provider" && (tool.id === "openai.web_search" || tool.id === "openai.web_search_preview"),
) as LanguageModelV3ProviderTool | undefined
)?.name
const {
tools: openaiTools,
toolChoice: openaiToolChoice,
hostedTools,
selectedHostedTool,
toolWarnings,
} = prepareResponsesTools({
tools,
toolChoice,
strictJsonSchema,
})
const getHostedToolName = (responseType: ResponsesHostedTool["responseType"]) => {
if (selectedHostedTool?.responseType === responseType) return selectedHostedTool.name
return hostedTools.find((tool) => tool.responseType === responseType)?.name ?? responseType
}
if (webSearchToolName) {
if (hostedTools.some((tool) => tool.responseType === "web_search")) {
addInclude("web_search_call.action.sources")
}
@@ -357,18 +364,8 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
baseArgs.service_tier = undefined
}
const {
tools: openaiTools,
toolChoice: openaiToolChoice,
toolWarnings,
} = prepareResponsesTools({
tools,
toolChoice,
strictJsonSchema,
})
return {
webSearchToolName,
getHostedToolName,
args: {
...baseArgs,
tools: openaiTools,
@@ -379,7 +376,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
}
async doGenerate(options: LanguageModelV3CallOptions) {
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
const { args: body, warnings, getHostedToolName } = await this.getArgs(options)
const url = this.config.url({
path: "/responses",
modelId: this.modelId,
@@ -526,7 +523,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-call",
toolCallId: part.id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
input: "{}",
providerExecuted: true,
})
@@ -534,7 +531,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-result",
toolCallId: part.id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
result: {
result: part.result,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
@@ -605,7 +602,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-call",
toolCallId: part.id,
toolName: webSearchToolName ?? "web_search",
toolName: getHostedToolName("web_search"),
input: JSON.stringify({ action: part.action }),
providerExecuted: true,
})
@@ -613,7 +610,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-result",
toolCallId: part.id,
toolName: webSearchToolName ?? "web_search",
toolName: getHostedToolName("web_search"),
result: { status: part.status },
})
@@ -645,7 +642,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-call",
toolCallId: part.id,
toolName: "file_search",
toolName: getHostedToolName("file_search"),
input: "{}",
providerExecuted: true,
})
@@ -653,7 +650,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-result",
toolCallId: part.id,
toolName: "file_search",
toolName: getHostedToolName("file_search"),
result: {
queries: part.queries,
results:
@@ -673,7 +670,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-call",
toolCallId: part.id,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
input: JSON.stringify({
code: part.code,
containerId: part.container_id,
@@ -684,7 +681,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-result",
toolCallId: part.id,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
result: {
outputs: part.outputs,
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
@@ -746,7 +743,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
}
async doStream(options: LanguageModelV3CallOptions) {
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
const { args: body, warnings, getHostedToolName } = await this.getArgs(options)
const { responseHeaders, value: response } = await postJsonToApi({
url: this.config.url({
@@ -866,7 +863,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-input-start",
id: value.item.id,
toolName: webSearchToolName ?? "web_search",
toolName: getHostedToolName("web_search"),
})
} else if (value.item.type === "computer_call") {
ongoingToolCalls[value.output_index] = {
@@ -889,7 +886,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-input-start",
id: value.item.id,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
})
controller.enqueue({
@@ -901,7 +898,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-call",
toolCallId: value.item.id,
toolName: "file_search",
toolName: getHostedToolName("file_search"),
input: "{}",
providerExecuted: true,
})
@@ -909,7 +906,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-call",
toolCallId: value.item.id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
input: "{}",
providerExecuted: true,
})
@@ -980,7 +977,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-call",
toolCallId: value.item.id,
toolName: "web_search",
toolName: getHostedToolName("web_search"),
input: JSON.stringify({ action: value.item.action }),
providerExecuted: true,
})
@@ -988,7 +985,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item.id,
toolName: "web_search",
toolName: getHostedToolName("web_search"),
result: { status: value.item.status },
})
} else if (value.item.type === "computer_call") {
@@ -1022,7 +1019,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item.id,
toolName: "file_search",
toolName: getHostedToolName("file_search"),
result: {
queries: value.item.queries,
results:
@@ -1041,7 +1038,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item.id,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
result: {
outputs: value.item.outputs,
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
@@ -1050,7 +1047,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item.id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
result: {
result: value.item.result,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
@@ -1099,7 +1096,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item_id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
result: {
result: value.partial_image_b64,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
@@ -1135,7 +1132,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-call",
toolCallId: toolCall.toolCallId,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
input: JSON.stringify({
code: value.code,
containerId: toolCall.codeInterpreter!.containerId,
@@ -1,4 +1,9 @@
import { type LanguageModelV3CallOptions, type SharedV3Warning, UnsupportedFunctionalityError } from "@ai-sdk/provider"
import {
type LanguageModelV3CallOptions,
type LanguageModelV3ProviderTool,
type SharedV3Warning,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import { codeInterpreterArgsSchema } from "./tool/code-interpreter.js"
import { fileSearchArgsSchema } from "./tool/file-search.js"
import { webSearchArgsSchema } from "./tool/web-search.js"
@@ -6,6 +11,28 @@ import { webSearchPreviewArgsSchema } from "./tool/web-search-preview.js"
import { imageGenerationArgsSchema } from "./tool/image-generation.js"
import type { OpenAIResponsesTool } from "./openai-responses-api-types.js"
export type ResponsesHostedTool = {
name: string
type: "file_search" | "web_search_preview" | "web_search" | "code_interpreter" | "image_generation"
responseType: "file_search" | "web_search" | "code_interpreter" | "image_generation"
}
export function getResponsesHostedTool(tool: LanguageModelV3ProviderTool): ResponsesHostedTool | undefined {
switch (tool.id) {
case "openai.file_search":
return { name: tool.name, type: "file_search", responseType: "file_search" }
case "openai.web_search_preview":
return { name: tool.name, type: "web_search_preview", responseType: "web_search" }
case "openai.web_search":
return { name: tool.name, type: "web_search", responseType: "web_search" }
case "openai.code_interpreter":
return { name: tool.name, type: "code_interpreter", responseType: "code_interpreter" }
case "openai.image_generation":
return { name: tool.name, type: "image_generation", responseType: "image_generation" }
}
return undefined
}
export function prepareResponsesTools({
tools,
toolChoice,
@@ -26,6 +53,8 @@ export function prepareResponsesTools({
| { type: "function"; name: string }
| { type: "code_interpreter" }
| { type: "image_generation" }
hostedTools: ResponsesHostedTool[]
selectedHostedTool?: ResponsesHostedTool
toolWarnings: SharedV3Warning[]
} {
// when the tools array is empty, change it to undefined to prevent errors:
@@ -34,7 +63,49 @@ export function prepareResponsesTools({
const toolWarnings: SharedV3Warning[] = []
if (tools == null) {
return { tools: undefined, toolChoice: undefined, toolWarnings }
return { tools: undefined, toolChoice: undefined, hostedTools: [], toolWarnings }
}
const hostedTools = tools.flatMap((tool) => {
if (tool.type !== "provider") return []
const hostedTool = getResponsesHostedTool(tool)
return hostedTool ? [hostedTool] : []
})
const selectedToolName = toolChoice?.type === "tool" ? toolChoice.toolName : undefined
const selectedTools = selectedToolName === undefined ? [] : tools.filter((tool) => tool.name === selectedToolName)
if (selectedTools.length > 1) {
throw new UnsupportedFunctionalityError({
functionality: `ambiguous tool choice '${selectedToolName}': multiple tool definitions share this name`,
})
}
const selectedHostedTool =
selectedTools[0]?.type === "provider" ? getResponsesHostedTool(selectedTools[0]) : undefined
const ambiguousHostedResponse =
toolChoice?.type === "none" || toolChoice?.type === "tool"
? undefined
: hostedTools.find(
(tool) =>
new Set(
hostedTools.filter((candidate) => candidate.responseType === tool.responseType).map((item) => item.name),
).size > 1,
)
if (ambiguousHostedResponse) {
const names = new Set(
hostedTools.filter((tool) => tool.responseType === ambiguousHostedResponse.responseType).map((tool) => tool.name),
)
throw new UnsupportedFunctionalityError({
functionality: `ambiguous ${ambiguousHostedResponse.responseType} response for hosted tools: ${[...names].join(", ")}`,
})
}
if (selectedHostedTool) {
const names = new Set(hostedTools.filter((tool) => tool.type === selectedHostedTool.type).map((tool) => tool.name))
if (names.size > 1) {
throw new UnsupportedFunctionalityError({
functionality: `ambiguous ${selectedHostedTool.type} tool choice for hosted tools: ${[...names].join(", ")}`,
})
}
}
const openaiTools: Array<OpenAIResponsesTool> = []
@@ -134,7 +205,7 @@ export function prepareResponsesTools({
}
if (toolChoice == null) {
return { tools: openaiTools, toolChoice: undefined, toolWarnings }
return { tools: openaiTools, toolChoice: undefined, hostedTools, selectedHostedTool, toolWarnings }
}
const type = toolChoice.type
@@ -143,20 +214,18 @@ export function prepareResponsesTools({
case "auto":
case "none":
case "required":
return { tools: openaiTools, toolChoice: type, toolWarnings }
case "tool":
return { tools: openaiTools, toolChoice: type, hostedTools, selectedHostedTool, toolWarnings }
case "tool": {
return {
tools: openaiTools,
toolChoice:
toolChoice.toolName === "code_interpreter" ||
toolChoice.toolName === "file_search" ||
toolChoice.toolName === "image_generation" ||
toolChoice.toolName === "web_search_preview" ||
toolChoice.toolName === "web_search"
? { type: toolChoice.toolName }
: { type: "function", name: toolChoice.toolName },
toolChoice: selectedHostedTool
? { type: selectedHostedTool.type }
: { type: "function", name: toolChoice.toolName },
hostedTools,
selectedHostedTool,
toolWarnings,
}
}
default: {
const _exhaustiveCheck: never = type
throw new UnsupportedFunctionalityError({
+13 -7
View File
@@ -61,7 +61,7 @@ export type AskResult = typeof AskResult.Type
export { Event } from "@opencode-ai/schema/permission"
export class Declined extends Schema.TaggedError<Declined>()("Permission.Declined", {}) {}
export class DeclinedError extends Schema.TaggedError<DeclinedError>()("Permission.DeclinedError", {}) {}
export class CorrectedError extends Schema.TaggedError<CorrectedError>()("Permission.CorrectedError", {
feedback: Schema.String,
@@ -82,7 +82,7 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Permissi
requestID: ID,
}) {}
export type Error = BlockedError | CorrectedError | Declined
export type Error = BlockedError | CorrectedError
export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
return (
@@ -114,7 +114,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pe
interface Pending {
readonly request: Request
readonly agent?: Agent.ID
readonly deferred: Deferred.Deferred<void, Declined | CorrectedError>
readonly deferred: Deferred.Deferred<void, DeclinedError | CorrectedError>
}
const layer = Layer.effect(
@@ -129,7 +129,7 @@ const layer = Layer.effect(
const pending = new Map<ID, Pending>()
yield* Effect.addFinalizer(() =>
Effect.forEach(pending.values(), (item) => Deferred.interrupt(item.deferred), {
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
discard: true,
}).pipe(
Effect.ensuring(
@@ -199,7 +199,7 @@ const layer = Layer.effect(
const create = (request: Request, agent?: Agent.ID) =>
Effect.uninterruptible(
Effect.gen(function* () {
const deferred = yield* Deferred.make<void, Declined | CorrectedError>()
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
const item = { request, agent, deferred }
if (pending.has(request.id))
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
@@ -234,6 +234,12 @@ const layer = Layer.effect(
if (result.effect === "allow") return
const item = yield* create(request(input, result.message), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
@@ -259,7 +265,7 @@ const layer = Layer.effect(
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new Declined(),
input.message ? new CorrectedError({ feedback: input.message }) : new DeclinedError(),
)
pending.delete(input.requestID)
for (const [id, item] of pending) {
@@ -269,7 +275,7 @@ const layer = Layer.effect(
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new Declined())
yield* Deferred.fail(item.deferred, new DeclinedError())
pending.delete(id)
}
return
+1 -1
View File
@@ -20,7 +20,7 @@ export interface Domains {
type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
// Only tool execute.before may fail, with Tool.Error or Tool.Declined before the call runs.
// Failure channel for each hook event. Only tool execute.before may fail: a Tool.Error rejects the call before it runs.
interface Failures extends Record<keyof Domains, unknown> {
readonly aisdk: NoFailures<AISDKHooks>
readonly session: NoFailures<SessionHooks>
+2 -1
View File
@@ -369,9 +369,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
},
vcs: {
get: () => response(vcs.info()),
base: () => response(vcs.base()),
branches: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })),
status: () => response(vcs.status()),
diff: (input) => response(vcs.diff(input.mode, { context: input.context })),
diff: (input) => response(vcs.diff(input.mode, { context: input.context, base: input.base })),
transform: vcs.transform,
reload: vcs.reload,
},
+137 -38
View File
@@ -4,10 +4,11 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { Base, BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "../../location.js"
import type { Adapter, BranchOptions, DiffOptions } from "../../vcs.js"
import { DiffError } from "../../vcs.js"
import {
chunksByFile,
emptyPatch,
@@ -35,9 +36,10 @@ export const Plugin = define({
id: "git",
name: "Git",
info: () => adapter.info(),
base: () => adapter.base(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
status: () => adapter.status(),
diff: (input) => adapter.diff(input.mode, { context: input.context }),
diff: (input) => adapter.diff(input.mode, { context: input.context, base: input.base }),
})
})
}),
@@ -48,7 +50,7 @@ export const Plugin = define({
* batched through one `git diff` invocation where possible and capped by
* per-file and total byte budgets, falling back to empty patches when capped.
*/
function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Adapter {
function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }) {
// Listing commands scope pathspecs to the requested directory; per-file
// commands run from the worktree root because git lists root-relative paths.
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
@@ -60,6 +62,7 @@ function make(proc: AppProcess.Interface, input: { directory: string; worktree:
})
return { branch: { current, default: root?.name } } satisfies Info
}),
base: () => ctx.git.base(ctx.directory),
branches: Effect.fn("VcsGit.branches")(function* (options?: BranchOptions) {
return yield* ctx.git.branches(ctx.directory, options)
}),
@@ -93,25 +96,23 @@ function make(proc: AppProcess.Interface, input: { directory: string; worktree:
return yield* track(ctx, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined, options)
}
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
if (!root) return []
if (current && current === root.name) return []
const ref = yield* git.mergeBase(ctx.directory, root.ref)
if (!ref) return []
return yield* diffAgainstRef(ctx, ref, options)
if (!(yield* git.hasHead(ctx.directory))) {
return mode === "committed" ? [] : yield* track(ctx, undefined, options)
}
const base = options?.base ?? (yield* git.defaultBranch(ctx.directory))?.ref
const ref = base ? yield* git.mergeBase(ctx.directory, base) : undefined
if (!ref) {
return yield* new DiffError({
message: base ? `No merge base available for ${base}` : "No review base available",
})
}
return yield* diffAgainstRef(ctx, ref, { ...options, target: mode === "committed" ? "HEAD" : undefined })
}),
}
} satisfies Adapter
}
type Kind = FileStatus["status"]
interface Base {
readonly name: string
readonly ref: string
}
interface Item {
readonly file: string
readonly code: string
@@ -124,8 +125,11 @@ interface Stat {
readonly deletions: number
}
interface PatchOptions {
readonly context?: number
interface GitDiffOptions extends DiffOptions {
readonly target?: "HEAD"
}
interface PatchOptions extends GitDiffOptions {
readonly maxOutputBytes?: number
}
@@ -196,7 +200,7 @@ function makeGit(proc: AppProcess.Interface) {
const result = yield* run(["config", "init.defaultBranch"], { cwd })
const name = result.text().trim()
if (!name || !list.includes(name)) return
return { name, ref: name } satisfies Base
return { name, ref: name }
})
const primary = Effect.fnUntraced(function* (cwd: string) {
@@ -239,15 +243,70 @@ function makeGit(proc: AppProcess.Interface) {
.trim()
.replace(/^refs\/remotes\//, "")
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
if (name) return { name, ref } satisfies Base
if (name) return { name, ref }
}
}
const list = yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
const next = yield* configured(cwd, list)
if (next) return next
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
if (list.includes("main")) return { name: "main", ref: "main" }
if (list.includes("master")) return { name: "master", ref: "master" }
})
const resolve = Effect.fnUntraced(function* (cwd: string, ref: string) {
const result = yield* run(["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], { cwd })
if (result.exitCode !== 0) return
return result.text().trim() || undefined
})
const ancestor = Effect.fnUntraced(function* (cwd: string, commit: string, ref: string) {
if (!/^[a-f0-9]{40,64}$/.test(commit)) return false
return (yield* run(["merge-base", "--is-ancestor", commit, ref], { cwd })).exitCode === 0
})
const namedRef = Effect.fnUntraced(function* (cwd: string, input: string) {
// Creation hints must identify a branch, not HEAD, an object ID, or a revision expression.
if (input === "HEAD" || input.endsWith("/HEAD") || /[~^:@{}\s]/.test(input)) return
const ref = (yield* text(["rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", input], {
cwd,
})).trim()
if (!/^refs\/(heads|remotes)\/.+/.test(ref) || ref.endsWith("/HEAD") || !(yield* resolve(cwd, ref))) return
return { name: ref.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\/[^/]+\//, ""), ref }
})
const base = Effect.fn("VcsGit.base")(function* (cwd: string) {
if (!(yield* hasHead(cwd))) return null
const current = yield* branch(cwd)
if (!current) return yield* new DiffError({ message: "Choose a review base" })
const history = (yield* lines(
["reflog", "show", "--max-count=256", "--format=%H%x00%gs", `refs/heads/${current}`],
{
cwd,
},
)).flatMap((line) => {
const match = /^([a-f0-9]+)\0(.+)$/.exec(line)
return match ? [{ commit: match[1], message: match[2] }] : []
})
const renamed = history.some((entry) => entry.message.startsWith("Branch: renamed "))
const creation = renamed ? undefined : history.find((entry) => entry.message.startsWith("branch: Created from "))
const origin = creation?.message.slice("branch: Created from ".length)
if (creation && origin) {
const candidate = yield* namedRef(cwd, origin)
if (
candidate &&
candidate.name !== current &&
(yield* ancestor(cwd, creation.commit, "HEAD")) &&
(yield* ancestor(cwd, creation.commit, candidate.ref))
) {
return { ...candidate, source: "reflog" } satisfies Base
}
}
const root = yield* defaultBranch(cwd)
if (!root || current !== root.name) return yield* new DiffError({ message: "Choose a review base" })
const candidate = yield* namedRef(cwd, root.ref)
if (!candidate) return yield* new DiffError({ message: "The default review base is unavailable" })
return { name: root.name, ref: candidate.ref, source: "default" } satisfies Base
})
const hasHead = Effect.fn("VcsGit.hasHead")(function* (cwd: string) {
@@ -256,7 +315,9 @@ function makeGit(proc: AppProcess.Interface) {
})
const mergeBase = Effect.fn("VcsGit.mergeBase")(function* (cwd: string, base: string) {
const result = yield* run(["merge-base", base, "HEAD"], { cwd })
const ref = yield* resolve(cwd, base)
if (!ref) return
const result = yield* run(["merge-base", ref, "HEAD"], { cwd })
if (result.exitCode !== 0) return
return result.text().trim() || undefined
})
@@ -272,10 +333,13 @@ function makeGit(proc: AppProcess.Interface) {
})
})
const diff = Effect.fn("VcsGit.diffNames")(function* (cwd: string, ref: string) {
const list = nuls(
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
const diff = Effect.fn("VcsGit.diffNames")(function* (cwd: string, ref: string, target?: string) {
const result = yield* run(
["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, ...(target ? [target] : []), "--", "."],
{ cwd },
)
if (result.exitCode !== 0) return yield* new DiffError({ message: "Unable to list Git changes" })
const list = nuls(result.text())
return list.flatMap((code, idx) => {
if (idx % 2 !== 0) return []
const file = list[idx + 1]
@@ -284,9 +348,12 @@ function makeGit(proc: AppProcess.Interface) {
})
})
const stats = Effect.fn("VcsGit.stats")(function* (cwd: string, ref: string) {
const stats = Effect.fn("VcsGit.stats")(function* (cwd: string, ref: string, target?: string) {
return nuls(
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
yield* text(
["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, ...(target ? [target] : []), "--", "."],
{ cwd },
),
).flatMap((item) => {
const a = item.indexOf("\t")
const b = item.indexOf("\t", a + 1)
@@ -309,7 +376,17 @@ function makeGit(proc: AppProcess.Interface) {
const patch = Effect.fn("VcsGit.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) {
const result = yield* run(
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file],
[
"diff",
"--patch",
"--no-ext-diff",
"--no-renames",
`--unified=${options?.context ?? 3}`,
ref,
...(options?.target ? [options.target] : []),
"--",
file,
],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
@@ -317,7 +394,17 @@ function makeGit(proc: AppProcess.Interface) {
const patchAll = Effect.fn("VcsGit.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) {
const result = yield* run(
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."],
[
"diff",
"--patch",
"--no-ext-diff",
"--no-renames",
`--unified=${options?.context ?? 3}`,
ref,
...(options?.target ? [options.target] : []),
"--",
".",
],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.text(), truncated: result.truncated } satisfies Patch
@@ -367,6 +454,7 @@ function makeGit(proc: AppProcess.Interface) {
return {
branch,
branches,
base,
defaultBranch,
hasHead,
mergeBase,
@@ -393,10 +481,11 @@ const merge = (...lists: Item[][]) => {
const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: Item[], options?: DiffOptions) {
const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: Item[], options?: GitDiffOptions) {
if (list.length === 0) return emptyBatch()
const result = yield* ctx.git.patchAll(ctx.directory, ref, {
target: options?.target,
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
@@ -407,7 +496,12 @@ const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: I
}
})
const nativePatch = Effect.fnUntraced(function* (ctx: Ctx, ref: string | undefined, item: Item, options?: DiffOptions) {
const nativePatch = Effect.fnUntraced(function* (
ctx: Ctx,
ref: string | undefined,
item: Item,
options?: GitDiffOptions,
) {
const result =
item.code === "??" || !ref
? yield* ctx.git.patchUntracked(ctx.worktree, item.file, {
@@ -415,6 +509,7 @@ const nativePatch = Effect.fnUntraced(function* (ctx: Ctx, ref: string | undefin
maxOutputBytes: MAX_PATCH_BYTES,
})
: yield* ctx.git.patch(ctx.worktree, ref, item.file, {
target: options?.target,
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_PATCH_BYTES,
})
@@ -434,7 +529,7 @@ const patchForItem = Effect.fnUntraced(function* (
item: Item,
batch: { patches: Map<string, string>; capped: boolean },
capped: boolean,
options?: DiffOptions,
options?: GitDiffOptions,
) {
if (capped) return emptyPatch(item.file)
@@ -450,7 +545,7 @@ const files = Effect.fnUntraced(function* (
list: Item[],
map: Map<string, { additions: number; deletions: number }>,
batch: { patches: Map<string, string>; capped: boolean },
options?: DiffOptions,
options?: GitDiffOptions,
) {
const next: FileDiff.Info[] = []
let total = 0
@@ -459,7 +554,7 @@ const files = Effect.fnUntraced(function* (
for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
const stat =
map.get(item.file) ??
(item.status === "added" ? yield* ctx.git.statUntracked(ctx.worktree, item.file) : undefined)
(!options?.target && item.status === "added" ? yield* ctx.git.statUntracked(ctx.worktree, item.file) : undefined)
const patch = yield* patchForItem(ctx, ref, item, batch, capped, options)
const result: { patch: string; capped: boolean } = capped
? { patch, capped: true }
@@ -481,9 +576,13 @@ const files = Effect.fnUntraced(function* (
return next
})
const diffAgainstRef = Effect.fnUntraced(function* (ctx: Ctx, ref: string, options?: DiffOptions) {
const diffAgainstRef = Effect.fnUntraced(function* (ctx: Ctx, ref: string, options?: GitDiffOptions) {
const [list, stats, extra] = yield* Effect.all(
[ctx.git.diff(ctx.directory, ref), ctx.git.stats(ctx.directory, ref), ctx.git.status(ctx.directory)],
[
ctx.git.diff(ctx.directory, ref, options?.target),
ctx.git.stats(ctx.directory, ref, options?.target),
options?.target ? Effect.succeed([]) : ctx.git.status(ctx.directory),
],
{ concurrency: 3 },
)
return yield* files(
+7 -1
View File
@@ -10,6 +10,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "../../location.js"
import type { Adapter, DiffOptions } from "../../vcs.js"
import { DiffError } from "../../vcs.js"
import {
addPatch,
chunksByFile,
@@ -42,7 +43,7 @@ export const Plugin = define({
info: () => adapter.info(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
status: () => adapter.status(),
diff: (input) => adapter.diff(input.mode, { context: input.context }),
diff: (input) => adapter.diff(input.mode, { context: input.context, base: input.base }),
})
})
}),
@@ -121,6 +122,11 @@ function make(
}),
diff: Effect.fn("VcsHg.diff")(function* (mode: Mode, options?: DiffOptions) {
if (mode === "working") return yield* diffAgainst(undefined, options)
if (mode === "committed" || options?.base !== undefined) {
return yield* new DiffError({
message: "The Mercurial provider does not support committed reviews or explicit bases",
})
}
const branch = yield* hg.branch()
if (!branch || branch === "default") return []
+58 -82
View File
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { Cause, Effect, Fiber, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project.js"
@@ -50,8 +50,7 @@ import { Job } from "./job.js"
import { Command } from "./command.js"
import { Shell } from "./shell.js"
import { Global } from "@opencode-ai/util/global"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { ShellResult } from "./shell/result.js"
import { fileURLToPath } from "url"
import { SessionEnvironment } from "./session/environment.js"
import { SessionHistory } from "./session/history.js"
@@ -347,8 +346,6 @@ const layer = Layer.effect(
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const scope = yield* Scope.Scope
const activeShells = new Set<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
const location = Location.Ref.make({
directory: session.location.directory,
@@ -680,10 +677,7 @@ const layer = Layer.effect(
// retried payload, metadata, and delivery mode.
if (admitted.type !== "user" || admitted.sessionID !== input.sessionID)
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) {
if (activeShells.has(admitted.sessionID)) return admitted
yield* execution.wake(admitted.sessionID)
}
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}),
),
@@ -717,60 +711,62 @@ const layer = Layer.effect(
}),
shell: Effect.fn("Session.shell")(function* (input) {
const session = yield* result.get(input.sessionID)
yield* shellLocks.withLock(input.sessionID)(
Effect.gen(function* () {
activeShells.add(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const shell = yield* Shell.Service
return yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie)
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(
SessionEvent.Shell.Started,
{
sessionID: input.sessionID,
shell: started,
},
{ id: input.id },
)
const completed = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
const terminal = yield* shell.wait(started.id).pipe(
Effect.map((info) => ({ info, retained: true as const })),
Effect.catchTag("Shell.NotFoundError", () =>
Effect.succeed({ info: synthesizeTerminalShellInfo(started), retained: false as const }),
),
)
const output = terminal.retained
? yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
: missingShellOutput()
return { shell: terminal.info, output }
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
shell: completed.shell,
output: completed.output,
// The server owns completion recording even if the submitting client disconnects.
const running = yield* Effect.gen(function* () {
// Resolve shell services here without pinning Session events to this Location after a move.
const shell = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Shell.Service
}).pipe(Effect.provide(locations.get(session.location)))
const started = yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID, background: true },
})
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
activeShells.delete(input.sessionID)
yield* execution.wake(input.sessionID)
}),
),
),
)
.pipe(
Effect.tapError((error) =>
result.synthetic({
sessionID: input.sessionID,
text: `User shell command failed to start:\n${input.command}\n\n${error.message}`,
description: input.command,
metadata: { source: "shell", state: "error" },
resume: false,
}),
),
Effect.orDie,
)
yield* bus.publish(
SessionEvent.Shell.Started,
{
sessionID: input.sessionID,
shell: started,
},
{ id: input.id },
)
const terminal = yield* shell.result(started)
const preview = yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(ShellResult.unavailable)))
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
shell: terminal.info,
output: preview,
})
yield* result
.synthetic({
...ShellResult.userNotification(terminal),
sessionID: input.sessionID,
resume: false,
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
Effect.orDie,
)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
yield* Fiber.join(running)
}),
skill: Effect.fn("Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
@@ -994,26 +990,6 @@ const layer = Layer.effect(
}),
)
function missingShellOutput() {
const output = "Shell command output is no longer available."
return {
output,
cursor: Buffer.byteLength(output),
size: Buffer.byteLength(output),
truncated: false,
}
}
function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Info {
return {
...started,
// The Shell record was removed before waiters could observe it; publish a terminal
// boundary instead of leaving the Session shell message permanently running.
status: "killed",
time: { ...started.time, completed: Date.now() },
}
}
const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
request: Parameters<Interface["prompt"]>[0],
messageID: SessionMessage.ID,
+3 -1
View File
@@ -164,7 +164,9 @@ const serialize = (message: SessionMessage.Info) => {
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell")
return `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
return message.metadata?.background === true
? ""
: `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
return ""
}
+1 -1
View File
@@ -90,7 +90,7 @@ export const layer = Layer.effect(
).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause) || Cause.squash(cause) instanceof UserInterruptedError
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
@@ -9,6 +9,7 @@ import { SessionEvent } from "../event.js"
import { SessionExecution } from "../execution.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { ShellResult } from "../../shell/result.js"
import { SubagentCompletion } from "../subagent-completion.js"
const CONTINUE_AFTER_SERVER_RESTART =
@@ -116,13 +117,13 @@ export const layer = (options?: Options) =>
id: background.notificationID,
sessionID: recovery.sessionID,
description: recovery.command,
text: `<shell id="${background.id}" state="${state}" command="${recovery.command}">\n${text}\n</shell>`,
metadata: {
source: "shell",
...ShellResult.notification({
jobID: background.id,
shellID: recovery.shellID,
command: recovery.command,
state,
},
text,
}),
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
})
.pipe(
+2 -1
View File
@@ -169,7 +169,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
SessionMessage.Shell.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "shell",
metadata: event.metadata,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
+25 -3
View File
@@ -5,11 +5,13 @@ import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Content } from "@opencode-ai/schema/tool"
import { Config, Context, Effect, Layer, Stream } from "effect"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js"
import { SessionModelTransport } from "./model-transport.js"
import { SessionRunnerModel } from "./runner/model.js"
@@ -26,6 +28,23 @@ const IMAGE_REMOVED =
const responsesWebSocketFlag = (providerID: string) =>
`OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
// User declines dive under the leaves' blanket `mapError` as defects (the deliberate
// tunnel entered in Permission.assert and the question tool), so a user's "no" can
// never become model-facing tool output. They resurface as typed failures exactly once,
// here at the seam the runner executes through.
const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
const decline = cause.reasons.flatMap((reason) =>
Cause.isDieReason(reason) &&
(reason.defect instanceof Permission.DeclinedError || reason.defect instanceof QuestionTool.CancelledError)
? [reason.defect]
: [],
)[0]
return decline ? Result.succeed(decline) : Result.fail(cause)
}
export interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
@@ -34,7 +53,7 @@ export interface Prepared {
* One request-scoped execution operation. Unknown and hook-removed calls
* fail individually through the same seam.
*/
readonly executeTool: Tool.Snapshot["execute"]
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
}
interface PrepareInput {
@@ -342,7 +361,10 @@ export const layer = Layer.effect(
? { webSocket: transport.bind(session.id) }
: {}),
}
const executeTool: Prepared["executeTool"] = (input) => tools.execute({ ...input, definitions: hooked })
const executeTool: Prepared["executeTool"] = (input) =>
tools
.execute({ ...input, definitions: hooked })
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid)
return {
request,
+29 -16
View File
@@ -13,10 +13,12 @@ import type { Agent } from "@opencode-ai/schema/agent"
import { Cause, Data, Effect, Exit, Fiber, Option, Stream } from "effect"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
import { Snapshot } from "../../snapshot.js"
import { Tool } from "../../tool.js"
import { ToolOutput } from "../../tool-output.js"
import { StepFailedError, UserInterruptedError } from "../error.js"
import { QuestionTool } from "../../tool/plugin/question.js"
import { StepFailedError } from "../error.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionModelRequest } from "../model-request.js"
@@ -78,7 +80,7 @@ export const make = Effect.gen(function* () {
})
const toolRuns: Array<{
readonly call: ToolCall
readonly fiber: Fiber.Fiber<void, Tool.Declined>
readonly fiber: Fiber.Fiber<void, Permission.DeclinedError | QuestionTool.CancelledError>
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const executeTool = (call: ToolCall) => {
@@ -139,10 +141,8 @@ export const make = Effect.gen(function* () {
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
if (Exit.isFailure(joined)) yield* interruptTools
const tools = classifyToolExits(joined, toolRuns)
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
if (
!interrupted &&
!publisher.record().outputStarted &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(input.recoverOverflow))
@@ -163,7 +163,6 @@ export const make = Effect.gen(function* () {
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmError = llmFailure && !recorded.providerFailed ? toSessionError(llmFailure) : undefined
if (
!interrupted &&
input.recoverContinuation &&
llmFailure?.reason._tag === "Transport" &&
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
@@ -171,7 +170,7 @@ export const make = Effect.gen(function* () {
)
return Outcome.RecoverFull()
const retry =
!interrupted && llmFailure && llmError && !isContextOverflowFailure(llmFailure)
llmFailure && llmError && !isContextOverflowFailure(llmFailure)
? yield* restore(
input.retry(
llmFailure,
@@ -191,8 +190,12 @@ export const make = Effect.gen(function* () {
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message: decline.reason.message,
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
const toolFailure = interrupted
? TOOLS_INTERRUPTED
: tools.failure !== undefined
@@ -237,12 +240,20 @@ export const make = Effect.gen(function* () {
})
}
if (llmFailure && llmError && retry?.retry && record.outputStarted && !interrupted)
if (
llmFailure &&
llmError &&
retry?.retry &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
)
return Outcome.Continue({ error: llmError, decision: retry })
if (tools.declines.length > 0 && !streamInterrupted) return yield* new UserInterruptedError()
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return Outcome.Completed({
needsContinuation: input.prepared.request.toolChoice?.type !== "none" && record.needsContinuation,
@@ -262,7 +273,7 @@ const isInterruptedStream = (failure: AIError) => {
/** Tool.Error settles in each fiber; only user declines remain in the typed error channel. */
const classifyToolExits = (
settled: Exit.Exit<Array<Exit.Exit<void, Tool.Declined>>>,
settled: Exit.Exit<Array<Exit.Exit<void, Permission.DeclinedError | QuestionTool.CancelledError>>>,
runs: ReadonlyArray<{ readonly call: ToolCall }>,
) => {
const exits = Exit.isSuccess(settled) ? settled.value : []
@@ -276,10 +287,12 @@ const classifyToolExits = (
const causes = Exit.isFailure(settled)
? [settled.cause]
: exits.flatMap((exit) => (Exit.isFailure(exit) ? [exit.cause] : []))
const failure = causes.find(Cause.hasInterrupts) ?? causes.find(Cause.hasDies)
return {
interrupted: failure !== undefined && Cause.hasInterrupts(failure),
declines,
failure: failure && Cause.fromReasons<never>(failure.reasons.filter((reason) => !Cause.isFailReason(reason))),
}
const failure = causes
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.filter(Cause.isDieReason)
return reasons.length > 0 ? [Cause.fromReasons<never>(reasons)] : []
})
.at(0)
return { interrupted: causes.some(Cause.hasInterrupts), declines, failure }
}
@@ -260,6 +260,8 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
case "system":
return [Message.system(message.text)]
case "shell":
// Background shell results enter context once, through their completion inbox item.
if (message.metadata?.background === true) return []
return [
Message.make({
id: message.id,
+30 -1
View File
@@ -18,6 +18,9 @@ import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks.js"
import { SessionEnvironment } from "./session/environment.js"
import { SessionSchema } from "./session/schema.js"
import { Config } from "./config.js"
import { ToolOutput } from "./tool-output.js"
import { ShellResult } from "./shell/result.js"
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Shell.NotFoundError", {
id: Shell.ID,
@@ -65,6 +68,8 @@ export interface Interface {
// Resolves once the command reaches a terminal status, returning its final Info. Fails with
// NotFoundError if the command is unknown or is removed before it terminates.
readonly wait: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
// A known shell's terminal state and bounded tail. Missing capture remains distinct from its exit status.
readonly result: (started: Shell.Info) => Effect.Effect<ShellResult.Result>
// Replaces the running command's timeout from now; zero clears it.
readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect<Shell.Info, NotFoundError>
readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect<Shell.Output, NotFoundError>
@@ -120,6 +125,7 @@ const layer = () =>
const environment = yield* Environment.Service
const hooks = yield* PluginHooks.Service
const environments = yield* SessionEnvironment.Service
const config = yield* Config.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const commands = new Map<Shell.ID, Active>()
@@ -217,6 +223,28 @@ const layer = () =>
}
})
const result = Effect.fn("Shell.result")(function* (started: Shell.Info) {
const info = yield* wait(started.id).pipe(
Effect.catchTag("Shell.NotFoundError", () =>
Effect.succeed({ ...started, status: "killed" as const, time: { ...started.time, completed: Date.now() } }),
),
)
const capture = yield* Effect.gen(function* () {
const limits = Config.latest(yield* config.entries(), "tool_output")
const maxLines = limits?.max_lines ?? ToolOutput.MAX_LINES
const maxBytes = limits?.max_bytes ?? ToolOutput.MAX_BYTES
const latest = yield* output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const page = yield* output(info.id, { cursor: Math.max(0, latest.size - maxBytes), limit: maxBytes })
const lines = page.output.split("\n")
if (page.output.endsWith("\n")) lines.pop()
const truncated = latest.size > maxBytes || lines.length > maxLines
const text = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return { output: `${text || "(no output)"}${notice}`, truncated }
}).pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(undefined)))
return { info, capture }
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
@@ -382,7 +410,7 @@ const layer = () =>
return command.info
})
return Service.of({ create, list, get, wait, timeout, output, remove })
return Service.of({ create, list, get, wait, result, timeout, output, remove })
}),
)
@@ -397,6 +425,7 @@ export const node = makeLocationNode({
Environment.node,
PluginHooks.node,
SessionEnvironment.node,
Config.node,
cleanupNode,
],
})
+74
View File
@@ -0,0 +1,74 @@
export * as ShellResult from "./result.js"
import type { Shell } from "@opencode-ai/schema/shell"
export type Result = {
info: Shell.Info
capture: { output: string; truncated: boolean } | undefined
}
type Output = { output: string; truncated: boolean; exit?: number; timeout?: boolean }
const missing = "Shell command output is no longer available."
export const unavailable: Shell.Output = {
output: missing,
cursor: Buffer.byteLength(missing),
size: Buffer.byteLength(missing),
truncated: false,
}
export function output(result: Result): Output {
return {
output: result.capture?.output ?? unavailable.output,
truncated: result.capture?.truncated ?? false,
...(result.info.exit !== undefined ? { exit: result.info.exit } : {}),
...(result.info.status === "timeout" ? { timeout: true } : {}),
}
}
export function notice(output: Pick<Output, "exit" | "timeout">) {
if (output.timeout) return "Command timed out before completion."
if (output.exit !== undefined) return `Command exited with code ${output.exit}.`
}
export function metadata(output: Output) {
return {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
}
export function notification(input: {
shellID: string
jobID?: string
command: string
state: "completed" | "cancelled" | "error"
text: string
output?: Output
}) {
return {
text: `<shell id="${input.jobID ?? input.shellID}" state="${input.state}" command="${input.command}">\n${input.text}\n</shell>`,
metadata: {
source: "shell",
shellID: input.shellID,
...(input.jobID !== undefined ? { jobID: input.jobID } : {}),
state: input.state,
...(input.output ? metadata(input.output) : {}),
},
}
}
export function userNotification(result: Result) {
const captured = output(result)
const status =
result.info.status === "killed" ? "Command cancelled." : (notice(captured) ?? "Command exited with code unknown.")
const message = notification({
shellID: result.info.id,
command: result.info.command,
state: result.info.status === "killed" ? "cancelled" : "completed",
text: `${captured.output}\n\n${status}`,
output: captured,
})
return { ...message, text: `The following shell command was executed by the user:\n${message.text}` }
}
+2 -2
View File
@@ -1,5 +1,5 @@
export * as Tool from "./tool.js"
export { CallID, Content, Declined, Error, FileContent, TextContent } from "@opencode-ai/schema/tool"
export { CallID, Content, Error, FileContent, TextContent } from "@opencode-ai/schema/tool"
export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/tool"
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
@@ -51,7 +51,7 @@ export interface Snapshot {
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
/** Surviving request definitions, keyed by the names advertised after session context hooks. */
readonly definitions?: ReadonlyMap<string, ToolDefinition>
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error | Tool.Declined>
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Tool") {}
+1 -1
View File
@@ -26,7 +26,7 @@ const source = {
}
```
Leaves own resolution, permission, and side-effect ordering. Registration accepts executors with arbitrary typed failures; `Tool.Snapshot.execute` normalizes ordinary failures into `Tool.Error` and preserves `Tool.Declined`. Translate expected operational errors into `ToolFailure` only when supplying curated messages or metadata, using narrow catches or mappings at the failing operation rather than blanket executor mappings. `Permission.assert` fails with typed `Permission.Declined`, which the tool execution boundary translates into `Tool.Declined`; question dismissals fail with `Tool.Declined` directly. Leaves must preserve these control failures. A decline with feedback (`Permission.CorrectedError`) remains an ordinary model-facing error so the model continues. Do not use `catchCause`: interruption and defects must survive.
Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive. User declines from `Permission.assert` and question dismissals travel as defects beneath leaf `mapError` blankets and resurface as typed failures at `SessionModelRequest.executeTool`; leaves must never catch or convert them. A decline with feedback (`Permission.CorrectedError`) stays typed so the leaf converts it into `ToolFailure` and the model continues.
## Registration
+4 -3
View File
@@ -104,9 +104,10 @@ export const layer = Layer.effect(
...(content.length === 0 ? {} : { content }),
}
}).pipe(
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
() => new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
),
})
+4 -11
View File
@@ -224,17 +224,10 @@ export const Plugin = {
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
metadata: { files: output.files },
})),
Effect.catchTag(
[
"PlatformError",
"Environment.NotFound",
"Environment.WrongKind",
"Environment.Failed",
"Permission.BlockedError",
"Permission.CorrectedError",
"Session.NotFoundError",
],
(error) => new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
),
)
},
+4 -10
View File
@@ -128,16 +128,10 @@ export const Plugin = {
),
metadata: { count: result.entries.length, truncated: result.truncated },
})),
Effect.catchTag(
[
"PlatformError",
"Environment.Failed",
"Ripgrep.Error",
"Permission.BlockedError",
"Permission.CorrectedError",
"Session.NotFoundError",
],
(error) => new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
),
),
}),
+6 -14
View File
@@ -151,20 +151,12 @@ export const Plugin = {
),
metadata: { matches: result.matches.length, truncated: result.truncated },
})),
Effect.catchTag(
"Ripgrep.InvalidPatternError",
(error) => new ToolFailure({ message: `Invalid regex pattern: ${error.message}` }),
),
Effect.catchTag(
[
"PlatformError",
"Environment.Failed",
"Ripgrep.Error",
"Permission.BlockedError",
"Permission.CorrectedError",
"Session.NotFoundError",
],
(error) => new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: error instanceof Ripgrep.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
),
),
}),
+6 -6
View File
@@ -185,9 +185,10 @@ export const Plugin = {
})
if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
}).pipe(
Effect.catchTag(
["PlatformError", "Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
),
)
}
@@ -288,9 +289,8 @@ export const Plugin = {
content: toModelContent(output),
metadata: { files: output.files },
})),
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => new ToolFailure({ message: "Unable to apply patch", error }),
Effect.mapError((error) =>
error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }),
),
)
},
+11 -7
View File
@@ -6,7 +6,6 @@ import { Effect, Schema } from "effect"
import { Form } from "../../form.js"
import { Permission } from "../../permission.js"
import { Question } from "@opencode-ai/schema/question"
import { Tool } from "@opencode-ai/schema/tool"
export const name = "question"
@@ -30,6 +29,12 @@ export const Output = Schema.Struct({
})
export type Output = typeof Output.Type
export class CancelledError extends Schema.TaggedError<CancelledError>()("QuestionTool.CancelledError", {}) {
override get message() {
return "The user dismissed this question"
}
}
export const toModelContent = (questions: ReadonlyArray<Question.Prompt>, answers: ReadonlyArray<Question.Answer>) => {
const formatted = questions
.map(
@@ -64,10 +69,7 @@ export const Plugin = {
source: { type: "tool", messageID: context.messageID, id: context.id },
})
.pipe(
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => new ToolFailure({ message: "Permission denied: question", error }),
),
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
Effect.andThen(
forms
.ask({
@@ -85,8 +87,10 @@ export const Plugin = {
.pipe(Effect.orDie),
),
Effect.flatMap((state) => {
if (state.status === "cancelled")
return new Tool.Declined({ message: "The user dismissed this question" })
// Deliberate defect tunnel (see Permission.assert): a dismissal must dodge
// leaf `mapError` blankets so it never becomes model-facing tool output; it
// resurfaces as a typed failure at SessionModelRequest.executeTool.
if (state.status === "cancelled") return Effect.die(new CancelledError())
const output = {
answers: input.questions.map((_, index): Question.Answer => {
const value = state.answer[`q${index}`]
+11 -19
View File
@@ -116,25 +116,17 @@ export const Plugin = {
content: toModelContent(input.path, input.offset, output),
metadata: { truncated: output.type === "file" ? false : output.truncated },
})),
Effect.catchTag(
[
"ReadTool.BinaryFileError",
"ReadTool.MediaIngestLimitError",
"ReadTool.OffsetOutOfRangeError",
"ReadTool.PathKindError",
],
(error) => new ToolFailure({ message: error.message, error }),
),
Effect.catchTag(
[
"PlatformError",
"Environment.Failed",
"Permission.BlockedError",
"Permission.CorrectedError",
"Session.NotFoundError",
],
(error) => new ToolFailure({ message: `Unable to read ${input.path}`, error }),
),
Effect.mapError((error) => {
if (error instanceof ToolFailure) return error
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
error instanceof ReadToolFileSystem.PathKindError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message, error })
}),
)
},
}),
+25 -98
View File
@@ -13,7 +13,7 @@ import { SessionSchema } from "../../session/schema.js"
import { Shell } from "../../shell.js"
import { ShellParse } from "../../shell/parse.js"
import { ShellSelect } from "../../shell/select.js"
import { ToolOutput } from "../../tool-output.js"
import { ShellResult } from "../../shell/result.js"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
@@ -71,11 +71,7 @@ const Output = Schema.Struct({
type Output = typeof Output.Type
const resultMessages = (output: Output) => {
const notice = (() => {
if (output.status === "running") return BACKGROUND_INSTRUCTION
if (output.timeout) return "Command timed out before completion."
if (output.exit !== undefined) return `Command exited with code ${output.exit}.`
})()
const notice = output.status === "running" ? BACKGROUND_INSTRUCTION : ShellResult.notice(output)
return [output.output, ...(notice ? [notice] : [])]
}
@@ -85,10 +81,8 @@ const toolResult = (output: Output) => {
content: resultMessages(output).map((text) => ({ type: "text" as const, text })),
metadata: {
status: output.status,
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...ShellResult.metadata(output),
...(output.shellID !== undefined ? { shellID: output.shellID } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}
@@ -132,21 +126,15 @@ export const Plugin = {
yield* runtime.session.synthetic({
...(info.notificationID ? { id: info.notificationID } : {}),
sessionID,
text: `<shell id="${id}" state="${info.status}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: {
source: "shell",
...ShellResult.notification({
jobID: id,
shellID,
command,
state: info.status,
...(output
? {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
: {}),
},
text,
output,
}),
})
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
},
@@ -187,11 +175,7 @@ export const Plugin = {
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
portable,
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
)
})
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({
path: LocationMutation.resolvePath(target.absolute, directory),
@@ -223,70 +207,29 @@ export const Plugin = {
source,
})
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
Effect.catchTag(
"Environment.NotFound",
() =>
new ToolFailure({
message: `Unable to execute command: ${input.command}`,
error: new Error(`Working directory does not exist: ${target.absolute}`),
}),
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
),
)
if (workdir !== "directory")
return yield* new ToolFailure({
message: `Unable to execute command: ${input.command}`,
error: new Error(`Working directory is not a directory: ${target.absolute}`),
})
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
}),
)
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fnUntraced(function* () {
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const page = yield* shell.output(info.id, {
cursor: Math.max(0, latest.size - maxBytes),
limit: maxBytes,
})
const lines = page.output.split("\n")
if (page.output.endsWith("\n")) lines.pop()
const truncated = latest.size > maxBytes || lines.length > maxLines
const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
const settled = yield* Deferred.make<Output>()
const run = Effect.gen(function* () {
const result = yield* shell.result(info)
if (!result.capture) return yield* new Shell.NotFoundError({ id: info.id })
const output = ShellResult.output(result)
return {
output: `${output || "(no output)"}${notice}`,
truncated,
}
})
const settleShell = Effect.fnUntraced(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
// `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 {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: capture.truncated,
timeout: true,
status: "completed" as const,
}
}
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: capture.output,
truncated: capture.truncated,
...output,
output: output.timeout
? `${output.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`
: output.output,
status: "completed" as const,
}
})
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
}).pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => resultMessages(output).join("\n\n")),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
@@ -321,29 +264,13 @@ export const Plugin = {
return backgroundResult(info.id, info.file)
}
if (result?.info.status === "error")
return yield* new ToolFailure({
message: `Unable to execute command: ${input.command}`,
error: new Error(result.info.error ?? "Command failed"),
})
if (result?.info.status === "cancelled")
return yield* new ToolFailure({
message: `Unable to execute command: ${input.command}`,
error: new Error("Command cancelled"),
})
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return yield* Deferred.await(settled)
}).pipe(
Effect.map(toolResult),
Effect.catchTag(
[
"PlatformError",
"Environment.Failed",
"AppProcessError",
"Shell.NotFoundError",
"Permission.BlockedError",
"Permission.CorrectedError",
"Session.NotFoundError",
],
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
),
+4 -12
View File
@@ -47,8 +47,8 @@ export const Plugin = {
Effect.gen(function* () {
const skill = yield* skills.get(input.id)
if (!skill) return yield* unableToLoad(input.id)
yield* permission
.assert({
return yield* Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [skill.id],
save: [skill.id],
@@ -56,16 +56,8 @@ export const Plugin = {
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
.pipe(
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => unableToLoad(input.id, error),
),
)
return {
name: skill.name,
...(yield* Skill.prepare(fs, skill).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))),
}
return { name: skill.name, ...(yield* Skill.prepare(fs, skill)) }
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
}).pipe(
Effect.map((output) => ({
output,
+1 -6
View File
@@ -147,12 +147,7 @@ export const Plugin = {
id: context.id,
},
})
.pipe(
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error }),
),
)
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
const existing =
input.sessionID === undefined
+12 -20
View File
@@ -118,25 +118,18 @@ export const Plugin = {
Effect.gen(function* () {
yield* Effect.try({
try: () => assertHttpUrl(new URL(input.url)),
catch: (error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }),
catch: (error) => error,
})
yield* permission
.assert({
action: name,
resources: [input.url],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
.pipe(
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }),
),
)
yield* permission.assert({
action: name,
resources: [input.url],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const { body, contentType } = yield* Effect.gen(function* () {
const response = yield* execute(http, input.url, input.format).pipe(
@@ -154,12 +147,11 @@ export const Plugin = {
duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
orElse: () => Effect.fail(new Error("Request timed out")),
}),
Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error })),
)
const content = new TextDecoder().decode(body)
const output = yield* Effect.try({
try: () => convert(content, contentType, input.format),
catch: (error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }),
catch: (error) => error,
})
const result = {
url: input.url,
@@ -168,7 +160,7 @@ export const Plugin = {
output,
}
return { output: result, content: result.output, metadata: { contentType: result.contentType } }
}),
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
}),
)
.pipe(Effect.orDie)
+39 -48
View File
@@ -41,22 +41,15 @@ export const Plugin = {
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission
.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
.pipe(
Effect.catchTag(
["Permission.BlockedError", "Permission.CorrectedError", "Session.NotFoundError"],
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
)
yield* permission.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
websearch.default().pipe(
Effect.flatMap((provider) => {
@@ -153,37 +146,7 @@ export const Plugin = {
)
}),
)
const result = yield* search().pipe(
Effect.mapError((error) => {
const fallback = `Unable to search the web for ${input.query}`
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
const status = HttpClientError.isHttpClientError(error.cause)
? error.cause.response?.status
: undefined
switch (status) {
case 429:
return new ToolFailure({
message: "Web search rate limited (HTTP 429)",
error,
metadata: { provider: error.providerID },
})
case 401:
return new ToolFailure({
message: "Web search authentication failed (HTTP 401)",
error,
metadata: { provider: error.providerID },
})
case undefined:
return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } })
default:
return new ToolFailure({
message: `Web search request failed (HTTP ${status})`,
error,
metadata: { provider: error.providerID },
})
}
}),
)
const result = yield* search()
const output = {
provider: result.data.providerID,
results: result.data.results,
@@ -200,7 +163,35 @@ export const Plugin = {
.join("\n\n")
: NO_RESULTS
return { output, content, metadata: { provider: output.provider } }
}),
}).pipe(
Effect.mapError((error) => {
const fallback = `Unable to search the web for ${input.query}`
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
const status = HttpClientError.isHttpClientError(error.cause) ? error.cause.response?.status : undefined
switch (status) {
case 429:
return new ToolFailure({
message: "Web search rate limited (HTTP 429)",
error,
metadata: { provider: error.providerID },
})
case 401:
return new ToolFailure({
message: "Web search authentication failed (HTTP 401)",
error,
metadata: { provider: error.providerID },
})
case undefined:
return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } })
default:
return new ToolFailure({
message: `Web search request failed (HTTP ${status})`,
error,
metadata: { provider: error.providerID },
})
}
}),
),
}),
)
.pipe(Effect.orDie)
+1 -12
View File
@@ -99,18 +99,7 @@ export const Plugin = {
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelContent(output) })),
Effect.catchTag(
[
"PlatformError",
"Environment.NotFound",
"Environment.WrongKind",
"Environment.Failed",
"Permission.BlockedError",
"Permission.CorrectedError",
"Session.NotFoundError",
],
(error) => new ToolFailure({ message: `Unable to write ${input.path}`, error }),
),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
)
+12 -21
View File
@@ -1,9 +1,8 @@
import type { ToolDefinition } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Cache, Cause, Effect, JsonSchema, Result, Schema, SchemaIssue, SchemaRepresentation } from "effect"
import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect"
import { $ZodType, toJSONSchema } from "zod/v4/core"
import { Permission } from "../permission.js"
const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1()
@@ -28,26 +27,18 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool, input)
// Keep user decisions distinct from failures the model can recover from.
// Tool implementations declare `Tool.Error` but plugins can fail with anything at
// runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")`
// downstream and leave its call permanently unsettled, so the declared contract is
// enforced here at the untrusted boundary. Declines tunnel through as defects and
// interrupts are not errors; neither is touched.
const result = yield* tool.execute(decoded, context).pipe(
// Promise wrappers turn inner Effect failures into exceptions. Only recover a lone decline;
// an accompanying interruption or defect must retain its original cause.
Effect.catchCauseFilter((cause) => {
const reason = cause.reasons[0]
return cause.reasons.length === 1 &&
Cause.isDieReason(reason) &&
(reason.defect instanceof Permission.Declined || reason.defect instanceof Tool.Declined)
? Result.succeed(reason.defect)
: Result.fail(cause)
}, Effect.fail),
Effect.mapError((error) =>
error instanceof Permission.Declined
? new Tool.Declined({ message: "The user declined this tool call" })
: error instanceof Tool.Error || error instanceof Tool.Declined
? error
: new Tool.Error({
message: error instanceof globalThis.Error ? error.message : String(error),
}),
Effect.mapError((error: unknown) =>
error instanceof Tool.Error
? error
: new Tool.Error({
message: error instanceof globalThis.Error ? error.message : String(error),
}),
),
)
if (tool.output === undefined) {
+41 -6
View File
@@ -5,7 +5,7 @@ import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { Base, BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -14,10 +14,15 @@ import { Bus } from "./bus.js"
import { State } from "./state.js"
import { emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch.js"
export { BranchList, FileStatus, Info, Mode }
export { Base, BranchList, FileStatus, Info, Mode }
export class DiffError extends Schema.TaggedError<DiffError>()("Vcs.DiffError", {
message: Schema.String,
}) {}
export interface DiffOptions {
readonly context?: number
readonly base?: string
}
export interface BranchOptions {
@@ -27,12 +32,15 @@ export interface BranchOptions {
export interface Adapter {
readonly info: () => Effect.Effect<Info>
readonly base?: () => Effect.Effect<Base | null, DiffError>
readonly branches: (options?: BranchOptions) => Effect.Effect<BranchList>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[], DiffError>
}
export interface Interface extends Adapter, State.Transformable<VcsDraft> {}
export interface Interface extends Adapter, State.Transformable<VcsDraft> {
readonly base: () => Effect.Effect<Base | null, DiffError>
}
interface Data {
readonly providers: Map<string, VcsDefinition>
@@ -56,6 +64,7 @@ const layer = Layer.effect(
...(vcs ? { store: vcs.store } : {}),
}
const decodeInfo = Schema.decodeUnknownEffect(Schema.toType(Info))
const decodeBase = Schema.decodeUnknownEffect(Schema.NullOr(Base))
const decodeBranches = Schema.decodeUnknownEffect(BranchList)
const decodeStatus = Schema.decodeUnknownEffect(Schema.Array(FileStatus))
const decodeDiff = Schema.decodeUnknownEffect(Schema.Array(FileDiff.Info))
@@ -86,6 +95,27 @@ const layer = Layer.effect(
),
),
)
const review = <A>(provider: VcsDefinition, operation: "base" | "diff", effect: Effect.Effect<A, unknown>) =>
effect.pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterrupts(cause)) return Effect.failCause(cause).pipe(Effect.orDie)
const error = Cause.squash(cause)
return Effect.logWarning("vcs provider failed", { provider: provider.id, operation, cause }).pipe(
Effect.andThen(
Effect.fail(
error instanceof DiffError
? error
: new DiffError({
message:
operation === "base"
? "VCS provider could not resolve a review base"
: "VCS provider could not produce a diff",
}),
),
),
)
}),
)
const refresh = Effect.fn("Vcs.refresh")(function* () {
const provider = selected()
const next: Info = provider
@@ -117,6 +147,11 @@ const layer = Layer.effect(
info: Effect.fn("Vcs.info")(function* () {
return current.info
}),
base: Effect.fn("Vcs.base")(function* () {
const provider = selected()
if (!provider?.base) return null
return yield* review(provider, "base", provider.base(scope).pipe(Effect.flatMap(decodeBase)))
}),
branches: Effect.fn("Vcs.branches")(function* (options?: BranchOptions) {
const provider = selected()
if (provider)
@@ -145,18 +180,18 @@ const layer = Layer.effect(
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
const provider = selected()
if (!provider) return []
const rows = yield* protect(
const rows = yield* review(
provider,
"diff",
provider
.diff({
...scope,
mode,
...(options?.base !== undefined ? { base: options.base } : {}),
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
.pipe(Effect.flatMap(decodeDiff)),
[],
)
let total = 0
return rows.map((row) => {
+1 -24
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
@@ -51,29 +51,6 @@ describe("Form", () => {
}),
)
it.effect("interrupts pending asks on service teardown rather than returning a dismissal", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* Layer.buildWithScope(Layer.fresh(Form.layer), scope)
const service = Context.get(context, Form.Service)
const bus = yield* Bus.Service
const created = yield* Deferred.make<void>()
const unsubscribe = yield* bus.listen((event) =>
event.type === Form.Event.Created.type ? Deferred.succeed(created, undefined).pipe(Effect.asVoid) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
yield* Deferred.await(created)
yield* Scope.close(scope, Exit.void)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(yield* service.state(formID)).toEqual({ status: "cancelled" })
}),
)
it.effect("supports the temporary global mcp elicitation owner", () =>
Effect.gen(function* () {
const service = yield* Form.Service
@@ -1,10 +1,197 @@
import { OpenAIResponsesLanguageModel } from "@opencode-ai/core/github-copilot/responses/openai-responses-language-model"
import { convertToOpenAIResponsesInput } from "@opencode-ai/core/github-copilot/responses/convert-to-openai-responses-input"
import { describe, test, expect, mock } from "bun:test"
import type { LanguageModelV3Prompt, LanguageModelV3StreamPart } from "@ai-sdk/provider"
import type { LanguageModelV3Prompt, LanguageModelV3ProviderTool, LanguageModelV3StreamPart } from "@ai-sdk/provider"
const TEST_PROMPT: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }]
const HOSTED_TOOL_CASES = [
{
id: "openai.web_search",
name: "current_web",
args: {},
wireType: "web_search",
output: {
type: "web_search_call",
id: "web_1",
status: "completed",
action: { type: "search", query: "news" },
},
stream: [
{
type: "response.output_item.added",
output_index: 0,
item: {
type: "web_search_call",
id: "web_1",
status: "in_progress",
action: { type: "search", query: "news" },
},
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "web_search_call",
id: "web_1",
status: "completed",
action: { type: "search", query: "news" },
},
},
],
streamEventTypes: ["tool-input-start", "tool-input-end", "tool-call", "tool-result"],
eventTypes: ["tool-input-start", "tool-call", "tool-result"],
},
{
id: "openai.web_search_preview",
name: "preview_web",
args: {},
wireType: "web_search_preview",
output: {
type: "web_search_call",
id: "preview_1",
status: "completed",
action: { type: "search", query: "news" },
},
stream: [
{
type: "response.output_item.added",
output_index: 0,
item: {
type: "web_search_call",
id: "preview_1",
status: "in_progress",
action: { type: "search", query: "news" },
},
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "web_search_call",
id: "preview_1",
status: "completed",
action: { type: "search", query: "news" },
},
},
],
streamEventTypes: ["tool-input-start", "tool-input-end", "tool-call", "tool-result"],
eventTypes: ["tool-input-start", "tool-call", "tool-result"],
},
{
id: "openai.file_search",
name: "documents",
args: { vectorStoreIds: ["store_1"] },
wireType: "file_search",
output: { type: "file_search_call", id: "file_1", queries: ["news"], results: null },
stream: [
{
type: "response.output_item.added",
output_index: 0,
item: { type: "file_search_call", id: "file_1" },
},
{
type: "response.output_item.done",
output_index: 0,
item: { type: "file_search_call", id: "file_1", queries: ["news"], results: null },
},
],
streamEventTypes: ["tool-call", "tool-result"],
eventTypes: ["tool-call", "tool-result"],
},
{
id: "openai.code_interpreter",
name: "python",
args: {},
wireType: "code_interpreter",
output: {
type: "code_interpreter_call",
id: "code_1",
code: "print(1)",
container_id: "container_1",
outputs: null,
},
stream: [
{
type: "response.output_item.added",
output_index: 0,
item: {
type: "code_interpreter_call",
id: "code_1",
code: null,
container_id: "container_1",
outputs: null,
status: "in_progress",
},
},
{
type: "response.code_interpreter_call_code.delta",
item_id: "code_1",
output_index: 0,
delta: "print(",
},
{
type: "response.code_interpreter_call_code.done",
item_id: "code_1",
output_index: 0,
code: "print(1)",
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "code_interpreter_call",
id: "code_1",
code: "print(1)",
container_id: "container_1",
outputs: null,
},
},
],
streamEventTypes: [
"tool-input-start",
"tool-input-delta",
"tool-input-delta",
"tool-input-delta",
"tool-input-end",
"tool-call",
"tool-result",
],
eventTypes: ["tool-input-start", "tool-call", "tool-result"],
},
{
id: "openai.image_generation",
name: "illustrate",
args: {},
wireType: "image_generation",
output: { type: "image_generation_call", id: "image_1", result: "final-image" },
stream: [
{
type: "response.output_item.added",
output_index: 0,
item: { type: "image_generation_call", id: "image_1" },
},
{
type: "response.image_generation_call.partial_image",
item_id: "image_1",
output_index: 0,
partial_image_b64: "partial-image",
},
{
type: "response.output_item.done",
output_index: 0,
item: { type: "image_generation_call", id: "image_1", result: "final-image" },
},
],
streamEventTypes: ["tool-call", "tool-result", "tool-result"],
eventTypes: ["tool-call", "tool-result", "tool-result"],
},
] as const
function hostedTool(testCase: (typeof HOSTED_TOOL_CASES)[number]): LanguageModelV3ProviderTool {
return { type: "provider", id: testCase.id, name: testCase.name, args: testCase.args }
}
function createMockFetch(body: unknown) {
return mock(
async () => new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }),
@@ -30,12 +217,144 @@ function createModel(fetchFn: ReturnType<typeof mock>) {
})
}
async function readStream(stream: ReadableStream<LanguageModelV3StreamPart>) {
const reader = stream.getReader()
const events: LanguageModelV3StreamPart[] = []
while (true) {
const item = await reader.read()
if (item.done) return events
events.push(item.value)
}
}
// GitHub Copilot's Responses model echoes item metadata (itemId, reasoningEncryptedContent,
// responseId, ...) under the "copilot" providerOptions/providerMetadata namespace, matching the
// namespace request options already use. It used to echo this metadata under "openai" (a leftover
// from forking the OpenAI Responses model), which left it unreachable by anything reading the
// "copilot" namespace and let stale itemIds slip past stripping meant for that namespace.
describe("doGenerate", () => {
test.each([...HOSTED_TOOL_CASES])("forces $id by its declared logical name", async (testCase) => {
const requests: unknown[] = []
const model = createModel(
mock(async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
requests.push(await new Response(init?.body).json())
return new Response(
JSON.stringify({
id: "resp_1",
created_at: 0,
model: "test-model",
output: [],
usage: { input_tokens: 1, output_tokens: 1 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}),
)
await model.doGenerate({
prompt: TEST_PROMPT,
tools: [hostedTool(testCase)],
toolChoice: { type: "tool", toolName: testCase.name },
})
expect(requests[0]).toMatchObject({ tool_choice: { type: testCase.wireType } })
})
test("does not mistake a colliding function name for a hosted tool", async () => {
const requests: unknown[] = []
const model = createModel(
mock(async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
requests.push(await new Response(init?.body).json())
return new Response(
JSON.stringify({
id: "resp_1",
created_at: 0,
model: "test-model",
output: [],
usage: { input_tokens: 1, output_tokens: 1 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}),
)
await model.doGenerate({
prompt: TEST_PROMPT,
tools: [
{ type: "provider", id: "openai.web_search", name: "lookup", args: {} },
{ type: "function", name: "web_search", inputSchema: { type: "object" } },
],
toolChoice: { type: "tool", toolName: "web_search" },
})
expect(requests[0]).toMatchObject({ tool_choice: { type: "function", name: "web_search" } })
})
test.each([...HOSTED_TOOL_CASES])("uses $name for generated $id calls and results", async (testCase) => {
const model = createModel(
createMockFetch({
id: "resp_1",
created_at: 0,
model: "test-model",
output: [testCase.output],
usage: { input_tokens: 1, output_tokens: 1 },
}),
)
const result = await model.doGenerate({ prompt: TEST_PROMPT, tools: [hostedTool(testCase)] })
expect(result.content.filter((part) => part.type === "tool-call" || part.type === "tool-result")).toMatchObject([
{ type: "tool-call", toolName: testCase.name },
{ type: "tool-result", toolName: testCase.name },
])
})
test("uses canonical names only when no hosted declaration matches", async () => {
const model = createModel(
createMockFetch({
id: "resp_1",
created_at: 0,
model: "test-model",
output: [
HOSTED_TOOL_CASES[0].output,
HOSTED_TOOL_CASES[2].output,
HOSTED_TOOL_CASES[3].output,
HOSTED_TOOL_CASES[4].output,
{ type: "computer_call", id: "computer_1", status: "completed" },
],
usage: { input_tokens: 1, output_tokens: 1 },
}),
)
const result = await model.doGenerate({ prompt: TEST_PROMPT })
expect(result.content.filter((part) => part.type === "tool-call").map((part) => part.toolName)).toEqual([
"web_search",
"file_search",
"code_interpreter",
"image_generation",
"computer_use",
])
})
test("rejects an automatic web response when both variants have different logical names", async () => {
const model = createModel(
createMockFetch({
id: "resp_1",
created_at: 0,
model: "test-model",
output: [HOSTED_TOOL_CASES[0].output],
usage: { input_tokens: 1, output_tokens: 1 },
}),
)
await expect(
model.doGenerate({
prompt: TEST_PROMPT,
tools: [hostedTool(HOSTED_TOOL_CASES[0]), hostedTool(HOSTED_TOOL_CASES[1])],
}),
).rejects.toThrow("ambiguous web_search response for hosted tools: current_web, preview_web")
})
test("attaches item metadata under the copilot namespace, not openai", async () => {
const mockFetch = createMockFetch({
id: "resp_1",
@@ -129,6 +448,105 @@ describe("doGenerate", () => {
})
describe("doStream", () => {
test.each([...HOSTED_TOOL_CASES])("uses $name for every streamed $id identity event", async (testCase) => {
const model = createModel(createStreamFetch(testCase.stream))
const result = await model.doStream({
prompt: TEST_PROMPT,
tools: [hostedTool(testCase)],
})
const streamEvents = (await readStream(result.stream)).filter(
(event) => event.type !== "stream-start" && event.type !== "finish",
)
const events = streamEvents.filter((event) => "toolName" in event)
expect(streamEvents.map((event) => event.type)).toEqual([...testCase.streamEventTypes])
expect(events.map((event) => event.type)).toEqual([...testCase.eventTypes])
expect(events.map((event) => event.toolName)).toEqual(testCase.eventTypes.map(() => testCase.name))
})
test("uses the forced web variant's logical name when both variants are declared", async () => {
const model = createModel(createStreamFetch(HOSTED_TOOL_CASES[1].stream))
const result = await model.doStream({
prompt: TEST_PROMPT,
tools: [hostedTool(HOSTED_TOOL_CASES[0]), hostedTool(HOSTED_TOOL_CASES[1])],
toolChoice: { type: "tool", toolName: "preview_web" },
})
const events = (await readStream(result.stream)).filter((event) => "toolName" in event)
expect(events.map((event) => event.toolName)).toEqual(["preview_web", "preview_web", "preview_web"])
})
test("rejects ambiguous web variants before fetching or exposing a stream", async () => {
const fetchFn = createStreamFetch(HOSTED_TOOL_CASES[0].stream)
const model = createModel(fetchFn)
await expect(
model.doStream({
prompt: TEST_PROMPT,
tools: [hostedTool(HOSTED_TOOL_CASES[0]), hostedTool(HOSTED_TOOL_CASES[1])],
}),
).rejects.toThrow("ambiguous web_search response for hosted tools")
expect(fetchFn).not.toHaveBeenCalled()
})
test("rejects an ambiguous forced wire choice before fetching or exposing a stream", async () => {
const fetchFn = createStreamFetch(HOSTED_TOOL_CASES[0].stream)
const model = createModel(fetchFn)
await expect(
model.doStream({
prompt: TEST_PROMPT,
tools: [hostedTool(HOSTED_TOOL_CASES[0]), { ...hostedTool(HOSTED_TOOL_CASES[0]), name: "backup_web" }],
toolChoice: { type: "tool", toolName: HOSTED_TOOL_CASES[0].name },
}),
).rejects.toThrow("ambiguous web_search tool choice for hosted tools")
expect(fetchFn).not.toHaveBeenCalled()
})
test("streams a shared logical name for both web variants", async () => {
const model = createModel(createStreamFetch(HOSTED_TOOL_CASES[0].stream))
const tools = [hostedTool(HOSTED_TOOL_CASES[0]), hostedTool(HOSTED_TOOL_CASES[1])].map((tool) => ({
...tool,
name: "web",
}))
const result = await model.doStream({ prompt: TEST_PROMPT, tools })
const events = (await readStream(result.stream)).filter((event) => "toolName" in event)
expect(events.map((event) => event.toolName)).toEqual(["web", "web", "web"])
})
test("uses canonical names for undeclared streamed web and computer calls", async () => {
const model = createModel(
createStreamFetch([
...HOSTED_TOOL_CASES[0].stream,
{
type: "response.output_item.added",
output_index: 1,
item: { type: "computer_call", id: "computer_1", status: "in_progress" },
},
{
type: "response.output_item.done",
output_index: 1,
item: { type: "computer_call", id: "computer_1", status: "completed" },
},
]),
)
const result = await model.doStream({ prompt: TEST_PROMPT })
const events = (await readStream(result.stream)).filter((event) => "toolName" in event)
expect(events.map((event) => event.toolName)).toEqual([
"web_search",
"web_search",
"web_search",
"computer_use",
"computer_use",
"computer_use",
])
})
test("streams sequential Copilot reasoning summary blocks", async () => {
const model = createModel(
createStreamFetch([
@@ -1,5 +1,9 @@
import { expect, test } from "bun:test"
import type { LanguageModelV3FunctionTool } from "@ai-sdk/provider"
import type {
LanguageModelV3CallOptions,
LanguageModelV3FunctionTool,
LanguageModelV3ProviderTool,
} from "@ai-sdk/provider"
import { prepareResponsesTools } from "@opencode-ai/core/github-copilot/responses/openai-responses-prepare-tools"
function prepare(strict: boolean | undefined, strictJsonSchema: boolean) {
@@ -18,3 +22,198 @@ test("function tools prefer explicit strictness over the global fallback", () =>
expect(prepare(undefined, true)).toMatchObject({ type: "function", strict: true })
expect(prepare(undefined, false)).toMatchObject({ type: "function", strict: false })
})
const webTools: LanguageModelV3ProviderTool[] = [
{ type: "provider", id: "openai.web_search", name: "current_web", args: {} },
{ type: "provider", id: "openai.web_search_preview", name: "preview_web", args: {} },
]
test.each([
{ order: webTools, toolChoice: undefined },
{ order: webTools.toReversed(), toolChoice: undefined },
{ order: webTools, toolChoice: { type: "auto" as const } },
{ order: webTools.toReversed(), toolChoice: { type: "auto" as const } },
{ order: webTools, toolChoice: { type: "required" as const } },
{ order: webTools.toReversed(), toolChoice: { type: "required" as const } },
])("rejects differently named web variants before automatic or required selection", ({ order, toolChoice }) => {
expect(() => prepareResponsesTools({ tools: order, toolChoice, strictJsonSchema: false })).toThrow(
"ambiguous web_search response for hosted tools",
)
})
test.each([
{ order: webTools, name: "current_web", wireType: "web_search" },
{ order: webTools.toReversed(), name: "current_web", wireType: "web_search" },
{ order: webTools, name: "preview_web", wireType: "web_search_preview" },
{ order: webTools.toReversed(), name: "preview_web", wireType: "web_search_preview" },
])("uses the uniquely forced web variant independent of declaration order", ({ order, name, wireType }) => {
const result = prepareResponsesTools({
tools: order,
toolChoice: { type: "tool", toolName: name },
strictJsonSchema: false,
})
expect(result.toolChoice).toEqual({ type: wireType })
expect(result.selectedHostedTool).toMatchObject({ name, type: wireType, responseType: "web_search" })
})
test.each([{ order: webTools }, { order: webTools.toReversed() }])(
"allows indistinguishable web variants when they share one logical name",
({ order }) => {
const tools = order.map((tool) => ({ ...tool, name: "web" }))
const result = prepareResponsesTools({ tools, toolChoice: { type: "required" }, strictJsonSchema: false })
expect(result.hostedTools.map((tool) => tool.name)).toEqual(["web", "web"])
},
)
const duplicateToolCases = [
[
{ type: "function", name: "lookup", inputSchema: { type: "object" } },
{ type: "provider", id: "openai.web_search", name: "lookup", args: {} },
],
[
{ type: "provider", id: "other.unsupported", name: "lookup", args: {} },
{ type: "provider", id: "openai.web_search", name: "lookup", args: {} },
],
[
{ type: "provider", id: "openai.web_search", name: "lookup", args: {} },
{ type: "provider", id: "openai.web_search_preview", name: "lookup", args: {} },
],
] satisfies Array<NonNullable<LanguageModelV3CallOptions["tools"]>>
test.each(duplicateToolCases.flatMap((tools) => [{ tools }, { tools: tools.toReversed() }]))(
"rejects duplicate forced definitions independent of type and order",
({ tools }) => {
expect(() =>
prepareResponsesTools({
tools,
toolChoice: { type: "tool", toolName: "lookup" },
strictJsonSchema: false,
}),
).toThrow("multiple tool definitions share this name")
},
)
const duplicateHostedToolCases = [
{ id: "openai.web_search", responseType: "web_search", args: {} },
{ id: "openai.web_search_preview", responseType: "web_search", args: {} },
{ id: "openai.file_search", responseType: "file_search", args: { vectorStoreIds: ["store_1"] } },
{ id: "openai.code_interpreter", responseType: "code_interpreter", args: {} },
{ id: "openai.image_generation", responseType: "image_generation", args: {} },
] as const
function duplicateHostedTools(testCase: (typeof duplicateHostedToolCases)[number], sameName = false) {
return [
{ type: "provider" as const, id: testCase.id, name: `${testCase.responseType}_one`, args: testCase.args },
{
type: "provider" as const,
id: testCase.id,
name: sameName ? `${testCase.responseType}_one` : `${testCase.responseType}_two`,
args: testCase.args,
},
]
}
test.each(
duplicateHostedToolCases.flatMap((testCase) =>
[undefined, { type: "auto" as const }, { type: "required" as const }].flatMap((toolChoice) => {
const tools = duplicateHostedTools(testCase)
return [
{ testCase, tools, toolChoice, selection: toolChoice?.type ?? "default" },
{ testCase, tools: tools.toReversed(), toolChoice, selection: toolChoice?.type ?? "default" },
]
}),
),
)(
"rejects differently named duplicate $testCase.id responses for $selection selection",
({ testCase, tools, toolChoice }) => {
expect(() => prepareResponsesTools({ tools, toolChoice, strictJsonSchema: false })).toThrow(
`ambiguous ${testCase.responseType} response for hosted tools`,
)
},
)
test.each(
duplicateHostedToolCases.flatMap((testCase) => {
const tools = duplicateHostedTools(testCase, true)
return [
{ testCase, tools },
{ testCase, tools: tools.toReversed() },
]
}),
)("allows duplicate $testCase.id responses with the same logical name", ({ tools }) => {
expect(
prepareResponsesTools({ tools, toolChoice: { type: "required" }, strictJsonSchema: false }).hostedTools.map(
(tool) => tool.name,
),
).toEqual([tools[0].name, tools[0].name])
})
test.each(
duplicateHostedToolCases.flatMap((testCase) => {
const tools = duplicateHostedTools(testCase)
return [
{ testCase, tools },
{ testCase, tools: tools.toReversed() },
]
}),
)("rejects a forced $testCase.id wire choice with multiple logical identities", ({ testCase, tools }) => {
expect(() =>
prepareResponsesTools({
tools,
toolChoice: { type: "tool", toolName: `${testCase.responseType}_one` },
strictJsonSchema: false,
}),
).toThrow(`ambiguous ${tools[0].id.replace("openai.", "")} tool choice for hosted tools`)
})
test.each(
duplicateHostedToolCases.flatMap((testCase) => {
const tools = duplicateHostedTools(testCase, true)
return [{ tools }, { tools: tools.toReversed() }]
}),
)("rejects a forced logical name shared by duplicate same-wire definitions", ({ tools }) => {
expect(() =>
prepareResponsesTools({
tools,
toolChoice: { type: "tool", toolName: tools[0].name },
strictJsonSchema: false,
}),
).toThrow("multiple tool definitions share this name")
})
test.each([...duplicateHostedToolCases])("skips ambiguous $id responses when tool choice is none", (testCase) => {
expect(
prepareResponsesTools({
tools: duplicateHostedTools(testCase),
toolChoice: { type: "none" },
strictJsonSchema: false,
}).toolChoice,
).toBe("none")
})
test.each([...duplicateHostedToolCases])("skips ambiguous $id responses for a uniquely forced function", (testCase) => {
expect(
prepareResponsesTools({
tools: [...duplicateHostedTools(testCase), { type: "function", name: "local", inputSchema: { type: "object" } }],
toolChoice: { type: "tool", toolName: "local" },
strictJsonSchema: false,
}).toolChoice,
).toEqual({ type: "function", name: "local" })
})
test.each([{ ambiguousWebTools: duplicateHostedTools(duplicateHostedToolCases[0]) }, { ambiguousWebTools: webTools }])(
"validates only the selected wire choice for a forced unrelated hosted tool",
({ ambiguousWebTools }) => {
const selected = duplicateHostedTools(duplicateHostedToolCases[2], true)[0]
const result = prepareResponsesTools({
tools: [...ambiguousWebTools, selected],
toolChoice: { type: "tool", toolName: selected.name },
strictJsonSchema: false,
})
expect(result.toolChoice).toEqual({ type: "file_search" })
expect(result.selectedHostedTool).toMatchObject({ name: selected.name, type: "file_search" })
},
)
+1 -1
View File
@@ -83,7 +83,7 @@ export interface ToolExecution {
export const executeTool = (
registry: Tool.Interface,
input: Parameters<Tool.Snapshot["execute"]>[0],
): Effect.Effect<ToolExecution, Tool.Declined> =>
): Effect.Effect<ToolExecution> =>
registry.snapshot().pipe(
Effect.flatMap((tools) => tools.execute(input)),
Effect.map((result) => ({ status: "completed" as const, ...result }) satisfies ToolExecution),
+16 -31
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -26,19 +26,20 @@ const current = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
)
const layer = AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionStore.node,
PermissionSaved.node,
Agent.node,
PluginHooks.node,
Permission.node,
]),
[[Location.node, current]],
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionStore.node,
PermissionSaved.node,
Agent.node,
PluginHooks.node,
Permission.node,
]),
[[Location.node, current]],
),
)
const it = testEffect(layer)
function setup(rules: Permission.Ruleset = [], sessionID = Session.ID.make("ses_test")) {
return Effect.gen(function* () {
@@ -337,7 +338,7 @@ describe("Permission", () => {
}),
)
it.effect("fails with a typed decline when an asked permission is rejected", () =>
it.effect("defects when an asked permission is declined", () =>
Effect.gen(function* () {
yield* setup()
const { service, fiber, request } = yield* waitForRequest()
@@ -348,29 +349,13 @@ describe("Permission", () => {
if (exit._tag === "Failure")
expect(
exit.cause.reasons.some(
(reason) => Cause.isFailReason(reason) && reason.error instanceof Permission.Declined,
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
),
).toBe(true)
expect(yield* service.list()).toEqual([])
}),
)
it.effect("interrupts pending assertions on service teardown without reporting a user decline", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* Layer.buildWithScope(Layer.fresh(layer), scope)
yield* setup().pipe(Effect.provide(context))
const pending = yield* waitForRequest().pipe(Effect.provide(context))
yield* Scope.close(scope, Exit.void)
const exit = yield* Fiber.await(pending.fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(yield* pending.service.list()).toEqual([])
}),
)
it.effect("stores and removes saved resources for a project", () =>
Effect.gen(function* () {
yield* setup()
+1
View File
@@ -129,6 +129,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
hook: () => Effect.die("unused tool.hook"),
},
vcs: overrides.vcs ?? {
base: () => Effect.die("unused vcs.base"),
get: () => Effect.die("unused vcs.get"),
branches: () => Effect.die("unused vcs.branches"),
status: () => Effect.die("unused vcs.status"),
+9 -196
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { Cause, DateTime, Effect, Exit, Schema } from "effect"
import { DateTime, Effect, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
@@ -9,7 +9,6 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { Permission } from "@opencode-ai/core/permission"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Vcs } from "@opencode-ai/core/vcs"
import { Session } from "@opencode-ai/core/session"
@@ -568,7 +567,7 @@ describe("fromPromise", () => {
}),
)
it.effect("registers a Promise VCS provider and forwards client reads", () =>
it.effect("registers a Promise VCS provider and preserves its receiver when forwarding client reads", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const plugin = yield* Plugin.Service
@@ -585,6 +584,11 @@ describe("fromPromise", () => {
signals.push(request.signal)
return { branch: { current: "feature", default: "main" } }
},
async base(_input, request) {
expect(this.id).toBe("custom")
signals.push(request.signal)
return { name: "main", ref: "refs/heads/main", source: "default" }
},
branches: async (input, request) => {
signals.push(request.signal)
expect(input.search).toBe("feat")
@@ -605,6 +609,7 @@ describe("fromPromise", () => {
})
expect((await ctx.vcs.get()).data.branch.current).toBe("feature")
expect((await ctx.vcs.base()).data).toEqual({ name: "main", ref: "refs/heads/main", source: "default" })
expect((await ctx.vcs.branches({ search: "feat" })).data).toEqual(["feature"])
expect((await ctx.vcs.status()).data).toHaveLength(1)
expect((await ctx.vcs.diff({ mode: "working", context: 2 })).data[0].patch).toBe("+hello")
@@ -613,7 +618,7 @@ describe("fromPromise", () => {
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect((yield* vcs.info()).branch.current).toBe("feature")
expect(signals).toHaveLength(4)
expect(signals).toHaveLength(5)
expect(signals.every((signal) => signal instanceof AbortSignal)).toBeTrue()
}),
)
@@ -727,198 +732,6 @@ describe("fromPromise", () => {
}),
)
it.live("preserves canonical declines from Promise tools and wrapped Effect tools", () =>
Effect.gen(function* () {
const PromisePlugin = yield* Effect.promise(() => import("@opencode-ai/plugin"))
const EffectPlugin = yield* Effect.promise(() => import("@opencode-ai/plugin/effect"))
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const declined = new PromisePlugin.Tool.Declined({ message: "The user declined this tool call" })
expect(PromisePlugin.Tool.Declined).toBe(Tool.Declined)
expect(EffectPlugin.Tool.Declined).toBe(Tool.Declined)
yield* host.tool.transform((draft) => {
for (const name of ["effect_decline", "wrapped_decline", "wrapped_permission_decline"]) {
draft.add({
name,
description: "Decline",
options: { codemode: false },
input: Schema.Struct({}),
execute: () => Effect.fail(name === "wrapped_permission_decline" ? new Permission.Declined() : declined),
})
}
})
yield* PluginPromise.fromPromise(
PromisePlugin.Plugin.define({
id: "promise-tool-declined",
setup: async (ctx) => {
await ctx.tool.transform((draft) => {
draft.add({
name: "sync_decline",
description: "Decline synchronously",
options: { codemode: false },
input: Schema.Struct({}),
execute: () => {
throw declined
},
})
draft.add({
name: "async_decline",
description: "Decline asynchronously",
options: { codemode: false },
input: Schema.Struct({}),
execute: async () => {
throw declined
},
})
for (const name of ["wrapped_decline", "wrapped_permission_decline"])
draft.update(name, (tool) => {
const execute = tool.execute
tool.execute = async (input, context) => execute(input, context)
})
})
},
}),
).effect(host)
const snapshot = yield* registry.snapshot()
for (const name of [
"sync_decline",
"async_decline",
"effect_decline",
"wrapped_decline",
"wrapped_permission_decline",
]) {
const error = yield* snapshot
.execute({
sessionID: Session.ID.make("ses_plugin_declined"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_plugin_declined"),
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.Declined)
if (name !== "wrapped_permission_decline") expect(error).toBe(declined)
expect(error.message).toBe("The user declined this tool call")
}
}),
)
it.live("preserves declines from Effect and synchronous or asynchronous Promise before hooks", () =>
Effect.gen(function* () {
const PromisePlugin = yield* Effect.promise(() => import("@opencode-ai/plugin"))
const EffectPlugin = yield* Effect.promise(() => import("@opencode-ai/plugin/effect"))
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const declined = new EffectPlugin.Tool.Declined({ message: "The plugin declined this tool call" })
const calls: string[] = []
yield* host.tool.transform((draft) => {
for (const name of ["effect_decline", "sync_decline", "async_decline"]) {
draft.add({
name,
description: "Must not execute",
options: { codemode: false },
input: Schema.Struct({}),
execute: () =>
Effect.sync(() => {
calls.push(name)
return { content: "executed" }
}),
})
}
})
yield* host.tool.hook("execute.before", (event) =>
event.tool === "effect_decline" ? Effect.fail(declined) : Effect.void,
)
yield* PluginPromise.fromPromise(
PromisePlugin.Plugin.define({
id: "promise-before-declined",
setup: async (ctx) => {
await ctx.tool.hook("execute.before", (event) => {
if (event.tool === "sync_decline") throw declined
})
await ctx.tool.hook("execute.before", async (event) => {
if (event.tool === "async_decline") throw declined
})
},
}),
).effect(host)
const snapshot = yield* registry.snapshot()
for (const name of ["effect_decline", "sync_decline", "async_decline"]) {
const error = yield* snapshot
.execute({
sessionID: Session.ID.make("ses_plugin_before_declined"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_plugin_before_declined"),
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
})
.pipe(Effect.flip)
expect(error).toBe(declined)
expect(error.message).toBe("The plugin declined this tool call")
}
expect(calls).toEqual([])
}),
)
it.live("keeps unrelated Promise exceptions and after-hook declines as defects", () =>
Effect.gen(function* () {
const PromisePlugin = yield* Effect.promise(() => import("@opencode-ai/plugin"))
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const failure = new Error("Unexpected plugin failure")
const declined = new PromisePlugin.Tool.Declined({ message: "Too late to decline" })
yield* PluginPromise.fromPromise(
PromisePlugin.Plugin.define({
id: "promise-tool-defects",
setup: async (ctx) => {
await ctx.tool.transform((draft) => {
for (const name of ["sync_defect", "async_defect", "before_defect", "after_decline"]) {
draft.add({
name,
description: "Fail unexpectedly",
options: { codemode: false },
input: Schema.Struct({}),
execute: () => {
if (name === "sync_defect") throw failure
if (name === "async_defect") return Promise.reject(failure)
return Promise.resolve({ content: "executed" })
},
})
}
})
await ctx.tool.hook("execute.before", async (event) => {
if (event.tool === "before_defect") throw failure
})
await ctx.tool.hook("execute.after", (event) => {
if (event.tool === "after_decline") throw declined
})
},
}),
).effect(host)
const snapshot = yield* registry.snapshot()
for (const name of ["sync_defect", "async_defect", "before_defect", "after_decline"]) {
const exit = yield* snapshot
.execute({
sessionID: Session.ID.make("ses_plugin_defects"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_plugin_defects"),
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.hasDies(exit.cause)).toBe(true)
expect(Cause.squash(exit.cause)).toBe(name === "after_decline" ? declined : failure)
}
}
}),
)
it.live("adapts listed and retrieved tool executors without invoking them eagerly", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+22 -1
View File
@@ -27,6 +27,7 @@ import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { Skill } from "@opencode-ai/schema/skill"
import { Shell } from "@opencode-ai/schema/shell"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -260,7 +261,25 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [userMessage],
messages: [
userMessage,
SessionMessage.Shell.make({
id: SessionMessage.ID.create(),
type: "shell",
shellID: Shell.ID.make("sh_background"),
status: "exited",
command: "pwd",
metadata: { background: true },
output: { output: "display-only-output", cursor: 19, size: 19, truncated: false },
time: { created: DateTime.makeUnsafe(0), completed: DateTime.makeUnsafe(1) },
}),
SessionMessage.Synthetic.make({
id: SessionMessage.ID.create(),
type: "synthetic",
text: "User shell pwd completed: /project",
time: { created: DateTime.makeUnsafe(2) },
}),
],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
).toEqual({ status: "completed" })
@@ -280,6 +299,8 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project")
expect(JSON.stringify(requests[0]?.messages)).not.toContain("display-only-output")
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
])
+9 -25
View File
@@ -136,28 +136,6 @@ describe("SessionExecution lifecycle", () => {
}),
)
it.effect("releases a declined execution's claim without resuming it after restart", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionID = Session.ID.make("ses_claim_declined")
yield* seedSessions(database, [sessionID])
const drained: Session.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, ({ sessionID }) =>
Effect.sync(() => drained.push(sessionID)).pipe(Effect.andThen(new UserInterruptedError())),
)
const execution = Context.get(context, SessionExecution.Service)
const error = yield* execution.resume(sessionID).pipe(Effect.flip)
expect(error).toBeInstanceOf(UserInterruptedError)
yield* execution.awaitIdle(sessionID)
expect((yield* claims(database))[sessionID]).toBe(false)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
expect(drained).toEqual([sessionID])
}),
)
it.effect("reports an idle interrupt as a no-op", () =>
Effect.gen(function* () {
const sessionID = Session.ID.make("ses_idle_cancel")
@@ -521,15 +499,21 @@ describe("SessionRestart background recovery", () => {
yield* Context.get(context, SessionExecution.Service).awaitIdle(sessionID)
expect(drained).toEqual([sessionID])
expect(yield* SessionInbox.list(database.db, sessionID)).toMatchObject([
const inbox = yield* SessionInbox.list(database.db, sessionID)
expect(inbox).toMatchObject([
{
type: "synthetic",
payload: {
text: expect.stringContaining("(no output)\n\nCommand exited with code 7."),
metadata: { source: "shell", shellID: "sh_completed", state: "completed" },
text: '<shell id="call-completed-shell" state="completed" command="exit 7">\n(no output)\n\nCommand exited with code 7.\n</shell>',
},
},
])
expect(inbox[0]).toHaveProperty("payload.metadata", {
source: "shell",
jobID: "call-completed-shell",
shellID: "sh_completed",
state: "completed",
})
expect(yield* restarted.pendingBackground).toEqual([])
}),
)
+21 -12
View File
@@ -330,18 +330,26 @@ describe("SessionProjector", () => {
text: "synthetic context",
metadata: { source: "projector-test" },
})
yield* bus.publish(SessionEvent.Shell.Started, {
sessionID,
shell: Shell.Info.make({
id: Shell.ID.make("sh_projector"),
status: "running",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_projector.out",
metadata: {},
time: { started: 0 },
}),
// Serialized events have no transient envelope metadata to supply the background marker.
yield* bus.replay({
id: Event.ID.create(),
created: 0,
aggregateID: sessionID,
seq: 3,
type: Bus.versionedType(SessionEvent.Shell.Started.type, 1),
data: {
sessionID,
shell: Shell.Info.make({
id: Shell.ID.make("sh_projector"),
status: "running",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_projector.out",
metadata: { background: true },
time: { started: 0 },
}),
},
})
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID,
@@ -421,6 +429,7 @@ describe("SessionProjector", () => {
command: "pwd",
status: "exited",
exit: 0,
metadata: { background: true },
output: { output: "/project", truncated: false },
time: { completed: DateTime.makeUnsafe(0) },
})
@@ -20,6 +20,38 @@ const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.
const build = Agent.defaultID
describe("toLLMMessages", () => {
test("background user shells enter model context only through their completion notification", () => {
const shell = SessionMessage.Shell.make({
id: id("background-shell"),
type: "shell",
shellID: Shell.ID.make("sh_background"),
status: "running",
command: "pwd",
metadata: { background: true },
time: { created },
})
const notification = SessionMessage.Synthetic.make({
id: id("shell-completion"),
type: "synthetic",
text: "User shell pwd completed: /project",
metadata: { source: "shell", shellID: shell.shellID, state: "completed" },
time: { created },
})
expect(toLLMMessages([shell], model)).toEqual([])
const completed = SessionMessage.Shell.make({
...shell,
status: "exited",
exit: 0,
output: { output: "/project", cursor: 8, size: 8, truncated: false },
time: { created, completed: created },
})
expect(toLLMMessages([completed], model)).toEqual([])
expect(toLLMMessages([completed, notification], model)).toEqual([
Message.make({ id: notification.id, role: "user", content: notification.text }),
])
})
test("omits empty assistant turns", () => {
const assistant = (value: string, content: SessionMessage.Assistant["content"]) =>
SessionMessage.Assistant.make({
+5 -89
View File
@@ -50,7 +50,7 @@ import { SessionUsage } from "@opencode-ai/core/session/usage"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { QuestionTool } from "@opencode-ai/core/tool/plugin/question"
import { Agent } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
@@ -3877,7 +3877,7 @@ describe("SessionRunnerLLM", () => {
description: "Fail because the user declined approval",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.fail(new Permission.Declined()),
execute: () => Effect.die(new Permission.DeclinedError()),
},
},
{ codemode: false },
@@ -3894,7 +3894,7 @@ describe("SessionRunnerLLM", () => {
const exit = yield* s.resume.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.squash(exit.cause)).toBeInstanceOf(UserInterruptedError)
if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(s.requests).toHaveLength(1)
expect(yield* s.context).toMatchObject([
Expected.user("Call declined"),
@@ -3908,43 +3908,6 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("does not continue a failed provider stream after a tool decline", function* (s) {
const registry = yield* Tool.Service
yield* transformTools(
registry,
{
declined: {
name: "declined",
description: "Decline execution",
input: Schema.Struct({}),
execute: () => new Tool.Declined({ message: "The user declined this tool call" }),
},
},
{ codemode: false },
)
yield* s.admit("Call declined")
yield* s.llm.push(
TestLLM.failAfter(
streamDisconnected(),
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }),
),
)
const error = yield* s.resume.pipe(Effect.flip)
expect(error).toBeInstanceOf(UserInterruptedError)
expect(s.requests).toHaveLength(1)
expect(yield* s.context).toMatchObject([
Expected.user("Call declined"),
Expected.assistant({}, [
Expected.failedTool(
{ id: "call-declined" },
{ error: { type: "aborted", message: "The user declined this tool call" } },
),
]),
])
})
scenario("returns permission corrections to the model and continues", function* (s) {
const registry = yield* Tool.Service
yield* transformTools(
@@ -4019,7 +3982,7 @@ describe("SessionRunnerLLM", () => {
description: "Ask the user",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.fail(new Tool.Declined({ message: "The user dismissed this question" })),
execute: () => Effect.die(new QuestionTool.CancelledError()),
},
},
{ codemode: false },
@@ -4032,7 +3995,7 @@ describe("SessionRunnerLLM", () => {
const exit = yield* Fiber.join(run)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.squash(exit.cause)).toBeInstanceOf(UserInterruptedError)
if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(s.requests).toHaveLength(1)
expect(yield* s.context).toMatchObject([
Expected.user("Ask then stop"),
@@ -4045,53 +4008,6 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("preserves form-service teardown as shutdown interruption after tool settlement", function* (s) {
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* Layer.buildWithScope(Layer.fresh(Form.layer), scope)
const forms = Context.get(context, Form.Service)
const registry = yield* Tool.Service
const execution = yield* SessionExecution.Service
const opened = yield* Deferred.make<void>()
const unsubscribe = yield* s.bus.listen((event) =>
event.type === Form.Event.Created.type ? Deferred.succeed(opened, undefined).pipe(Effect.asVoid) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* transformTools(
registry,
{
waiting: {
name: "waiting",
description: "Wait for a form answer",
input: Schema.Struct({}),
execute: () =>
forms
.ask({ sessionID, title: "Question", fields: [{ key: "answer", type: "string" }] })
.pipe(Effect.as({ content: "answered" })),
},
},
{ codemode: false },
)
yield* s.admit("Ask a question")
yield* s.llm.push(TestLLM.tool("call-waiting", "waiting", {}))
const run = yield* execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped)
yield* Deferred.await(opened)
yield* Scope.close(scope, Exit.void)
const exit = yield* Fiber.join(run)
expect(SessionExecution.terminal(exit)).toEqual({ type: "interrupted", reason: "shutdown" })
expect(s.requests).toHaveLength(1)
expect(yield* s.context).toMatchObject([
Expected.user("Ask a question"),
Expected.assistant({}, [
Expected.failedTool(
{ id: "call-waiting" },
{ error: { type: "aborted", message: "Tool execution interrupted" } },
),
]),
])
})
scenario("awaits started local tools before surfacing provider stream failure", function* (s) {
yield* s.admit("Settle before failing")
// Non-retryable so the step settles terminally instead of continuing after tool output.
+375
View File
@@ -0,0 +1,375 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { Shell } from "@opencode-ai/core/shell"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { location } from "./fixture/location"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
class ExecutionControl extends Context.Service<
ExecutionControl,
{
started: Deferred.Deferred<void>
release: Deferred.Deferred<void>
wakes: Session.ID[]
}
>()("test/SessionShell/ExecutionControl") {}
const controlLayer = Layer.effect(
ExecutionControl,
Effect.gen(function* () {
return { started: yield* Deferred.make<void>(), release: yield* Deferred.make<void>(), wakes: [] }
}),
)
const executionLayer = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const control = yield* ExecutionControl
const coordinator = yield* SessionRunCoordinator.make<Session.ID, never>({
drain: () => Deferred.succeed(control.started, undefined).pipe(Effect.andThen(Deferred.await(control.release))),
})
return SessionExecution.Service.of({
active: coordinator.active,
resume: coordinator.run,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
awaitIdle: coordinator.awaitIdle,
wake: (sessionID) =>
Effect.sync(() => control.wakes.push(sessionID)).pipe(Effect.andThen(coordinator.wake(sessionID))),
})
}),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Bus.node, Session.node, SessionExecution.node, LocationServiceMap.node]), [
[Bus.node, Bus.configured({ persist: true })],
[SessionExecution.node, executionLayer],
]).pipe(Layer.provideMerge(controlLayer)),
)
const setup = Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const session = yield* Session.Service
const created = yield* session.create({ location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) })
const locations = yield* LocationServiceMap.Service
const shell = yield* Shell.Service.pipe(Effect.provide(locations.get(created.location)))
const control = yield* ExecutionControl
const execution = yield* SessionExecution.Service
return { tmp, session, created, shell, control, execution }
})
const log = (session: Session.Interface, sessionID: Session.ID, follow = false) =>
session.log({ sessionID, follow }).pipe(Stream.filter((event) => !Bus.isSynced(event)))
// File gates prove the child process is running without relying on elapsed-time assertions.
const launch = Effect.fn(function* (fixture: Effect.Success<typeof setup>, name: string) {
const release = Effect.promise(() => Bun.write(path.join(fixture.tmp.path, `${name}.release`), "")).pipe(
Effect.asVoid,
)
yield* Effect.addFinalizer(() => release)
const command =
process.platform === "win32"
? `Write-Output '${name} started'; New-Item '${name}.started' -ItemType File | Out-Null; while (!(Test-Path '${name}.release') -and (Test-Path $PWD)) { Start-Sleep -Milliseconds 10 }; Write-Output '${name} finished'`
: `printf '${name} started\\n'; touch ${name}.started; while [ ! -f ${name}.release ] && [ -d "$PWD" ]; do sleep 0.01; done; printf '${name} finished\\n'`
const caller = yield* fixture.session
.shell({ sessionID: fixture.created.id, command })
.pipe(Effect.scoped, Effect.forkScoped)
yield* Effect.promise(() => Bun.file(path.join(fixture.tmp.path, `${name}.started`)).exists()).pipe(
Effect.repeat({ until: (exists) => exists, schedule: Schedule.spaced("10 millis") }),
Effect.timeout("5 seconds"),
)
const event = yield* log(fixture.session, fixture.created.id, true).pipe(
Stream.filter((event): event is SessionEvent.Shell.Started => event.type === "session.shell.started"),
Stream.filter((event) => event.data.shell.command === command),
Stream.runHead,
Effect.map(Option.getOrThrow),
Effect.timeout("5 seconds"),
)
return { caller, shellID: event.data.shell.id, release }
})
describe("Session.shell", () => {
it.live("runs shells concurrently with an active model and waits for each shell's own completion", () =>
Effect.gen(function* () {
const fixture = yield* setup
const model = yield* fixture.execution.resume(fixture.created.id).pipe(Effect.forkScoped)
yield* Deferred.await(fixture.control.started).pipe(Effect.timeout("5 seconds"))
const first = yield* launch(fixture, "first")
const second = yield* launch(fixture, "second")
expect(yield* fixture.execution.active).toContain(fixture.created.id)
expect(first.caller.pollUnsafe()).toBeUndefined()
expect(second.caller.pollUnsafe()).toBeUndefined()
expect(yield* fixture.session.messages({ sessionID: fixture.created.id, order: "asc" })).toMatchObject([
{ type: "shell", shellID: first.shellID, status: "running", metadata: { background: true } },
{ type: "shell", shellID: second.shellID, status: "running", metadata: { background: true } },
])
yield* second.release
yield* Fiber.join(second.caller).pipe(Effect.timeout("5 seconds"))
expect(first.caller.pollUnsafe()).toBeUndefined()
expect(yield* fixture.session.inbox(fixture.created.id)).toMatchObject([
{
type: "synthetic",
payload: {
metadata: { source: "shell", shellID: second.shellID, state: "completed" },
text: expect.stringContaining("second finished"),
},
},
])
yield* first.release
yield* Fiber.join(first.caller).pipe(Effect.timeout("5 seconds"))
expect(
(yield* fixture.session.inbox(fixture.created.id))
.filter((item) => item.type === "synthetic")
.map((item) => item.payload.metadata?.shellID),
).toEqual([second.shellID, first.shellID])
expect(yield* fixture.execution.active).toContain(fixture.created.id)
expect(fixture.control.wakes).toEqual([])
yield* Deferred.succeed(fixture.control.release, undefined)
yield* Fiber.join(model).pipe(Effect.timeout("5 seconds"))
}),
)
it.live("routes shell completion to the moved Session while retaining output from its original Location", () =>
Effect.gen(function* () {
const fixture = yield* setup
const destination = Location.Ref.make({ directory: AbsolutePath.make((yield* tmpdirScoped()).path) })
const command = yield* launch(fixture, "moved")
const bus = yield* Bus.Service
const Done = Bus.ephemeral({ type: "test.shell.move.done", schema: {} })
const readers = yield* Effect.forEach([fixture.created.location, destination], (ref) =>
bus.subscribe([SessionEvent.Shell.Ended, SessionEvent.InboxEnqueued, Done]).pipe(
Stream.takeUntil((event) => event.type === Done.type),
Stream.runCollect,
Effect.provideService(Location.Service, location(ref)),
Effect.forkScoped({ startImmediately: true }),
),
)
// This fixture's runner does not drain move requests; project the actual move event instead.
yield* bus.publish(SessionEvent.Moved, {
sessionID: fixture.created.id,
location: destination,
projectID: fixture.created.projectID,
})
expect((yield* fixture.session.get(fixture.created.id)).location).toEqual(destination)
yield* command.release
yield* Fiber.join(command.caller).pipe(Effect.timeout("5 seconds"))
yield* bus.publish(Done, {})
const events = yield* Effect.forEach(readers, (reader) => Fiber.join(reader).pipe(Effect.timeout("5 seconds")))
expect(events.map((items) => items.map((event) => event.type))).toEqual([
[Done.type],
["session.shell.ended", "session.inbox.enqueued", Done.type],
])
expect(events[1][0]).toMatchObject({
data: { output: { output: expect.stringContaining("moved finished") } },
})
const inbox = yield* fixture.session.inbox(fixture.created.id)
expect(inbox).toHaveLength(1)
expect(inbox[0]).toMatchObject({
type: "synthetic",
payload: {
text: expect.stringContaining("moved finished"),
metadata: { shellID: command.shellID, state: "completed" },
},
})
}),
)
it.live("does not suppress a normal prompt wake while a shell is running or wake again when it completes", () =>
Effect.gen(function* () {
const fixture = yield* setup
const command = yield* launch(fixture, "prompt")
const prompt = yield* fixture.session.prompt({ sessionID: fixture.created.id, text: "Continue while this runs" })
yield* Deferred.await(fixture.control.started).pipe(Effect.timeout("5 seconds"))
expect(fixture.control.wakes).toEqual([fixture.created.id])
expect(command.caller.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(fixture.control.release, undefined)
yield* fixture.execution.awaitIdle(fixture.created.id)
yield* command.release
yield* Fiber.join(command.caller).pipe(Effect.timeout("5 seconds"))
expect(fixture.control.wakes).toEqual([fixture.created.id])
expect(yield* fixture.execution.active).toEqual(new Set())
expect(yield* fixture.session.inbox(fixture.created.id)).toMatchObject([
{ id: prompt.id, type: "user" },
{ type: "synthetic", payload: { metadata: { source: "shell" } } },
])
}),
)
for (const exit of [0, 7]) {
it.live(`records output and admits one completion without waking the model after exit ${exit}`, () =>
Effect.gen(function* () {
const fixture = yield* setup
const command =
process.platform === "win32"
? `Write-Output 'user output'; [Console]::Error.WriteLine('user error'); exit ${exit}`
: `printf 'user output\\n'; printf 'user error\\n' >&2; exit ${exit}`
yield* fixture.session.shell({ sessionID: fixture.created.id, command })
const events = yield* log(fixture.session, fixture.created.id).pipe(Stream.runCollect)
expect(events.map((event) => event.type)).toEqual([
"session.created",
"session.shell.started",
"session.shell.ended",
"session.inbox.enqueued",
])
expect(events[1]).toMatchObject({ data: { shell: { metadata: { background: true } } } })
const messages = yield* fixture.session.messages({ sessionID: fixture.created.id })
expect(messages).toHaveLength(1)
expect(messages[0]).toMatchObject({
type: "shell",
command,
status: "exited",
exit,
metadata: { background: true },
})
const message = messages[0]
if (message?.type !== "shell") return yield* Effect.die("Missing shell projection")
expect(message.time.completed).toBeDefined()
expect(message.output?.output).toContain("user output")
expect(message.output?.output).toContain("user error")
expect(message.output?.truncated).toBe(false)
const inbox = yield* fixture.session.inbox(fixture.created.id)
expect(inbox).toHaveLength(1)
expect(inbox[0]).toMatchObject({
type: "synthetic",
delivery: "steer",
})
const completion = inbox[0]
if (completion?.type !== "synthetic") return yield* Effect.die("Missing shell completion")
expect(completion.payload.metadata).toEqual({
source: "shell",
shellID: message.shellID,
state: "completed",
exit,
truncated: false,
})
expect(completion.payload.description).toBeUndefined()
expect(completion.payload.text).toContain(command)
expect(completion.payload.text).toContain("user output")
expect(completion.payload.text).toContain("user error")
expect(completion.payload.text).toContain(`exited with code ${exit}`)
expect(fixture.control.wakes).toEqual([])
}),
)
}
for (const outcome of [
{
status: "killed",
state: "cancelled",
text: "Command cancelled",
output: "Shell command output is no longer available.",
},
{ status: "timeout", state: "completed", text: "Command timed out", output: "timeout started" },
]) {
it.live(`records a ${outcome.status} shell and admits its completion without waking the model`, () =>
Effect.gen(function* () {
const fixture = yield* setup
const command = yield* launch(fixture, outcome.status)
yield* outcome.status === "killed"
? fixture.shell.remove(command.shellID)
: fixture.shell.timeout(command.shellID, 1)
yield* Fiber.join(command.caller).pipe(Effect.timeout("5 seconds"))
expect(yield* fixture.session.messages({ sessionID: fixture.created.id })).toMatchObject([
{
type: "shell",
shellID: command.shellID,
status: outcome.status,
time: { completed: expect.anything() },
output: { output: expect.stringContaining(outcome.output) },
},
])
const inbox = yield* fixture.session.inbox(fixture.created.id)
expect(inbox).toHaveLength(1)
expect(inbox).toMatchObject([
{
type: "synthetic",
payload: {
text: expect.stringContaining(outcome.text),
metadata: { source: "shell", shellID: command.shellID, state: outcome.state },
},
},
])
expect(inbox[0]).not.toHaveProperty("payload.metadata.exit")
expect(fixture.control.wakes).toEqual([])
}),
)
}
it.live("admits a spawn failure without waking the model before failing the caller", () =>
Effect.gen(function* () {
const fixture = yield* setup
// Keep the Location initialized, but invalidate the real child's working directory.
yield* Effect.promise(() => fs.rm(fixture.tmp.path, { recursive: true, force: true }))
const command = "echo should-not-start"
const exit = yield* fixture.session.shell({ sessionID: fixture.created.id, command }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(yield* fixture.session.messages({ sessionID: fixture.created.id })).toEqual([])
const inbox = yield* fixture.session.inbox(fixture.created.id)
expect(inbox).toHaveLength(1)
expect(inbox[0]).toMatchObject({
type: "synthetic",
payload: { text: expect.stringContaining(command), metadata: { source: "shell", state: "error" } },
})
expect(fixture.control.wakes).toEqual([])
}),
)
it.live("keeps the shell and completion alive when the waiting caller is interrupted", () =>
Effect.gen(function* () {
const fixture = yield* setup
const command = yield* launch(fixture, "detached")
yield* Fiber.interrupt(command.caller).pipe(Effect.timeout("5 seconds"))
const exit = yield* Fiber.await(command.caller)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(yield* fixture.shell.get(command.shellID)).toMatchObject({ status: "running" })
expect(yield* fixture.session.inbox(fixture.created.id)).toEqual([])
yield* command.release
yield* log(fixture.session, fixture.created.id, true).pipe(
Stream.filter((event) => event.type === "session.inbox.enqueued"),
Stream.runHead,
Effect.timeout("5 seconds"),
)
expect(yield* fixture.session.messages({ sessionID: fixture.created.id })).toMatchObject([
{
type: "shell",
shellID: command.shellID,
status: "exited",
exit: 0,
output: { output: expect.stringContaining("detached finished") },
},
])
expect(yield* fixture.session.inbox(fixture.created.id)).toMatchObject([
{
type: "synthetic",
payload: {
text: expect.stringContaining("detached finished"),
metadata: { shellID: command.shellID, state: "completed" },
},
},
])
expect(fixture.control.wakes).toEqual([])
}),
)
})
+1 -1
View File
@@ -111,7 +111,7 @@ for (const fixture of [
executeTool: () =>
Effect.sync(() => {
executions++
return { content: [{ type: "text", text: "Completed tool" }] }
return { content: "Completed tool" }
}),
},
retry: (_cause, _error, retry) =>
+6 -53
View File
@@ -1,14 +1,12 @@
import { expect, test } from "bun:test"
import { CodeModeTool } from "@opencode-ai/core/codemode/tool"
import { Tool } from "@opencode-ai/core/tool"
import { Permission } from "@opencode-ai/core/permission"
import { definition, execute } from "@opencode-ai/core/tool/runtime"
import { execute } from "@opencode-ai/core/tool/runtime"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Info } from "@opencode-ai/schema/tool"
import { Declined } from "@opencode-ai/schema/tool"
import { Cause, Effect, Exit, Schema } from "effect"
import { Effect, Schema } from "effect"
const context = {
sessionID: Session.ID.make("ses_execute"),
@@ -100,63 +98,18 @@ test("foreign typed failures settle as Tool.Error at the untrusted boundary", as
class ForeignFailure extends Schema.TaggedError<ForeignFailure>()("Plugin.ForeignFailure", {
message: Schema.String,
}) {}
const foreign: Info = {
name: "foreign",
const lying: Info = {
name: "lying",
description: "Fails with a non-Tool.Error typed failure",
input: Schema.Struct({}),
execute: () => new ForeignFailure({ message: "transport died" }),
execute: () => new ForeignFailure({ message: "transport died" }) as never,
}
const error = await Effect.runPromise(execute(foreign, {}, context).pipe(Effect.flip))
const error = await Effect.runPromise(execute(lying, {}, context).pipe(Effect.flip))
expect(error).toBeInstanceOf(Tool.Error)
expect(error.message).toBe("transport died")
})
test("execution classifies permission declines without leaking the permission failure", async () => {
const tool: Info = {
name: "declined",
description: "Decline execution",
input: Schema.Struct({}),
execute: () => new Permission.Declined(),
}
const error = await Effect.runPromise(execute(tool, {}, context).pipe(Effect.flip))
expect(Tool.Declined).toBe(Declined)
expect(error).toBeInstanceOf(Tool.Declined)
expect(error).not.toBeInstanceOf(Permission.Declined)
if (!(error instanceof Tool.Declined)) throw error
expect(Schema.encodeSync(Declined)(error)).toEqual({
_tag: "Tool.Declined",
message: "The user declined this tool call",
})
expect(definition(tool)).not.toHaveProperty("outputSchema")
})
test("execution recovers lone decline exceptions but preserves compound causes", async () => {
const declined = new Tool.Declined({ message: "The user declined this tool call" })
const tool: Info = {
name: "exception",
description: "Fail through an exception boundary",
input: Schema.Struct({}),
execute: () => Effect.die(declined),
}
expect(await Effect.runPromise(execute(tool, {}, context).pipe(Effect.flip))).toBe(declined)
for (const cause of [
Cause.combine(Cause.die(declined), Cause.die("unexpected defect")),
Cause.combine(Cause.die(new Permission.Declined()), Cause.interrupt()),
]) {
const exit = await Effect.runPromiseExit(execute({ ...tool, execute: () => Effect.failCause(cause) }, {}, context))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(exit.cause.reasons.map((reason) => reason._tag)).toEqual(cause.reasons.map((reason) => reason._tag))
expect(exit.cause.reasons.filter(Cause.isDieReason).map((reason) => reason.defect)).toEqual(
cause.reasons.filter(Cause.isDieReason).map((reason) => reason.defect),
)
}
}
})
test("execute supports callable namespace tools", async () => {
const callable: Info = {
name: "admin",
+1 -2
View File
@@ -255,8 +255,7 @@ describe("QuestionTool", () => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
expect(error).toBeInstanceOf(Tool.Declined)
expect(Cause.hasDies(exit.cause)).toBe(false)
expect(error).toBeInstanceOf(QuestionTool.CancelledError)
expect(error).toHaveProperty("message", "The user dismissed this question")
}
}),
-170
View File
@@ -202,176 +202,6 @@ describe("Tool", () => {
}),
)
for (const [name, code] of [
["try/catch", 'try { await tools.decline({}) } catch { return "recovered" }'],
["Promise.allSettled", 'await Promise.allSettled([tools.decline({})]); return "recovered"'],
["an unawaited call", 'tools.decline({}); return "escaped"'],
] as const) {
it.effect(`stops Code Mode on a decline despite ${name}`, () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
const declined = new Tool.Declined({ message: "User declined this tool" })
const observed: string[] = []
const after: string[] = []
const progress: Tool.Metadata[] = []
const tool: Info = {
name: "decline",
description: "Decline execution",
input: Schema.Struct({}),
execute: () => Effect.sync(() => observed.push("decline")).pipe(Effect.andThen(Effect.fail(declined))),
}
yield* transform(service, { decline: tool, echo: make() })
yield* transform(service, { direct: tool }, { codemode: false })
yield* hooks.register("tool", "execute.after", (event) => Effect.sync(() => after.push(event.tool)))
const snapshot = yield* service.snapshot()
expect(yield* snapshot.execute(call("direct")).pipe(Effect.flip)).toBe(declined)
expect(
yield* snapshot
.execute({
...call("execute"),
call: { type: "tool-call", id: "declined", name: "execute", input: { code } },
progress: (update) => Effect.sync(() => progress.push(update)),
})
.pipe(Effect.flip),
).toBe(declined)
expect(observed).toEqual(["decline", "decline"])
expect(after).toEqual([])
expect(progress).toEqual([
{ toolCalls: [{ tool: "decline", status: "running" }] },
{ toolCalls: [{ tool: "decline", status: "error" }] },
])
const result = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "healthy",
name: "execute",
input: { code: 'return await tools.echo({ text: "healthy" })' },
},
})
expect(result.output).toMatchObject({ output: '{\n "text": "healthy"\n}' })
expect(after).toEqual(["echo", "execute"])
}),
)
}
it.effect("awaits already-running Code Mode sibling cleanup before returning a decline", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
const declined = new Tool.Declined({ message: "User declined this tool" })
const started = yield* Deferred.make<void>()
const cleaning = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const events: string[] = []
const after: string[] = []
const progress: Tool.Metadata[] = []
yield* transform(service, {
sibling: {
name: "sibling",
description: "Wait until interrupted",
input: Schema.Struct({}),
execute: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(
Deferred.succeed(cleaning, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Effect.sync(() => events.push("cleaned"))),
),
),
),
},
decline: {
name: "decline",
description: "Decline after the sibling starts",
input: Schema.Struct({}),
execute: () => Deferred.await(started).pipe(Effect.andThen(Effect.fail(declined))),
},
})
yield* hooks.register("tool", "execute.after", (event) => Effect.sync(() => after.push(event.tool)))
const snapshot = yield* service.snapshot()
const fiber = yield* snapshot
.execute({
...call("execute"),
call: {
type: "tool-call",
id: "siblings",
name: "execute",
input: { code: "return await Promise.allSettled([tools.sibling({}), tools.decline({})])" },
},
progress: (update) => Effect.sync(() => progress.push(update)),
})
.pipe(Effect.forkChild)
yield* Deferred.await(cleaning)
expect(fiber.pollUnsafe()).toBeUndefined()
expect(events).toEqual([])
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(fiber).pipe(Effect.flip)).toBe(declined)
expect(events).toEqual(["cleaned"])
expect(after).toEqual([])
expect(progress.at(-1)).toEqual({
toolCalls: [
{ tool: "sibling", status: "error" },
{ tool: "decline", status: "error" },
],
})
}),
)
it.effect("keeps Code Mode tool errors and operational defects catchable", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
const after: string[] = []
yield* transform(service, {
failed: {
name: "failed",
description: "Fail with an ordinary tool error",
input: Schema.Struct({}),
execute: () => Effect.fail(new Tool.Error({ message: "Expected tool failure" })),
},
defect: {
name: "defect",
description: "Fail with an operational defect",
input: Schema.Struct({}),
execute: () => Effect.die(new Error("Unexpected tool defect")),
},
})
yield* hooks.register("tool", "execute.after", (event) =>
Effect.sync(() => after.push(`${event.tool}:${event.status}`)),
)
const snapshot = yield* service.snapshot()
const result = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "recovered",
name: "execute",
input: {
code: `const caught = [];
try { await tools.failed({}) } catch (error) { caught.push(error.message) }
const settled = await Promise.allSettled([tools.defect({})]);
caught.push(settled[0].reason.message);
return caught;`,
},
},
})
expect(result.output).toMatchObject({
output: '[\n "Expected tool failure",\n "Unexpected tool defect"\n]',
})
expect(result.metadata).toEqual({
toolCalls: [
{ tool: "failed", status: "error" },
{ tool: "defect", status: "error" },
],
})
expect(after).toEqual(["failed:error", "execute:completed"])
}),
)
it.effect("replays mutations on refreshed sources and restores tools on disposal and scope cleanup", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+9 -70
View File
@@ -35,11 +35,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { EditTool } from "@opencode-ai/core/tool/plugin/edit"
import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
import { WriteTool } from "@opencode-ai/core/tool/plugin/write"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
@@ -292,69 +288,6 @@ const runPermissionCommand = (
return { exit, requests }
}).pipe(Effect.scoped, Effect.timeout(Duration.seconds(5)))
permissionIt.live("preserves declines through file-tool wrappers before mutation", () =>
withScanner(false, (registry, fixture) =>
Effect.gen(function* () {
yield* registerToolPlugin(ReadTool.Plugin)
yield* registerToolPlugin(WriteTool.Plugin)
yield* registerToolPlugin(EditTool.Plugin)
yield* registerToolPlugin(PatchTool.Plugin)
const target = path.join(fixture.active, "target.txt")
const outside = path.join(fixture.outside, "target.txt")
yield* Effect.promise(() => Promise.all([Bun.write(target, "before\n"), Bun.write(outside, "outside\n")]))
const permission = yield* Permission.Service
const bus = yield* Bus.Service
const requests = yield* Queue.unbounded<Permission.Request>()
yield* bus.subscribe(Permission.Event.Asked).pipe(
Stream.runForEach((event) => Queue.offer(requests, event.data)),
Effect.forkScoped({ startImmediately: true }),
)
const tools = yield* registry.snapshot()
for (const fixture of [
{ name: "read", action: "read", input: { path: target } },
{ name: "write", action: "edit", input: { path: target, content: "after\n" } },
{ name: "edit", action: "edit", input: { path: target, oldString: "before", newString: "after" } },
{
name: "patch",
action: "edit",
input: { patchText: `*** Begin Patch\n*** Delete File: ${target}\n*** End Patch` },
},
{
name: "patch",
action: "external_directory",
input: { patchText: `*** Begin Patch\n*** Delete File: ${outside}\n*** End Patch` },
},
]) {
const execution = yield* tools
.execute({
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: `decline-${fixture.name}-${fixture.action}`,
name: fixture.name,
input: fixture.input,
},
})
.pipe(Effect.forkScoped)
const request = yield* Queue.take(requests)
expect(request.action).toBe(fixture.action)
yield* permission.reply({ requestID: request.id, reply: "reject" })
expect(yield* Fiber.await(execution)).toMatchObject({
_tag: "Failure",
cause: {
reasons: [{ _tag: "Fail", error: new Tool.Declined({ message: "The user declined this tool call" }) }],
},
})
expect(yield* Effect.promise(() => Bun.file(target).text())).toBe("before\n")
expect(yield* Effect.promise(() => Bun.file(outside).text())).toBe("outside\n")
expect(yield* permission.list()).toEqual([])
}
expect(yield* Queue.size(requests)).toBe(0)
}).pipe(Effect.scoped, Effect.timeout(Duration.seconds(5))),
),
)
// Directory cases still document inherited limitations; fixed scanner cases require matching behavior.
describe("ShellTool scanner permissions", () => {
const test = isWindows || !Bun.which("sh") ? permissionIt.live.skip : permissionIt.live
@@ -394,7 +327,7 @@ describe("ShellTool scanner permissions", () => {
if (Exit.isFailure(result.exit))
expect(
result.exit.cause.reasons.some(
(reason) => Cause.isFailReason(reason) && reason.error instanceof Tool.Declined,
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
),
).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
@@ -491,7 +424,7 @@ describe("ShellTool scanner permissions", () => {
if (Exit.isFailure(result.exit))
expect(
result.exit.cause.reasons.some(
(reason) => Cause.isFailReason(reason) && reason.error instanceof Tool.Declined,
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
),
).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
@@ -572,7 +505,7 @@ describe("ShellTool scanner permissions", () => {
if (Exit.isFailure(result.exit))
expect(
result.exit.cause.reasons.some(
(reason) => Cause.isFailReason(reason) && reason.error instanceof Tool.Declined,
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
),
).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
@@ -1358,6 +1291,7 @@ describe("ShellTool", () => {
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.metadata).not.toHaveProperty("exit")
expect(settled.content?.[0]).toMatchObject(Expected.text(expect.stringContaining("before timeout")))
expect(settled.content?.[1]).toMatchObject(Expected.text(expect.stringContaining("Command timed out")))
}),
@@ -1553,6 +1487,7 @@ describe("ShellTool", () => {
Effect.gen(function* () {
const info = yield* shell.create({ command: idleCommand, timeout: 0 })
yield* shell.remove(info.id)
expect((yield* shell.result(info)).capture).toBeUndefined()
yield* Effect.sleep(Duration.millis(10))
}),
)
@@ -1560,6 +1495,10 @@ describe("ShellTool", () => {
const info = yield* shell.create({ command: helloCommand, timeout: 0 })
const settled = yield* shell.wait(info.id).pipe(Effect.timeoutOption(Duration.seconds(2)))
expect(settled._tag).toBe("Some")
expect(yield* shell.result(info)).toMatchObject({
info: { status: "exited", exit: 0 },
capture: { output: expect.stringContaining("hello"), truncated: false },
})
}),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
+1 -1
View File
@@ -390,7 +390,7 @@ describe("WebSearchTool registration", () => {
const sessionError = toSessionError(error)
expect(sessionError).toEqual({ type: "tool.execution", message })
expect(sessionError.message).not.toContain("secret")
expect(error).toHaveProperty("metadata", { provider: "exa" })
expect(error.metadata).toEqual({ provider: "exa" })
expect(progress).toEqual([{ provider: "exa" }])
}),
{ discard: true },
+16
View File
@@ -73,6 +73,22 @@ async function commitAll(directory: string, message: string) {
await hg(directory, "commit", "-q", "-m", message, "-u", "test")
}
it.live("Mercurial rejects unsupported review modes and bases without requiring hg", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const context = host()
yield* VcsHgPlugin.Plugin.effect({
...context,
vcs: { ...context.vcs, transform: vcs.transform, reload: vcs.reload },
})
expect(yield* vcs.base()).toBeNull()
expect(yield* vcs.diff("committed").pipe(Effect.flip)).toMatchObject({ _tag: "Vcs.DiffError" })
expect(yield* vcs.diff("branch", { base: "release" }).pipe(Effect.flip)).toMatchObject({ _tag: "Vcs.DiffError" })
}).pipe(provide(directory)),
),
)
describeHg("Vcs mercurial", () => {
it.live("reports modified, missing, and untracked files", () =>
withHg((directory) =>
+499 -10
View File
@@ -5,6 +5,8 @@ import path from "path"
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppProcess } from "@opencode-ai/util/process"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "@opencode-ai/core/git"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -18,9 +20,9 @@ import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { host } from "./plugin/host"
const provide = (directory: string, input: { git?: boolean } = {}) =>
const provide = (directory: string, input: { git?: boolean; worktree?: string } = {}) =>
Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node]), [
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node, FSUtil.node, Git.node]), [
[
Location.node,
Layer.succeed(
@@ -28,7 +30,12 @@ const provide = (directory: string, input: { git?: boolean } = {}) =>
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
{
projectDirectory: input.worktree ? AbsolutePath.make(input.worktree) : undefined,
...(input.git
? { vcs: { type: "git", store: AbsolutePath.make(path.join(input.worktree ?? directory, ".git")) } }
: {}),
},
),
),
),
@@ -42,9 +49,12 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
const withGit = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
const withGit = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>, input: { scope?: string } = {}) =>
withTmp((directory) =>
Effect.promise(() => initRepo(directory)).pipe(
Effect.promise(async () => {
await initRepo(directory)
if (input.scope) await fs.mkdir(path.join(directory, input.scope), { recursive: true })
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
@@ -54,7 +64,7 @@ const withGit = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
vcs: { ...context.vcs, transform: vcs.transform, reload: vcs.reload },
})
return yield* f(directory)
}).pipe(provide(directory, { git: true })),
}).pipe(provide(path.join(directory, input.scope ?? "."), { git: true, worktree: directory })),
),
),
)
@@ -90,6 +100,7 @@ describe("Vcs", () => {
const vcs = yield* Vcs.Service
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.branches()).toEqual([])
expect(yield* vcs.base()).toBeNull()
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
expect(yield* vcs.diff("branch")).toEqual([])
@@ -107,6 +118,7 @@ describe("Vcs", () => {
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.base()).toBeNull()
expect(yield* vcs.branches()).toEqual(["feature", "main"])
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
expect(yield* vcs.diff("working")).toEqual([
@@ -151,13 +163,14 @@ describe("Vcs", () => {
draft.default.set("custom")
})
yield* vcs.diff("branch", { context: 3 })
yield* vcs.diff("committed", { context: 3, base: "release" })
expect(observed).toEqual([
{
directory,
worktree: directory,
canonical: directory,
mode: "branch",
mode: "committed",
base: "release",
context: 3,
maxOutputBytes: 10_000_000,
},
@@ -197,7 +210,7 @@ describe("Vcs", () => {
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
draft.add(provider({ status: () => Effect.never }))
draft.add(provider({ status: () => Effect.never, diff: () => Effect.never, base: () => Effect.never }))
draft.default.set("custom")
})
@@ -205,6 +218,14 @@ describe("Vcs", () => {
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
const diff = yield* Effect.forkChild(vcs.diff("committed"))
yield* Fiber.interrupt(diff)
const interrupted = yield* Fiber.await(diff)
expect(Exit.isFailure(interrupted) && Cause.hasInterrupts(interrupted.cause)).toBeTrue()
const base = yield* Effect.forkChild(vcs.base())
yield* Fiber.interrupt(base)
const cancelled = yield* Fiber.await(base)
expect(Exit.isFailure(cancelled) && Cause.hasInterrupts(cancelled.cause)).toBeTrue()
}).pipe(provide(directory)),
),
)
@@ -343,6 +364,9 @@ describe("Vcs", () => {
const diff = yield* vcs.diff("working")
expect(diff).toHaveLength(1)
expect(diff[0].patch).toContain("+hello")
expect(yield* vcs.base()).toBeNull()
expect(yield* vcs.diff("branch")).toEqual(diff)
expect(yield* vcs.diff("committed")).toEqual([])
}),
),
)
@@ -358,7 +382,7 @@ describe("Vcs", () => {
expect(yield* vcs.diff("branch")).toEqual([])
yield* Effect.promise(async () => {
await $`git checkout -q -b feature`.cwd(directory).quiet()
await $`git checkout -q -b feature main`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "file.txt"), "one\ntwo\n")
await commitAll(directory, "feature change")
})
@@ -370,4 +394,469 @@ describe("Vcs", () => {
}),
),
)
it.live("separates committed, combined, and staged/unstaged/untracked working changes", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "base\n")
await commitAll(directory, "initial")
await $`git checkout -b feature main`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "file.txt"), "committed\n")
await commitAll(directory, "feature")
await fs.writeFile(path.join(directory, "staged.txt"), "staged\n")
await $`git add staged.txt`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "file.txt"), "unstaged\n")
await fs.writeFile(path.join(directory, "untracked.txt"), "untracked\n")
})
const vcs = yield* Vcs.Service
const committed = yield* vcs.diff("committed")
expect(committed.map((row) => row.file)).toEqual(["file.txt"])
expect(committed[0].patch).toContain("-base\n+committed")
expect(committed[0]).toMatchObject({ additions: 1, deletions: 1 })
const combined = yield* vcs.diff("branch")
expect(combined.map((row) => row.file)).toEqual(["file.txt", "staged.txt", "untracked.txt"])
expect(combined[0].patch).toContain("-base\n+unstaged")
const working = yield* vcs.diff("working")
expect(working.map((row) => row.file)).toEqual(["file.txt", "staged.txt", "untracked.txt"])
expect(working[0].patch).toContain("-committed\n+unstaged")
}),
),
)
it.live("keeps a locally undone commit visible only in committed and working modes", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "base\n")
await commitAll(directory, "initial")
await $`git checkout -b feature main`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "file.txt"), "committed\n")
await commitAll(directory, "feature")
await fs.writeFile(path.join(directory, "file.txt"), "base\n")
})
const vcs = yield* Vcs.Service
expect(yield* vcs.diff("branch")).toEqual([])
expect((yield* vcs.diff("committed"))[0].patch).toContain("-base\n+committed")
expect((yield* vcs.diff("working"))[0].patch).toContain("-committed\n+base")
}),
),
)
it.live("shows local WIP on the default branch", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "base\n")
await commitAll(directory, "initial")
await fs.writeFile(path.join(directory, "file.txt"), "dirty\n")
})
const vcs = yield* Vcs.Service
expect(yield* vcs.base()).toEqual({ name: "main", ref: "refs/heads/main", source: "default" })
expect(yield* vcs.diff("branch")).toEqual(yield* vcs.diff("working"))
expect(yield* vcs.diff("committed")).toEqual([])
}),
),
)
it.live("keeps unpushed default-branch commits in the resolved review", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "base\n")
await commitAll(directory, "initial")
await $`git remote add origin ${directory}`.cwd(directory).quiet()
await $`git update-ref refs/remotes/origin/main HEAD`.cwd(directory).quiet()
await $`git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "file.txt"), "committed\n")
await commitAll(directory, "unpushed")
await fs.writeFile(path.join(directory, "file.txt"), "dirty\n")
})
const vcs = yield* Vcs.Service
const base = yield* vcs.base()
expect(base).toEqual({ name: "main", ref: "refs/remotes/origin/main", source: "default" })
expect((yield* vcs.diff("committed", { base: base?.ref }))[0].patch).toContain("-base\n+committed")
expect((yield* vcs.diff("branch", { base: base?.ref }))[0].patch).toContain("-base\n+dirty")
expect((yield* vcs.diff("working"))[0].patch).toContain("-committed\n+dirty")
}),
),
)
it.live("scopes committed diffs to nested paths and retains binary, rename, and deletion metadata", () =>
withGit(
(directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "outside.txt"), "base\n")
await fs.writeFile(path.join(directory, "nested/old.txt"), "rename me\n")
await fs.writeFile(path.join(directory, "nested/gone.txt"), "delete me\n")
await fs.writeFile(path.join(directory, "nested/image.png"), Buffer.from([137, 80, 78, 71, 0, 1]))
await commitAll(directory, "initial")
await $`git checkout -b feature main`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "outside.txt"), "changed\n")
await fs.rename(path.join(directory, "nested/old.txt"), path.join(directory, "nested/new.txt"))
await fs.rm(path.join(directory, "nested/gone.txt"))
await fs.writeFile(path.join(directory, "nested/image.png"), Buffer.from([137, 80, 78, 71, 0, 2]))
await commitAll(directory, "feature")
})
const vcs = yield* Vcs.Service
const committed = yield* vcs.diff("committed")
expect(committed.map((row) => ({ file: row.file, status: row.status }))).toEqual([
{ file: "nested/gone.txt", status: "deleted" },
{ file: "nested/image.png", status: "modified" },
{ file: "nested/new.txt", status: "added" },
{ file: "nested/old.txt", status: "deleted" },
])
expect(committed[0].patch).toContain("-delete me")
expect(committed[1]).toMatchObject({ additions: 0, deletions: 0 })
expect(committed[1].patch).toContain("Binary files")
expect(committed[2].patch).toContain("+rename me")
yield* Effect.promise(async () => {
await fs.rm(path.join(directory, "nested/image.png"))
await fs.writeFile(path.join(directory, "nested/new.txt"), "dirty\n")
await fs.writeFile(path.join(directory, "nested/gone.txt"), "untracked resurrection\n")
await $`git add nested/new.txt`.cwd(directory).quiet()
})
expect(yield* vcs.diff("committed")).toEqual(committed)
}),
{ scope: "nested" },
),
)
it.live("uses an explicit diff base without treating upstream or Git config as a parent", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "base\n")
await commitAll(directory, "initial")
await $`git checkout -b release`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "release.txt"), "release\n")
await commitAll(directory, "release")
await $`git checkout -b feature`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "feature.txt"), "feature\n")
await commitAll(directory, "feature")
await $`git branch --set-upstream-to=release feature`.cwd(directory).quiet()
await $`git config branch.feature.opencode-merge-base refs/heads/release`.cwd(directory).quiet()
})
const vcs = yield* Vcs.Service
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
expect((yield* vcs.diff("committed", { base: "release" })).map((row) => row.file)).toEqual(["feature.txt"])
expect((yield* vcs.diff("committed")).map((row) => row.file)).toEqual(["feature.txt", "release.txt"])
expect((yield* vcs.diff("branch", { base: "release" })).map((row) => row.file)).toEqual(["feature.txt"])
expect((yield* vcs.diff("branch")).map((row) => row.file)).toEqual(["feature.txt", "release.txt"])
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
}),
),
)
it.live("reports missing explicit bases and missing default bases without calling them clean", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "base\n")
await commitAll(directory, "initial")
await $`git branch -m feature`.cwd(directory).quiet()
})
const vcs = yield* Vcs.Service
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
expect(yield* vcs.diff("committed").pipe(Effect.flip)).toMatchObject({ _tag: "Vcs.DiffError" })
expect(yield* vcs.diff("branch").pipe(Effect.flip)).toMatchObject({
_tag: "Vcs.DiffError",
message: "No review base available",
})
expect(yield* vcs.diff("branch", { base: "missing" }).pipe(Effect.flip)).toMatchObject({
_tag: "Vcs.DiffError",
})
expect(yield* vcs.diff("working", { base: "missing" })).toEqual([])
}),
),
)
it.live("diffs explicit remote-only bases without borrowing another fork's branch", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "base\n")
await commitAll(directory, "initial")
await $`git remote add origin https://github.com/team/project.git`.cwd(directory).quiet()
await $`git remote add other https://github.com/other/project.git`.cwd(directory).quiet()
await $`git update-ref refs/remotes/other/release HEAD`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "release.txt"), "release parent\n")
await $`git checkout -b feature`.cwd(directory).quiet()
await commitAll(directory, "release parent")
await $`git update-ref refs/remotes/origin/release HEAD`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "feature.txt"), "feature\n")
await commitAll(directory, "feature")
})
const vcs = yield* Vcs.Service
expect((yield* vcs.diff("committed", { base: "refs/remotes/origin/release" })).map((row) => row.file)).toEqual([
"feature.txt",
])
expect((yield* vcs.diff("committed")).map((row) => row.file)).toEqual(["feature.txt", "release.txt"])
expect((yield* vcs.diff("committed", { base: "refs/remotes/other/release" })).map((row) => row.file)).toEqual([
"feature.txt",
"release.txt",
])
yield* Effect.promise(async () => {
await $`git update-ref -d refs/remotes/origin/release`.cwd(directory).quiet()
})
expect(yield* vcs.diff("committed", { base: "refs/remotes/origin/release" }).pipe(Effect.flip)).toMatchObject({
_tag: "Vcs.DiffError",
})
expect((yield* vcs.diff("committed", { base: "refs/remotes/other/release" })).map((row) => row.file)).toEqual([
"feature.txt",
"release.txt",
])
}),
),
)
for (const scenario of [
{ name: "failed", base: () => Effect.fail(new Error("provider failed")) },
{
name: "invalid",
base: () =>
Effect.succeed({
name: "release",
ref: "refs/heads/release",
source: "invalid" as const,
}),
},
]) {
it.live(`reports ${scenario.name} base metadata as unavailable rather than absent`, () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
// @ts-expect-error Invalid third-party wire metadata must be checked at runtime.
draft.add(provider({ base: scenario.base }))
draft.default.set("custom")
})
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ _tag: "Vcs.DiffError" })
}).pipe(provide(directory)),
),
)
}
it.live("reports provider failures and validates optional base metadata", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
draft.add(
provider({
base: () => Effect.succeed({ name: "release", ref: "refs/heads/release", source: "reflog" }),
diff: () => Effect.fail(new Error("failed")),
}),
)
draft.default.set("custom")
})
expect((yield* vcs.base())?.source).toBe("reflog")
expect(yield* vcs.diff("committed").pipe(Effect.flip)).toMatchObject({ _tag: "Vcs.DiffError" })
}).pipe(provide(directory)),
),
)
it.live("reviews every feature commit and local changes against inferred v2, not the unrelated default", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "base.txt"), "base\n")
await commitAll(directory, "initial")
await $`git remote add origin https://example.com/team/repo.git`.cwd(directory).quiet()
await $`git update-ref refs/remotes/origin/dev HEAD`.cwd(directory).quiet()
await $`git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/dev`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "v2.txt"), "v2 parent\n")
await commitAll(directory, "v2")
await $`git update-ref refs/remotes/origin/v2 HEAD`.cwd(directory).quiet()
await $`git checkout -b feature origin/v2`.cwd(directory).quiet()
await $`git config branch.feature.opencode-merge-base refs/remotes/origin/dev`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "feature.txt"), "feature\n")
await commitAll(directory, "feature")
await fs.writeFile(path.join(directory, "second.txt"), "second\n")
await commitAll(directory, "second")
await fs.writeFile(path.join(directory, "third.txt"), "third\n")
await commitAll(directory, "third")
await fs.writeFile(path.join(directory, "untracked.txt"), "local\n")
})
const vcs = yield* Vcs.Service
const config = yield* Effect.promise(() => fs.readFile(path.join(directory, ".git/config"), "utf8"))
const base = yield* vcs.base()
expect(base).toEqual({ name: "v2", ref: "refs/remotes/origin/v2", source: "reflog" })
expect((yield* vcs.diff("committed", { base: base?.ref })).map((row) => row.file)).toEqual([
"feature.txt",
"second.txt",
"third.txt",
])
expect((yield* vcs.diff("branch", { base: base?.ref })).map((row) => row.file)).toEqual([
"feature.txt",
"second.txt",
"third.txt",
"untracked.txt",
])
expect((yield* vcs.diff("committed")).map((row) => row.file)).toEqual([
"feature.txt",
"second.txt",
"third.txt",
"v2.txt",
])
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, ".git/config"), "utf8"))).toBe(config)
expect(
yield* Effect.promise(() => Bun.file(path.join(directory, ".git/opencode-review.json")).exists()),
).toBeFalse()
yield* Effect.promise(() => $`git config branch.feature.opencode-merge-base missing`.cwd(directory).quiet())
expect(yield* vcs.base()).toEqual({ name: "v2", ref: "refs/remotes/origin/v2", source: "reflog" })
yield* Effect.promise(() => $`git checkout -b uncertain HEAD`.cwd(directory).quiet())
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
}),
),
)
it.live("ignores stale, deleted, renamed, and self-mirroring creation hints", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "base.txt"), "base\n")
await commitAll(directory, "initial")
await $`git branch parent`.cwd(directory).quiet()
await $`git checkout -b feature parent`.cwd(directory).quiet()
})
const vcs = yield* Vcs.Service
expect((yield* vcs.base())?.source).toBe("reflog")
yield* Effect.promise(() => $`git branch -m renamed`.cwd(directory).quiet())
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
yield* Effect.promise(async () => {
await $`git checkout -b deleted parent`.cwd(directory).quiet()
await $`git branch -D parent`.cwd(directory).quiet()
})
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
yield* Effect.promise(async () => {
await $`git update-ref refs/remotes/origin/mirror HEAD`.cwd(directory).quiet()
await $`git checkout -b mirror origin/mirror`.cwd(directory).quiet()
})
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
yield* Effect.promise(async () => {
await $`git checkout -b stale main`.cwd(directory).quiet()
await $`git checkout --orphan unrelated`.cwd(directory).quiet()
await $`git commit -m unrelated`.cwd(directory).quiet()
await $`git branch -f main HEAD`.cwd(directory).quiet()
await $`git checkout stale`.cwd(directory).quiet()
})
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
yield* Effect.promise(async () => {
await $`git checkout -b rebased unrelated`.cwd(directory).quiet()
await $`git reset --soft stale`.cwd(directory).quiet()
})
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
}),
),
)
it.live("creates detached worktrees without review metadata and requires an explicit base without a named hint", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "base.txt"), "base\n")
await commitAll(directory, "initial")
await $`git remote add origin https://example.com/team/repo.git`.cwd(directory).quiet()
await $`git update-ref refs/remotes/origin/dev HEAD`.cwd(directory).quiet()
await $`git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/dev`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "v2.txt"), "v2\n")
await commitAll(directory, "v2")
await $`git update-ref refs/remotes/origin/v2 HEAD`.cwd(directory).quiet()
})
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(directory))
if (!repository) throw new Error("Expected Git repository")
const linked = yield* git.worktree.create({
repository,
directory: AbsolutePath.make(path.join(directory, "linked")),
ref: "origin/v2",
})
expect(yield* git.history.branch(linked)).toBeUndefined()
expect(
yield* Effect.promise(() => Bun.file(path.join(linked.gitDirectory, "opencode-review.json")).exists()),
).toBeFalse()
yield* Effect.gen(function* () {
const vcs = yield* Vcs.Service
const context = host()
yield* VcsGitPlugin.Plugin.effect({
...context,
vcs: { ...context.vcs, transform: vcs.transform, reload: vcs.reload },
})
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
expect(yield* vcs.diff("committed", { base: "origin/v2" })).toEqual([])
expect((yield* vcs.diff("committed", { base: "origin/dev" })).map((row) => row.file)).toEqual(["v2.txt"])
yield* Effect.promise(() => $`git checkout -b child`.cwd(linked.worktree).quiet())
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
yield* Effect.promise(() => $`git checkout -b named origin/v2`.cwd(linked.worktree).quiet())
expect(yield* vcs.base()).toEqual({ name: "v2", ref: "refs/remotes/origin/v2", source: "reflog" })
}).pipe(provide(linked.worktree, { git: true }))
expect(
yield* Effect.promise(() => Bun.file(path.join(linked.gitDirectory, "opencode-review.json")).exists()),
).toBeFalse()
const fromHead = yield* git.worktree.create({
repository,
directory: AbsolutePath.make(path.join(directory, "from-head")),
})
expect(
yield* Effect.promise(() => Bun.file(path.join(fromHead.gitDirectory, "opencode-review.json")).exists()),
).toBeFalse()
const detached = yield* git.worktree.create({
repository: fromHead,
directory: AbsolutePath.make(path.join(directory, "detached-source")),
})
expect(
yield* Effect.promise(() => Bun.file(path.join(detached.gitDirectory, "opencode-review.json")).exists()),
).toBeFalse()
}),
),
)
it.live("rejects a branch creation anchor removed by rebase", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "base.txt"), "base\n")
await commitAll(directory, "initial")
await $`git checkout -b parent main`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "parent.txt"), "parent\n")
await commitAll(directory, "parent")
await $`git checkout -b feature parent`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "feature.txt"), "feature\n")
await commitAll(directory, "feature")
})
const vcs = yield* Vcs.Service
expect((yield* vcs.base())?.name).toBe("parent")
yield* Effect.promise(() => $`git rebase --onto main parent feature`.cwd(directory).quiet())
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
}),
),
)
it.live("does not infer beyond the bounded creation-reflog window", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "base\n")
await commitAll(directory, "initial")
await $`git checkout -b feature main`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "file.txt"), "feature\n")
await commitAll(directory, "feature")
})
const vcs = yield* Vcs.Service
expect((yield* vcs.base())?.source).toBe("reflog")
yield* Effect.promise(async () => {
const before = (await $`git rev-parse main`.cwd(directory).text()).trim()
const after = (await $`git rev-parse HEAD`.cwd(directory).text()).trim()
const updates = Array.from(
{ length: 260 },
(_, index) => `start\nupdate refs/heads/feature ${index % 2 === 0 ? before : after}\nprepare\ncommit\n`,
).join("")
await $`git update-ref -m test --stdin < ${Buffer.from(updates)}`.cwd(directory).quiet()
})
expect(yield* vcs.base().pipe(Effect.flip)).toMatchObject({ message: "Choose a review base" })
}),
),
)
})
-16
View File
@@ -124,22 +124,6 @@ await ctx.tool.transform((tools) => {
})
```
## Declining A Tool
Tools and `execute.before` hooks can decline a call by throwing the canonical
`Tool.Declined`. OpenCode treats this as a refused interaction, not an ordinary
tool error for the model to recover from. Other thrown errors remain defects.
```ts
import { Tool } from "@opencode-ai/plugin"
await ctx.tool.hook("execute.before", (event) => {
if (event.tool === "deploy") {
throw new Tool.Declined({ message: "Deployment was declined" })
}
})
```
## Reloading A Domain
When data captured by a transform changes, reload the affected domain:
-15
View File
@@ -107,21 +107,6 @@ yield *
)
```
## Declining A Tool
Tools and `execute.before` hooks can fail with the canonical `Tool.Declined`.
OpenCode treats this as a refused interaction, not an ordinary tool error for
the model to recover from. Defects and interruptions retain their meaning.
```ts
import { Tool } from "@opencode-ai/plugin/effect"
yield *
ctx.tool.hook("execute.before", (event) =>
event.tool === "deploy" ? Effect.fail(new Tool.Declined({ message: "Deployment was declined" })) : Effect.void,
)
```
## Reloading A Domain
When data captured by a transform changes, reload the affected domain:
-1
View File
@@ -13,6 +13,5 @@ export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
export { Tool } from "@opencode-ai/schema/tool"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
+2 -2
View File
@@ -44,9 +44,9 @@ export interface ToolHooks {
)
}
// Only execute.before may fail, rejecting or declining the call before the tool runs.
// Only execute.before may fail: a Tool.Error rejects the call before the tool runs.
export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
readonly "execute.before": Tool.Error | Tool.Declined
readonly "execute.before": Tool.Error
readonly "execute.after": never
}
+2
View File
@@ -18,6 +18,7 @@ export interface VcsBranchesInput extends VcsScope {
export interface VcsDiffInput extends VcsScope {
readonly mode: Vcs.Mode
readonly base?: string
readonly context: number
readonly maxOutputBytes: number
}
@@ -26,6 +27,7 @@ export interface VcsDefinition {
readonly id: string
readonly name: string
readonly info: (input: VcsScope) => Effect.Effect<Vcs.Info, unknown>
readonly base?: (input: VcsScope) => Effect.Effect<Vcs.Base | null, unknown>
readonly branches: (input: VcsBranchesInput) => Effect.Effect<Vcs.BranchList, unknown>
readonly status: (input: VcsScope) => Effect.Effect<readonly Vcs.FileStatus[], unknown>
readonly diff: (input: VcsDiffInput) => Effect.Effect<readonly FileDiff.Info[], unknown>
+8 -15
View File
@@ -3,7 +3,6 @@ import { Effect, Schema, SchemaAST, Stream } from "effect"
import type { Scope } from "effect"
import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
import { define } from "../effect/plugin.js"
import type { ToolFailures } from "../effect/tool.js"
import type { Context, Plugin } from "./plugin.js"
import type { Info } from "./tool.js"
@@ -342,20 +341,11 @@ export function fromPromise(plugin: Plugin) {
),
),
hook: (name, callback) =>
register(
host.tool.hook(name, (event) =>
Effect.promise(() => Promise.resolve(callback(event))).pipe(
Effect.catchDefect((error) =>
name === "execute.before" && error instanceof Tool.Declined
? Effect.fail(error as ToolFailures[typeof name])
: Effect.die(error),
),
),
),
),
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
vcs: {
get: adaptApiMethod(VcsEndpoints["vcs.get"], host.vcs.get),
base: adaptApiMethod(VcsEndpoints["vcs.base"], host.vcs.base),
branches: adaptApiMethod(VcsEndpoints["vcs.branches"], host.vcs.branches),
status: adaptApiMethod(VcsEndpoints["vcs.status"], host.vcs.status),
diff: adaptApiMethod(VcsEndpoints["vcs.diff"], host.vcs.diff),
@@ -364,15 +354,18 @@ export function fromPromise(plugin: Plugin) {
register(
host.vcs.transform((draft) => {
callback({
add: (definition) =>
add: (definition) => {
const base = definition.base?.bind(definition)
draft.add({
id: definition.id,
name: definition.name,
info: (input) => attempt((signal) => definition.info(input, { signal })),
base: base ? (input) => attempt((signal) => base(input, { signal })) : undefined,
branches: (input) => attempt((signal) => definition.branches(input, { signal })),
status: (input) => attempt((signal) => definition.status(input, { signal })),
diff: (input) => attempt((signal) => definition.diff(input, { signal })),
}),
})
},
default: draft.default,
})
}),
@@ -442,4 +435,4 @@ const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
...context,
progress: (update) => Effect.runPromise(context.progress(update)),
}),
).pipe(Effect.catchDefect((error) => (error instanceof Tool.Declined ? Effect.fail(error) : Effect.die(error))))
)
-1
View File
@@ -14,6 +14,5 @@ export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
export { Tool } from "@opencode-ai/schema/tool"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
+1 -1
View File
@@ -1,4 +1,4 @@
export { CallID, Declined, Error } from "@opencode-ai/schema/tool"
export { CallID, Error } from "@opencode-ai/schema/tool"
export type { Metadata, Options, Result } from "@opencode-ai/schema/tool"
import { Tool } from "@opencode-ai/schema/tool"
+2
View File
@@ -17,6 +17,7 @@ export interface VcsBranchesInput extends VcsScope {
export interface VcsDiffInput extends VcsScope {
readonly mode: Vcs.Mode
readonly base?: string
readonly context: number
readonly maxOutputBytes: number
}
@@ -25,6 +26,7 @@ export interface VcsDefinition {
readonly id: string
readonly name: string
readonly info: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<Vcs.Info>
readonly base?: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<Vcs.Base | null>
readonly branches: (input: VcsBranchesInput, context: { readonly signal: AbortSignal }) => Promise<Vcs.BranchList>
readonly status: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<readonly Vcs.FileStatus[]>
readonly diff: (input: VcsDiffInput, context: { readonly signal: AbortSignal }) => Promise<readonly FileDiff.Info[]>
@@ -12,13 +12,11 @@ import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Provider } from "@opencode-ai/schema/provider"
import { Reference } from "@opencode-ai/schema/reference"
import { Skill } from "@opencode-ai/schema/skill"
import { Tool } from "@opencode-ai/schema/tool"
import { Vcs } from "@opencode-ai/schema/vcs"
import { WebSearch } from "@opencode-ai/schema/websearch"
const Plugin = await import("../src/effect/index")
const PromisePlugin = await import("../src/promise/index")
const PromiseTool = await import("../src/promise/tool")
const TuiPlugin = await import("../src/tui/index")
test.each([
@@ -37,8 +35,6 @@ test.each([
expect(entrypoint.Provider).toBe(Provider)
expect(entrypoint.Reference).toBe(Reference)
expect(entrypoint.Skill).toBe(Skill)
expect(entrypoint.Tool).toBe(Tool)
expect(entrypoint.Tool.Declined).toBe(Tool.Declined)
expect(entrypoint.Vcs).toBe(Vcs)
expect(entrypoint.WebSearch).toBe(WebSearch)
expect(Object.keys(entrypoint).sort()).toEqual([
@@ -55,7 +51,6 @@ test.each([
"Provider",
"Reference",
"Skill",
"Tool",
"Vcs",
"WebSearch",
])
@@ -68,10 +63,6 @@ test.each([
expect(plugin.vcs).toEqual({ markers: [".svn"] })
})
test("Promise tool entrypoint exposes the canonical decline", () => {
expect(PromiseTool.Declined).toBe(Tool.Declined)
})
test("tui entrypoint exposes the plugin definition", () => {
const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} })
expect(plugin.id).toBe("demo")
+149 -18
View File
@@ -7358,14 +7358,7 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
@@ -8243,14 +8236,7 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
@@ -12078,6 +12064,116 @@
"summary": "VCS info"
}
},
"/api/vcs/base": {
"get": {
"tags": ["vcs"],
"operationId": "v2.vcs.base",
"parameters": [
{
"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.InfoEncoded"
},
"data": {
"anyOf": [
{
"$ref": "#/components/schemas/Vcs.Base"
},
{
"type": "null"
}
]
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Infer a local review base from named branch creation history, or the repository default only when currently on that branch. Returns null before the first commit or when the provider lacks base metadata; ambiguous Git history requires an explicit base on diff requests.",
"summary": "VCS review base"
}
},
"/api/vcs/status": {
"get": {
"tags": ["vcs"],
@@ -12350,6 +12446,14 @@
},
"required": true
},
{
"name": "base",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "context",
"in": "query",
@@ -12410,9 +12514,19 @@
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Diff the working copy against HEAD (mode git) or the default-branch merge base (mode branch) for the requested location.",
"description": "Diff HEAD to the working copy (working), the base merge-base to the working copy (branch), or the base merge-base to HEAD (committed). Omitting base preserves repository-default comparison; supplying it overrides the comparison without saving it.",
"summary": "VCS diff"
}
},
@@ -18770,6 +18884,23 @@
"type": "string",
"contentMediaType": "application/json"
},
"Vcs.Base": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"ref": {
"type": "string"
},
"source": {
"type": "string",
"enum": ["reflog", "default"]
}
},
"required": ["name", "ref", "source"],
"additionalProperties": false
},
"Vcs.Branch": {
"type": "object",
"properties": {
@@ -18822,7 +18953,7 @@
},
"Vcs.Mode": {
"type": "string",
"enum": ["working", "branch"]
"enum": ["working", "branch", "committed"]
},
"WebSearch.Provider": {
"type": "object",
+21 -2
View File
@@ -1,10 +1,11 @@
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Location } from "@opencode-ai/schema/location"
import { NonNegativeInt, PositiveInt } from "@opencode-ai/schema/schema"
import { NonNegativeInt, PositiveInt, optional } from "@opencode-ai/schema/schema"
import { Vcs } from "@opencode-ai/schema/vcs"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
import { ServiceUnavailableError } from "../errors.js"
const BranchesQuery = Schema.Struct({
...LocationQuery.fields,
@@ -15,6 +16,7 @@ const BranchesQuery = Schema.Struct({
const DiffQuery = Schema.Struct({
...LocationQuery.fields,
mode: Vcs.Mode,
base: optional(Schema.String),
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional),
})
@@ -33,6 +35,22 @@ export const VcsGroup = HttpApiGroup.make("server.vcs")
}),
),
)
.add(
HttpApiEndpoint.get("vcs.base", "/api/vcs/base", {
query: LocationQuery,
success: Location.response(Schema.NullOr(Vcs.Base)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.vcs.base",
summary: "VCS review base",
description:
"Infer a local review base from named branch creation history, or the repository default only when currently on that branch. Returns null before the first commit or when the provider lacks base metadata; ambiguous Git history requires an explicit base on diff requests.",
}),
),
)
.add(
HttpApiEndpoint.get("vcs.status", "/api/vcs/status", {
query: LocationQuery,
@@ -65,6 +83,7 @@ export const VcsGroup = HttpApiGroup.make("server.vcs")
HttpApiEndpoint.get("vcs.diff", "/api/vcs/diff", {
query: DiffQuery,
success: Location.response(Schema.Array(FileDiff.Info)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
@@ -72,7 +91,7 @@ export const VcsGroup = HttpApiGroup.make("server.vcs")
identifier: "v2.vcs.diff",
summary: "VCS diff",
description:
"Diff the working copy against HEAD (mode git) or the default-branch merge base (mode branch) for the requested location.",
"Diff HEAD to the working copy (working), the base merge-base to the working copy (branch), or the base merge-base to HEAD (committed). Omitting base preserves repository-default comparison; supplying it overrides the comparison without saving it.",
}),
),
)
+1 -7
View File
@@ -59,11 +59,6 @@ export class Error extends Schema.TaggedError<Error>()("Tool.Error", {
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
/** A refused interaction, not a model-facing tool error or a shutdown interruption. */
export class Declined extends Schema.TaggedError<Declined>()("Tool.Declined", {
message: Schema.String,
}) {}
export interface TextContent extends Schema.Schema.Type<typeof TextContent> {}
export const TextContent = Schema.Struct({
type: Schema.Literal("text"),
@@ -96,8 +91,7 @@ export type Info<
readonly name: string
readonly input: Input
readonly description: string
/** Producer failures are classified by the host at execution, not at registration. */
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<Result<Output>, unknown>
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<Result<Output>, Error>
readonly output?: Output
readonly options?: Options
}
+8 -1
View File
@@ -17,7 +17,14 @@ export interface Info extends Schema.Schema.Type<typeof Info> {}
export const BranchList = Schema.Array(Schema.String).annotate({ identifier: "Vcs.BranchList" })
export type BranchList = typeof BranchList.Type
export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" })
export const Base = Schema.Struct({
name: Schema.String,
ref: Schema.String,
source: Schema.Literals(["reflog", "default"]),
}).annotate({ identifier: "Vcs.Base" })
export interface Base extends Schema.Schema.Type<typeof Base> {}
export const Mode = Schema.Literals(["working", "branch", "committed"]).annotate({ identifier: "Vcs.Mode" })
export type Mode = typeof Mode.Type
export const FileStatus = Schema.Struct({
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Vcs } from "../src/vcs.js"
test("review base preserves its stable identity and local provenance", () => {
expect(Vcs.Base.ast.annotations?.identifier).toBe("Vcs.Base")
const base = { name: "release", ref: "refs/heads/release" }
for (const source of ["reflog", "default"] as const) {
expect(Schema.encodeSync(Vcs.Base)(Schema.decodeUnknownSync(Vcs.Base)({ ...base, source }))).toEqual({
...base,
source,
})
}
expect(() => Schema.decodeUnknownSync(Vcs.Base)({ ...base, source: "configured" })).toThrow()
expect(() => Schema.decodeUnknownSync(Vcs.Base)({ ...base, source: "worktree" })).toThrow()
})
test("review modes preserve shipped working and combined branch names", () => {
for (const mode of ["working", "branch", "committed"] as const) {
expect(Schema.decodeUnknownSync(Vcs.Mode)(mode)).toBe(mode)
}
expect(() => Schema.decodeUnknownSync(Vcs.Mode)("unknown")).toThrow()
})
+1 -1
View File
@@ -1,3 +1,3 @@
export { RegistrationError } from "@opencode-ai/core/tool"
export { Declined, Error } from "@opencode-ai/schema/tool"
export { Error } from "@opencode-ai/schema/tool"
export type { Context, Info } from "@opencode-ai/schema/tool"
+1 -1
View File
@@ -1,3 +1,3 @@
export { RegistrationError } from "@opencode-ai/core/tool"
export { Declined, Error } from "@opencode-ai/schema/tool"
export { Error } from "@opencode-ai/schema/tool"
export type { ToolContext as Context, Info } from "@opencode-ai/plugin/promise/tool"
@@ -15,7 +15,6 @@ import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Tool } from "@opencode-ai/schema/tool"
import { Api } from "@opencode-ai/server/api"
import { ClientApi, groupNames, promiseOmitEndpoints } from "@opencode-ai/protocol/client"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
@@ -93,12 +92,6 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
expect(Workspace.ID.create()).toStartWith("wrk_")
})
test("both SDK tool facades expose the canonical decline", async () => {
const EffectSDK = await import("../src/effect/index")
expect(SDK.Tool.Declined).toBe(Tool.Declined)
expect(EffectSDK.Tool.Declined).toBe(Tool.Declined)
})
test("client and Server contracts generate identically", () => {
const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints })
const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
+21 -1
View File
@@ -1,8 +1,14 @@
import { Vcs } from "@opencode-ai/core/vcs"
import { ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
import { pluginReadiness } from "./plugin-readiness"
const flushPlugins = pluginReadiness(
() => new ServiceUnavailableError({ service: "vcs", message: "VCS initialization timed out" }),
)
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
Effect.gen(function* () {
@@ -15,6 +21,17 @@ export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
}),
),
)
.handle("vcs.base", () =>
response(
Effect.gen(function* () {
yield* flushPlugins
const vcs = yield* Vcs.Service
return yield* vcs
.base()
.pipe(Effect.mapError((error) => new ServiceUnavailableError({ service: "vcs", message: error.message })))
}),
),
)
.handle("vcs.status", () =>
response(
Effect.gen(function* () {
@@ -34,8 +51,11 @@ export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
.handle("vcs.diff", (ctx) =>
response(
Effect.gen(function* () {
yield* flushPlugins
const vcs = yield* Vcs.Service
return yield* vcs.diff(ctx.query.mode, { context: ctx.query.context })
return yield* vcs
.diff(ctx.query.mode, { context: ctx.query.context, base: ctx.query.base })
.pipe(Effect.mapError((error) => new ServiceUnavailableError({ service: "vcs", message: error.message })))
}),
),
)
+1
View File
@@ -95,6 +95,7 @@ const unavailable = (what: string) => Effect.die(new Error(`${what} is unavailab
const vcsLayer = Layer.succeed(
Vcs.Service,
Vcs.Service.of({
base: () => Effect.succeed(null),
transform: () => Effect.succeed({ dispose: Effect.void }),
reload: () => Effect.void,
info: () => Effect.succeed({ branch: {} }),
+123
View File
@@ -0,0 +1,123 @@
import path from "node:path"
import { $ } from "bun"
import { expect } from "bun:test"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Effect, Layer } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
import { ServerFetch } from "../src/fetch"
it.live(
"serves lazy review bases, committed diffs, and unavailable-base errors",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-endpoint-")))
yield* Effect.promise(async () => {
await $`git init -b main`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
await $`git config user.name Test`.cwd(tmp.path).quiet()
await Bun.write(path.join(tmp.path, "file.txt"), "base\n")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit -m initial`.cwd(tmp.path).quiet()
await $`git checkout -b feature main`.cwd(tmp.path).quiet()
await Bun.write(path.join(tmp.path, "file.txt"), "committed\n")
await $`git commit -am feature`.cwd(tmp.path).quiet()
await Bun.write(path.join(tmp.path, "file.txt"), "dirty\n")
})
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/base", server.base)
url.searchParams.set("location[directory]", tmp.path)
const base = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(base.status).toBe(200)
expect(yield* Effect.promise(() => base.json())).toMatchObject({
data: { name: "main", ref: "refs/heads/main", source: "reflog" },
})
yield* Effect.promise(() => $`git branch -m ambiguous`.cwd(tmp.path).quiet())
const ambiguous = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(ambiguous.status).toBe(503)
expect(yield* Effect.promise(() => ambiguous.json())).toMatchObject({
_tag: "ServiceUnavailableError",
message: "Choose a review base",
})
url.pathname = "/api/vcs/diff"
url.searchParams.set("mode", "committed")
url.searchParams.set("base", "refs/heads/main")
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(diff.status).toBe(200)
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
data: [{ file: "file.txt", patch: expect.stringContaining("-base\n+committed"), additions: 1, deletions: 1 }],
})
url.searchParams.set("base", "missing")
const unavailable = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(unavailable.status).toBe(503)
expect(yield* Effect.promise(() => unavailable.json())).toMatchObject({
_tag: "ServiceUnavailableError",
service: "vcs",
})
yield* Effect.promise(() => $`git branch -D main`.cwd(tmp.path).quiet())
url.searchParams.delete("base")
url.searchParams.set("mode", "branch")
const noBase = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(noBase.status).toBe(503)
expect(yield* Effect.promise(() => noBase.json())).toMatchObject({
_tag: "ServiceUnavailableError",
service: "vcs",
message: "No review base available",
})
}),
20_000,
)
it.live("maps a failing base provider to HTTP 503 instead of null metadata", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-failure-")))
const handler = yield* ServerFetch.make(
{ database: { path: ":memory:" }, config: { directory: tmp.path }, fs: { filewatcher: false } },
{
overrides: [
[
SdkPlugins.node,
Layer.succeed(
SdkPlugins.Service,
SdkPlugins.Service.of({
register: () => Effect.void,
all: () => [
{
id: "failing-vcs",
version: "test",
effect: (ctx) =>
ctx.vcs
.transform((draft) => {
draft.add({
id: "failing",
name: "Failing VCS",
info: () => Effect.succeed({ branch: {} }),
branches: () => Effect.succeed([]),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
base: () => Effect.fail(new Error("provider failure")),
})
draft.default.set("failing")
})
.pipe(Effect.asVoid),
},
],
}),
),
],
],
},
)
const url = new URL("http://opencode.local/api/vcs/base")
url.searchParams.set("location[directory]", tmp.path)
const response = yield* Effect.promise(() => handler(new Request(url)))
expect(response.status).toBe(503)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "ServiceUnavailableError",
service: "vcs",
message: "VCS provider could not resolve a review base",
})
}),
)
@@ -0,0 +1,39 @@
import { expect, story } from "../../storybook/playwright/story"
story("streams user shell output and retains the saved completion", async ({ mount }) => {
const root = await mount("current-session-terminal-work--live-user-command")
const shell = root.locator('[data-component="session-shell-message"]')
const output = shell.locator('[data-slot="bash-result"]')
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("printf ready")
await expect(output).toHaveText("ready\n")
await root.getByRole("button", { name: "Update output", exact: true }).click()
await expect(output).toHaveText("ready\nnext line\n")
await root.getByRole("button", { name: "Complete command", exact: true }).click()
await expect(output).toHaveText("ready\nnext line\nfinished\n")
await expect(shell.locator('[data-kind="tool-error-card"]')).toHaveCount(0)
})
for (const [outcome, error] of [
["nonzero", "Command exited with code 1"],
["timeout", "Command timed out"],
["killed", "Command cancelled"],
]) {
story(`shows a direct shell ${outcome} outcome even without output`, async ({ mount }) => {
const root = await mount("current-session-terminal-work--live-user-command", { args: { outcome, output: false } })
const shell = root.locator('[data-component="session-shell-message"]')
await root.getByRole("button", { name: "Complete command", exact: true }).click()
await expect(shell.locator('[data-kind="tool-error-card"]')).toContainText(error)
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("printf ready")
await expect(shell.locator('[data-slot="bash-result"]')).toHaveCount(0)
})
}
for (const [id, output] of [
["running-a-user-command", "Starting Storybook manager...\nBuilding preview..."],
["user-command-completed", " M packages/session-ui/src/timeline/session-timeline.tsx"],
]) {
story(`keeps captured output without a live reader: ${id}`, async ({ mount }) => {
const root = await mount(`current-session-terminal-work--${id}`)
await expect(root.locator('[data-component="session-shell-message"] [data-slot="bash-result"]')).toHaveText(output)
})
}
@@ -53,6 +53,13 @@ describe("current session timeline rows", () => {
output: { output: "/repo", cursor: 5, size: 5, truncated: false },
time: { created: 1, completed: 2 },
},
{
id: "msg_shell_completion",
type: "synthetic",
text: "User shell pwd completed: /repo",
metadata: { source: "shell", shellID: "shell_1", state: "completed" },
time: { created: 3 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" })
@@ -1,6 +1,8 @@
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import type { SessionMessageAssistant, SessionMessageShell } from "@opencode-ai/client/promise"
import { createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { DataProvider } from "../context/data"
import { SessionShellMessage } from "../tools/tool-renderer"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
executeCodeDocument,
@@ -66,6 +68,68 @@ export const UserCommandCompleted = {
),
}
export const LiveUserCommand = {
args: { outcome: "exited", output: true },
argTypes: { outcome: { control: "select", options: ["exited", "nonzero", "timeout", "killed"] } },
render: (args: { outcome: "exited" | "nonzero" | "timeout" | "killed"; output: boolean }) => {
const [message, setMessage] = createSignal<SessionMessageShell>({
id: "msg_shell_live",
type: "shell",
shellID: "shell_live",
command: "printf ready",
status: "running",
time: { created: 1 },
})
let output = args.output ? "ready\n" : ""
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<button type="button" onClick={() => (output += "next line\n")}>
Update output
</button>
<button
type="button"
onClick={() => {
if (args.output) output += "finished\n"
setMessage((value) => ({
...value,
status: args.outcome === "nonzero" ? "exited" : args.outcome,
exit: args.outcome === "nonzero" ? 1 : args.outcome === "exited" ? 0 : undefined,
output: { output, cursor: output.length, size: output.length, truncated: false },
time: { created: 1, completed: 2 },
}))
}}
>
Complete command
</button>
<DataProvider
directory="/workspace"
data={{ session: [], session_status: {}, session_diff: {} }}
shellOutput={async (input) => {
if (message().status !== "running") throw new Error("Shell output unavailable")
if (input.id !== "shell_live" || input.location?.directory !== "/workspace") {
throw new Error("Unexpected shell output request")
}
return {
location: {
directory: "/workspace",
project: { id: "project_shell", directory: "/workspace", canonical: "/workspace" },
},
data: {
output: output.slice(input.cursor ?? 0),
cursor: output.length,
size: output.length,
truncated: false,
},
}
}}
>
<SessionShellMessage message={message()} defaultOpen />
</DataProvider>
</section>
)
},
}
const CollapsedShell = {
render: () => (
<CurrentSessionTimelineStory
@@ -1475,7 +1475,14 @@ ToolRegistry.register({
(typeof props.metadata.shellID === "string" && data.shellRunning?.(props.metadata.shellID) === true)
const sawStreaming = streaming()
const [streamed, setStreamed] = createSignal("")
// Direct-user terminal snapshots are authoritative; agent results can describe background shells.
const saved = createMemo(() =>
props.metadata.status === "exited" || props.metadata.status === "timeout" || props.metadata.status === "killed"
? props.output
: undefined,
)
createEffect(() => {
if (saved() !== undefined) return
const id = props.metadata.shellID
const shellOutput = data.shellOutput
if (typeof id !== "string" || !shellOutput) return
@@ -1511,7 +1518,7 @@ ToolRegistry.register({
return ""
}
const output = createMemo(() =>
stripAnsi((typeof props.metadata.shellID === "string" && streamed()) || props.output || "").replace(
stripAnsi(saved() ?? ((typeof props.metadata.shellID === "string" && streamed()) || props.output || "")).replace(
/\r\n?/g,
"\n",
),
@@ -1560,7 +1567,15 @@ export function SessionShellMessage(props: {
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const i18n = useI18n()
const render = ToolRegistry.render("shell") ?? GenericTool
const error = createMemo(() => {
const message = props.message
if (message.status === "timeout") return i18n.t("ui.tool.shell.timeout")
if (message.status === "killed") return i18n.t("ui.tool.shell.cancelled")
if (message.status !== "exited" || message.exit === undefined || message.exit === 0) return
return i18n.t("ui.tool.shell.exit", { code: message.exit })
})
return (
<div data-component="session-shell-message" data-timeline-part-id={props.message.id}>
<Dynamic
@@ -1568,6 +1583,7 @@ export function SessionShellMessage(props: {
tool="shell"
input={{ command: props.message.command }}
metadata={{
shellID: props.message.shellID,
status: props.message.status,
exit: props.message.exit,
truncated: props.message.output?.truncated,
@@ -1578,6 +1594,9 @@ export function SessionShellMessage(props: {
open={props.open}
onOpenChange={props.onOpenChange}
/>
<Show when={error()}>
{(error) => <ToolErrorCard tool="shell" error={error()} subtitle={props.message.command} />}
</Show>
</div>
)
}
+4
View File
@@ -1,6 +1,7 @@
export * as Config from "."
import { createBindingLookup } from "@opentui/keymap/extras"
import { Vcs } from "@opencode-ai/schema/vcs"
import { Schema } from "effect"
import { createContext, onCleanup, type JSX, useContext } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
@@ -90,6 +91,9 @@ export const Info = Schema.Struct({
).annotate({ description: "System notification and sound settings" }),
diffs: Schema.optional(
Schema.Struct({
source: Schema.optional(Vcs.Mode).annotate({
description: "Initial diff source; defaults to 'branch' (branch and uncommitted changes)",
}),
wrap: Schema.optional(Schema.Literals(["word", "none"])).annotate({
description: "Line wrapping behavior in diff output",
}),
@@ -21,6 +21,7 @@ export type DiffViewerFileTreeProps = {
readonly onRowClick?: (row: FileTreeRow) => void
readonly onFileContextMenu?: (fileIndex: number, event: MouseEvent) => void
readonly source?: string
readonly sourceDetail?: string
readonly onSwitchSource?: () => void
readonly footer?: JSX.Element
}
@@ -65,21 +66,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
paddingRight={2}
backgroundColor={theme.background.default}
>
<box height={1} flexShrink={0} flexDirection="row" marginBottom={1} gap={1}>
<text
<box id="diff-source-header" height={1} flexShrink={0} flexDirection="row" marginBottom={1} gap={1}>
<box
id="diff-source-switch"
fg={
props.onSwitchSource
? sourceHovered()
? theme.text.action.secondary.hovered
: theme.text.action.secondary.default
: theme.text.default
}
attributes={TextAttributes.BOLD}
flexDirection="row"
flexGrow={1}
wrapMode="none"
truncate
selectable={false}
minWidth={0}
onMouseOver={() => setSourceHovered(true)}
onMouseOut={() => setSourceHovered(false)}
onMouseUp={(event) => {
@@ -88,10 +80,30 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
props.onSwitchSource?.()
}}
>
{props.source ?? "Files"}
</text>
<text fg={theme.text.subdued} wrapMode="none" flexShrink={0}>
{reviewedCount()}/{props.files.length} reviewed
<text
fg={
props.onSwitchSource
? sourceHovered()
? theme.text.action.secondary.hovered
: theme.text.action.secondary.default
: theme.text.default
}
attributes={TextAttributes.BOLD}
flexShrink={0}
wrapMode="none"
selectable={false}
>
{props.source ?? "Files"}
</text>
<Show when={props.sourceDetail}>
<text fg={theme.text.subdued} selectable={false} flexGrow={1} minWidth={0} wrapMode="none" truncate>
{` · ${props.sourceDetail}`}
</text>
</Show>
</box>
<text id="diff-review-count" fg={theme.text.subdued} wrapMode="none" flexShrink={0}>
{reviewedCount()}/{props.files.length}
{props.source ? "" : " reviewed"}
</text>
</box>
<scrollbox
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import type { FileDiffInfo } from "@opencode-ai/client"
import type { FileDiffInfo, LocationRef } from "@opencode-ai/client"
import type { Vcs } from "@opencode-ai/schema/vcs"
import { Plugin } from "@opencode-ai/plugin/tui"
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
import {
@@ -18,7 +19,9 @@ import { DialogSelect } from "../../ui/dialog-select"
import { EmptyBorder } from "../../ui/border"
import { FilePath } from "../../ui/file-path"
import { getScrollAcceleration } from "../../util/scroll"
import { createDebouncedSignal } from "../../util/signal"
import { useConfig } from "../../config"
import { locationKey } from "../../context/data"
import { useThemes } from "../../context/theme"
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
import {
@@ -40,7 +43,7 @@ const FILE_TREE_MIN_WIDTH = 30
const FILE_TREE_MAX_WIDTH = 40
const FILE_HEADER_HEIGHT = 2
const VCS_DIFF_CONTEXT_LINES = 12
type DiffMode = "working" | "branch"
type DiffMode = Vcs.Mode
type DiffView = "split" | "unified"
type SelectedHunk = { readonly fileIndex: number; readonly hunkIndex: number; readonly scrollTop: number }
type FileMenuState = { readonly fileIndex: number; readonly x: number; readonly y: number }
@@ -67,14 +70,18 @@ function storedView(value: unknown): DiffView | undefined {
}
function diffSourceLabel(mode: DiffMode) {
if (mode === "branch") return "Main branch"
return "Working tree"
if (mode === "branch") return "All"
if (mode === "committed") return "Committed"
return "Uncommitted"
}
function DiffViewer(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const config = useConfig()
const dialog = props.context.ui.dialog
const [memory, updateMemory] = props.context.storage.memory<{
source?: DiffMode
bases: Record<string, string>
}>("review", { initial: { bases: {} } })
const params = () => {
const route = props.context.ui.router.current()
return (route.type === "plugin" ? route.data : undefined) as
@@ -85,38 +92,85 @@ function DiffViewer(props: { context: Plugin.Context }) {
}
| undefined
}
const mode = () => params()?.mode ?? "working"
const diffInput = createMemo(() => {
const sessionID = params()?.sessionID
return {
mode: mode(),
sessionID,
location: sessionID
const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch")
const location = createMemo(
() => {
const sessionID = params()?.sessionID
return sessionID
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
: props.context.data.location.default(),
}
})
: props.context.data.location.default()
},
undefined,
{ equals: (a, b) => a.directory === b.directory && a.workspaceID === b.workspaceID },
)
const baseKey = createMemo(() =>
JSON.stringify([locationKey(location()), props.context.data.location.vcs.info(location())?.branch.current]),
)
const selectedBase = () => memory.bases[baseKey()]
// Mode changes share the same lazy base lookup until this viewer is closed.
const bases = new Map<string, ReturnType<Plugin.Context["client"]["vcs"]["base"]>>()
const [reportedBases, setReportedBases] = createSignal<ReadonlyMap<string, Vcs.Base | null>>(new Map())
const loadBase = (location: LocationRef, key: string) => {
const cached = bases.get(key)
if (cached) return cached
const pending = props.context.client.vcs.base({ location }).then((result) => {
setReportedBases((known) => new Map(known).set(key, result.data))
return result
})
bases.set(key, pending)
return pending
}
const diffInput = createMemo(() => ({
mode: mode(),
location: location(),
key: baseKey(),
selected: mode() === "working" ? undefined : selectedBase(),
}))
const [diff] = createResource(diffInput, async (input) => {
const base =
input.mode === "working"
? undefined
: input.selected
? { name: input.selected, ref: input.selected }
: (await loadBase(input.location, input.key)).data
if (input !== diffInput() || (input.mode === "committed" && !base)) {
return { base: null, files: [] }
}
const result = await props.context.client.vcs.diff({
location: input.location,
mode: input.mode,
...(base ? { base: base.ref } : {}),
context: VCS_DIFF_CONTEXT_LINES,
})
return normalizeDiffs(result.data ?? [])
return { base, files: normalizeDiffs(result.data ?? []) }
})
const sourceBase = () => {
const ref = selectedBase()
return ref ? { name: ref, ref } : reportedBases().get(baseKey())
}
const result = () => (diff.error || diff.loading ? undefined : diff())
const sourceDetail = () => {
if (mode() === "working") return "vs HEAD"
if (diff.error) return "Base or diff unavailable"
if (!result()) return "Resolving diff..."
const base = result()?.base
if (!base) return "Base not reported"
return `vs ${base.name}`
}
return (
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
<DiffViewerContent
context={props.context}
files={diff.error ? [] : (diff() ?? [])}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={config.data.diffs}
loadImage={(file, signal) =>
props.context.client.file.read({ path: file, location: diffInput().location }, { signal })
}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
@@ -126,18 +180,78 @@ function DiffViewer(props: { context: Plugin.Context }) {
}}
onClose={() => props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })}
onSwitchSource={(mode) => {
dialog.clear()
props.context.ui.router.navigate({
type: "plugin",
name: ROUTE,
data: { mode, sessionID: params()?.sessionID, returnRoute: params()?.returnRoute },
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
/>
</box>
)
}
function DiffBaseDialog(props: {
context: Plugin.Context
location: LocationRef
current?: string
onSelect: (ref: string) => void
}) {
const theme = props.context.theme.contextual.elevated
const [search, setSearch] = createDebouncedSignal("", 150)
const [branches] = createResource(search, (search) =>
props.context.client.vcs.branches({ location: props.location, search, limit: 100 }),
)
const Empty = () => (
<box paddingLeft={4} paddingRight={4}>
<text fg={branches.error ? theme.text.feedback.error.default : theme.text.subdued}>
{branches.loading
? "Loading branches..."
: branches.error
? "Could not load branches. Reopen the picker to try again."
: "No branches found"}
</text>
</box>
)
return (
<DialogSelect
title="Base branch"
placeholder="Search local and remote branches"
skipFilter
current={props.current?.replace(/^refs\/(heads|remotes)\//, "")}
onFilter={setSearch}
emptyView={<Empty />}
noMatchView={<Empty />}
footer={<text fg={theme.text.subdued}>Remembered until the TUI exits</text>}
options={(branches.loading || branches.error ? [] : (branches()?.data ?? [])).map((name) => ({
title: name,
value: name,
onSelect() {
props.onSelect(name)
props.context.ui.dialog.clear()
},
}))}
/>
)
}
type DiffPreferences = { tree?: boolean; single?: boolean; view?: "auto" | DiffView }
export function DiffViewerContent(props: {
@@ -146,12 +260,16 @@ export function DiffViewerContent(props: {
loading?: boolean
error?: unknown
mode: DiffMode
sourceDetail?: string
sourceBase?: Pick<Vcs.Base, "name" | "ref"> | null
unavailable?: boolean
navigation?: "tree" | "list"
loadImage?: (file: string, signal: AbortSignal) => Promise<Uint8Array>
preferences?: DiffPreferences
onPreferencesChange?: (value: DiffPreferences) => void
onClose: () => void
onSwitchSource: (mode: DiffMode) => void
onChooseBase?: () => void
}) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
@@ -585,29 +703,46 @@ export function DiffViewerContent(props: {
const openSwitchDiffDialog = () => {
const options = [
{
title: "Working tree",
value: "working" as const,
description: "Show current git changes",
value: "branch" as const,
description: "Branch + local changes",
},
{
title: "Main branch",
value: "branch" as const,
description: "Show changes compared to main branch",
value: "committed" as const,
description: "Branch commits only",
},
{
value: "working" as const,
description: "Local changes only",
},
]
dialog.show(() => (
<DialogSelect
title="Switch source"
<DialogSelect<DiffMode | "base">
title="Diff source"
skipFilter={true}
renderFilter={false}
current={mode()}
options={options.map((option) => ({
...option,
onSelect() {
dialog.clear()
props.onSwitchSource(option.value)
},
}))}
options={[
...options.map((option) => ({
...option,
title: diffSourceLabel(option.value),
titleView: diffSourceLabel(option.value).padEnd(11),
onSelect() {
dialog.clear()
props.onSwitchSource(option.value)
},
})),
...(props.onChooseBase
? [
{
title: "Base",
titleView: "Base".padEnd(11),
value: "base" as const,
description: props.sourceBase?.name ?? "Choose...",
onSelect: props.onChooseBase,
},
]
: []),
]}
/>
))
}
@@ -647,6 +782,47 @@ export function DiffViewerContent(props: {
return (
<box width="100%" height="100%" backgroundColor={theme.background.default}>
<Show when={!showFileTree()}>
<box
id="diff-source-header"
paddingLeft={2}
paddingRight={2}
height={1}
flexShrink={0}
flexDirection="row"
gap={1}
>
<box
id="diff-source-switch"
flexDirection="row"
flexGrow={1}
minWidth={0}
onMouseUp={(event) => {
if (event.button !== MouseButton.LEFT) return
event.stopPropagation()
openSwitchDiffDialog()
}}
>
<text
fg={theme.text.action.secondary.default}
attributes={TextAttributes.BOLD}
selectable={false}
flexShrink={0}
wrapMode="none"
>
{diffSourceLabel(mode())}
</text>
<Show when={props.sourceDetail}>
<text fg={theme.text.subdued} selectable={false} flexGrow={1} minWidth={0} wrapMode="none" truncate>
{` · ${props.sourceDetail}`}
</text>
</Show>
</box>
<text id="diff-review-count" fg={theme.text.subdued} flexShrink={0} wrapMode="none">
{files().filter((file) => reviewedFileNames().has(file.file)).length}/{files().length}
</text>
</box>
</Show>
<box flexGrow={1} minHeight={0}>
<Switch>
<Match when={props.loading}>
@@ -657,7 +833,16 @@ export function DiffViewerContent(props: {
<Match when={!props.loading && props.error}>
<box flexGrow={1} padding={2}>
<text fg={theme.text.feedback.error.default}>
Could not load diff. Reopen the diff viewer to try again.
{!props.sourceBase && mode() !== "working"
? "Could not load diff. Choose a base branch from Diff source, or select Uncommitted."
: "Could not load diff. Reopen the diff viewer to try again."}
</text>
</box>
</Match>
<Match when={!props.loading && props.unavailable}>
<box flexGrow={1} padding={2}>
<text fg={theme.text.subdued}>
Committed comparison unavailable without base metadata. Choose a base branch from Diff source.
</text>
</box>
</Match>
@@ -683,6 +868,7 @@ export function DiffViewerContent(props: {
onRowClick={clickFileTreeRow}
onFileContextMenu={openFileMenu}
source={diffSourceLabel(mode())}
sourceDetail={props.sourceDetail}
onSwitchSource={openSwitchDiffDialog}
footer={<HelpShortcut />}
/>
@@ -810,17 +996,26 @@ export function DiffViewerContent(props: {
fallback={
<box width="100%" flexShrink={0} paddingLeft={1} paddingRight={1} paddingBottom={1}>
<text fg={theme.text.subdued}>
{entry.file.status === "deleted" && image()
? "Deleted image. The previous revision is not available for preview."
: "No patch available for this file."}
{mode() === "committed" && image()
? "Committed image preview unavailable. The working-tree image is not shown."
: entry.file.status === "deleted" && image()
? "Deleted image. The previous revision is not available for preview."
: "No patch available for this file."}
</text>
</box>
}
>
<Match when={entry.file.status !== "deleted" && image() && props.loadImage}>
<Match
when={
mode() !== "committed" &&
entry.file.status !== "deleted" &&
image() &&
props.loadImage
}
>
{(load) => <DiffViewerImage file={entry.file.file} load={load()} />}
</Match>
<Match when={entry.file.patch}>
<Match when={!(mode() === "committed" && image()) && entry.file.patch}>
{(patch) => (
<PatchDiff
ref={(component) => {
@@ -862,7 +1057,7 @@ export function DiffViewerContent(props: {
</Switch>
</box>
<Show when={!showFileTree()}>
<box position="absolute" top={1} right={0} width={1} height={1}>
<box position="absolute" top={0} right={0} width={1} height={1}>
<HelpShortcut compact />
</box>
</Show>
@@ -1074,7 +1269,6 @@ function Commands(props: { context: Plugin.Context }) {
type: "plugin",
name: ROUTE,
data: {
mode: "working",
sessionID: route.type === "session" ? route.sessionID : undefined,
returnRoute,
},
+60 -40
View File
@@ -2329,26 +2329,21 @@ function RevertMessage(props: {
}
function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "shell" }> }) {
const theme = useTheme("elevated")
const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? ""))
const error = createMemo(() => {
if (props.message.status === "killed") return "Command cancelled"
if (props.message.status === "timeout") return "Command timed out"
if (props.message.exit !== undefined && props.message.exit !== 0)
return `Command exited with code ${props.message.exit}`
})
return (
<box
width="100%"
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
gap={1}
backgroundColor={theme.background.default}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.background.default}
>
<text fg={theme.text.default}>$ {props.message.command}</text>
<Show when={output()}>
<text fg={theme.text.subdued}>{output()}</text>
</Show>
</box>
<ShellDisplay
shellID={props.message.shellID}
command={props.message.command}
status={props.message.status === "running" ? "running" : "completed"}
output={props.message.output?.output}
error={error()}
/>
)
}
@@ -3091,6 +3086,7 @@ type BlockToolProps = {
onClick?: () => void
part?: SessionMessageAssistantTool
spinner?: boolean
error?: string
}
function BlockTool(props: BlockToolProps) {
@@ -3107,7 +3103,9 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
const ctx = use()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined))
const error = createMemo(
() => props.error ?? (props.part?.state.status === "error" ? props.part.state.error.message : undefined),
)
const permission = useToolPermission(() => props.part)
return (
<box
@@ -3178,22 +3176,44 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
const SHELL_DISPLAY_LIMIT = 1024 * 1024
function Shell(props: ToolProps) {
return (
<ShellDisplay
part={props.part}
shellID={stringValue(props.metadata.shellID)}
command={stringValue(props.input.command)}
workdir={stringValue(props.input.workdir)}
status={props.part.state.status}
background={Boolean(stringValue(props.metadata.shellID)) && props.part.state.status !== "running"}
output={stringValue(props.metadata.shellID) ? undefined : props.output}
/>
)
}
function ShellDisplay(props: {
part?: SessionMessageAssistantTool
shellID?: string
command?: string
workdir?: string
status: SessionMessageAssistantTool["state"]["status"]
background?: boolean
output?: string
error?: string
}) {
const theme = useTheme()
const ctx = use()
const client = useClient()
const data = useData()
const pathFormatter = usePathFormatter()
// A Session can move while its shell is still running in the original Location.
const location = data.shell.get(props.shellID ?? "")?.location ?? data.session.get(ctx.sessionID)?.location
const permission = useToolPermission(() => props.part)
const color = createMemo(() => (permission() ? theme.text.feedback.warning.default : theme.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()
const id = props.shellID
return Boolean(id && data.shell.get(id))
})
const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning())
const command = createMemo(() => stringValue(props.input.command))
const workdir = createMemo(() => pathFormatter.format(stringValue(props.input.workdir)))
const isRunning = createMemo(() => props.status === "running" || backgroundRunning())
const workdir = createMemo(() => pathFormatter.format(props.workdir))
const [expanded, setExpanded] = createSignal(false)
const [backgroundOutput, setBackgroundOutput] = createSignal("")
const [outputTruncated, setOutputTruncated] = createSignal(false)
@@ -3202,14 +3222,14 @@ function Shell(props: ToolProps) {
let cursor = 0
let wasRunning = false
const loadBackgroundOutput = async (drain = false) => {
const id = shellID()
if (props.status === "completed" && props.output !== undefined) return
const id = props.shellID
if (!id) return
if (loading) {
if (drain) drainRequested = true
return
}
loading = true
const location = data.session.get(ctx.sessionID)?.location
do {
const response = await client.api.shell
.output({
@@ -3250,25 +3270,25 @@ function Shell(props: ToolProps) {
return
}
wasRunning = true
if (background() && !expanded()) return
if (props.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()) {
if (background() && !expanded()) return ""
const text = backgroundOutput().trim()
if (props.status === "streaming") return ""
if (props.shellID) {
if (props.background && !expanded()) return ""
if (props.status === "completed" && props.output !== undefined) return stripAnsi(props.output.trim())
const text = stripAnsi((backgroundOutput() || props.output || "").trim())
return outputTruncated() ? `[earlier output omitted]\n${text}` : text
}
const content = toolDisplayContent(props.part.state)[0]
return stripAnsi(content?.type === "text" ? content.text.trim() : "")
return stripAnsi(props.output?.trim() ?? "")
})
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const prefix = createMemo(() => (workdir() && workdir() !== "." ? `cd ${workdir()} && ` : ""))
const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${prefix()}${command()}` : ""))
const input = createMemo(() => (props.command ? `${isRunning() ? "" : "$ "}${prefix()}${props.command}` : ""))
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
const limited = createMemo(() => {
@@ -3277,7 +3297,7 @@ function Shell(props: ToolProps) {
})
const limitedInput = createMemo(() => limited().slice(0, input().length))
const limitedOutput = createMemo(() => limited().slice(Math.min(limited().length, input().length + 2)))
const expandable = createMemo(() => Boolean(shellID()) || collapsed().overflow)
const expandable = createMemo(() => Boolean(props.shellID) || collapsed().overflow)
const toggle = () => {
const next = !expanded()
setExpanded(next)
@@ -3285,12 +3305,12 @@ function Shell(props: ToolProps) {
}
return (
<BlockTool part={props.part} onClick={expandable() ? toggle : undefined}>
<BlockTool part={props.part} error={props.error} onClick={expandable() ? toggle : undefined}>
<box gap={1}>
<Show
when={command()}
when={props.command}
fallback={
isRunning() || props.part.state.status === "streaming" ? (
isRunning() || props.status === "streaming" ? (
<Spinner color={color()}>Writing command…</Spinner>
) : (
<text fg={theme.text.subdued}>Writing command…</text>
@@ -3304,7 +3324,7 @@ function Shell(props: ToolProps) {
<text fg={theme.text.subdued}>{limitedOutput()}</text>
</Show>
</Show>
<Show when={background()}>
<Show when={props.background}>
<StatusBadge>Background</StatusBadge>
</Show>
</box>
+122
View File
@@ -867,6 +867,128 @@ test("shows jump to latest after scrolling one line above the final message", as
}
})
test("completed user shell output replaces a partial live read when the final read fails", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const events = createEventStream()
const session = {
id: "ses_shell_output",
title: "Shell output fixture",
projectID: "proj_test",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const shell = {
id: "sh_output",
command: "echo shell-output-fixture",
status: "running" as const,
cwd: directory,
shell: "/bin/sh",
file: `${directory}/shell.out`,
metadata: { sessionID: session.id, background: true },
time: { started: 1 },
}
const partial = "first live output\n"
const completed = `${partial}last persisted output\n`
let finished = false
let failedReads = 0
const calls = createFetch((url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: [
{
id: "msg_shell_output",
type: "shell",
shellID: shell.id,
command: shell.command,
status: "running",
metadata: { background: true },
time: { created: 1 },
},
],
cursor: {},
})
if (url.pathname === `/api/session/${session.id}/inbox` || url.pathname === `/api/session/${session.id}/permission`)
return json({ data: [] })
if (url.pathname === "/api/shell") return json({ location: { directory }, data: finished ? [] : [shell] })
if (url.pathname === `/api/shell/${shell.id}/output`) {
if (finished) {
failedReads++
return json({ message: "Shell output unavailable" }, { status: 404 })
}
return json({
location: { directory },
data: {
output: partial.slice(Number(url.searchParams.get("cursor") ?? 0)),
cursor: partial.length,
size: partial.length,
truncated: false,
},
})
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ animations: false, tabs: { enabled: false }, session: { sidebar: "hide" } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: session.id },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
const running = await setup.waitForFrame((frame) => frame.includes(partial.trim()))
expect(running).toContain(shell.command)
expect(running).not.toContain("Background")
finished = true
events.emit({
id: "evt_shell_exited",
created: 2,
type: "shell.exited",
location: { directory },
data: { id: shell.id, status: "exited", exit: 0 },
})
await setup.waitFor(() => failedReads > 0)
events.emit({
id: "evt_shell_ended",
created: 3,
type: "session.shell.ended",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: {
sessionID: session.id,
shell: { ...shell, status: "exited", exit: 0, time: { started: 1, completed: 2 } },
output: { output: completed, cursor: completed.length, size: completed.length, truncated: false },
},
})
const terminal = await setup.waitForFrame((frame) => frame.includes("last persisted output"))
expect(terminal).toContain(partial.trim())
expect(terminal).toContain(`$ ${shell.command}`)
expect(terminal).not.toContain("Background")
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test("new session inherits the active session model", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, kittyKeyboard: true })
setup.renderer.start()
File diff suppressed because it is too large Load Diff
+14 -4
View File
@@ -152,9 +152,7 @@ test("assigns stable IDs to tool rows for direct navigation", () => {
{ type: "text", text: "Starting a shell" },
{ type: "tool", id: "shell-1", name: "shell", state: pending(), time: { created: 2 } },
]),
assistant("assistant-2", [
{ type: "tool", id: "shell-2", name: "shell", state: pending(), time: { created: 3 } },
]),
assistant("assistant-2", [{ type: "tool", id: "shell-2", name: "shell", state: pending(), time: { created: 3 } }]),
]
const rows = reduceSessionRows(messages)
const boundaries = messageBoundaryIDs(rows, messages)
@@ -408,17 +406,28 @@ test("completes exploration groups when another row follows", () => {
test("hides synthetic messages without descriptions", () => {
const messages: SessionMessageInfo[] = [
{
id: "shell-message",
type: "shell",
shellID: "sh_user",
command: "pwd",
status: "exited",
time: { created: 0 },
},
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
{
type: "synthetic",
id: "synthetic-1",
text: "internal context",
metadata: { source: "shell", shellID: "sh_user", state: "completed" },
time: { created: 2 },
},
assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
]
expect(reduceSessionRows(messages)).toEqual([
const rows = reduceSessionRows(messages)
expect(rows).toEqual([
{ type: "message", messageID: "shell-message" },
{
type: "group",
kind: "exploration",
@@ -430,6 +439,7 @@ test("hides synthetic messages without descriptions", () => {
],
},
])
expect(reduceSessionRows(messages, new Set(["synthetic-1"]))).toEqual(rows)
})
test("renders synthetic messages with descriptions", () => {

Some files were not shown because too many files have changed in this diff Show More