mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-02 13:36:22 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ddb0cb1d3 | ||
|
|
f3a23e52b3 | ||
|
|
dbe08d2ac3 | ||
|
|
77eceb8c85 | ||
|
|
e2a700dc90 | ||
|
|
ebad4bc84c | ||
|
|
d82b75f090 | ||
|
|
134d0ede4d | ||
|
|
4a2c024ea7 | ||
|
|
4cb56a829e | ||
|
|
41516c78c8 |
No files matched your search
@@ -133,6 +133,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-g3k0cAFGqzmRYlcIkg1NDvlx1WxHYhnYPL0/a8E+qTg=",
|
||||
"aarch64-linux": "sha256-a+3ymqdxOONGe2Tpq4GUccl1b+Dwzxlb9LFXgE1gZ+0=",
|
||||
"aarch64-darwin": "sha256-h8xIzuMmaWfJqjHCO74xUDCWNKQLFrIGoKYZ+2TauYc="
|
||||
"x86_64-linux": "sha256-EEBz2IQ14YPShAzY13zpZ7btq/EuSw86pZ6nrizc9BI=",
|
||||
"aarch64-linux": "sha256-X7om4U4OlKL4OddEMcyKL+1DCQXc9/f1xzzopE16Zmk=",
|
||||
"aarch64-darwin": "sha256-3bejEuX3AGv4SR/16O8KjNSO2t9WDaWCHRbBXsJ6z8A="
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,29 @@ import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
for (const view of ["desktop", "mobile"] as const) {
|
||||
test(`offers Git initialization for a project without VCS (${view})`, async ({ page }) => {
|
||||
if (view === "mobile") await page.setViewportSize({ width: 390, height: 844 })
|
||||
const requests: { directory: string; provider?: string }[] = []
|
||||
const workspace = await openSession(page, {
|
||||
name: "ReviewWithoutGit",
|
||||
project: { vcs: undefined },
|
||||
onVcsInit: (input) => requests.push(input),
|
||||
})
|
||||
if (view === "desktop") await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
else await page.getByRole("tablist", { name: "Session view" }).getByRole("tab", { name: "Changes" }).click()
|
||||
|
||||
const panel = view === "desktop" ? page.locator("#review-panel") : page.locator('[data-component="session-review"]')
|
||||
await expect(panel.getByText("Track, review, and undo changes in this project")).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
|
||||
const init = panel.getByRole("button", { name: "Create Git repository" })
|
||||
await init.click()
|
||||
await expect.poll(() => requests).toEqual([{ directory: workspace.directory, provider: "git" }])
|
||||
await expect(panel.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await expect(init).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test("open file tab browses, searches, and tracks missing files", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
const directory = "C:/OpenCode/ReviewOpenFile"
|
||||
|
||||
@@ -200,9 +200,11 @@ test("terminal stacks under review by default and spans the bottom when configur
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const review = document.querySelector("#review-panel")!.getBoundingClientRect()
|
||||
const terminal = document.querySelector("#terminal-panel")!.getBoundingClientRect()
|
||||
const sidebar = document.querySelector('#review-panel [data-slot="session-review-v2-sidebar"]')!
|
||||
const review = document.querySelector("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector("#terminal-panel")?.getBoundingClientRect()
|
||||
const sidebar = document.querySelector('#review-panel [data-slot="session-review-v2-sidebar"]')
|
||||
// The side region appears before its lazy review content after reload.
|
||||
if (!review || !terminal || !sidebar) return false
|
||||
const gap = terminal.top - review.bottom
|
||||
return (
|
||||
terminal.left <= 9 &&
|
||||
|
||||
@@ -169,6 +169,7 @@ const Group = HttpApiGroup.make("mock")
|
||||
)
|
||||
.add(HttpApiEndpoint.get("formRequests", "/api/form", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
|
||||
.add(HttpApiEndpoint.post("vcsInit", "/api/vcs/init", { success: NoContent, error: Unsupported }))
|
||||
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branch", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { query: Query, success: Json }))
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface MockServerConfig {
|
||||
cursor?: string
|
||||
}
|
||||
vcs?: { current: string; default: string }
|
||||
// Initializes the mock project's VCS; without a handler this mutation answers 501.
|
||||
onVcsInit?: (input: { directory: string; provider?: string }) => void
|
||||
vcsDiff?: unknown[] | ((input: { mode?: string }) => unknown[])
|
||||
// Benchmark latency only. Tests hold message pages with `beforeMessagesResponse`.
|
||||
messageDelay?: number
|
||||
@@ -817,6 +819,24 @@ function mockHandlers(
|
||||
location: location(config),
|
||||
data: { branch: config.vcs ?? { current: "main", default: "main" } },
|
||||
}),
|
||||
vcsInit: (ctx) => {
|
||||
if (!config.onVcsInit) return unsupported("initialize VCS", "onVcsInit")
|
||||
return Effect.sync(() => {
|
||||
const url = new URL(ctx.request.url, "http://localhost")
|
||||
const provider = url.searchParams.get("provider") ?? undefined
|
||||
config.onVcsInit?.({ directory: requestDirectory(config, ctx.request), provider })
|
||||
const project = config.project as { id: string; vcs?: string }
|
||||
project.vcs = provider ?? "git"
|
||||
state.emit([
|
||||
{
|
||||
id: "evt_vcs_initialized",
|
||||
type: "worktree.updated",
|
||||
created: Date.now(),
|
||||
data: { projectID: project.id },
|
||||
},
|
||||
])
|
||||
}).pipe(Effect.andThen(noContent))
|
||||
},
|
||||
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
vcsBranches: () => Effect.succeed({ location: location(config), data: ["main"] }),
|
||||
vcsDiff: (ctx) =>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
|
||||
@@ -115,7 +115,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
const acp = ACPConnection.make(app, stream)
|
||||
const connection = acp.connection
|
||||
const sessions = yield* ACPSessions.make({ client, connection, catalog })
|
||||
const capabilities = yield* Ref.make({ childSessionUpdates: false, formElicitation: false })
|
||||
const capabilities = yield* Ref.make({ childSessionUpdates: false, formElicitation: false, compaction: false })
|
||||
const turn = yield* ACPTurn.make({ client, connection, sessions, catalog, capabilities })
|
||||
yield* Deferred.succeed(ready, ACPService.make({ client, connection, catalog, sessions, capabilities, turn }))
|
||||
return acp.agent
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type { EventSubscribeOutput, SessionMessageInfo, SessionStructuredError } from "@opencode/client/promise"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
|
||||
const MarkerMeta = "opencode/compaction"
|
||||
|
||||
/** Message IDs follow core's compaction message projection, so live compactions match replayed ones. */
|
||||
export type Started = { readonly status: "started"; readonly messageId: string; readonly reason: "auto" | "manual" }
|
||||
|
||||
type Compaction =
|
||||
| Started
|
||||
| {
|
||||
readonly status: "completed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly summary: string
|
||||
}
|
||||
| {
|
||||
readonly status: "failed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: SessionStructuredError
|
||||
}
|
||||
|
||||
/** Each session's compaction that has started and not yet settled, by session ID. */
|
||||
export type Tracked = ReadonlyMap<string, Started>
|
||||
|
||||
type LifecycleEvent = Extract<
|
||||
EventSubscribeOutput,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
|
||||
>
|
||||
type OpeningEvent = Extract<
|
||||
LifecycleEvent,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.failed" }
|
||||
>
|
||||
|
||||
// Core reports a cancelled or interrupted compaction as a failure with one of these error types.
|
||||
const Cancelled = new Set(["aborted", "compaction.interrupted"])
|
||||
|
||||
/**
|
||||
* Whether compactions go out as the standard session updates rather than the `_meta` marker. A child compaction
|
||||
* projected onto the parent session keeps the marker, since a standard update there would read as compaction of the
|
||||
* parent's own context.
|
||||
*/
|
||||
export function usesStandardUpdates(
|
||||
ctx: { readonly compaction: boolean; readonly childUpdates: boolean },
|
||||
child: boolean,
|
||||
) {
|
||||
return ctx.compaction && (!child || ctx.childUpdates)
|
||||
}
|
||||
|
||||
/** Tracks a lifecycle event and returns the updates it sends. */
|
||||
export function apply(event: LifecycleEvent, tracked: Tracked, standard: boolean) {
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.compaction.started") {
|
||||
const started = open(event)
|
||||
return { tracked: new Map(tracked).set(sessionID, started), updates: [update(started, standard)] }
|
||||
}
|
||||
const current = tracked.get(sessionID)
|
||||
const remaining = new Map(tracked)
|
||||
remaining.delete(sessionID)
|
||||
if (event.type === "session.compaction.ended") {
|
||||
if (!current) return { tracked: remaining, updates: [] }
|
||||
const completed: Compaction = {
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
}
|
||||
return { tracked: remaining, updates: [update(completed, standard)] }
|
||||
}
|
||||
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet. A live
|
||||
// standard compaction still opens before it settles.
|
||||
const started = current ?? open(event)
|
||||
const failed: Compaction = { ...started, status: "failed", reason: event.data.reason, error: event.data.error }
|
||||
return {
|
||||
tracked: remaining,
|
||||
updates: [...(current || !standard ? [] : [update(started, standard)]), update(failed, standard)],
|
||||
}
|
||||
}
|
||||
|
||||
/** A summary chunk for the session's tracked compaction, if it has one and uses standard updates. */
|
||||
export function chunk(started: Started | undefined, text: string, standard: boolean): SessionUpdate | undefined {
|
||||
if (!started || !standard) return undefined
|
||||
return { sessionUpdate: "compaction_summary_chunk", compactionId: started.messageId, content: { type: "text", text } }
|
||||
}
|
||||
|
||||
/** Settles a compaction a cancelled turn left open the way core settles a cancelled one. */
|
||||
export function abandon(started: Started, standard: boolean) {
|
||||
return update({ ...started, status: "failed", error: { type: "aborted", message: "Compaction cancelled" } }, standard)
|
||||
}
|
||||
|
||||
/** A settled compaction as one terminal update. A running one has no live turn on this connection to settle it. */
|
||||
export function replay(message: Extract<SessionMessageInfo, { type: "compaction" }>, standard: boolean) {
|
||||
if (message.status === "running") return undefined
|
||||
const base = { messageId: message.id, reason: message.reason }
|
||||
return update(
|
||||
message.status === "failed"
|
||||
? { ...base, status: "failed", error: message.error }
|
||||
: { ...base, status: "completed", summary: message.summary },
|
||||
standard,
|
||||
)
|
||||
}
|
||||
|
||||
function open(event: OpeningEvent): Started {
|
||||
return {
|
||||
status: "started",
|
||||
messageId: event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id)),
|
||||
reason: event.data.reason,
|
||||
}
|
||||
}
|
||||
|
||||
function update(compaction: Compaction, standard: boolean): SessionUpdate {
|
||||
if (!standard) {
|
||||
const marker = {
|
||||
status: compaction.status,
|
||||
messageId: compaction.messageId,
|
||||
reason: compaction.reason,
|
||||
...(compaction.status === "failed" ? { error: compaction.error } : {}),
|
||||
}
|
||||
return { sessionUpdate: "session_info_update", _meta: { [MarkerMeta]: marker } }
|
||||
}
|
||||
const compactionId = compaction.messageId
|
||||
if (compaction.status === "started")
|
||||
return { sessionUpdate: "compaction_update", compactionId, status: "in_progress" }
|
||||
if (compaction.status === "completed") {
|
||||
const summary = compaction.summary ? [{ type: "text" as const, text: compaction.summary }] : null
|
||||
return { sessionUpdate: "compaction_update", compactionId, status: "completed", summary }
|
||||
}
|
||||
if (Cancelled.has(compaction.error.type))
|
||||
return { sessionUpdate: "compaction_update", compactionId, status: "cancelled", summary: null }
|
||||
return {
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId,
|
||||
status: "failed",
|
||||
summary: null,
|
||||
error: errorMessage(compaction.error),
|
||||
}
|
||||
}
|
||||
|
||||
// Core reports a defect as `compaction.failed` with the pretty-printed cause, whose stack trace spans several lines.
|
||||
function errorMessage(error: SessionStructuredError) {
|
||||
if (!error.message || (error.type === "compaction.failed" && error.message.includes("\n"))) return "Compaction failed"
|
||||
return error.message
|
||||
}
|
||||
|
||||
export * as ACPCompaction from "./compaction"
|
||||
@@ -24,28 +24,13 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
||||
}
|
||||
return [{ type: "text", text: block.text }]
|
||||
}
|
||||
case "image":
|
||||
if (block.data) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: `data:${block.mimeType};base64,${block.data}`,
|
||||
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (block.uri?.startsWith("data:") || block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: block.uri,
|
||||
filename: filenameFromUri(block.uri) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
case "image": {
|
||||
const url = block.data ? `data:${block.mimeType};base64,${block.data}` : block.uri
|
||||
if (!url) return []
|
||||
const filename = filenameFromUri(block.uri ?? undefined) ?? "image"
|
||||
if (url.startsWith("data:")) return [{ type: "file", url, filename, mime: block.mimeType }]
|
||||
return [resourceLinkToPart({ uri: url, name: filename, mimeType: block.mimeType })]
|
||||
}
|
||||
case "resource_link":
|
||||
return [resourceLinkToPart(block)]
|
||||
case "resource":
|
||||
@@ -180,7 +165,10 @@ export function linkReference(name: string | undefined, uri: string): PromptPart
|
||||
|
||||
function filenameFromUri(uri: string | undefined): string | undefined {
|
||||
if (!uri || uri.startsWith("data:")) return undefined
|
||||
if (URL.canParse(uri)) return path.basename(new URL(uri).pathname) || undefined
|
||||
if (URL.canParse(uri)) {
|
||||
const url = new URL(uri)
|
||||
return path.basename((url.protocol === "zed:" && url.searchParams.get("path")) || url.pathname) || undefined
|
||||
}
|
||||
return path.basename(uri) || undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ type InputField = Exclude<Form.Field, Form.ExternalField>
|
||||
type SelectField = Form.StringField | Form.MultiselectField
|
||||
|
||||
// Form mode must not collect secrets, so only forms from flows known not to ask for credentials are elicited.
|
||||
const ElicitedKind = Schema.Struct({ kind: Schema.Literals(["question", "websearch.provider"]) })
|
||||
const Credential = /password|passphrase|secret|token|api[_-]?key|credential|private[_-]?key/i
|
||||
const QuestionKind = "question"
|
||||
const ElicitedKind = Schema.Struct({ kind: Schema.Literals([QuestionKind, "websearch.provider"]) })
|
||||
const Credential = /password|passphrase|secret|token|api[_ -]?key|credential|private[_ -]?key/i
|
||||
const ToolSource = Schema.Struct({ tool: Schema.Struct({ id: Schema.String }) })
|
||||
|
||||
type Input = {
|
||||
@@ -57,9 +58,12 @@ export const reply = Effect.fn("cli.acp.elicitation.reply")(function* (input: In
|
||||
)
|
||||
})
|
||||
|
||||
export const UnshownQuestionMessage =
|
||||
"The question couldn't be shown to the user in this client. Continue without an answer: make reasonable assumptions and state them, or ask the user in your reply if you can't proceed."
|
||||
|
||||
/** Cancels a form, interrupting its session when the server can't cancel it. */
|
||||
export function cancel(client: OpenCodeClient, form: AskedForm) {
|
||||
return settle(() => client.session.form.cancel({ sessionID: form.sessionID, formID: form.id })).pipe(
|
||||
function cancel(client: OpenCodeClient, form: AskedForm, message?: string) {
|
||||
return settle(() => client.session.form.cancel({ sessionID: form.sessionID, formID: form.id, message })).pipe(
|
||||
Effect.catch(() =>
|
||||
Effect.tryPromise(() => client.session.interrupt({ sessionID: form.sessionID })).pipe(Effect.ignore),
|
||||
),
|
||||
@@ -68,15 +72,16 @@ export function cancel(client: OpenCodeClient, form: AskedForm) {
|
||||
|
||||
/**
|
||||
* The form-mode schema for a form, or undefined when the form is cancelled instead: the client lacks form
|
||||
* elicitation, the form is not from an allowed flow or has a field that looks like a credential, or ACP can't
|
||||
* represent it faithfully. Unrepresentable forms have `external` fields, `when` conditions, a hidden required field
|
||||
* without a default, a default outside a field's options, or a free-text answer alongside options that must also
|
||||
* satisfy `required` or item bounds across both inputs. Hidden fields are not asked and answer with their default.
|
||||
* elicitation, the form is not from an allowed flow or has a field whose key, title, description, or option label
|
||||
* looks like a credential, or ACP can't represent it faithfully. Unrepresentable forms have `external` fields, `when`
|
||||
* conditions, a hidden required field without a default, a default outside a field's options, or a free-text answer
|
||||
* alongside options that must also satisfy `required` or item bounds across both inputs. Hidden fields are not asked
|
||||
* and answer with their default.
|
||||
*/
|
||||
export function requestedSchema(form: AskedForm, capabilities: ACPService.Capabilities): ElicitationSchema | undefined {
|
||||
if (!capabilities.formElicitation) return undefined
|
||||
if (Option.isNone(Schema.decodeUnknownOption(ElicitedKind)(form.metadata))) return undefined
|
||||
if (form.fields.some((field) => Credential.test(field.key) || Credential.test(field.title ?? ""))) return undefined
|
||||
if (form.fields.some(credentialLike)) return undefined
|
||||
const fields = form.fields.filter((field): field is InputField => field.type !== "external")
|
||||
if (fields.length !== form.fields.length || fields.some((field) => field.when?.length)) return undefined
|
||||
if (fields.some((field) => field.hidden && field.required && field.default === undefined)) return undefined
|
||||
@@ -90,6 +95,14 @@ export function requestedSchema(form: AskedForm, capabilities: ACPService.Capabi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a form that `requestedSchema` won't show. A question is cancelled with a message the question tool returns
|
||||
* to the model, so the turn continues instead of ending as interrupted; other forms are cancelled silently.
|
||||
*/
|
||||
export function cancelUnshown(client: OpenCodeClient, form: AskedForm) {
|
||||
return cancel(client, form, form.metadata?.kind === QuestionKind ? UnshownQuestionMessage : undefined)
|
||||
}
|
||||
|
||||
/** The answer for an accepted response, or undefined when the user declined, cancelled, or sent invalid content. */
|
||||
export function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answer | undefined {
|
||||
if (response.action !== "accept") return undefined
|
||||
@@ -137,6 +150,14 @@ function settle(evaluate: () => Promise<void>) {
|
||||
)
|
||||
}
|
||||
|
||||
function credentialLike(field: Form.Field) {
|
||||
const labels =
|
||||
field.type === "string" || field.type === "multiselect" ? (field.options ?? []).map((option) => option.label) : []
|
||||
return [field.key, field.title, field.description, ...labels].some(
|
||||
(text) => text !== undefined && Credential.test(text),
|
||||
)
|
||||
}
|
||||
|
||||
function representable(field: InputField, keys: ReadonlySet<string>) {
|
||||
if (field.type !== "string" && field.type !== "multiselect") return true
|
||||
if (!hasOptions(field)) return true
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
|
||||
import type { PermissionOption, ToolCallLocation } from "@agentclientprotocol/sdk"
|
||||
import type { EventSubscribeOutput, OpenCodeClient, PermissionReplyInput } from "@opencode/client/promise"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { applyPatch } from "diff"
|
||||
import { Cause, Effect, Option, Schema } from "effect"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPPromise } from "./promise"
|
||||
import { ACPTranslate } from "./translate"
|
||||
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
|
||||
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
|
||||
type Tool = { readonly name: string; readonly input: ToolInput }
|
||||
type Tool = { readonly id: string; readonly name: string; readonly input: ToolInput }
|
||||
type Preview = ReturnType<typeof diff>
|
||||
|
||||
type Input = {
|
||||
readonly client: OpenCodeClient
|
||||
@@ -17,8 +21,7 @@ type Input = {
|
||||
readonly clientSessionID: string
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly toolCallPrefix?: string
|
||||
readonly titlePrefix?: string
|
||||
readonly child?: ACPTranslate.ChildSession
|
||||
}
|
||||
|
||||
const options: PermissionOption[] = [
|
||||
@@ -27,6 +30,8 @@ const options: PermissionOption[] = [
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
const decodeFiles = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
|
||||
|
||||
/**
|
||||
* Asks the client, then replies to the server. Once `cancelled` completes, the client's request is cancelled or never
|
||||
* sent, and the server gets `reject`. The server reply is uninterruptible, so a server that is alive but stuck can
|
||||
@@ -45,23 +50,28 @@ export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Inp
|
||||
|
||||
const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const toolName = input.tool?.name ?? input.event.data.action
|
||||
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
||||
const previews = yield* permissionPreviews(toolName, toolInput, input.cwd)
|
||||
const toolCallID = input.event.data.source?.id ?? input.event.data.id
|
||||
const toolInput = input.tool?.input ?? input.event.data.metadata ?? {}
|
||||
const previews = yield* permissionPreviews(toolName, toolInput, input.event.data.metadata, input.cwd).pipe(
|
||||
Effect.orElseSucceed((): Preview[] => []),
|
||||
)
|
||||
const toolCallID = input.tool?.id ?? input.event.data.id
|
||||
const toolCall = pendingToolCall({
|
||||
toolCallId: input.child ? `${input.child.id}:${toolCallID}` : toolCallID,
|
||||
toolName,
|
||||
state: {
|
||||
input: toolInput,
|
||||
title: prefixedTitle(input.child?.title, permissionTitle(toolName, toolInput, previews)),
|
||||
},
|
||||
cwd: input.cwd,
|
||||
})
|
||||
const result = yield* input.connection.requestPermission({
|
||||
sessionId: input.clientSessionID,
|
||||
toolCall: {
|
||||
...pendingToolCall({
|
||||
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
|
||||
toolName,
|
||||
state: {
|
||||
input: toolInput,
|
||||
title: prefixedTitle(input.titlePrefix, permissionTitle(toolName, toolInput, previews)),
|
||||
},
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd),
|
||||
...toolCall,
|
||||
rawInput: input.tool ? toolCall.rawInput : undefined,
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data, input.cwd),
|
||||
...(previews.length > 0 ? { content: previews } : {}),
|
||||
...(input.child ? { _meta: ACPTranslate.childSessionMeta(input.child) } : {}),
|
||||
},
|
||||
options,
|
||||
})
|
||||
@@ -81,29 +91,33 @@ function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
return `${prefix}: ${title}`
|
||||
}
|
||||
|
||||
const permissionPreviews = Effect.fnUntraced(function* (toolName: string, input: ToolInput, cwd: string) {
|
||||
// Core sends the edit and write previews as file diffs; a diff that no longer applies to the file gets no preview.
|
||||
// The patch tool's diffs have their common indentation trimmed for display, so its previews come from its own hunks.
|
||||
const permissionPreviews = Effect.fnUntraced(function* (
|
||||
toolName: string,
|
||||
input: ToolInput,
|
||||
metadata: ToolInput | undefined,
|
||||
cwd: string,
|
||||
) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
|
||||
const file = filePath(input)
|
||||
if (!file) return []
|
||||
const path = absolutePath(file, cwd)
|
||||
if (tool === "write") {
|
||||
const content = stringValue(input.content)
|
||||
if (content === undefined) return []
|
||||
const oldText = yield* readText(path)
|
||||
return [diff(path, oldText, content)]
|
||||
}
|
||||
if (tool !== "edit") return []
|
||||
const oldString = stringValue(input.oldString)
|
||||
const newString = stringValue(input.newString)
|
||||
if (oldString === undefined || newString === undefined) return []
|
||||
const oldText = yield* readText(path)
|
||||
const newText =
|
||||
input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
|
||||
return [diff(path, oldText, newText)]
|
||||
const files = Option.getOrElse(decodeFiles(metadata?.files), () => [])
|
||||
const previews = yield* Effect.forEach(
|
||||
files,
|
||||
(file) =>
|
||||
Effect.gen(function* () {
|
||||
const path = absolutePath(file.file, cwd)
|
||||
const oldText = file.status === "added" ? null : yield* Effect.tryPromise(() => Bun.file(path).text())
|
||||
const newText = applyPatch(oldText ?? "", file.patch)
|
||||
return newText === false ? [] : [diff(path, oldText, newText)]
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return previews.flat()
|
||||
})
|
||||
|
||||
// Patch.derive throws when a hunk does not match the current file; the patch then gets no previews.
|
||||
// Patch.derive throws when a hunk does not match the current file, and a read can fail; the patch then gets no
|
||||
// previews.
|
||||
function patchPreviews(input: ToolInput, cwd: string) {
|
||||
return Effect.forEach(
|
||||
patchHunks(input),
|
||||
@@ -112,22 +126,22 @@ function patchPreviews(input: ToolInput, cwd: string) {
|
||||
const path = absolutePath(hunk.path, cwd)
|
||||
if (hunk.type === "add") {
|
||||
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
return diff(path, "", newText)
|
||||
return diff(path, null, newText)
|
||||
}
|
||||
const oldText = yield* readText(path)
|
||||
const oldText = yield* Effect.tryPromise(() => Bun.file(path).text())
|
||||
if (hunk.type === "delete") return diff(path, oldText, "")
|
||||
const derived = yield* Effect.try(() => Patch.derive(hunk.path, hunk.chunks, oldText))
|
||||
return diff(hunk.movePath ? absolutePath(hunk.movePath, cwd) : path, oldText, derived.content)
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.orElseSucceed((): ToolCallContent[] => []))
|
||||
)
|
||||
}
|
||||
|
||||
function diff(path: string, oldText: string, newText: string): ToolCallContent {
|
||||
return { type: "diff", path, oldText, newText }
|
||||
function diff(path: string, oldText: string | null, newText: string) {
|
||||
return { type: "diff" as const, path, oldText, newText }
|
||||
}
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
|
||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<Preview>) {
|
||||
if (previews.length > 1) return `${previews.length} files`
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "external_directory":
|
||||
@@ -144,26 +158,29 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
|
||||
case "write":
|
||||
case "patch":
|
||||
case "apply_patch":
|
||||
return filePath(input) ?? (previews[0]?.type === "diff" ? previews[0].path : undefined)
|
||||
return filePath(input) ?? previews[0]?.path
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Only these actions ask with path resources; an external directory asks for `<dir>/*`, located at the directory.
|
||||
// Core's wildcard matching treats only `*` and `?` as special.
|
||||
function permissionLocations(
|
||||
toolName: string,
|
||||
input: ToolInput,
|
||||
resources: ReadonlyArray<string>,
|
||||
ask: PermissionEvent["data"],
|
||||
cwd: string,
|
||||
): ToolCallLocation[] {
|
||||
const locations = toLocations(toolName, input, cwd)
|
||||
if (locations.length > 0) return locations
|
||||
return resources.filter((resource) => resource !== "*").map((path) => ({ path: absolutePath(path, cwd) }))
|
||||
if (locations.length > 0 || !PathActions.has(ask.action)) return locations
|
||||
const paths = ask.resources.flatMap((resource) => {
|
||||
const path = resource.endsWith("/*") ? resource.slice(0, -2) : resource
|
||||
return path && !/[*?]/.test(path) ? [absolutePath(path, cwd)] : []
|
||||
})
|
||||
return Array.from(new Set(paths), (path) => ({ path }))
|
||||
}
|
||||
|
||||
// A missing file previews as empty.
|
||||
function readText(path: string) {
|
||||
return Effect.tryPromise(() => Bun.file(path).text()).pipe(Effect.orElseSucceed(() => ""))
|
||||
}
|
||||
const PathActions = new Set(["read", "edit", "external_directory"])
|
||||
|
||||
export * as ACPPermission from "./permission"
|
||||
@@ -53,7 +53,11 @@ export const AuthMethodID = "opencode-login"
|
||||
export type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
|
||||
/** What the client advertised in `initialize`. */
|
||||
export type Capabilities = { readonly childSessionUpdates: boolean; readonly formElicitation: boolean }
|
||||
export type Capabilities = {
|
||||
readonly childSessionUpdates: boolean
|
||||
readonly formElicitation: boolean
|
||||
readonly compaction: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse>
|
||||
@@ -128,18 +132,24 @@ export function make(input: {
|
||||
return session
|
||||
})
|
||||
|
||||
const replay = (attached: Attached) =>
|
||||
Stream.paginate(undefined, (cursor: string | undefined) =>
|
||||
const replay = Effect.fnUntraced(function* (attached: Attached) {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
yield* Stream.paginate(undefined, (cursor: string | undefined) =>
|
||||
ACPPromise.promise(() =>
|
||||
cursor
|
||||
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor })
|
||||
: input.client.message.list({ sessionID: attached.id, limit: 200, order: "asc" }),
|
||||
).pipe(Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const)),
|
||||
).pipe(Stream.runForEach((message) => replayMessage(attached, message)))
|
||||
).pipe(Stream.runForEach((message) => replayMessage(attached, message, capabilities)))
|
||||
})
|
||||
|
||||
// A message that fails to translate keeps the updates before the failure and does not stop the replay.
|
||||
const replayMessage = Effect.fnUntraced(function* (attached: Attached, message: SessionMessageInfo) {
|
||||
const updates = ACPTranslate.replayMessage(message, attached.cwd)
|
||||
const replayMessage = Effect.fnUntraced(function* (
|
||||
attached: Attached,
|
||||
message: SessionMessageInfo,
|
||||
capabilities: Capabilities,
|
||||
) {
|
||||
const updates = ACPTranslate.replayMessage(message, attached.cwd, capabilities)
|
||||
while (true) {
|
||||
const next = yield* Effect.result(Effect.try(() => updates.next()))
|
||||
if (Result.isFailure(next))
|
||||
@@ -152,9 +162,11 @@ export function make(input: {
|
||||
return {
|
||||
initialize: Effect.fnUntraced(function* (params) {
|
||||
const elicitation = params.clientCapabilities?.elicitation
|
||||
const compaction = params.clientCapabilities?.session?.compaction
|
||||
yield* Ref.set(input.capabilities, {
|
||||
childSessionUpdates: params.clientCapabilities?._meta?.[ACPTranslate.ChildSessionUpdatesCapability] === true,
|
||||
formElicitation: elicitation?.form !== undefined && elicitation.form !== null,
|
||||
compaction: compaction !== undefined && compaction !== null,
|
||||
})
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
@@ -196,7 +208,10 @@ export function make(input: {
|
||||
}),
|
||||
)
|
||||
const attached = yield* input.sessions.attach(created, params.cwd, params.mcpServers)
|
||||
return { sessionId: attached.id, configOptions: yield* currentOptions(attached) }
|
||||
return yield* currentOptions(attached).pipe(
|
||||
Effect.map((configOptions) => ({ sessionId: attached.id, configOptions })),
|
||||
Effect.onError(() => input.sessions.detach(attached.id)),
|
||||
)
|
||||
}),
|
||||
loadSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
@@ -248,7 +263,10 @@ export function make(input: {
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
yield* ACPDirectories.activate(input.client, session, directories)
|
||||
const attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers ?? [])
|
||||
return { configOptions: yield* currentOptions(attached) }
|
||||
return yield* currentOptions(attached).pipe(
|
||||
Effect.map((configOptions) => ({ configOptions })),
|
||||
Effect.onError(() => input.sessions.detach(attached.id)),
|
||||
)
|
||||
}),
|
||||
closeSession: Effect.fnUntraced(function* (params) {
|
||||
yield* input.turn.close(params.sessionId)
|
||||
|
||||
@@ -6,17 +6,16 @@ import type {
|
||||
SessionStructuredError,
|
||||
TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import { ACPCompaction } from "./compaction"
|
||||
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||
import { ACPError } from "./error"
|
||||
import type { ACPService } from "./service"
|
||||
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, type ToolInput } from "./tool"
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
const RetryMeta = "opencode/retry"
|
||||
const CompactionMeta = "opencode/compaction"
|
||||
|
||||
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: string }
|
||||
|
||||
@@ -27,6 +26,8 @@ export type Context = {
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly childUpdates: boolean
|
||||
/** Whether the client advertised `session.compaction`, so compactions use the standard session updates. */
|
||||
readonly compaction: boolean
|
||||
/** A background consumer follows open children after the parent turn ends; it never writes `session/update`. */
|
||||
readonly mode: "turn" | "background"
|
||||
}
|
||||
@@ -72,7 +73,7 @@ export type TurnState = {
|
||||
readonly started: boolean
|
||||
readonly tools: ReadonlyMap<string, Tool>
|
||||
readonly retries: ReadonlyMap<string, RetryStatus>
|
||||
readonly compactions: ReadonlyMap<string, string>
|
||||
readonly compactions: ACPCompaction.Tracked
|
||||
readonly children: ReadonlyMap<string, ChildSession>
|
||||
readonly openChildren: ReadonlySet<string>
|
||||
/** Forms asked of the client that the server has not yet answered or cancelled. */
|
||||
@@ -110,18 +111,6 @@ export type Step = {
|
||||
readonly terminal?: Terminal
|
||||
}
|
||||
|
||||
type CompactionEvent = Extract<
|
||||
EventSubscribeOutput,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
|
||||
>
|
||||
|
||||
type CompactionMarker = {
|
||||
readonly status: "started" | "completed" | "failed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error?: SessionStructuredError
|
||||
}
|
||||
|
||||
export const initial: TurnState = {
|
||||
started: false,
|
||||
tools: new Map(),
|
||||
@@ -207,11 +196,20 @@ export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context
|
||||
case "session.compaction.started":
|
||||
case "session.compaction.ended":
|
||||
case "session.compaction.failed": {
|
||||
const compacted = compactionMarker(event, state.compactions)
|
||||
return {
|
||||
state: { ...state, compactions: compacted.compactions },
|
||||
outputs: compacted.marker ? send(compactionUpdate(compacted.marker)) : [],
|
||||
}
|
||||
const applied = ACPCompaction.apply(
|
||||
event,
|
||||
state.compactions,
|
||||
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
|
||||
)
|
||||
return { state: { ...state, compactions: applied.tracked }, outputs: applied.updates.flatMap(send) }
|
||||
}
|
||||
case "session.compaction.delta": {
|
||||
const update = ACPCompaction.chunk(
|
||||
state.compactions.get(eventSessionID),
|
||||
event.data.text,
|
||||
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
|
||||
)
|
||||
return { state, outputs: update ? send(update) : [] }
|
||||
}
|
||||
case "session.text.delta":
|
||||
return {
|
||||
@@ -378,29 +376,42 @@ export function response(state: TurnState, sessionID: string, terminal: Terminal
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
/** Fails the tools a cancelled turn left open, for when the server's wind-down never reports them. */
|
||||
export function abandonTools(state: TurnState, ctx: Context): Step {
|
||||
/**
|
||||
* Fails the tools and cancels the session's compaction a cancelled turn left open, for when the server's wind-down
|
||||
* never reports them. Child compactions are left to the consumer that follows children after the turn.
|
||||
*/
|
||||
export function abandon(state: TurnState, ctx: Context): Step {
|
||||
const compaction = state.compactions.get(ctx.sessionID)
|
||||
return {
|
||||
state: { ...state, tools: new Map() },
|
||||
outputs: [...state.tools.values()].flatMap((tool) =>
|
||||
route(ctx, state.children.get(tool.sessionID), {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: tool.metadata,
|
||||
content: [],
|
||||
error: "Cancelled",
|
||||
cwd: ctx.cwd,
|
||||
state: { ...state, tools: new Map(), compactions: without(state.compactions, ctx.sessionID) },
|
||||
outputs: [
|
||||
...[...state.tools.values()].flatMap((tool) =>
|
||||
route(ctx, state.children.get(tool.sessionID), {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: tool.metadata,
|
||||
content: [],
|
||||
error: "Cancelled",
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
...(compaction
|
||||
? route(ctx, undefined, ACPCompaction.abandon(compaction, ACPCompaction.usesStandardUpdates(ctx, false)))
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazy, so a message that fails to translate part way still replays the updates before the failure. */
|
||||
export function* replayMessage(message: SessionMessageInfo, cwd: string): Generator<SessionUpdate> {
|
||||
export function* replayMessage(
|
||||
message: SessionMessageInfo,
|
||||
cwd: string,
|
||||
capabilities: ACPService.Capabilities,
|
||||
): Generator<SessionUpdate> {
|
||||
if (message.type === "user") {
|
||||
yield { sessionUpdate: "user_message_chunk", messageId: message.id, content: { type: "text", text: message.text } }
|
||||
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
|
||||
@@ -413,14 +424,9 @@ export function* replayMessage(message: SessionMessageInfo, cwd: string): Genera
|
||||
yield { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk }
|
||||
return
|
||||
}
|
||||
// A running compaction has no live turn on this connection to settle it, so replay only settled ones.
|
||||
if (message.type === "compaction" && message.status !== "running") {
|
||||
yield compactionUpdate({
|
||||
status: message.status,
|
||||
messageId: message.id,
|
||||
reason: message.reason,
|
||||
...(message.status === "failed" ? { error: message.error } : {}),
|
||||
})
|
||||
if (message.type === "compaction") {
|
||||
const update = ACPCompaction.replay(message, capabilities.compaction)
|
||||
if (update) yield update
|
||||
return
|
||||
}
|
||||
if (message.type !== "assistant") return
|
||||
@@ -567,40 +573,18 @@ function toolKey(sessionID: string, id: string) {
|
||||
return `${sessionID}:${id}`
|
||||
}
|
||||
|
||||
// Message IDs follow core's compaction message projection, so live markers match replayed ones.
|
||||
function compactionMarker(event: CompactionEvent, compactions: ReadonlyMap<string, string>) {
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.compaction.started") {
|
||||
const messageId = event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id))
|
||||
const marker: CompactionMarker = { status: "started", messageId, reason: event.data.reason }
|
||||
return { marker, compactions: new Map(compactions).set(sessionID, messageId) }
|
||||
}
|
||||
const tracked = compactions.get(sessionID)
|
||||
const remaining = without(compactions, sessionID)
|
||||
if (event.type === "session.compaction.ended") {
|
||||
const marker: CompactionMarker | undefined = tracked
|
||||
? { status: "completed", messageId: tracked, reason: event.data.reason }
|
||||
: undefined
|
||||
return { marker, compactions: remaining }
|
||||
}
|
||||
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet.
|
||||
const marker: CompactionMarker = {
|
||||
status: "failed",
|
||||
messageId: tracked ?? event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id)),
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
}
|
||||
return { marker, compactions: remaining }
|
||||
}
|
||||
|
||||
function compactionUpdate(marker: CompactionMarker): SessionUpdate {
|
||||
return { sessionUpdate: "session_info_update", _meta: { [CompactionMeta]: marker } }
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
const projected = { ...update }
|
||||
projected._meta = {
|
||||
...projected._meta,
|
||||
projected._meta = { ...projected._meta, ...childSessionMeta(child) }
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
export function childSessionMeta(child: ChildSession) {
|
||||
return {
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
@@ -608,11 +592,6 @@ function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
|
||||
|
||||
@@ -142,7 +142,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
clientSessionID: ctx.sessionID,
|
||||
cwd: ctx.cwd,
|
||||
tool: ask.tool,
|
||||
...(ask.child ? { toolCallPrefix: ask.child.id, titlePrefix: ask.child.title } : {}),
|
||||
child: ask.child,
|
||||
},
|
||||
Deferred.await(subscription.cancelled),
|
||||
)
|
||||
@@ -161,7 +161,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
return Effect.gen(function* () {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const requestedSchema = ACPElicitation.requestedSchema(output.form, capabilities)
|
||||
if (!requestedSchema) return yield* ACPElicitation.cancel(input.client, output.form)
|
||||
if (!requestedSchema) return yield* ACPElicitation.cancelUnshown(input.client, output.form)
|
||||
const settled = yield* Deferred.make<void>()
|
||||
subscription.forms.set(output.form.id, settled)
|
||||
yield* Queue.offer(
|
||||
@@ -251,7 +251,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
ACPPromise.promise(() => input.client.session.interrupt({ sessionID })).pipe(Effect.ignoreCause)
|
||||
|
||||
// Rejects pending asks, interrupts the server once, then forwards its wind-down until the terminal event or the
|
||||
// timeout. Tools still open at the timeout are reported failed so the client never shows them running.
|
||||
// timeout. Tools and a compaction still open at the timeout are settled so the client never shows them running.
|
||||
const windDown = Effect.fnUntraced(function* (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
@@ -263,7 +263,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
if (!(yield* Ref.get(state)).started) return
|
||||
if (Option.isSome(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)))) return
|
||||
yield* Fiber.interrupt(events)
|
||||
const abandoned = ACPTranslate.abandonTools(yield* Ref.get(state), ctx)
|
||||
const abandoned = ACPTranslate.abandon(yield* Ref.get(state), ctx)
|
||||
yield* Ref.set(state, abandoned.state)
|
||||
yield* Effect.forEach(abandoned.outputs, (output) => interpret(subscription, ctx, output), { discard: true }).pipe(
|
||||
Effect.ignore,
|
||||
@@ -352,17 +352,15 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
)
|
||||
|
||||
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (
|
||||
attached: Attached,
|
||||
prompt: PreparedPrompt,
|
||||
childUpdates: boolean,
|
||||
) {
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (attached: Attached, prompt: PreparedPrompt) {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const state = yield* Ref.make(ACPTranslate.initial)
|
||||
const ctx: ACPTranslate.Context = {
|
||||
sessionID: attached.id,
|
||||
cwd: attached.cwd,
|
||||
start: prompt.start,
|
||||
childUpdates,
|
||||
childUpdates: capabilities.childSessionUpdates,
|
||||
compaction: capabilities.compaction,
|
||||
mode: "turn",
|
||||
}
|
||||
const exit = yield* Effect.exit(Effect.interruptible(execute(attached, prompt, ctx, state)))
|
||||
@@ -373,7 +371,9 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
prompt: Effect.fn("cli.acp.turn.prompt")(function* (params, signal) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const childUpdates = (yield* Ref.get(input.capabilities)).childSessionUpdates
|
||||
if (params.prompt.some((block) => block.type === "image" && !block.data && !block.uri)) {
|
||||
return yield* new ACPError.InvalidRequestError({ message: "image content has no data or uri", field: "prompt" })
|
||||
}
|
||||
const parts = yield* Effect.forEach(promptContentToParts(params.prompt), referenceUnreadableFile, {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
@@ -388,7 +388,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt, childUpdates), { uninterruptible: true })
|
||||
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt), { uninterruptible: true })
|
||||
FiberMap.setUnsafe(turns, attached.id, forked)
|
||||
return Effect.succeed(forked)
|
||||
})
|
||||
|
||||
@@ -2,30 +2,16 @@ import type { NewSessionResponse, PromptResponse } from "@agentclientprotocol/sd
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createAcpFixture, expectOk, initialize } from "./subprocess"
|
||||
import { createAcpFixture, expectOk, initialize, toolCallStream } from "./subprocess"
|
||||
|
||||
// The first completion reads the file outside cwd; the follow-up completion ends the turn.
|
||||
function readingModel(file: () => string) {
|
||||
return (request: unknown) => {
|
||||
if (JSON.stringify(request).includes('"role":"tool"')) return "done"
|
||||
return new Response(toolCall(file()), { headers: { "content-type": "text/event-stream" } })
|
||||
return toolCallStream("call_read", "read", { path: file() })
|
||||
}
|
||||
}
|
||||
|
||||
function toolCall(file: string) {
|
||||
const call = { index: 0, id: "call_read", type: "function", function: { name: "read", arguments: "" } }
|
||||
const chunks = [
|
||||
{ choices: [{ delta: { role: "assistant", tool_calls: [call] }, finish_reason: null }], usage: null },
|
||||
{
|
||||
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ path: file }) } }] } }],
|
||||
usage: null,
|
||||
},
|
||||
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: null },
|
||||
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } },
|
||||
]
|
||||
return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`
|
||||
}
|
||||
|
||||
describe("acp additional directories subprocess", () => {
|
||||
test("tools read files in an additional directory without an external directory ask", async () => {
|
||||
const target = { file: "" }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import type { CompactionUpdate, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
@@ -7,8 +7,10 @@ import { Schema } from "effect"
|
||||
import {
|
||||
assistantMessage,
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
interrupted,
|
||||
makeSession,
|
||||
startSession,
|
||||
startWire,
|
||||
@@ -16,11 +18,14 @@ import {
|
||||
succeeded,
|
||||
textDelta,
|
||||
turn,
|
||||
type InitializeOptions,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
|
||||
const summary = "Summary of the earlier conversation"
|
||||
const providerError = { type: "provider.error", message: "summary request failed", status: 500 }
|
||||
const decodeCompact = Schema.decodeUnknownSync(Schema.Struct({ id: Schema.String }))
|
||||
const standard: InitializeOptions = { compaction: {} }
|
||||
|
||||
describe("acp compaction markers over the wire", () => {
|
||||
test("marks a /compact turn without forwarding the summary text", async () => {
|
||||
@@ -191,10 +196,397 @@ describe("acp compaction markers over the wire", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp standard compaction updates over the wire", () => {
|
||||
test("streams a /compact turn's summary and completes it with the full summary", async () => {
|
||||
const compacted = await compactTurn(
|
||||
(sessionID, id) => [
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "Summary of " }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "the earlier conversation" }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
|
||||
],
|
||||
standard,
|
||||
)
|
||||
await using acp = compacted.acp
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId: compacted.id, status: "in_progress" }),
|
||||
summaryChunk(acp.sessionId, compacted.id, "Summary of "),
|
||||
summaryChunk(acp.sessionId, compacted.id, "the earlier conversation"),
|
||||
compaction(acp.sessionId, {
|
||||
compactionId: compacted.id,
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: summary }],
|
||||
}),
|
||||
])
|
||||
expect(compacted.response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("completes a compaction without a readable summary with a cleared summary", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: "", recent: "" }),
|
||||
textDelta(sessionID, "msg_after", "after"),
|
||||
stepEnded(sessionID, "msg_after"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId, status: "completed", summary: null }),
|
||||
chunk(acp.sessionId, "msg_after", "after"),
|
||||
])
|
||||
})
|
||||
|
||||
test("fails a compaction with its error and clears the partial summary", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: providerError }),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
|
||||
summaryChunk(acp.sessionId, compactionId, "partial"),
|
||||
compaction(acp.sessionId, { compactionId, status: "failed", summary: null, error: providerError.message }),
|
||||
])
|
||||
})
|
||||
|
||||
test("opens a compaction that fails before it starts before settling it", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.failed", {
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
}),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.failed")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId, status: "failed", summary: null, error: "Nothing to compact yet" }),
|
||||
])
|
||||
})
|
||||
|
||||
test("reports a defect as a generic failure and keeps core's own failure messages", async () => {
|
||||
const limit = { type: "compaction.failed", message: "Compaction summary reached the output token limit" }
|
||||
const defect = { type: "compaction.failed", message: "Error: database is locked\n at run (compaction.ts:1:1)" }
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: limit }),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: defect }),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const first = await events.messageID("session.compaction.started")
|
||||
const second = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId: first, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId: first, status: "failed", summary: null, error: limit.message }),
|
||||
compaction(acp.sessionId, { compactionId: second, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId: second, status: "failed", summary: null, error: "Compaction failed" }),
|
||||
])
|
||||
})
|
||||
|
||||
test("drops summary chunks outside an open compaction", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "early" }),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: summary, recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "late" }),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId, status: "completed", summary: [{ type: "text", text: summary }] }),
|
||||
])
|
||||
})
|
||||
|
||||
test("cancels a compaction the server interrupts during a cancelled turn's wind-down", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [
|
||||
durableEvent("session.compaction.failed", {
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
}),
|
||||
interrupted(sessionID),
|
||||
],
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "compaction_update")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect((await prompt).stopReason).toBe("cancelled")
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
expect(updatesBeforeResponse(acp)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }).update,
|
||||
compaction(acp.sessionId, { compactionId, status: "cancelled", summary: null }).update,
|
||||
])
|
||||
})
|
||||
|
||||
test("cancels a compaction left open when the wind-down never reports it", async () => {
|
||||
const submitted: string[] = []
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
cancelDrainTimeout: "50 millis",
|
||||
onPrompt: ({ sessionID, id }) => {
|
||||
submitted.push(id)
|
||||
return [
|
||||
delivered(sessionID, id),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "compaction_summary_chunk")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect((await prompt).stopReason).toBe("cancelled")
|
||||
const compactionId = submitted[0]
|
||||
expect(updatesBeforeResponse(acp)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }).update,
|
||||
summaryChunk(acp.sessionId, compactionId, "partial").update,
|
||||
compaction(acp.sessionId, { compactionId, status: "cancelled", summary: null }).update,
|
||||
])
|
||||
})
|
||||
|
||||
test("closes the marker of a compaction left open when the wind-down never reports it", async () => {
|
||||
const submitted: string[] = []
|
||||
await using acp = await startSession({
|
||||
cancelDrainTimeout: "50 millis",
|
||||
onPrompt: ({ sessionID, id }) => {
|
||||
submitted.push(id)
|
||||
return [
|
||||
delivered(sessionID, id),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "session_info_update")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect((await prompt).stopReason).toBe("cancelled")
|
||||
const messageId = submitted[0]
|
||||
expect(updatesBeforeResponse(acp)).toEqual([
|
||||
marker(acp.sessionId, { status: "started", messageId, reason: "manual" }).update,
|
||||
marker(acp.sessionId, {
|
||||
status: "failed",
|
||||
messageId,
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Compaction cancelled" },
|
||||
}).update,
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps child compactions projected onto the parent as markers", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore"),
|
||||
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID: "ses_child", text: summary }),
|
||||
durableEvent("session.compaction.ended", {
|
||||
sessionID: "ses_child",
|
||||
reason: "auto",
|
||||
text: summary,
|
||||
recent: "",
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const messageId = await events.messageID("session.compaction.started")
|
||||
const child = { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" }
|
||||
expect(turnUpdates(acp.updates).map((item) => item.update._meta)).toEqual([
|
||||
{ "opencode/compaction": { status: "started", messageId, reason: "auto" }, "opencode/child-session": child },
|
||||
{ "opencode/compaction": { status: "completed", messageId, reason: "auto" }, "opencode/child-session": child },
|
||||
])
|
||||
})
|
||||
|
||||
test("sends child compactions as standard updates through child session updates", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { ...standard, childSessionUpdates: true },
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore"),
|
||||
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.ended", {
|
||||
sessionID: "ses_child",
|
||||
reason: "auto",
|
||||
text: summary,
|
||||
recent: "",
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
const child = { "opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" } }
|
||||
expect(acp.childUpdates.flatMap((item) => (item.type === "update" ? [item.update] : []))).toEqual([
|
||||
{ sessionUpdate: "compaction_update", compactionId, status: "in_progress", _meta: child },
|
||||
{
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId,
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: summary }],
|
||||
_meta: child,
|
||||
},
|
||||
])
|
||||
expect(turnUpdates(acp.updates)).toEqual([])
|
||||
})
|
||||
|
||||
test("replays settled compactions as terminal updates at their position on session/load", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_compacted", makeSession("ses_compacted"))
|
||||
acp.server.messages.set("ses_compacted", [
|
||||
...compactedHistory().slice(0, 3),
|
||||
{
|
||||
id: "msg_compaction_cancelled",
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Compaction cancelled" },
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_compaction_native",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
summary: "",
|
||||
recent: "",
|
||||
time: { created: 3 },
|
||||
},
|
||||
...compactedHistory().slice(3),
|
||||
])
|
||||
await acp.initialize(standard)
|
||||
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_compacted", mcpServers: [] })
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
{
|
||||
sessionId: "ses_compacted",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "msg_user",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
},
|
||||
compaction("ses_compacted", {
|
||||
compactionId: "msg_compaction_failed",
|
||||
status: "failed",
|
||||
summary: null,
|
||||
error: providerError.message,
|
||||
}),
|
||||
compaction("ses_compacted", {
|
||||
compactionId: "msg_compaction",
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: summary }],
|
||||
}),
|
||||
compaction("ses_compacted", { compactionId: "msg_compaction_cancelled", status: "cancelled", summary: null }),
|
||||
compaction("ses_compacted", { compactionId: "msg_compaction_native", status: "completed", summary: null }),
|
||||
chunk("ses_compacted", "msg_after", "after"),
|
||||
])
|
||||
})
|
||||
|
||||
test("treats a null compaction capability as unsupported", async () => {
|
||||
const compacted = await compactTurn(
|
||||
(sessionID, id) => [
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: summary }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
|
||||
],
|
||||
{ compaction: null },
|
||||
)
|
||||
await using acp = compacted.acp
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
marker(acp.sessionId, { status: "started", messageId: compacted.id, reason: "manual" }),
|
||||
marker(acp.sessionId, { status: "completed", messageId: compacted.id, reason: "manual" }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// Holds the compact response so the test can publish the turn's events while the request is in flight.
|
||||
async function compactTurn(events: (sessionID: string, id: string) => OpenCodeEvent[]) {
|
||||
async function compactTurn(
|
||||
events: (sessionID: string, id: string) => OpenCodeEvent[],
|
||||
capabilities?: InitializeOptions,
|
||||
) {
|
||||
const held = Promise.withResolvers<Response>()
|
||||
const acp = await startSession({ fetch: (request) => (request.path.endsWith("/compact") ? held.promise : undefined) })
|
||||
const acp = await startSession({
|
||||
capabilities,
|
||||
fetch: (request) => (request.path.endsWith("/compact") ? held.promise : undefined),
|
||||
})
|
||||
const response = acp.prompt(acp.sessionId, "/compact")
|
||||
const request = await acp.until(
|
||||
() => acp.server.requests.find((item) => item.path.endsWith("/compact")),
|
||||
@@ -227,10 +619,35 @@ function marker(sessionId: string, value: Record<string, unknown>): SessionNotif
|
||||
return { sessionId, update: { sessionUpdate: "session_info_update", _meta: { "opencode/compaction": value } } }
|
||||
}
|
||||
|
||||
function compaction(sessionId: string, update: CompactionUpdate): SessionNotification {
|
||||
return { sessionId, update: { sessionUpdate: "compaction_update", ...update } }
|
||||
}
|
||||
|
||||
function summaryChunk(sessionId: string, compactionId: string, text: string): SessionNotification {
|
||||
return {
|
||||
sessionId,
|
||||
update: { sessionUpdate: "compaction_summary_chunk", compactionId, content: { type: "text", text } },
|
||||
}
|
||||
}
|
||||
|
||||
function chunk(sessionId: string, messageId: string, text: string): SessionNotification {
|
||||
return { sessionId, update: { sessionUpdate: "agent_message_chunk", messageId, content: { type: "text", text } } }
|
||||
}
|
||||
|
||||
const isCancelledResponse = Schema.is(
|
||||
Schema.Struct({ result: Schema.Struct({ stopReason: Schema.Literal("cancelled") }) }),
|
||||
)
|
||||
|
||||
// Session updates the client received before the cancelled prompt response.
|
||||
function updatesBeforeResponse(acp: Wire) {
|
||||
const response = acp.received.findIndex(isCancelledResponse)
|
||||
expect(response).toBeGreaterThan(-1)
|
||||
const count = acp.received
|
||||
.slice(0, response)
|
||||
.filter((message) => "method" in message && message.method === "session/update").length
|
||||
return turnUpdates(acp.updates.slice(0, count)).map((item) => item.update)
|
||||
}
|
||||
|
||||
function turnUpdates(updates: readonly SessionNotification[]) {
|
||||
return updates.filter(
|
||||
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
|
||||
|
||||
@@ -45,22 +45,48 @@ describe("acp content conversion", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("image block with http URI becomes a file part", () => {
|
||||
test("image block with only a data URL URI becomes a file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "data:image/png;base64,AAAA" }),
|
||||
).toEqual([{ type: "file", url: "data:image/png;base64,AAAA", filename: "image", mime: "image/png" }])
|
||||
})
|
||||
|
||||
test("image block with only an http URI becomes a markdown link", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "image",
|
||||
data: "",
|
||||
mimeType: "image/jpeg",
|
||||
uri: "http://example.com/assets/photo.jpg",
|
||||
uri: "https://example.com/assets/photo.jpg",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "http://example.com/assets/photo.jpg",
|
||||
filename: "photo.jpg",
|
||||
mime: "image/jpeg",
|
||||
},
|
||||
).toEqual([{ type: "text", text: "[photo.jpg](https://example.com/assets/photo.jpg)" }])
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/" }),
|
||||
).toEqual([{ type: "text", text: "[image](https://example.com/)" }])
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "http://example.com/a.png" }),
|
||||
).toEqual([{ type: "text", text: "[a.png](http://example.com/a.png)" }])
|
||||
})
|
||||
|
||||
test("image block with only a URI of another scheme or a bare path becomes a markdown link", () => {
|
||||
expect(contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "ftp://host/a.png" })).toEqual([
|
||||
{ type: "text", text: "[a.png](ftp://host/a.png)" },
|
||||
])
|
||||
expect(contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "assets/b.png" })).toEqual([
|
||||
{ type: "text", text: "[b.png](assets/b.png)" },
|
||||
])
|
||||
})
|
||||
|
||||
test("image block with only a file URI becomes a file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "file:///tmp/screenshot.png" }),
|
||||
).toEqual([{ type: "file", url: "file:///tmp/screenshot.png", filename: "screenshot.png", mime: "image/png" }])
|
||||
})
|
||||
|
||||
test("image block with only a zed path becomes a file part named after the path", () => {
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "zed://file?path=/tmp/shot.png" }),
|
||||
).toEqual([{ type: "file", url: pathToFileURL("/tmp/shot.png").href, filename: "shot.png", mime: "image/png" }])
|
||||
})
|
||||
|
||||
test("resource_link file URL becomes a file part with name and fallback mime", () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ const questions = (sessionID: string, id = "frm_question", tool = "call_question
|
||||
},
|
||||
})
|
||||
|
||||
const capable = { childSessionUpdates: false, formElicitation: true }
|
||||
const capable = { childSessionUpdates: false, formElicitation: true, compaction: false }
|
||||
|
||||
const form = (
|
||||
fields: ACPElicitation.AskedForm["fields"],
|
||||
@@ -191,6 +191,8 @@ describe("acp elicitation mapping", () => {
|
||||
[{ key: "api_key", type: "string" }],
|
||||
[{ key: "q0", title: "GitHub token", type: "string" }],
|
||||
[{ key: "q0", title: "Password", type: "string", hidden: true, default: "" }],
|
||||
[{ key: "q0", title: "Setup", description: "Paste your API key", type: "string" }],
|
||||
[{ key: "q0", title: "Setup", type: "string", options: [{ value: "a", label: "Use my access token" }] }],
|
||||
]
|
||||
expect(credentials.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
|
||||
credentials.map(() => undefined),
|
||||
@@ -332,6 +334,7 @@ describe("acp elicitation over the wire", () => {
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toHaveLength(ids.length)
|
||||
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(ids)
|
||||
expect(acp.server.cancelledForms.map((item) => item.message)).toEqual(ids.map(() => undefined))
|
||||
expect(acp.server.repliedForms).toEqual([])
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
})
|
||||
@@ -359,6 +362,62 @@ describe("acp elicitation over the wire", () => {
|
||||
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_plugin" }])
|
||||
})
|
||||
|
||||
test("cancels a question that looks like it asks for a credential with a message for the model", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_token",
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: "msg_tools", id: "call_question" } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
title: "Setup",
|
||||
description: "Paste your API key",
|
||||
type: "string",
|
||||
options: [{ value: "Later", label: "Later" }],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toEqual([])
|
||||
expect(acp.server.cancelledForms).toEqual([
|
||||
{ sessionID: acp.sessionId, formID: "frm_token", message: ACPElicitation.UnshownQuestionMessage },
|
||||
])
|
||||
})
|
||||
|
||||
test("cancels other forms it cannot show without a message", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_websearch",
|
||||
sessionID,
|
||||
title: "Web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [{ key: "provider", type: "string", options: [{ value: "exa", label: "Exa" }] }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toEqual([])
|
||||
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_websearch" }])
|
||||
})
|
||||
|
||||
test("cancelling the turn cancels its pending elicitation and the form, and never sends queued ones", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { Cause } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -31,6 +32,10 @@ describe("acp permissions over the wire", () => {
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_once", "shell"),
|
||||
toolCalled(sessionID, "call_once", { command: "printf hello" }),
|
||||
toolStarted(sessionID, "call_always", "read"),
|
||||
toolCalled(sessionID, "call_always", { path: "/workspace/file.ts" }),
|
||||
permissionAsked(sessionID, "perm_once", {
|
||||
action: "shell",
|
||||
metadata: { command: "printf hello" },
|
||||
@@ -101,8 +106,84 @@ describe("acp permissions over the wire", () => {
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "/tmp/outside",
|
||||
locations: [{ path: "/tmp/outside/a.ts" }],
|
||||
rawInput: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
|
||||
})
|
||||
expect(acp.permissions[0]?.toolCall).not.toHaveProperty("rawInput")
|
||||
})
|
||||
|
||||
test("locates path resources without glob patterns when the ask has no tool locations", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_external", {
|
||||
action: "external_directory",
|
||||
resources: ["/tmp/outside/*", "/tmp/other/*", "/tmp/outside/*"],
|
||||
metadata: {},
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_read", {
|
||||
action: "read",
|
||||
resources: ["src/[slug].ts", "src/{a,b}.ts", "**/*.ts", "src/?.ts"],
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_search", { action: "websearch", resources: ["acp spec"] }),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.locations)).toEqual([
|
||||
[{ path: "/tmp/outside" }, { path: "/tmp/other" }],
|
||||
[{ path: path.resolve("/workspace", "src/[slug].ts") }, { path: path.resolve("/workspace", "src/{a,b}.ts") }],
|
||||
[],
|
||||
])
|
||||
})
|
||||
|
||||
test("asks under the permission ID when ACP does not track the asking tool call", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_untracked", {
|
||||
source: { type: "tool", messageID: "msg_tools", id: "call_untracked" },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_untracked"])
|
||||
expect(acp.permissions[0]?.toolCall).not.toHaveProperty("rawInput")
|
||||
})
|
||||
|
||||
test("asks about a tracked tool call with its own input", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_read", "read"),
|
||||
toolCalled(sessionID, "call_read", { path: "/workspace/file.ts" }),
|
||||
permissionAsked(sessionID, "perm_read", {
|
||||
action: "read",
|
||||
metadata: { files: ["file.ts"] },
|
||||
source: { type: "tool", messageID: "msg_read", id: "call_read" },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const running = acp.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "tool_call_update" && item.update.status === "in_progress"
|
||||
? [item.update.rawInput]
|
||||
: [],
|
||||
)
|
||||
expect(running).toEqual([{ path: "/workspace/file.ts" }])
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({ toolCallId: "call_read", rawInput: running[0] })
|
||||
})
|
||||
|
||||
test("routes foreground child permissions through the parent ACP session", async () => {
|
||||
@@ -113,9 +194,10 @@ describe("acp permissions over the wire", () => {
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Review code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
toolStarted("ses_child", "call_child", "read"),
|
||||
toolCalled("ses_child", "call_child", { path: "/workspace/child.ts" }),
|
||||
permissionAsked("ses_child", "perm_child", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/child.ts" },
|
||||
source: { type: "tool", messageID: "msg_child", id: "call_child" },
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
@@ -125,11 +207,17 @@ describe("acp permissions over the wire", () => {
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const childMeta = {
|
||||
"opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Review code" },
|
||||
}
|
||||
expect(acp.permissions).toHaveLength(1)
|
||||
expect(acp.permissions[0]).toMatchObject({
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts" },
|
||||
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts", _meta: childMeta },
|
||||
})
|
||||
expect(
|
||||
acp.updates.flatMap((item) => (item.update.sessionUpdate === "tool_call" ? [item.update._meta] : [])),
|
||||
).toEqual([childMeta])
|
||||
expect(acp.server.replies).toEqual([{ sessionID: "ses_child", requestID: "perm_child", decision: "once" }])
|
||||
})
|
||||
|
||||
@@ -149,10 +237,17 @@ describe("acp permissions over the wire", () => {
|
||||
)
|
||||
await acp.until(() => acp.server.replies.length === 1, "background permission reply")
|
||||
|
||||
const childMeta = {
|
||||
"opencode/child-session": { id: "ses_background", parentID: acp.sessionId, depth: 1, title: "Research" },
|
||||
}
|
||||
expect(acp.permissions).toMatchObject([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: { toolCallId: "ses_background:perm_background", title: "Research: /workspace/notes.md" },
|
||||
toolCall: {
|
||||
toolCallId: "ses_background:perm_background",
|
||||
title: "Research: /workspace/notes.md",
|
||||
_meta: childMeta,
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(acp.server.replies).toEqual([
|
||||
@@ -353,27 +448,24 @@ describe("acp permissions over the wire", () => {
|
||||
})
|
||||
|
||||
describe("acp edit previews over the wire", () => {
|
||||
test("previews edits during approval", async () => {
|
||||
test("previews core's edit diff during approval", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = path.join(dir.path, "file.ts")
|
||||
await fs.writeFile(file, "before")
|
||||
await fs.writeFile(file, "one\r\ntwo\r\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "before", newString: "after" }),
|
||||
permissionAsked(sessionID, "perm_edit", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
||||
}),
|
||||
],
|
||||
onPermissionReply: async ({ sessionID }) => {
|
||||
await fs.writeFile(file, "after")
|
||||
return [
|
||||
toolSucceeded(sessionID, "call_edit", { files: [{ file: "file.ts" }], replacements: 1 }, "edited"),
|
||||
succeeded(sessionID),
|
||||
]
|
||||
},
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "one\ntwo", newString: "one\nthree" }),
|
||||
permissionAsked(sessionID, "perm_edit", {
|
||||
action: "edit",
|
||||
resources: ["file.ts"],
|
||||
metadata: { files: [fileDiff("file.ts", "one\r\ntwo\r\n", "one\r\nthree\r\n")] },
|
||||
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
@@ -385,7 +477,7 @@ describe("acp edit previews over the wire", () => {
|
||||
title: "file.ts",
|
||||
kind: "edit",
|
||||
locations: [{ path: file }],
|
||||
content: [{ type: "diff", path: file, oldText: "before", newText: "after" }],
|
||||
content: [{ type: "diff", path: file, oldText: "one\r\ntwo\r\n", newText: "one\r\nthree\r\n" }],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -445,6 +537,85 @@ describe("acp edit previews over the wire", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("previews added files as new and deleted files as empty", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "gone.ts"), "gone\n")
|
||||
const patchText = ["*** Begin Patch", "*** Add File: added.ts", "+one", "*** End Patch"].join("\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_write", "write"),
|
||||
toolCalled(sessionID, "call_write", { path: "written.ts", content: "two\n" }),
|
||||
permissionAsked(sessionID, "perm_write", {
|
||||
action: "edit",
|
||||
metadata: { files: [fileDiff("written.ts", "", "two\n", "added")] },
|
||||
source: { type: "tool", messageID: "msg_write", id: "call_write" },
|
||||
}),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText }),
|
||||
permissionAsked(sessionID, "perm_patch", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_delete", {
|
||||
action: "edit",
|
||||
metadata: { files: [fileDiff("gone.ts", "gone\n", "", "deleted")] },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([
|
||||
[{ type: "diff", path: path.join(dir.path, "written.ts"), oldText: null, newText: "two\n" }],
|
||||
[{ type: "diff", path: path.join(dir.path, "added.ts"), oldText: null, newText: "one\n" }],
|
||||
[{ type: "diff", path: path.join(dir.path, "gone.ts"), oldText: "gone\n", newText: "" }],
|
||||
])
|
||||
})
|
||||
|
||||
test("asks without previews when core's diff no longer applies or the file cannot be read", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "file.ts"), "changed\n")
|
||||
await fs.mkdir(path.join(dir.path, "folder"))
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_stale", {
|
||||
action: "edit",
|
||||
resources: ["file.ts"],
|
||||
metadata: { files: [fileDiff("file.ts", "one\n", "two\n")] },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_folder", {
|
||||
action: "edit",
|
||||
resources: ["folder"],
|
||||
metadata: { files: [fileDiff("folder", "one\n", "two\n")] },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall)).toMatchObject([
|
||||
{ kind: "edit", locations: [{ path: path.join(dir.path, "file.ts") }] },
|
||||
{ kind: "edit", locations: [{ path: path.join(dir.path, "folder") }] },
|
||||
])
|
||||
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([undefined, undefined])
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_stale", "once"],
|
||||
["perm_folder", "once"],
|
||||
])
|
||||
})
|
||||
|
||||
test("asks without previews when a patch does not apply to the current file", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "first.ts"), "changed\n")
|
||||
@@ -563,3 +734,7 @@ describe("acp edit previews over the wire", () => {
|
||||
function decisions(acp: Wire) {
|
||||
return acp.server.replies.map((reply) => [reply.requestID, reply.decision])
|
||||
}
|
||||
|
||||
function fileDiff(file: string, before: string, after: string, status: "added" | "deleted" | "modified" = "modified") {
|
||||
return { file, patch: createTwoFilesPatch(file, file, before, after), additions: 1, deletions: 1, status }
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess
|
||||
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
|
||||
|
||||
describe("acp prompt content subprocess", () => {
|
||||
test("accepts embedded text resource image and file resource link prompt content", async () => {
|
||||
test("accepts embedded text resource, inline and remote image, and file resource link prompt content", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
await Bun.write(path.join(fixture.home, "README.md"), "# ACP content smoke\n")
|
||||
const acp = fixture.spawn()
|
||||
@@ -41,6 +41,16 @@ describe("acp prompt content subprocess", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const remote = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [
|
||||
{ type: "text", text: "Use this remote image." },
|
||||
{ type: "image", mimeType: "image/png", data: "", uri: "https://example.com/remote.png" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const linked = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
@@ -70,8 +80,9 @@ describe("acp prompt content subprocess", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(remote.stopReason).toBe("end_turn")
|
||||
expect(linked.stopReason).toBe("end_turn")
|
||||
expect(missing.stopReason).toBe("end_turn")
|
||||
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(4)
|
||||
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(5)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -142,6 +142,44 @@ describe("acp prompt turns over the wire", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("attaches local image links and references remote or unreadable ones in place", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const local = pathToFileURL(path.join(dir.path, "local.png")).href
|
||||
const missing = pathToFileURL(path.join(dir.path, "missing.png")).href
|
||||
await Bun.write(path.join(dir.path, "local.png"), "png")
|
||||
await using acp = await startSession()
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "compare" },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/remote.png" },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: local },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: missing },
|
||||
])
|
||||
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
expect(acp.server.submissions).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "prompt",
|
||||
text: `compare\n[remote.png](https://example.com/remote.png)\n[missing.png](${missing})`,
|
||||
files: [{ uri: local, name: "local.png" }],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("rejects an image with neither data nor uri as invalid params", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "image", data: "", mimeType: "image/png" },
|
||||
]),
|
||||
),
|
||||
).toMatchObject({ code: -32602, message: expect.stringContaining("image content has no data or uri") })
|
||||
expect(acp.server.submissions).toEqual([])
|
||||
})
|
||||
|
||||
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
|
||||
const assistantTokens = { input: 100, output: 40, reasoning: 7, cache: { read: 11, write: 13 } }
|
||||
await using acp = await startSession({
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { PromptResponse } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Option, Schema } from "effect"
|
||||
import { ACPElicitation } from "../../src/acp/elicitation"
|
||||
import { createAcpFixture, expectOk, initialize, newSession, toolCallStream } from "./subprocess"
|
||||
|
||||
const ChatRequest = Schema.Struct({
|
||||
messages: Schema.Array(
|
||||
Schema.Struct({ role: Schema.String, tool_call_id: Schema.optional(Schema.String), content: Schema.Unknown }),
|
||||
),
|
||||
})
|
||||
|
||||
// The first completion asks a question; the follow-up completion ends the turn.
|
||||
function askingModel(request: unknown) {
|
||||
if (JSON.stringify(request).includes('"role":"tool"')) return "done"
|
||||
return toolCallStream("call_question", "question", {
|
||||
questions: [
|
||||
{
|
||||
header: "Runtime",
|
||||
question: "Which runtime?",
|
||||
options: [
|
||||
{ label: "Bun", description: "Fast" },
|
||||
{ label: "Node", description: "Stable" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe("acp question subprocess", () => {
|
||||
test("a question the client cannot show returns to the model and the turn continues", async () => {
|
||||
await using fixture = await createAcpFixture({ respond: askingModel })
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const result = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "ask me something" }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.stopReason).toBe("end_turn")
|
||||
const results = fixture.llm.requests
|
||||
.flatMap((request) => Option.toArray(Schema.decodeUnknownOption(ChatRequest)(request)))
|
||||
.flatMap((request) => request.messages)
|
||||
.filter((message) => message.role === "tool" && message.tool_call_id === "call_question")
|
||||
expect(results).toHaveLength(1)
|
||||
expect(String(results[0]?.content)).toContain(ACPElicitation.UnshownQuestionMessage)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -205,6 +205,39 @@ describe("acp session lifecycle over the wire", () => {
|
||||
).toMatchObject({ code: -32602, data: { sessionId: "ses_loaded" } })
|
||||
})
|
||||
|
||||
test("detaches sessions whose new or resume fails after attaching", async () => {
|
||||
// A model without a name forces a crash building config options after attach.
|
||||
await using acp = await startWire({
|
||||
fetch: (request) =>
|
||||
request.path === "/api/model"
|
||||
? Response.json({
|
||||
location: { directory: "/workspace", project: { id: "global", directory: "/workspace" } },
|
||||
data: [testModel, { ...secondModel, name: null }],
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
acp.server.sessions.set("ses_resumed", makeSession("ses_resumed"))
|
||||
await acp.initialize()
|
||||
const existing = new Set(acp.server.sessions.keys())
|
||||
|
||||
expect(await rpcError(acp.newSession())).toMatchObject({ code: -32603 })
|
||||
const created = acp.server.sessions.keys().find((id) => !existing.has(id))
|
||||
if (!created) throw new Error("session/new did not create a server session")
|
||||
expect(
|
||||
await rpcError(acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_resumed", mcpServers: [] })),
|
||||
).toMatchObject({ code: -32603 })
|
||||
const failed = [created, "ses_resumed"]
|
||||
|
||||
expect(
|
||||
await Promise.all(
|
||||
failed.map((sessionId) =>
|
||||
rpcError(acp.request("session/set_config_option", { sessionId, configId: "mode", value: "plan" })),
|
||||
),
|
||||
),
|
||||
).toMatchObject(failed.map((sessionId) => ({ code: -32602, data: { sessionId } })))
|
||||
expect(acp.server.sessions.has(created)).toBe(true)
|
||||
})
|
||||
|
||||
test("lists server-backed pages for the requested cwd", async () => {
|
||||
await using acp = await startWire()
|
||||
Array.from({ length: 101 }, (_, index) =>
|
||||
|
||||
@@ -161,6 +161,30 @@ export function expectOk<T>(response: JsonRpcResponse<T>) {
|
||||
return response.result
|
||||
}
|
||||
|
||||
/** A streamed completion that calls one tool. */
|
||||
export function toolCallStream(id: string, name: string, input: unknown) {
|
||||
const chunks = [
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: { role: "assistant", tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }] },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
},
|
||||
{
|
||||
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify(input) } }] } }],
|
||||
usage: null,
|
||||
},
|
||||
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: null },
|
||||
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } },
|
||||
]
|
||||
return new Response(`${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
|
||||
function verifierConfig(llmUrl: string, skills?: string) {
|
||||
const model = {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
|
||||
@@ -22,6 +22,7 @@ const ctx: ACPTranslate.Context = {
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "msg_input" },
|
||||
childUpdates: false,
|
||||
compaction: false,
|
||||
mode: "turn",
|
||||
}
|
||||
|
||||
@@ -164,7 +165,7 @@ describe("acp turn translation", () => {
|
||||
failed("ses_child", { type: "aborted", message: "interrupted" }),
|
||||
)
|
||||
|
||||
const abandoned = ACPTranslate.abandonTools(result.state, ctx)
|
||||
const abandoned = ACPTranslate.abandon(result.state, ctx)
|
||||
|
||||
expect(abandoned.state.tools.size).toBe(0)
|
||||
expect(updates(abandoned.outputs)).toEqual([
|
||||
@@ -177,6 +178,6 @@ describe("acp turn translation", () => {
|
||||
}),
|
||||
expect.objectContaining({ toolCallId: "ses_child:call_child", status: "failed", title: "Explore: read" }),
|
||||
])
|
||||
expect(ACPTranslate.abandonTools(abandoned.state, ctx).outputs).toEqual([])
|
||||
expect(ACPTranslate.abandon(abandoned.state, ctx).outputs).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { resolve } from "node:path"
|
||||
import { ACPElicitation } from "../../src/acp/elicitation"
|
||||
import {
|
||||
assistantMessage,
|
||||
childCreated,
|
||||
@@ -363,7 +364,9 @@ describe("acp turn events over the wire", () => {
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_question" }])
|
||||
expect(acp.server.cancelledForms).toEqual([
|
||||
{ sessionID: acp.sessionId, formID: "frm_question", message: ACPElicitation.UnshownQuestionMessage },
|
||||
])
|
||||
expect(acp.elicitations).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type AgentRequestParamsByMethod,
|
||||
type AgentRequestResponsesByMethod,
|
||||
type AnyMessage,
|
||||
type CompactionCapabilities,
|
||||
type ContentBlock,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
@@ -164,6 +165,7 @@ export type InitializeOptions = {
|
||||
readonly childSessionUpdates?: boolean
|
||||
readonly terminalAuth?: boolean
|
||||
readonly elicitation?: boolean
|
||||
readonly compaction?: CompactionCapabilities | null
|
||||
}
|
||||
|
||||
export const testModel = {
|
||||
@@ -370,6 +372,7 @@ export function permissionAsked(
|
||||
id: string,
|
||||
input: {
|
||||
readonly action?: string
|
||||
readonly resources?: EventData<"permission.asked">["resources"]
|
||||
readonly metadata?: EventData<"permission.asked">["metadata"]
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
} = {},
|
||||
@@ -378,7 +381,7 @@ export function permissionAsked(
|
||||
id,
|
||||
sessionID,
|
||||
action: input.action ?? "shell",
|
||||
resources: ["*"],
|
||||
resources: input.resources ?? ["*"],
|
||||
metadata: input.metadata ?? { command: "printf hello" },
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
})
|
||||
@@ -490,6 +493,7 @@ export async function startWire(options: WireOptions = {}) {
|
||||
clientCapabilities: {
|
||||
...(capabilities.writeTextFile ? { fs: { writeTextFile: true, readTextFile: false } } : {}),
|
||||
...(capabilities.elicitation ? { elicitation: { form: {} } } : {}),
|
||||
...(capabilities.compaction !== undefined ? { session: { compaction: capabilities.compaction } } : {}),
|
||||
_meta: {
|
||||
...(capabilities.childSessionUpdates ? { "opencode/child-session-updates": true } : {}),
|
||||
...(capabilities.terminalAuth ? { "terminal-auth": true } : {}),
|
||||
@@ -565,7 +569,7 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
const selections: Selection[] = []
|
||||
const interrupts: string[] = []
|
||||
const replies: Array<{ readonly sessionID: string; readonly requestID: string; readonly decision: string }> = []
|
||||
const cancelledForms: Array<{ readonly sessionID: string; readonly formID: string }> = []
|
||||
const cancelledForms: Array<{ readonly sessionID: string; readonly formID: string; readonly message?: string }> = []
|
||||
const repliedForms: FormReply[] = []
|
||||
const mcp: Array<{ readonly name: string; readonly directory?: string; readonly config: unknown }> = []
|
||||
const fake = {
|
||||
@@ -827,8 +831,8 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/form/:formID": {
|
||||
DELETE: route(async (req) => {
|
||||
const form = { sessionID: req.params.sessionID, formID: req.params.formID }
|
||||
DELETE: route(async (req, query) => {
|
||||
const form = { sessionID: req.params.sessionID, formID: req.params.formID, message: query.message }
|
||||
fake.cancelledForms.push(form)
|
||||
await emit(options.onFormCancel?.(form))
|
||||
return noContent()
|
||||
|
||||
@@ -2270,6 +2270,13 @@ export interface WorktreeApi<E = never> {
|
||||
readonly refresh: WorktreeRefreshOperation<E>
|
||||
}
|
||||
|
||||
export type VcsInitInput = {
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly provider?: string | undefined
|
||||
}
|
||||
export type VcsInitOutput = void
|
||||
export type VcsInitOperation<E = never> = (input?: VcsInitInput) => Effect.Effect<VcsInitOutput, E>
|
||||
|
||||
export type VcsGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type VcsGetOutput = { readonly location: Location.PublicRef; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: VcsGetInput) => Effect.Effect<VcsGetOutput, E>
|
||||
@@ -2300,6 +2307,7 @@ export type VcsDiffOutput = { readonly location: Location.PublicRef; readonly da
|
||||
export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect<VcsDiffOutput, E>
|
||||
|
||||
export interface VcsApi<E = never> {
|
||||
readonly init: VcsInitOperation<E>
|
||||
readonly get: VcsGetOperation<E>
|
||||
readonly base: VcsBaseOperation<E>
|
||||
readonly status: VcsStatusOperation<E>
|
||||
|
||||
@@ -242,6 +242,8 @@ import type {
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
VcsInitInput,
|
||||
VcsInitOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsBaseInput,
|
||||
@@ -1481,6 +1483,13 @@ const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
|
||||
refresh: EndpointWorktreeRefresh(raw),
|
||||
})
|
||||
|
||||
const EndpointVcsInit = (raw: RawClient["server.vcs"]) => (input?: VcsInitInput) =>
|
||||
preserveEffect<VcsInitOutput>()(
|
||||
raw["vcs.init"]({ query: { location: input?.["location"], provider: input?.["provider"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
|
||||
preserveEffect<VcsGetOutput>()(
|
||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -1511,6 +1520,7 @@ const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput)
|
||||
)
|
||||
|
||||
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
|
||||
init: EndpointVcsInit(raw),
|
||||
get: EndpointVcsGet(raw),
|
||||
base: EndpointVcsBase(raw),
|
||||
status: EndpointVcsStatus(raw),
|
||||
|
||||
@@ -240,6 +240,8 @@ import type {
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
VcsInitInput,
|
||||
VcsInitOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsBaseInput,
|
||||
@@ -2040,6 +2042,18 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
vcs: {
|
||||
init: (input?: VcsInitInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsInitOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/vcs/init`,
|
||||
query: { location: input?.["location"], provider: input?.["provider"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 409, 501, 503],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsGetOutput>(
|
||||
{
|
||||
|
||||
@@ -2742,6 +2742,14 @@ export type WorktreeError = {
|
||||
export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "WorktreeError"
|
||||
|
||||
export type VcsInitNotSupportedError = {
|
||||
readonly _tag: "VcsInitNotSupportedError"
|
||||
readonly providerID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isVcsInitNotSupportedError = (value: unknown): value is VcsInitNotSupportedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "VcsInitNotSupportedError"
|
||||
|
||||
export type ServerInfoOutput = ServerInfo
|
||||
|
||||
export type ServerPairOutput = PairingCode
|
||||
@@ -6495,6 +6503,19 @@ export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: s
|
||||
|
||||
export type WorktreeRefreshOutput = void
|
||||
|
||||
export type VcsInitInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly provider?: string | undefined
|
||||
}["location"]
|
||||
readonly provider?: {
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly provider?: string | undefined
|
||||
}["provider"]
|
||||
}
|
||||
|
||||
export type VcsInitOutput = void
|
||||
|
||||
export type VcsGetInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export * as ConfigFormatterPlugin from "./formatter.js"
|
||||
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode/schema/config"
|
||||
import type { ConfigFormatter } from "@opencode/schema/config/formatter"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
@@ -25,7 +27,7 @@ export const Plugin = define({
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, formatter.reload())
|
||||
|
||||
yield* formatter.transform((editor) => {
|
||||
const configured = Config.latest(loaded.entries, "formatter")
|
||||
const configured = resolve(loaded.entries)
|
||||
if (!configured) return
|
||||
const builtIns = make({
|
||||
directory: location.directory,
|
||||
@@ -56,3 +58,19 @@ export const Plugin = define({
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
// Documents apply from lowest to highest priority. A boolean resets everything before it; an object
|
||||
// merges each named formatter's supplied fields over the same name from earlier objects.
|
||||
function resolve(entries: readonly Entry[]) {
|
||||
return entries.reduce<boolean | Record<string, ConfigFormatter.Entry> | undefined>((result, entry) => {
|
||||
if (entry.type !== "document" || entry.info.formatter === undefined) return result
|
||||
if (typeof entry.info.formatter === "boolean") return entry.info.formatter
|
||||
return Object.entries(entry.info.formatter).reduce(
|
||||
(merged, [name, item]) => ({
|
||||
...merged,
|
||||
[name]: { ...merged[name], ...item, environment: { ...merged[name]?.environment, ...item.environment } },
|
||||
}),
|
||||
typeof result === "object" ? result : {},
|
||||
)
|
||||
}, undefined)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const Frontmatter = Schema.Struct({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
metadata: Schema.Unknown.pipe(Schema.optional),
|
||||
"disable-model-invocation": Schema.Unknown.pipe(Schema.optional),
|
||||
})
|
||||
const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter)
|
||||
|
||||
@@ -22,12 +23,16 @@ const metadataBoolean = (metadata: unknown, key: string) => {
|
||||
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return undefined
|
||||
}
|
||||
const value = (metadata as Record<string, unknown>)[key]
|
||||
return booleanValue((metadata as Record<string, unknown>)[key])
|
||||
}
|
||||
|
||||
const booleanValue = (value: unknown) => {
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined
|
||||
if (typeof value !== "string") return undefined
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === "true") return true
|
||||
if (normalized === "false") return false
|
||||
if (["true", "yes", "on", "1"].includes(normalized)) return true
|
||||
if (["false", "no", "off", "0"].includes(normalized)) return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -41,7 +46,9 @@ export function parse(directory: string, filepath: string, content: string): Par
|
||||
path.dirname(filepath) === directory && path.basename(filepath) !== "SKILL.md"
|
||||
? path.basename(filepath, ".md")
|
||||
: path.basename(path.dirname(filepath))
|
||||
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
|
||||
const opencodeAutoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
|
||||
const disableModelInvocation = booleanValue(frontmatter["disable-model-invocation"])
|
||||
const autoinvoke = opencodeAutoinvoke ?? (disableModelInvocation ? false : undefined)
|
||||
return {
|
||||
_tag: "Parsed",
|
||||
skill: {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-context automation","famLine truncated
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
@@ -1,13 +1,12 @@
|
||||
export * as PluginHost from "./host.js"
|
||||
|
||||
import { Plugin } from "@opencode/plugin/effect"
|
||||
import { SessionList } from "@opencode/schema/session-list"
|
||||
import type { IntegrationMethodRegistration } from "@opencode/plugin/effect/integration"
|
||||
import { EventManifest } from "@opencode/schema/event-manifest"
|
||||
import type { Event } from "@opencode/schema/event"
|
||||
import { ServerConfig } from "@opencode/schema/mcp"
|
||||
import { App } from "../app.js"
|
||||
import { DateTime, Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { AISDK } from "../aisdk.js"
|
||||
import { Command } from "../command.js"
|
||||
@@ -527,39 +526,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback, options) => hooks.register("session", name, callback, options),
|
||||
list: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const query =
|
||||
input?.cursor === undefined
|
||||
? (input ?? {})
|
||||
: yield* SessionList.Cursor.parse(input.cursor).pipe(Effect.mapError(() => new Error("Invalid cursor")))
|
||||
const page = yield* sessions.list({ ...query, limit: input?.limit ?? 50 })
|
||||
const first = page.data[0]
|
||||
const last = page.data.at(-1)
|
||||
return {
|
||||
data: page.data,
|
||||
cursor: {
|
||||
previous: first
|
||||
? SessionList.Cursor.make({
|
||||
...query,
|
||||
anchor: { id: first.id, time: DateTime.toEpochMillis(first.time.updated), direction: "previous" },
|
||||
})
|
||||
: undefined,
|
||||
next: last
|
||||
? SessionList.Cursor.make({
|
||||
...query,
|
||||
anchor: { id: last.id, time: DateTime.toEpochMillis(last.time.updated), direction: "next" },
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
active: () =>
|
||||
sessions.active.pipe(
|
||||
Effect.map((active) =>
|
||||
Object.fromEntries(Array.from(active, (sessionID) => [sessionID, { type: "running" as const }])),
|
||||
),
|
||||
),
|
||||
create: (input) =>
|
||||
sessions.create({
|
||||
id: input?.id,
|
||||
|
||||
@@ -23,7 +23,8 @@ export const Plugin = define({
|
||||
id: "opencode.vcs.git",
|
||||
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
if (location.vcs?.type !== "git") return
|
||||
// Markerless locations need this definition to initialize their first repository.
|
||||
if (location.vcs && location.vcs.type !== "git") return
|
||||
|
||||
const processes = yield* AppProcess.Service
|
||||
const adapter = make(processes, {
|
||||
@@ -35,6 +36,13 @@ export const Plugin = define({
|
||||
editor.add({
|
||||
id: "git",
|
||||
name: "Git",
|
||||
init: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* processes.run(
|
||||
ChildProcess.make(gitExecutable, ["init"], { cwd: input.worktree, stdin: "ignore" }),
|
||||
)
|
||||
if (result.exitCode !== 0) return yield* Effect.fail(new Error("Git initialization failed"))
|
||||
}),
|
||||
info: () => adapter.info(),
|
||||
base: () => adapter.base(),
|
||||
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
|
||||
|
||||
@@ -26,7 +26,8 @@ export const Plugin = define({
|
||||
id: "opencode.vcs.hg",
|
||||
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
if (location.vcs?.type !== "hg") return
|
||||
// Markerless locations need this definition to initialize their first repository.
|
||||
if (location.vcs && location.vcs.type !== "hg") return
|
||||
|
||||
const processes = yield* AppProcess.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
@@ -39,6 +40,18 @@ export const Plugin = define({
|
||||
editor.add({
|
||||
id: "hg",
|
||||
name: "Mercurial",
|
||||
init: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* processes.run(
|
||||
ChildProcess.make("hg", ["init"], {
|
||||
cwd: input.worktree,
|
||||
env: { HGPLAIN: "1" },
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
if (result.exitCode !== 0) return yield* Effect.fail(new Error("Mercurial initialization failed"))
|
||||
}),
|
||||
info: () => adapter.info(),
|
||||
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
|
||||
status: () => adapter.status(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { VcsEvent } from "@opencode/schema/vcs-event"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Location } from "./location.js"
|
||||
import { Project } from "./project.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { State } from "./state.js"
|
||||
import { emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch.js"
|
||||
@@ -20,6 +21,10 @@ export class DiffError extends Schema.TaggedError<DiffError>()("Vcs.DiffError",
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class InitializeError extends Schema.TaggedError<InitializeError>()("Vcs.InitializeError", {
|
||||
kind: Schema.Literals(["missing", "conflict", "unknown", "unsupported", "failed"]),
|
||||
}) {}
|
||||
|
||||
export interface DiffOptions {
|
||||
readonly context?: number
|
||||
readonly base?: string
|
||||
@@ -40,6 +45,7 @@ export interface Adapter {
|
||||
|
||||
export interface Interface extends Adapter, State.Transformable<VcsEditor> {
|
||||
readonly base: () => Effect.Effect<Base | null, DiffError>
|
||||
readonly initialize: (providerID: string) => Effect.Effect<void, InitializeError>
|
||||
}
|
||||
|
||||
interface Data {
|
||||
@@ -124,7 +130,9 @@ const layer = Layer.effect(
|
||||
const provider = selected()
|
||||
const next: Info = provider
|
||||
? {
|
||||
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })),
|
||||
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), {
|
||||
branch: {},
|
||||
})),
|
||||
provider: provider.id,
|
||||
}
|
||||
: { branch: {} }
|
||||
@@ -166,6 +174,24 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
initialize: Effect.fn("Vcs.initialize")(function* (providerID: string) {
|
||||
if (!(yield* fs.isDir(location.project.directory))) return yield* new InitializeError({ kind: "missing" })
|
||||
if (yield* Project.root(fs, location.project.directory)) return yield* new InitializeError({ kind: "conflict" })
|
||||
const provider = state.get().providers.get(providerID)
|
||||
if (!provider) return yield* new InitializeError({ kind: "unknown" })
|
||||
if (!provider.init) return yield* new InitializeError({ kind: "unsupported" })
|
||||
yield* provider
|
||||
.init(scope)
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterrupts(cause)
|
||||
? Effect.failCause(cause).pipe(Effect.orDie)
|
||||
: Effect.logWarning("vcs initialization failed", { provider: providerID, cause }).pipe(
|
||||
Effect.andThen(Effect.fail(new InitializeError({ kind: "failed" }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
info: Effect.fn("Vcs.info")(function* () {
|
||||
return current.info
|
||||
}),
|
||||
|
||||
@@ -123,6 +123,18 @@ function emitAndWait(update: Watcher.Update) {
|
||||
}
|
||||
|
||||
describe("SkillFile.parse", () => {
|
||||
test("reads disable-model-invocation as autoinvoke", () => {
|
||||
const parse = (frontmatter: string) =>
|
||||
SkillFile.parse("/repo/skills", "/repo/skills/deploy/SKILL.md", `---\n${frontmatter}\n---\n# deploy`)
|
||||
expect(parse("disable-model-invocation: true")).toMatchObject({ skill: { autoinvoke: false } })
|
||||
expect(parse("disable-model-invocation: yes")).toMatchObject({ skill: { autoinvoke: false } })
|
||||
expect(parse("disable-model-invocation: false")).not.toMatchObject({ skill: { autoinvoke: expect.anything() } })
|
||||
expect(parse("disable-model-invocation: maybe")).not.toMatchObject({ skill: { autoinvoke: expect.anything() } })
|
||||
expect(
|
||||
parse("disable-model-invocation: true\nmetadata:\n opencode/autoinvoke: true"),
|
||||
).toMatchObject({ skill: { autoinvoke: true } })
|
||||
})
|
||||
|
||||
test("parses root and nested skill ids and metadata flags", () => {
|
||||
const directory = "/repo/skills"
|
||||
expect(
|
||||
|
||||
@@ -20,10 +20,10 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
]),
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
|
||||
[Global.node.replace(tempGlobalLayer), offlineModels],
|
||||
),
|
||||
)
|
||||
type ConfigInput = typeof Info.Encoded
|
||||
|
||||
@@ -59,7 +59,117 @@ function withFormatter<A, E, R>(
|
||||
)
|
||||
}
|
||||
|
||||
function append(expression: string) {
|
||||
return [process.execPath, "-e", `require('fs').appendFileSync(process.argv.at(-1), ${expression})`, "$FILE"]
|
||||
}
|
||||
|
||||
const markdown = { command: append("'md'"), extensions: [".md"] }
|
||||
const project = { command: append("'project'"), extensions: [".project"] }
|
||||
|
||||
// Writes formatter config layers from lowest to highest priority: global, project, then project .opencode.
|
||||
function withLayers<A, E, R>(
|
||||
layers: ConfigInput["formatter"][],
|
||||
body: (formatter: Formatter.Interface, directory: string) => Effect.Effect<A, E, R>,
|
||||
) {
|
||||
return withTemp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(directory, ".opencode"))
|
||||
const files = [
|
||||
path.join(global.config, "opencode.jsonc"),
|
||||
path.join(directory, "opencode.json"),
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
]
|
||||
await Promise.all(layers.map((formatter, index) => fs.writeFile(files[index], JSON.stringify({ formatter }))))
|
||||
})
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
return yield* body(yield* Formatter.Service, directory)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const layered: { name: string; layers: ConfigInput["formatter"][]; expected: Record<string, string | false> }[] = [
|
||||
{
|
||||
name: "keeps lower formatters when a higher config adds another",
|
||||
layers: [{ markdown }, undefined, { project }],
|
||||
expected: { "test.md": "md", "test.project": "project" },
|
||||
},
|
||||
{
|
||||
name: "inherits omitted fields and replaces supplied arrays",
|
||||
layers: [
|
||||
{ markdown: { command: append("'A'"), extensions: [".md", ".project"] } },
|
||||
{ markdown: { command: append("'B'"), extensions: [".project"] } },
|
||||
],
|
||||
expected: { "test.md": false, "test.project": "B" },
|
||||
},
|
||||
{
|
||||
name: "merges environment variables by key",
|
||||
layers: [
|
||||
{
|
||||
markdown: {
|
||||
...markdown,
|
||||
command: append("process.env.FIRST + process.env.SECOND"),
|
||||
environment: { FIRST: "global", SECOND: "global" },
|
||||
},
|
||||
},
|
||||
{ markdown: { environment: { SECOND: "project" } } },
|
||||
],
|
||||
expected: { "test.md": "globalproject" },
|
||||
},
|
||||
{
|
||||
name: "keeps a lower disable through overrides that omit it",
|
||||
layers: [{ markdown: { ...markdown, disabled: true } }, { markdown: { command: append("'override'") }, project }],
|
||||
expected: { "test.md": false, "test.project": "project" },
|
||||
},
|
||||
{
|
||||
name: "re-enables a lower disabled formatter",
|
||||
layers: [{ markdown: { ...markdown, disabled: true } }, { markdown: { disabled: false } }],
|
||||
expected: { "test.md": "md" },
|
||||
},
|
||||
{
|
||||
name: "keeps lower formatters through an empty object",
|
||||
layers: [{ markdown }, {}],
|
||||
expected: { "test.md": "md" },
|
||||
},
|
||||
{
|
||||
name: "clears lower formatters when a higher config is false",
|
||||
layers: [{ markdown }, false],
|
||||
expected: { "test.md": false },
|
||||
},
|
||||
{
|
||||
name: "clears lower formatters before a later object when a higher config is true",
|
||||
layers: [{ markdown }, true, { project }],
|
||||
expected: { "test.md": false, "test.project": "project" },
|
||||
},
|
||||
{
|
||||
name: "enables only formatters configured after false",
|
||||
layers: [{ markdown }, false, { project }],
|
||||
expected: { "test.md": false, "test.project": "project" },
|
||||
},
|
||||
]
|
||||
|
||||
describe("Formatter", () => {
|
||||
layered.forEach((entry) =>
|
||||
it.live(entry.name, () =>
|
||||
withLayers(entry.layers, (formatter, directory) =>
|
||||
Effect.forEach(Object.entries(entry.expected), ([name, expected]) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, name)
|
||||
yield* Effect.promise(() => fs.writeFile(file, ""))
|
||||
const formatted = yield* formatter.file(file)
|
||||
expect(formatted && (yield* Effect.promise(() => fs.readFile(file, "utf8")))).toBe(expected)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
;[
|
||||
{ file: "test.match", extension: ".match", matches: true },
|
||||
{ file: "test.other", extension: ".match", matches: false },
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { PluginPromise } from "@opencode/core/plugin/promise"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
it.live("Effect plugins list filtered sessions with server-style cursors", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const context = yield* PluginHost.make(plugins)
|
||||
const first = yield* context.session.create({ title: "match one" })
|
||||
yield* context.session.create({ title: "different" })
|
||||
const second = yield* context.session.create({ title: "match two" })
|
||||
const query = { directory: context.location.directory, limit: 1, order: "asc" as const, search: "match" }
|
||||
const page = yield* context.session.list(query)
|
||||
expect(page.data.map((session) => session.id)).toEqual([first.id])
|
||||
expect(page.cursor.next).toBeDefined()
|
||||
|
||||
const next = yield* context.session.list({ cursor: page.cursor.next, limit: 1 })
|
||||
expect(next.data.map((session) => session.id)).toEqual([second.id])
|
||||
const previous = yield* context.session.list({ cursor: next.cursor.previous, limit: 1 })
|
||||
expect(previous.data.map((session) => session.id)).toEqual([first.id])
|
||||
expect(
|
||||
yield* context.session.list({ cursor: "invalid" as NonNullable<typeof page.cursor.next> }).pipe(Effect.flip),
|
||||
).toMatchObject({ message: "Invalid cursor" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("Promise plugins list sessions and report active sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const sessions = yield* Session.Service
|
||||
const context = yield* PluginHost.make(plugins)
|
||||
const created = yield* context.session.create({ title: "running" })
|
||||
const active = yield* PluginHost.make(plugins).pipe(
|
||||
Effect.provideService(
|
||||
Session.Service,
|
||||
Session.Service.of({ ...sessions, active: Effect.succeed(new Set([created.id])) }),
|
||||
),
|
||||
)
|
||||
|
||||
yield* PluginPromise.fromPromise({
|
||||
id: "test.session-list-active",
|
||||
async setup(ctx) {
|
||||
const page = await ctx.session.list({ directory: ctx.location.directory, search: "running", limit: 1 })
|
||||
expect(page.data.map((session) => session.id)).toEqual([created.id])
|
||||
expect(page.cursor.next).toBeDefined()
|
||||
expect(await ctx.session.list({ cursor: page.cursor.next, limit: 1 })).toMatchObject({ data: [] })
|
||||
expect(await ctx.session.active()).toEqual({ [created.id]: { type: "running" } })
|
||||
},
|
||||
}).effect(active)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Plugin } from "@opencode/plugin"
|
||||
import type { VcsDefinition } from "@opencode/plugin/effect/vcs"
|
||||
import { PluginPromise } from "@opencode/core/plugin/promise"
|
||||
import { State } from "@opencode/core/state"
|
||||
import { Effect } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
it.live("Promise VCS initializer reaches the registered provider with its location scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: string[] = []
|
||||
const state = State.create({
|
||||
initial: () => new Map<string, VcsDefinition>(),
|
||||
editor: (providers) => ({
|
||||
add: (definition: VcsDefinition) => providers.set(definition.id, definition),
|
||||
default: { get: () => undefined, set: (_selection: string) => {} },
|
||||
}),
|
||||
})
|
||||
const context = host()
|
||||
const plugin = PluginPromise.fromPromise(
|
||||
Plugin.define({
|
||||
id: "init-vcs",
|
||||
async setup(ctx) {
|
||||
await ctx.vcs.transform((editor) =>
|
||||
editor.add({
|
||||
id: "custom",
|
||||
name: "Custom VCS",
|
||||
init: async (scope, { signal }) => {
|
||||
expect(signal.aborted).toBe(false)
|
||||
calls.push(scope.worktree)
|
||||
},
|
||||
info: async () => ({ branch: {} }),
|
||||
branches: async () => [],
|
||||
status: async () => [],
|
||||
diff: async () => [],
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* plugin.effect(host({ vcs: { ...context.vcs, transform: state.transform, reload: state.reload } }))
|
||||
const init = state.get().get("custom")?.init
|
||||
if (!init) return yield* Effect.die("Initializer was not registered")
|
||||
yield* init({ directory: "/workspace", worktree: "/workspace", canonical: "/workspace" })
|
||||
expect(calls).toEqual(["/workspace"])
|
||||
}),
|
||||
)
|
||||
@@ -103,6 +103,29 @@ const provider = (input: Partial<VcsDefinition> = {}) =>
|
||||
}) satisfies VcsDefinition
|
||||
|
||||
describe("Vcs", () => {
|
||||
it.live("routes initialization through the requested provider before a repository exists", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const calls: string[] = []
|
||||
yield* vcs.transform((editor) => {
|
||||
editor.add(provider({ id: "read-only" }))
|
||||
editor.add(
|
||||
provider({
|
||||
id: "custom",
|
||||
init: (input) => Effect.sync(() => void calls.push(input.worktree)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(yield* vcs.initialize("missing").pipe(Effect.flip)).toMatchObject({ kind: "unknown" })
|
||||
expect(yield* vcs.initialize("read-only").pipe(Effect.flip)).toMatchObject({ kind: "unsupported" })
|
||||
expect(calls).toEqual([])
|
||||
yield* vcs.initialize("custom")
|
||||
expect(calls).toEqual([directory])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns empty results outside version control", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
import type { SessionReviewLineComment } from "@opencode/session-ui/session-review"
|
||||
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
|
||||
import { checksum } from "@opencode/util/encode"
|
||||
import { showToast } from "@opencode/ui/toast"
|
||||
import { createQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { createComputed, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
@@ -46,6 +47,7 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
|
||||
scroll: undefined as HTMLDivElement | undefined,
|
||||
pendingFile: undefined as string | undefined,
|
||||
deferRender: false,
|
||||
initializingGit: false,
|
||||
// The filter is transient by design: a persisted filter would silently hide
|
||||
// files after a reload.
|
||||
filter: "",
|
||||
@@ -218,6 +220,45 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
|
||||
if (!stored()) return false
|
||||
return !diffQuery.isPending
|
||||
}
|
||||
const lifetime = { disposed: false }
|
||||
onCleanup(() => {
|
||||
lifetime.disposed = true
|
||||
})
|
||||
const initializeGit = () => {
|
||||
if (state.initializingGit) return
|
||||
const location = view.location
|
||||
if (!location || !view.server.connected) {
|
||||
showToast({ variant: "error", title: ctx.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
const key = view.key
|
||||
const sessionID = view.id
|
||||
setState("initializingGit", true)
|
||||
void view.server.client.vcs
|
||||
.init({ location, provider: "git" })
|
||||
.then(async () => {
|
||||
if (lifetime.disposed || ctx.signal.aborted || view.key !== key) return
|
||||
const data = view.server.data
|
||||
data.project.invalidate()
|
||||
data.session.invalidate(sessionID)
|
||||
data.location.invalidate(location)
|
||||
data.location.vcs.invalidate(location)
|
||||
await data.project.sync()
|
||||
await data.session.sync(sessionID)
|
||||
await Promise.all([data.location.syncInfo(location), data.location.vcs.sync(location)])
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (lifetime.disposed || ctx.signal.aborted || view.key !== key) return
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: ctx.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (!lifetime.disposed && !ctx.signal.aborted && view.key === key) setState("initializingGit", false)
|
||||
})
|
||||
}
|
||||
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
|
||||
const source = diffs().find((diff) => diff.file === path)
|
||||
const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => {
|
||||
@@ -445,6 +486,8 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
|
||||
kinds,
|
||||
focusFile,
|
||||
hasChanges,
|
||||
initializeGit,
|
||||
initializingGit: () => state.initializingGit,
|
||||
loadDiff,
|
||||
mode,
|
||||
noGit: createMemo(() => !!view.project && !view.project.vcs),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Match, Show, Switch } from "solid-js"
|
||||
import { SessionReviewEmptyChangesV2 } from "@opencode/session-ui/v2/session-review-empty-changes-v2"
|
||||
import { SessionReviewEmptyNoGitV2 } from "@opencode/session-ui/v2/session-review-empty-no-git-v2"
|
||||
import { Select } from "@opencode/ui/select"
|
||||
import { useExtension } from "../sdk"
|
||||
import type { ChangeMode, ReviewModel } from "./model"
|
||||
@@ -12,7 +13,7 @@ export function ReviewTitle(props: { review: ReviewModel }) {
|
||||
return ctx.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
return (
|
||||
<Show when={props.review.canReview()}>
|
||||
<Show when={props.review.canReview() && props.review.options().length > 0}>
|
||||
<Select
|
||||
options={props.review.options()}
|
||||
current={props.review.mode()}
|
||||
@@ -28,7 +29,6 @@ export function ReviewTitle(props: { review: ReviewModel }) {
|
||||
export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }) {
|
||||
const ctx = useExtension()
|
||||
const loading = () => !props.review.ready()
|
||||
const noGit = () => props.review.noGit()
|
||||
const text = () => {
|
||||
if (props.review.mode() === "git") return ctx.t("empty.git")
|
||||
if (props.review.mode() === "branch") return ctx.t("empty.branch")
|
||||
@@ -39,14 +39,9 @@ export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }
|
||||
<Match when={loading()}>
|
||||
<div class={props.loadingClass}>{ctx.t("loadingChanges")}</div>
|
||||
</Match>
|
||||
<Match when={noGit()}>
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-14-medium text-text-strong">{ctx.t("git.title")}</div>
|
||||
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||
{ctx.t("git.description")}
|
||||
</div>
|
||||
</div>
|
||||
<Match when={props.review.noGit()}>
|
||||
<div class="h-full flex flex-col">
|
||||
<SessionReviewEmptyNoGitV2 pending={props.review.initializingGit()} onInitGit={props.review.initializeGit} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
@@ -61,16 +56,13 @@ export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }
|
||||
export function ReviewPanelEmpty(props: { review: ReviewModel }) {
|
||||
const ctx = useExtension()
|
||||
const loading = () => !props.review.ready()
|
||||
const noGit = () => props.review.noGit()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class="px-6 py-4 text-text-weak">{ctx.t("loadingChanges")}</div>
|
||||
</Match>
|
||||
<Match when={noGit()}>
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">{ctx.t("git.description")}</div>
|
||||
</div>
|
||||
<Match when={props.review.noGit()}>
|
||||
<SessionReviewEmptyNoGitV2 pending={props.review.initializingGit()} onInitGit={props.review.initializeGit} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewEmptyChangesV2 />
|
||||
|
||||
@@ -152,8 +152,6 @@ export interface SessionHooks {
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
SessionApi<unknown>,
|
||||
| "list"
|
||||
| "active"
|
||||
| "create"
|
||||
| "get"
|
||||
| "remove"
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface VcsDiffInput extends VcsScope {
|
||||
export interface VcsDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly init?: (input: VcsScope) => Effect.Effect<void, unknown>
|
||||
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>
|
||||
@@ -33,7 +34,7 @@ export interface VcsDefinition {
|
||||
readonly diff: (input: VcsDiffInput) => Effect.Effect<readonly FileDiff.Info[], unknown>
|
||||
}
|
||||
|
||||
export interface VcsDomain extends VcsApi<unknown> {
|
||||
export interface VcsDomain extends Omit<VcsApi<unknown>, "init"> {
|
||||
readonly transform: Transform<VcsEditor>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -518,10 +518,12 @@ export function fromPromise(plugin: Plugin) {
|
||||
host.vcs.transform((editor) => {
|
||||
callback({
|
||||
add: (definition) => {
|
||||
const init = definition.init?.bind(definition)
|
||||
const base = definition.base?.bind(definition)
|
||||
editor.add({
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
init: init ? (input) => attempt((signal) => init(input, { signal })) : undefined,
|
||||
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 })),
|
||||
@@ -579,8 +581,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
register(
|
||||
host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options),
|
||||
),
|
||||
list: adaptApiMethod(SessionEndpoints["session.list"], host.session.list),
|
||||
active: adaptApiMethod(SessionEndpoints["session.active"], host.session.active),
|
||||
create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
|
||||
get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
|
||||
remove: adaptApiMethod(SessionEndpoints["session.remove"], host.session.remove),
|
||||
|
||||
@@ -152,8 +152,6 @@ export interface SessionHooks {
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
SessionApi,
|
||||
| "list"
|
||||
| "active"
|
||||
| "create"
|
||||
| "get"
|
||||
| "remove"
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface VcsDiffInput extends VcsScope {
|
||||
export interface VcsDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly init?: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<void>
|
||||
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>
|
||||
@@ -32,7 +33,7 @@ export interface VcsDefinition {
|
||||
readonly diff: (input: VcsDiffInput, context: { readonly signal: AbortSignal }) => Promise<readonly FileDiff.Info[]>
|
||||
}
|
||||
|
||||
export interface VcsDomain extends VcsApi {
|
||||
export interface VcsDomain extends Omit<VcsApi, "init"> {
|
||||
readonly transform: Transform<VcsEditor>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
|
||||
+623
-19
@@ -48,6 +48,106 @@
|
||||
"summary": "Get server info"
|
||||
}
|
||||
},
|
||||
"/api/pair": {
|
||||
"post": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.pair",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingCode",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
|
||||
"summary": "Create pairing code"
|
||||
}
|
||||
},
|
||||
"/auth/connect/{code}": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.connect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingSession",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
|
||||
"summary": "Redeem pairing code"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
"get": {
|
||||
"tags": ["location"],
|
||||
@@ -886,9 +986,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a session at the requested location.",
|
||||
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
|
||||
"summary": "Create session",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -907,6 +1017,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"parentID": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -4508,6 +4629,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4591,21 +4727,6 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -7197,6 +7318,125 @@
|
||||
"summary": "List MCP resources"
|
||||
}
|
||||
},
|
||||
"/api/credential": {
|
||||
"get": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List every stored integration credential, including its secret value.",
|
||||
"summary": "List credentials"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
},
|
||||
"required": ["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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
|
||||
"summary": "Create credential",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Credential.CreateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/credential/{credentialID}": {
|
||||
"patch": {
|
||||
"tags": ["credential"],
|
||||
@@ -11451,6 +11691,124 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/vcs/init": {
|
||||
"post": {
|
||||
"tags": ["vcs"],
|
||||
"operationId": "vcs.init",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
},
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"501": {
|
||||
"description": "VcsInitNotSupportedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VcsInitNotSupportedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "ServiceUnavailableError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization. An unknown provider returns 400; a registered provider without init returns 501.",
|
||||
"summary": "Initialize VCS repository"
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": ["vcs"],
|
||||
@@ -13237,6 +13595,27 @@
|
||||
"required": ["command"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Model.Capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tools": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"input": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Model.CostEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13328,7 +13707,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"capabilities": {
|
||||
"$ref": "#/components/schemas/Model.Capabilities"
|
||||
"$ref": "#/components/schemas/Config.Model.Capabilities"
|
||||
},
|
||||
"variants": {
|
||||
"type": "array",
|
||||
@@ -13433,8 +13812,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerTimeout": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"chunkTimeout": {
|
||||
"type": "number"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
@@ -13631,6 +14029,9 @@
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label", "method"],
|
||||
@@ -13645,6 +14046,9 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "name"],
|
||||
@@ -13660,6 +14064,128 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Connection.Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["needs_auth"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.CreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["integrationID", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
}
|
||||
},
|
||||
"required": ["id", "integrationID", "label", "active", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["key"]
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"configuration": {
|
||||
"$ref": "#/components/schemas/Form.Answer_1"
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.OAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["oauth"]
|
||||
},
|
||||
"methodID": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["type", "methodID", "refresh", "access", "expires"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.OAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.Key"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileDiff.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13746,6 +14272,12 @@
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.Answer_1": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.BooleanField": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15709,6 +16241,29 @@
|
||||
"Money.USDPerMillionTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"PairingCode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["code", "expires_in"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PairingSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Permission.Effect": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny", "ask"]
|
||||
@@ -16601,8 +17156,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerTimeout": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"chunkTimeout": {
|
||||
"type": "number"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
@@ -18234,6 +18808,16 @@
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["body"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
@@ -18653,6 +19237,9 @@
|
||||
"exit": {
|
||||
"type": "number"
|
||||
},
|
||||
"signal": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18929,6 +19516,23 @@
|
||||
"type": "string",
|
||||
"enum": ["working", "branch", "committed"]
|
||||
},
|
||||
"VcsInitNotSupportedErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["VcsInitNotSupportedError"]
|
||||
},
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["_tag", "providerID", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WebSearch.Provider": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -5,10 +5,16 @@ import { Session } from "@opencode/schema/session"
|
||||
import { SessionStats } from "@opencode/schema/session-stats"
|
||||
import { InstructionEntry } from "@opencode/schema/instruction-entry"
|
||||
import { Project } from "@opencode/schema/project"
|
||||
import { AbsolutePath, DateTimeUtcFromMillis, NonNegativeInt } from "@opencode/schema/schema"
|
||||
import { SessionList } from "@opencode/schema/session-list"
|
||||
import {
|
||||
AbsolutePath,
|
||||
DateTimeUtcFromMillis,
|
||||
NonNegativeInt,
|
||||
PositiveInt,
|
||||
RelativePath,
|
||||
statics,
|
||||
} from "@opencode/schema/schema"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { Context, Effect, Schema, SchemaGetter, Struct } from "effect"
|
||||
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import {
|
||||
ConflictError,
|
||||
@@ -37,8 +43,75 @@ import { FileDiff } from "@opencode/schema/file-diff"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import { PublicSessionMessage } from "./message.js"
|
||||
|
||||
export const SessionsCursor = SessionList.Cursor
|
||||
export type SessionsCursor = SessionList.Cursor
|
||||
const ParentIDFilter = Schema.Union([
|
||||
Session.ID,
|
||||
Schema.Null.pipe(
|
||||
Schema.encodeTo(Schema.Literal("null"), {
|
||||
decode: SchemaGetter.transform(() => null),
|
||||
encode: SchemaGetter.transform(() => "null" as const),
|
||||
}),
|
||||
),
|
||||
]).annotate({
|
||||
description: "Filter by parent session. Use null to return only root sessions.",
|
||||
})
|
||||
|
||||
const SessionsQueryFields = {
|
||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
search: Schema.optional(Schema.String),
|
||||
parentID: ParentIDFilter.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const SessionsDirectoryQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
|
||||
const SessionsProjectQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
project: Project.ID,
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
|
||||
|
||||
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
|
||||
schema.mapFields((fields) => ({
|
||||
...Struct.omit(fields, ["limit"]),
|
||||
anchor: Session.ListAnchor,
|
||||
}))
|
||||
|
||||
const SessionsCursorInput = Schema.Union([
|
||||
withCursor(SessionsDirectoryQuery),
|
||||
withCursor(SessionsProjectQuery),
|
||||
withCursor(SessionsAllQuery),
|
||||
])
|
||||
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
|
||||
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
|
||||
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
|
||||
const invalidCursor = "Invalid cursor" as const
|
||||
|
||||
export const SessionsCursor = Schema.String.pipe(
|
||||
Schema.brand("SessionsCursor"),
|
||||
statics((schema) => {
|
||||
const make = schema.make.bind(schema)
|
||||
return {
|
||||
make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
|
||||
parse: (input: string) =>
|
||||
Effect.suspend(() => {
|
||||
const result = Encoding.decodeBase64UrlString(input)
|
||||
return Result.isFailure(result)
|
||||
? Effect.fail(invalidCursor)
|
||||
: decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
export type SessionsCursor = typeof SessionsCursor.Type
|
||||
|
||||
const SessionActive = Schema.Struct({
|
||||
type: Schema.Literal("running"),
|
||||
@@ -85,12 +158,24 @@ const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
|
||||
}),
|
||||
)
|
||||
|
||||
export const SessionsQuery = SessionList.Query
|
||||
const SessionsQueryCursor = SessionsCursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
})
|
||||
|
||||
export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI extends HttpApiMiddleware.AnyId, FormS>(
|
||||
sessionLocationMiddleware: Context.Key<I, S>,
|
||||
formLocationMiddleware: Context.Key<FormI, FormS>,
|
||||
) =>
|
||||
export const SessionsQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
directory: AbsolutePath.pipe(Schema.optional),
|
||||
project: Project.ID.pipe(Schema.optional),
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
cursor: SessionsQueryCursor.pipe(Schema.optional),
|
||||
}).annotate({ identifier: "SessionsQuery" })
|
||||
|
||||
export const makeSessionGroup = <
|
||||
I extends HttpApiMiddleware.AnyId,
|
||||
S,
|
||||
FormI extends HttpApiMiddleware.AnyId,
|
||||
FormS,
|
||||
>(sessionLocationMiddleware: Context.Key<I, S>, formLocationMiddleware: Context.Key<FormI, FormS>) =>
|
||||
HttpApiGroup.make("server.session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.list", "/api/session", {
|
||||
@@ -477,7 +562,9 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
|
||||
error: [SessionNotFoundError, SessionBusyError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" })),
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" }),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
|
||||
|
||||
@@ -3,9 +3,20 @@ import { Location } from "@opencode/schema/location"
|
||||
import { NonNegativeInt, PositiveInt, optional } from "@opencode/schema/schema"
|
||||
import { Vcs } from "@opencode/schema/vcs"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
import { ServiceUnavailableError } from "../errors.js"
|
||||
import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "../errors.js"
|
||||
|
||||
const InitQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
provider: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export class VcsInitNotSupportedError extends Schema.TaggedError<VcsInitNotSupportedError>()(
|
||||
"VcsInitNotSupportedError",
|
||||
{ providerID: Schema.String, message: Schema.String },
|
||||
{ httpApiStatus: 501 },
|
||||
) {}
|
||||
|
||||
const BranchesQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
@@ -21,6 +32,22 @@ const DiffQuery = Schema.Struct({
|
||||
})
|
||||
|
||||
export const VcsGroup = HttpApiGroup.make("server.vcs")
|
||||
.add(
|
||||
HttpApiEndpoint.post("vcs.init", "/api/vcs/init", {
|
||||
query: InitQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [ConflictError, InvalidRequestError, VcsInitNotSupportedError, ServiceUnavailableError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "vcs.init",
|
||||
summary: "Initialize VCS repository",
|
||||
description:
|
||||
"Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization. An unknown provider returns 400; a registered provider without init returns 501.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("vcs.get", "/api/vcs", {
|
||||
query: LocationQuery,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
export * as SessionList from "./session-list.js"
|
||||
|
||||
import { Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||
import { Project } from "./project.js"
|
||||
import { Session } from "./session.js"
|
||||
import { AbsolutePath, PositiveInt, RelativePath, statics } from "./schema.js"
|
||||
|
||||
const ParentIDFilter = Schema.Union([
|
||||
Session.ID,
|
||||
Schema.Null.pipe(
|
||||
Schema.encodeTo(Schema.Literal("null"), {
|
||||
decode: SchemaGetter.transform(() => null),
|
||||
encode: SchemaGetter.transform(() => "null" as const),
|
||||
}),
|
||||
),
|
||||
]).annotate({
|
||||
description: "Filter by parent session. Use null to return only root sessions.",
|
||||
})
|
||||
|
||||
const QueryFields = {
|
||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
search: Schema.optional(Schema.String),
|
||||
parentID: ParentIDFilter.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const DirectoryQuery = Schema.Struct({ ...QueryFields, directory: AbsolutePath })
|
||||
const ProjectQuery = Schema.Struct({ ...QueryFields, project: Project.ID, subpath: RelativePath.pipe(Schema.optional) })
|
||||
const AllQuery = Schema.Struct(QueryFields)
|
||||
|
||||
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
|
||||
schema.mapFields((fields) => ({ ...Struct.omit(fields, ["limit"]), anchor: Session.ListAnchor }))
|
||||
|
||||
const CursorInput = Schema.Union([withCursor(DirectoryQuery), withCursor(ProjectQuery), withCursor(AllQuery)])
|
||||
const CursorJson = Schema.fromJsonString(CursorInput)
|
||||
const encodeCursor = Schema.encodeSync(CursorJson)
|
||||
const decodeCursor = Schema.decodeUnknownEffect(CursorJson)
|
||||
const invalidCursor = "Invalid cursor" as const
|
||||
|
||||
export const Cursor = Schema.String.pipe(
|
||||
Schema.brand("SessionsCursor"),
|
||||
statics((schema) => {
|
||||
const make = schema.make.bind(schema)
|
||||
return {
|
||||
make: (input: typeof CursorInput.Type) => make(Encoding.encodeBase64Url(encodeCursor(input))),
|
||||
parse: (input: string) =>
|
||||
Effect.suspend(() => {
|
||||
const result = Encoding.decodeBase64UrlString(input)
|
||||
return Result.isFailure(result)
|
||||
? Effect.fail(invalidCursor)
|
||||
: decodeCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
export type Cursor = typeof Cursor.Type
|
||||
|
||||
export const Query = Schema.Struct({
|
||||
...QueryFields,
|
||||
directory: AbsolutePath.pipe(Schema.optional),
|
||||
project: Project.ID.pipe(Schema.optional),
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
cursor: Cursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
}).pipe(Schema.optional),
|
||||
}).annotate({ identifier: "SessionsQuery" })
|
||||
@@ -1,13 +1,59 @@
|
||||
import { Vcs } from "@opencode/core/vcs"
|
||||
import { ServiceUnavailableError } from "@opencode/protocol/errors"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { Worktree } from "@opencode/core/worktree"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { LocationServiceMap } from "@opencode/core/location-services"
|
||||
import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "@opencode/protocol/errors"
|
||||
import { VcsInitNotSupportedError } from "@opencode/protocol/groups/vcs"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
|
||||
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
return handlers
|
||||
.handle("vcs.init", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const directory = location.project.directory
|
||||
const providerID = ctx.query.provider ?? "git"
|
||||
yield* Plugin.awaitActivation
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.initialize(providerID).pipe(
|
||||
Effect.mapError((error) => {
|
||||
if (error.kind === "missing")
|
||||
return new InvalidRequestError({ message: "Project directory does not exist", field: "location" })
|
||||
if (error.kind === "conflict")
|
||||
return new ConflictError({ message: "Project already has version control", resource: directory })
|
||||
if (error.kind === "unknown")
|
||||
return new InvalidRequestError({
|
||||
message: "Unknown VCS provider",
|
||||
field: "provider",
|
||||
})
|
||||
if (error.kind === "unsupported")
|
||||
return new VcsInitNotSupportedError({
|
||||
providerID,
|
||||
message: "VCS provider does not support initialization",
|
||||
})
|
||||
return new ServiceUnavailableError({ service: providerID, message: "VCS initialization failed" })
|
||||
}),
|
||||
)
|
||||
const resolved = yield* project.resolve(directory)
|
||||
if (!resolved.vcs)
|
||||
return yield* new ServiceUnavailableError({ service: providerID, message: "VCS initialization failed" })
|
||||
yield* locations.invalidate(
|
||||
Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||
)
|
||||
yield* bus.publish(Worktree.Event.Updated, { projectID: resolved.id })
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle("vcs.get", () =>
|
||||
response(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -96,6 +96,7 @@ const vcsLayer = Layer.succeed(
|
||||
Vcs.Service,
|
||||
Vcs.Service.of({
|
||||
base: () => Effect.succeed(null),
|
||||
initialize: () => Effect.fail(new Vcs.InitializeError({ kind: "unsupported" })),
|
||||
transform: () => Effect.succeed({ dispose: Effect.void }),
|
||||
reload: () => Effect.void,
|
||||
info: () => Effect.succeed({ branch: {} }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path"
|
||||
import { $ } from "bun"
|
||||
import { expect } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { SdkPlugins } from "@opencode/core/plugin/sdk"
|
||||
import { Effect, Layer, Schedule } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
@@ -8,6 +8,204 @@ import { it } from "../../core/test/lib/effect"
|
||||
import { startServer } from "./fixture/server"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
it.live(
|
||||
"initializes Git in a markerless project and refreshes its location",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-init-")))
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "hello.txt"), "hello\n"))
|
||||
const server = yield* startServer(path.join(tmp.path, "config"))
|
||||
const url = new URL("/api/vcs/init", server.base)
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
const before = yield* Effect.promise(() =>
|
||||
fetch(new URL(`/api/location${url.search}`, server.base), { headers: server.headers }),
|
||||
)
|
||||
expect(before.status).toBe(200)
|
||||
const original = yield* Effect.promise(() => before.json())
|
||||
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
expect(initialized.status).toBe(204)
|
||||
expect(yield* Effect.promise(() => $`git -C ${tmp.path} rev-parse --is-inside-work-tree`.text())).toBe("true\n")
|
||||
url.pathname = "/api/location"
|
||||
const location = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
expect(location.status).toBe(200)
|
||||
const refreshed = yield* Effect.promise(() => location.json())
|
||||
expect(refreshed).toMatchObject({ project: { directory: tmp.path } })
|
||||
expect(refreshed.project.id).not.toBe(original.project.id)
|
||||
const projects = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/project", server.base), { headers: server.headers }),
|
||||
)
|
||||
expect(yield* Effect.promise(() => projects.json())).toContainEqual(
|
||||
expect.objectContaining({ id: refreshed.project.id, vcs: "git" }),
|
||||
)
|
||||
url.pathname = "/api/vcs"
|
||||
const vcs = yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const response = await fetch(url, { headers: server.headers })
|
||||
const body: unknown = await response.json()
|
||||
if (!isRecord(body) || !isRecord(body.data) || body.data.provider !== "git")
|
||||
throw new Error("Git provider not ready")
|
||||
return body
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
}).pipe(Effect.retry(Schedule.spaced("10 millis")), Effect.timeout("2 seconds"))
|
||||
expect(vcs).toMatchObject({ data: { provider: "git" } })
|
||||
url.pathname = "/api/vcs/diff"
|
||||
url.searchParams.set("mode", "working")
|
||||
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
expect(diff.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
|
||||
data: expect.arrayContaining([expect.objectContaining({ file: "hello.txt" })]),
|
||||
})
|
||||
url.searchParams.delete("mode")
|
||||
url.pathname = "/api/vcs/init"
|
||||
const repeated = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
expect(repeated.status).toBe(409)
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"does not create a project directory while initializing Git",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-missing-")))
|
||||
const server = yield* startServer(path.join(tmp.path, "config"))
|
||||
const directory = path.join(tmp.path, "absent")
|
||||
const url = new URL("/api/vcs/init", server.base)
|
||||
url.searchParams.set("location[directory]", directory)
|
||||
const response = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
expect(response.status).not.toBe(204)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(directory, ".git", "HEAD")).exists())).toBe(false)
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"accepts an explicit Git provider and rejects unknown providers without initializing",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-provider-")))
|
||||
const server = yield* startServer(path.join(tmp.path, "config"))
|
||||
const url = new URL("/api/vcs/init", server.base)
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
url.searchParams.set("provider", "unknown")
|
||||
const unsupported = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
expect(unsupported.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => unsupported.json())).toMatchObject({
|
||||
_tag: "InvalidRequestError",
|
||||
field: "provider",
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(false)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(false)
|
||||
url.searchParams.set("provider", "git")
|
||||
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
expect(initialized.status).toBe(204)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(true)
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live("returns 501 when a registered VCS provider has no initializer", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-no-init-")))
|
||||
const handler = yield* ServerFetch.make(
|
||||
{
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: tmp.path },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
SdkPlugins.node.replace(
|
||||
Layer.succeed(
|
||||
SdkPlugins.Service,
|
||||
SdkPlugins.Service.of({
|
||||
register: () => Effect.void,
|
||||
all: () => [
|
||||
{
|
||||
id: "read-only-vcs",
|
||||
revision: "test",
|
||||
effect: (ctx) =>
|
||||
ctx.vcs
|
||||
.transform((editor) => {
|
||||
editor.add({
|
||||
id: "read-only",
|
||||
name: "Read-only VCS",
|
||||
info: () => Effect.succeed({ branch: {} }),
|
||||
branches: () => Effect.succeed([]),
|
||||
status: () => Effect.succeed([]),
|
||||
diff: () => Effect.succeed([]),
|
||||
})
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
const url = new URL("http://opencode.local/api/vcs/init")
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
url.searchParams.set("provider", "read-only")
|
||||
const response = yield* Effect.promise(() => handler(new Request(url, { method: "POST" })))
|
||||
expect(response.status).toBe(501)
|
||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({
|
||||
_tag: "VcsInitNotSupportedError",
|
||||
providerID: "read-only",
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(false)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
const describeHg = Bun.which("hg") ? describe : describe.skip
|
||||
|
||||
describeHg("Mercurial initialization", () => {
|
||||
it.live(
|
||||
"initializes and serves an untracked-file diff through the Hg provider",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-hg-init-")))
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "hello.txt"), "hello\n"))
|
||||
const server = yield* startServer(path.join(tmp.path, "config"))
|
||||
const url = new URL("/api/vcs/init", server.base)
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
url.searchParams.set("provider", "hg")
|
||||
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
expect(initialized.status).toBe(204)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(true)
|
||||
url.pathname = "/api/vcs"
|
||||
const info = yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const response = await fetch(url, { headers: server.headers })
|
||||
const body: unknown = await response.json()
|
||||
if (!isRecord(body) || !isRecord(body.data) || body.data.provider !== "hg")
|
||||
throw new Error("Mercurial provider not ready")
|
||||
return body
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
}).pipe(Effect.retry(Schedule.spaced("10 millis")), Effect.timeout("2 seconds"))
|
||||
expect(info).toMatchObject({ data: { provider: "hg" } })
|
||||
url.pathname = "/api/vcs/diff"
|
||||
url.searchParams.set("mode", "working")
|
||||
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
expect(diff.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
|
||||
data: expect.arrayContaining([expect.objectContaining({ file: "hello.txt" })]),
|
||||
})
|
||||
url.pathname = "/api/vcs/init"
|
||||
url.searchParams.delete("mode")
|
||||
url.searchParams.set("provider", "git")
|
||||
const repeated = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
|
||||
expect(repeated.status).toBe(409)
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
})
|
||||
|
||||
it.live(
|
||||
"serves lazy review bases, committed diffs, and unavailable-base errors",
|
||||
() =>
|
||||
|
||||
+628
-20
@@ -48,6 +48,106 @@
|
||||
"summary": "Get server info"
|
||||
}
|
||||
},
|
||||
"/api/pair": {
|
||||
"post": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.pair",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingCode",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
|
||||
"summary": "Create pairing code"
|
||||
}
|
||||
},
|
||||
"/auth/connect/{code}": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.connect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingSession",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
|
||||
"summary": "Redeem pairing code"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
"get": {
|
||||
"tags": ["location"],
|
||||
@@ -886,9 +986,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a session at the requested location.",
|
||||
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
|
||||
"summary": "Create session",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -907,6 +1017,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"parentID": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -4508,6 +4629,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4591,21 +4727,6 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -7197,6 +7318,125 @@
|
||||
"summary": "List MCP resources"
|
||||
}
|
||||
},
|
||||
"/api/credential": {
|
||||
"get": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List every stored integration credential, including its secret value.",
|
||||
"summary": "List credentials"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
},
|
||||
"required": ["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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
|
||||
"summary": "Create credential",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Credential.CreateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/credential/{credentialID}": {
|
||||
"patch": {
|
||||
"tags": ["credential"],
|
||||
@@ -11451,6 +11691,124 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/vcs/init": {
|
||||
"post": {
|
||||
"tags": ["vcs"],
|
||||
"operationId": "vcs.init",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
},
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"501": {
|
||||
"description": "VcsInitNotSupportedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VcsInitNotSupportedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "ServiceUnavailableError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization. An unknown provider returns 400; a registered provider without init returns 501.",
|
||||
"summary": "Initialize VCS repository"
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": ["vcs"],
|
||||
@@ -13237,6 +13595,27 @@
|
||||
"required": ["command"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Model.Capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tools": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"input": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Model.CostEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13328,7 +13707,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"capabilities": {
|
||||
"$ref": "#/components/schemas/Model.Capabilities"
|
||||
"$ref": "#/components/schemas/Config.Model.Capabilities"
|
||||
},
|
||||
"variants": {
|
||||
"type": "array",
|
||||
@@ -13433,8 +13812,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerTimeout": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"chunkTimeout": {
|
||||
"type": "number"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
@@ -13631,6 +14029,9 @@
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label", "method"],
|
||||
@@ -13645,6 +14046,9 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "name"],
|
||||
@@ -13660,6 +14064,128 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Connection.Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["needs_auth"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.CreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["integrationID", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
}
|
||||
},
|
||||
"required": ["id", "integrationID", "label", "active", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["key"]
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"configuration": {
|
||||
"$ref": "#/components/schemas/Form.Answer_1"
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.OAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["oauth"]
|
||||
},
|
||||
"methodID": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["type", "methodID", "refresh", "access", "expires"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.OAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.Key"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileDiff.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13746,6 +14272,12 @@
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.Answer_1": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.BooleanField": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15709,6 +16241,29 @@
|
||||
"Money.USDPerMillionTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"PairingCode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["code", "expires_in"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PairingSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Permission.Effect": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny", "ask"]
|
||||
@@ -16601,8 +17156,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerTimeout": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"chunkTimeout": {
|
||||
"type": "number"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
@@ -18234,6 +18808,16 @@
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["body"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
@@ -18653,6 +19237,9 @@
|
||||
"exit": {
|
||||
"type": "number"
|
||||
},
|
||||
"signal": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18929,6 +19516,23 @@
|
||||
"type": "string",
|
||||
"enum": ["working", "branch", "committed"]
|
||||
},
|
||||
"VcsInitNotSupportedErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["VcsInitNotSupportedError"]
|
||||
},
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["_tag", "providerID", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WebSearch.Provider": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19053,6 +19657,10 @@
|
||||
"WorktreeErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["WorktreeError"]
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"enum": ["WorktreeError"]
|
||||
@@ -19078,7 +19686,7 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["name", "data"],
|
||||
"required": ["_tag", "name", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -48,6 +48,106 @@
|
||||
"summary": "Get server info"
|
||||
}
|
||||
},
|
||||
"/api/pair": {
|
||||
"post": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.pair",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingCode",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
|
||||
"summary": "Create pairing code"
|
||||
}
|
||||
},
|
||||
"/auth/connect/{code}": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.connect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingSession",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
|
||||
"summary": "Redeem pairing code"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
"get": {
|
||||
"tags": ["location"],
|
||||
@@ -886,9 +986,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a session at the requested location.",
|
||||
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
|
||||
"summary": "Create session",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -907,6 +1017,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"parentID": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -4508,6 +4629,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4591,21 +4727,6 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -7197,6 +7318,125 @@
|
||||
"summary": "List MCP resources"
|
||||
}
|
||||
},
|
||||
"/api/credential": {
|
||||
"get": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List every stored integration credential, including its secret value.",
|
||||
"summary": "List credentials"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
},
|
||||
"required": ["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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
|
||||
"summary": "Create credential",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Credential.CreateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/credential/{credentialID}": {
|
||||
"patch": {
|
||||
"tags": ["credential"],
|
||||
@@ -11451,6 +11691,124 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/vcs/init": {
|
||||
"post": {
|
||||
"tags": ["vcs"],
|
||||
"operationId": "vcs.init",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
},
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"501": {
|
||||
"description": "VcsInitNotSupportedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VcsInitNotSupportedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "ServiceUnavailableError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization. An unknown provider returns 400; a registered provider without init returns 501.",
|
||||
"summary": "Initialize VCS repository"
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": ["vcs"],
|
||||
@@ -13237,6 +13595,27 @@
|
||||
"required": ["command"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Model.Capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tools": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"input": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Model.CostEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13328,7 +13707,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"capabilities": {
|
||||
"$ref": "#/components/schemas/Model.Capabilities"
|
||||
"$ref": "#/components/schemas/Config.Model.Capabilities"
|
||||
},
|
||||
"variants": {
|
||||
"type": "array",
|
||||
@@ -13433,8 +13812,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerTimeout": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"chunkTimeout": {
|
||||
"type": "number"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
@@ -13631,6 +14029,9 @@
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label", "method"],
|
||||
@@ -13645,6 +14046,9 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "name"],
|
||||
@@ -13660,6 +14064,128 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Connection.Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["needs_auth"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.CreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["integrationID", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
}
|
||||
},
|
||||
"required": ["id", "integrationID", "label", "active", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["key"]
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"configuration": {
|
||||
"$ref": "#/components/schemas/Form.Answer_1"
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.OAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["oauth"]
|
||||
},
|
||||
"methodID": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["type", "methodID", "refresh", "access", "expires"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.OAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.Key"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileDiff.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13746,6 +14272,12 @@
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.Answer_1": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.BooleanField": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15709,6 +16241,29 @@
|
||||
"Money.USDPerMillionTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"PairingCode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["code", "expires_in"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PairingSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Permission.Effect": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny", "ask"]
|
||||
@@ -16601,8 +17156,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerTimeout": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"chunkTimeout": {
|
||||
"type": "number"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
@@ -18234,6 +18808,16 @@
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["body"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
@@ -18653,6 +19237,9 @@
|
||||
"exit": {
|
||||
"type": "number"
|
||||
},
|
||||
"signal": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18929,6 +19516,23 @@
|
||||
"type": "string",
|
||||
"enum": ["working", "branch", "committed"]
|
||||
},
|
||||
"VcsInitNotSupportedErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["VcsInitNotSupportedError"]
|
||||
},
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["_tag", "providerID", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WebSearch.Provider": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19053,6 +19657,10 @@
|
||||
"WorktreeErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["WorktreeError"]
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"enum": ["WorktreeError"]
|
||||
@@ -19078,7 +19686,7 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["name", "data"],
|
||||
"required": ["_tag", "name", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -785,18 +785,6 @@ interface ReferenceDomain extends ReferenceApi<unknown> {
|
||||
|
||||
### Sessions
|
||||
|
||||
List sessions with server-style filters and cursor pagination. `active()` reports the sessions running in this server
|
||||
process.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const page = yield* ctx.session.list({ directory: ctx.location.directory, limit: 20, order: "desc" })
|
||||
const next = page.cursor.next ? yield* ctx.session.list({ cursor: page.cursor.next, limit: 20 }) : undefined
|
||||
const active = yield* ctx.session.active()
|
||||
}),
|
||||
```
|
||||
|
||||
Create or read a session, then select the agent and model used by later work.
|
||||
|
||||
```ts
|
||||
@@ -861,8 +849,6 @@ Schemas: [`Session.Info`](/api#schema-Session.Info), [`Model.Ref`](/api#schema-M
|
||||
```ts
|
||||
type SessionDomain = Pick<
|
||||
SessionApi<unknown>,
|
||||
| "list"
|
||||
| "active"
|
||||
| "create"
|
||||
| "get"
|
||||
| "remove"
|
||||
@@ -1100,6 +1086,11 @@ Provider callbacks receive the current location, working-copy root, canonical pr
|
||||
store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery
|
||||
continues to use OpenCode's built-in Git and Mercurial detectors.
|
||||
|
||||
Providers can optionally implement `init(scope)` to create a repository in `scope.worktree`. Initialization selects
|
||||
the provider by ID (Git by default), not by the detected repository type. Register a provider with `init` even when
|
||||
the location has no repository yet; OpenCode checks that the directory exists and has no repository before calling
|
||||
it, then rediscovers the project. New repository formats still need discovery support beyond this callback.
|
||||
|
||||
Schemas: [`Vcs.Info`](/api#schema-Vcs.Info), [`Vcs.FileStatus`](/api#schema-Vcs.FileStatus),
|
||||
[`FileDiff.Info`](/api#schema-FileDiff.Info).
|
||||
|
||||
@@ -1112,10 +1103,15 @@ interface VcsEditor {
|
||||
}
|
||||
}
|
||||
|
||||
interface VcsDomain extends VcsApi<unknown> {
|
||||
interface VcsDomain extends Omit<VcsApi<unknown>, "init"> {
|
||||
readonly transform: Transform<VcsEditor>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
interface VcsDefinition {
|
||||
readonly init?: (scope: VcsScope) => Effect.Effect<void, unknown>
|
||||
// Existing info, branches, status, and diff callbacks are required.
|
||||
}
|
||||
```
|
||||
|
||||
### Websearch
|
||||
|
||||
@@ -742,15 +742,6 @@ await ctx.permission.rules({
|
||||
|
||||
### Sessions
|
||||
|
||||
List sessions with the same filters and cursor pagination as the server API. The cursor preserves the filters and order;
|
||||
pass `cursor.next` to load the next page. `active()` returns the sessions currently running in this server process.
|
||||
|
||||
```ts
|
||||
const page = await ctx.session.list({ directory: ctx.location.directory, limit: 20, order: "desc" })
|
||||
const next = page.cursor.next ? await ctx.session.list({ cursor: page.cursor.next, limit: 20 }) : undefined
|
||||
const active = await ctx.session.active()
|
||||
```
|
||||
|
||||
Create or read a session.
|
||||
|
||||
```ts
|
||||
@@ -803,8 +794,6 @@ Schemas: [`Session.Info`](/api#schema-Session.Info), [`Model.Ref`](/api#schema-M
|
||||
|
||||
```ts
|
||||
interface SessionContext {
|
||||
list(input?: SessionListInput, requestOptions?: RequestOptions): Promise<SessionListOutput>
|
||||
active(requestOptions?: RequestOptions): Promise<SessionActiveOutput>
|
||||
create(input?: SessionCreateInput, requestOptions?: RequestOptions): Promise<SessionInfo>
|
||||
get(input: SessionGetInput, requestOptions?: RequestOptions): Promise<SessionInfo>
|
||||
remove(input: SessionRemoveInput, requestOptions?: RequestOptions): Promise<void>
|
||||
@@ -1056,6 +1045,11 @@ Provider callbacks receive the current location, working-copy root, canonical pr
|
||||
store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery
|
||||
continues to use OpenCode's built-in Git and Mercurial detectors.
|
||||
|
||||
Providers can optionally implement `init(scope, { signal })` to create a repository in `scope.worktree`. Initialization
|
||||
selects the provider by ID (Git by default), not by the detected repository type. Register a provider with `init` even
|
||||
when the location has no repository yet; OpenCode checks that the directory exists and has no repository before calling
|
||||
it, then rediscovers the project. New repository formats still need discovery support beyond this callback.
|
||||
|
||||
#### Reference
|
||||
|
||||
Schemas: [`Vcs.Info`](/api#schema-Vcs.Info), [`Vcs.FileStatus`](/api#schema-Vcs.FileStatus),
|
||||
@@ -1078,6 +1072,11 @@ interface VcsEditor {
|
||||
set(providerID: string): void
|
||||
}
|
||||
}
|
||||
|
||||
interface VcsDefinition {
|
||||
init?(scope: VcsScope, context: { signal: AbortSignal }): Promise<void>
|
||||
// Existing info, branches, status, and diff callbacks are required.
|
||||
}
|
||||
```
|
||||
|
||||
### Worktrees
|
||||
|
||||
@@ -26,7 +26,8 @@ available.
|
||||
```
|
||||
|
||||
An object also enables the built-ins and lets you override them or add custom
|
||||
formatters. An empty object is therefore equivalent to `true`.
|
||||
formatters. An empty object preserves inherited formatter settings, while `true`
|
||||
resets them to the built-ins.
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
@@ -177,8 +178,8 @@ OpenCode logs the failure and tries the next match.
|
||||
|
||||
## Disable
|
||||
|
||||
Omit `formatter` or set it to `false` to disable all formatting. An explicit
|
||||
`false` can override a lower-priority configuration that enabled formatters.
|
||||
Formatting is disabled when no config file sets `formatter`. To disable
|
||||
formatting that a lower-priority file enabled, set it to `false`.
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
|
||||
@@ -120,7 +120,6 @@ Use frontmatter to name the skill and control where it appears:
|
||||
---
|
||||
name: Git Release
|
||||
description: Prepare a repository release
|
||||
slash: true
|
||||
metadata:
|
||||
opencode/autoinvoke: false
|
||||
---
|
||||
@@ -128,17 +127,16 @@ metadata:
|
||||
Prepare the changelog, version bump, tag, and release notes.
|
||||
```
|
||||
|
||||
| Field | Behavior |
|
||||
| ------------------------------ | ------------------------------------------------------------------ |
|
||||
| `name` | Display name; defaults to the path-derived ID |
|
||||
| `description` | Summary used to show the skill to the model |
|
||||
| `slash` | Set to `false` to hide the skill from interactive command catalogs |
|
||||
| `metadata.opencode/slash` | Boolean or `"true"`/`"false"`; overrides `slash` |
|
||||
| `metadata.opencode/autoinvoke` | Set to `false` to omit the skill from the model's available list |
|
||||
| Field | Behavior |
|
||||
| ------------------------------ | ----------------------------------------------------------------- |
|
||||
| `name` | Display name; defaults to the path-derived ID |
|
||||
| `description` | Summary used to show the skill to the model |
|
||||
| `metadata.opencode/autoinvoke` | Set to `false` to omit the skill from the model's available list |
|
||||
| `disable-model-invocation` | Set to `true` for the same effect as `opencode/autoinvoke: false` |
|
||||
|
||||
All frontmatter is optional at runtime. Add a clear `description` when the model should discover the skill; skills without one are not advertised.
|
||||
|
||||
`opencode/autoinvoke: false` only hides the skill from the model's available list. The skill remains registered and can still be loaded explicitly by ID. V2 accepts portability fields such as `license` and `compatibility` but does not interpret them.
|
||||
`opencode/autoinvoke: false` only hides the skill from the model's available list. The skill remains registered and can still be loaded explicitly by ID. When both `disable-model-invocation` and `opencode/autoinvoke` are set, `opencode/autoinvoke` wins. V2 accepts portability fields such as `license` and `compatibility` but does not interpret them.
|
||||
|
||||
## IDs
|
||||
|
||||
@@ -197,6 +195,8 @@ OpenCode then:
|
||||
3. Adds the Markdown body, without frontmatter, to the conversation.
|
||||
4. Provides the skill's base directory and a sample of up to ten supporting file paths.
|
||||
|
||||
To load a skill yourself, mention it in your prompt as `@skill-id`.
|
||||
|
||||
Supporting file contents are not loaded automatically. The agent reads them when the skill directs it to do so. The file sample is available for directory-based `SKILL.md` skills; flat Markdown skills do not receive a neighboring file list.
|
||||
|
||||
## Permissions
|
||||
|
||||
Reference in new issue
Block a user