mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-29 11:54:42 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ab166579f | |||
| 370acf9070 | |||
| e3bda5e2d0 | |||
| ad9117b107 | |||
| 6b7a1d419c |
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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}` }
|
||||
}
|
||||
@@ -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") {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)}` }),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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}`]
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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" })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "" },
|
||||
])
|
||||
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))))
|
||||
)
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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,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 })
|
||||
|
||||
@@ -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 })))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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: {} }),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user