mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 20:46:14 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e506cfbde |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Correct directory page headings when the read offset is zero.
|
||||
@@ -966,7 +966,6 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -976,7 +975,6 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/pacote": "11.1.8",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -5814,7 +5812,7 @@
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
@@ -6644,6 +6642,8 @@
|
||||
|
||||
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
|
||||
|
||||
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
|
||||
@@ -7450,12 +7450,6 @@
|
||||
|
||||
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
@@ -8162,8 +8156,6 @@
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||
|
||||
@@ -1000,12 +1000,33 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
|
||||
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
|
||||
|
||||
// Reasoning is one response-wide channel: it stays open alongside text and
|
||||
// refusal output so late reasoning deltas and details join the same block,
|
||||
// and `finishEvents` closes it once with the complete metadata.
|
||||
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
if (delta?.content) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
}
|
||||
|
||||
if (delta?.refusal) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
if (delta?.refusal) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
}
|
||||
|
||||
// Compatible providers may omit indexes. Prefer durable identity, then use
|
||||
// batch position for parallel deltas or the latest call for sparse chunks.
|
||||
@@ -1111,12 +1132,10 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
|
||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||
}
|
||||
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
|
||||
// Snapshot details at publish time so the emitted event never observes later
|
||||
// mutation of the accumulated `reasoningDetails` array.
|
||||
const metadata = reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? [...state.reasoningDetails] : undefined,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
)
|
||||
const started =
|
||||
state.reasoningDetailsObserved && !state.reasoningEmitted
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Message,
|
||||
LanguageModel,
|
||||
ToolCallPart,
|
||||
@@ -1150,7 +1149,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves scalar reasoning after content starts in one lifecycle", () =>
|
||||
it.effect("preserves scalar reasoning after content starts", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -1167,35 +1166,8 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("detailscalar")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps one reasoning lifecycle across many content chunks", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
|
||||
...Array.from({ length: 25 }, (_, index) => deltaChunk({ content: `chunk-${index} ` })),
|
||||
deltaChunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.text).toBe(Array.from({ length: 25 }, (_, index) => `chunk-${index} `).join(""))
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
@@ -1241,18 +1213,7 @@ describe("OpenAI Chat route", () => {
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
// Snapshot reasoning-end metadata as each event is published so the
|
||||
// assertion cannot pass through later mutation of a shared array.
|
||||
const publishedEndMetadata: unknown[] = []
|
||||
const response = yield* LLMClient.stream(request).pipe(
|
||||
Stream.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
if (LLMEvent.is.reasoningEnd(event))
|
||||
publishedEndMetadata.push(decodeJson(encodeJson(event.providerMetadata)))
|
||||
}),
|
||||
),
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.map((state) => LLMResponse.complete(state)!),
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
@@ -1273,12 +1234,10 @@ describe("OpenAI Chat route", () => {
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(publishedEndMetadata).toEqual([{ openai: { reasoningField: "reasoning", reasoningDetails: merged } }])
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningStart)).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.textStart),
|
||||
)
|
||||
// Reasoning stays open alongside text and closes once during finalization.
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeGreaterThan(
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
})
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.textStart),
|
||||
)
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ export function createSessionRequestModel() {
|
||||
if (message.type !== "synthetic") return []
|
||||
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
|
||||
return [message.metadata.childID]
|
||||
if (message.metadata?.source === "shell")
|
||||
return [message.metadata.shellID, message.metadata.jobID].filter((id): id is string => typeof id === "string")
|
||||
if (message.metadata?.source === "shell" && typeof message.metadata.jobID === "string")
|
||||
return [message.metadata.jobID]
|
||||
return []
|
||||
}),
|
||||
)
|
||||
@@ -121,7 +121,6 @@ export function createSessionRequestModel() {
|
||||
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
|
||||
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
|
||||
const shellID = part.state.metadata.shellID
|
||||
if (typeof shellID === "string" && completed.has(shellID)) return []
|
||||
const command = part.state.input.command
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -85,10 +85,10 @@ export default Runtime.handler(Commands, (input) =>
|
||||
},
|
||||
packages: {
|
||||
resolve: (spec, install = true) =>
|
||||
runPromise(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })),
|
||||
check: (spec) =>
|
||||
runPromise(
|
||||
npm.check(spec).pipe(Effect.mapError((error) => (error.cause instanceof Error ? error.cause : error))),
|
||||
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
|
||||
Effect.map((result) => result.entrypoint),
|
||||
),
|
||||
),
|
||||
},
|
||||
environment: requestedServer === undefined ? Env.session() : undefined,
|
||||
|
||||
@@ -88,16 +88,8 @@ export type PluginListInput = {
|
||||
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly target: string
|
||||
}
|
||||
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: Plugin.PackageStatus }
|
||||
export type PluginCheckOperation<E = never> = (input: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
|
||||
|
||||
export interface PluginApi<E = never> {
|
||||
readonly list: PluginListOperation<E>
|
||||
readonly check: PluginCheckOperation<E>
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -320,17 +318,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input: PluginCheckInput) =>
|
||||
preserveEffect<PluginCheckOutput>()(
|
||||
raw["plugin.check"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
|
||||
list: EndpointPluginList(raw),
|
||||
check: EndpointPluginCheck(raw),
|
||||
})
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
|
||||
|
||||
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
|
||||
preserveEffect<SessionListOutput>()(
|
||||
|
||||
@@ -32,7 +32,6 @@ export { Location } from "@opencode-ai/schema/location"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Plugin } from "@opencode-ai/schema/plugin"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
|
||||
@@ -9,8 +9,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -464,19 +462,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
check: (input: PluginCheckInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginCheckOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/check`,
|
||||
query: { location: input["location"] },
|
||||
body: { target: input["target"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
session: {
|
||||
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -16,8 +16,6 @@ export type PluginSource =
|
||||
| { type: "local"; path: string }
|
||||
| { type: "sdk" }
|
||||
|
||||
export type PluginPackageStatus = { installed?: string; available?: string; mutable: boolean }
|
||||
|
||||
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
||||
|
||||
export type MoneyUSD = number
|
||||
@@ -443,8 +441,8 @@ export type ProviderRequest = {
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
export type PluginInfo =
|
||||
| { id: string; source: PluginSource; revision?: string; status: "active"; tui: boolean }
|
||||
| { id?: string; source: PluginSource; revision?: string; status: "failed"; error: string; tui: boolean }
|
||||
| { id: string; source: PluginSource; status: "active"; tui: boolean }
|
||||
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
|
||||
|
||||
export type SessionMessageLocationSwitched = {
|
||||
id: string
|
||||
@@ -2340,10 +2338,6 @@ export type AgentNotFoundError = {
|
||||
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
|
||||
|
||||
export type PluginCheckError = { readonly _tag: "PluginCheckError"; readonly message: string }
|
||||
export const isPluginCheckError = (value: unknown): value is PluginCheckError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PluginCheckError"
|
||||
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
|
||||
@@ -2554,18 +2548,6 @@ export type PluginListOutput = {
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly target: { readonly target: string }["target"]
|
||||
}
|
||||
|
||||
export type PluginCheckOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: PluginPackageStatus
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
readonly workspace?: {
|
||||
readonly workspace?: string | undefined
|
||||
|
||||
@@ -29,7 +29,6 @@ import type {
|
||||
SessionMessageAssistantTool,
|
||||
SessionInfo,
|
||||
SessionInboxInfo,
|
||||
SessionInboxCompaction,
|
||||
ShellInfo,
|
||||
SkillInfo,
|
||||
VcsInfo,
|
||||
@@ -285,11 +284,12 @@ export function createData(config: CreateDataInput) {
|
||||
setStore("session", "pending", sessionID, index, { ...item, delivery })
|
||||
}
|
||||
|
||||
// Inbox IDs of optimistic admissions awaiting acknowledgement, so rejection
|
||||
// only rolls back unacknowledged rows and a pending re-fetch cannot wipe a
|
||||
// row the server does not know about yet. Prompts clear on their durable
|
||||
// echo, positive pending read, or rollback; compactions also reconcile the
|
||||
// POST's canonical ID.
|
||||
// Inbox IDs of optimistic prompt admissions still awaiting their durable
|
||||
// echo. This is the one deliberate piece of in-flight bookkeeping in this
|
||||
// layer: it exists so a rejection only rolls back rows the server never
|
||||
// acknowledged, and so a concurrent pending re-fetch cannot wipe a row the
|
||||
// server does not know about yet. Entries clear on the enqueued echo or on
|
||||
// rollback — not on POST success, which typically precedes the echo.
|
||||
const outbox = new Set<string>()
|
||||
|
||||
// Session IDs of optimistic create admissions still awaiting acknowledgement
|
||||
@@ -303,13 +303,11 @@ export function createData(config: CreateDataInput) {
|
||||
// to exist server-side instead of failing with "not found".
|
||||
const creating = new Map<string, Promise<unknown>>()
|
||||
|
||||
// Per-session send chain: prompts and compactions must be admitted in
|
||||
// submission order. Each waits for the previous POST to settle, so one
|
||||
// failure does not block the next.
|
||||
// Per-session send chain: prompts must be admitted in submission order,
|
||||
// and HTTP gives no ordering across concurrent POSTs. Each prompt waits
|
||||
// for the previous prompt's POST (settled, so one failure does not block
|
||||
// the next) before sending its own.
|
||||
const sending = new Map<string, Promise<unknown>>()
|
||||
const messageLoads = new Map<string, Promise<unknown>>()
|
||||
const compacting = new Map<string, { id: string; observed: Set<string>; request: Promise<SessionInboxCompaction> }>()
|
||||
onCleanup(() => compacting.clear())
|
||||
|
||||
// Register `promise` under `key` until it settles. A later registration
|
||||
// replaces an earlier one; settlement only clears its own entry.
|
||||
@@ -321,24 +319,9 @@ export function createData(config: CreateDataInput) {
|
||||
void promise.then(settle, settle)
|
||||
}
|
||||
|
||||
// Capture creation before settlement clears its entry, so dependent RPCs still see a failed create.
|
||||
function sendAdmission<Value>(sessionID: string, send: () => Promise<Value>, gate?: Promise<unknown>) {
|
||||
const created = creating.get(sessionID)
|
||||
const previous = sending.get(sessionID)
|
||||
const request = Promise.resolve()
|
||||
.then(() => Promise.all([gate, created, previous]))
|
||||
.then(send)
|
||||
track(
|
||||
sending,
|
||||
sessionID,
|
||||
request.catch(() => undefined),
|
||||
)
|
||||
return request
|
||||
}
|
||||
|
||||
// Upsert an admitted inbox item into pending, input, and (for user and
|
||||
// synthetic items) the visible transcript. Used by the inbox.enqueued
|
||||
// handler and by optimistic admission; the upsert is what reconciles
|
||||
// handler and by optimistic prompt admission; the upsert is what reconciles
|
||||
// the durable echo with an optimistic placeholder — the durable payload and
|
||||
// times replace the client's guess.
|
||||
function admitLocal(item: SessionInboxInfo) {
|
||||
@@ -351,7 +334,6 @@ export function createData(config: CreateDataInput) {
|
||||
item.sessionID,
|
||||
at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)),
|
||||
)
|
||||
if (item.type === "compaction") return
|
||||
const input = store.session.input[item.sessionID] ?? []
|
||||
if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id])
|
||||
materializeInboxMessage(item)
|
||||
@@ -686,7 +668,6 @@ export function createData(config: CreateDataInput) {
|
||||
draft.push(existing)
|
||||
message.reindex(draft, index, position)
|
||||
})
|
||||
compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID)
|
||||
return
|
||||
}
|
||||
case "session.inbox.delivery.changed":
|
||||
@@ -694,7 +675,6 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
case "session.inbox.cancelled": {
|
||||
retractLocal(event.data.sessionID, event.data.inboxID)
|
||||
compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID)
|
||||
return
|
||||
}
|
||||
case "session.inbox.enqueued": {
|
||||
@@ -705,12 +685,6 @@ export function createData(config: CreateDataInput) {
|
||||
timeCreated: event.created,
|
||||
...event.data.item,
|
||||
})
|
||||
if (event.data.item.type === "compaction") {
|
||||
const active = compacting.get(event.data.sessionID)
|
||||
active?.observed.add(event.data.inboxID)
|
||||
if (active && active.id !== event.data.inboxID && outbox.delete(active.id))
|
||||
removePending(event.data.sessionID, active.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
case "session.instructions.updated":
|
||||
@@ -1009,7 +983,6 @@ export function createData(config: CreateDataInput) {
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
|
||||
return
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
@@ -1107,7 +1080,6 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
message.append(draft, index, failed)
|
||||
})
|
||||
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
|
||||
return
|
||||
case "permission.asked":
|
||||
if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) return
|
||||
@@ -1294,17 +1266,12 @@ export function createData(config: CreateDataInput) {
|
||||
sync(sessionID: string) {
|
||||
return sync.run(`session.pending:${sessionID}`, async () => {
|
||||
const pending = await api().session.inbox.list({ sessionID })
|
||||
// A positive read acknowledges admission even when its SSE echo is delayed.
|
||||
pending.forEach((item) => outbox.delete(item.id))
|
||||
// Compactions also coalesce by Session, not just by the proposed ID.
|
||||
if (pending.some((item) => item.type === "compaction"))
|
||||
store.session.pending[sessionID]
|
||||
?.filter((item) => item.type === "compaction")
|
||||
.forEach((item) => outbox.delete(item.id))
|
||||
// Keep optimistic rows still awaiting their echo: this fetch may
|
||||
// have raced ahead of an in-flight admission the server does not
|
||||
// know about yet.
|
||||
const inflight = (store.session.pending[sessionID] ?? []).filter((item) => outbox.has(item.id))
|
||||
const inflight = (store.session.pending[sessionID] ?? []).filter(
|
||||
(item) => outbox.has(item.id) && !pending.some((row) => row.id === item.id),
|
||||
)
|
||||
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
|
||||
batch(() => {
|
||||
setStore("session", "pending", sessionID, reconcile(merged))
|
||||
@@ -1378,56 +1345,13 @@ export function createData(config: CreateDataInput) {
|
||||
if (fresh) track(creating, id, request)
|
||||
return { id, request }
|
||||
},
|
||||
compact(input: { sessionID: string; model?: ModelRef }) {
|
||||
const active = compacting.get(input.sessionID)
|
||||
if (active) return active.request
|
||||
// A known pending control ID may be consumed while setup waits. Propose
|
||||
// a fresh ID and let the server coalesce, without duplicating its row.
|
||||
const id = SessionMessage.ID.create()
|
||||
if (!store.session.pending[input.sessionID]?.some((item) => item.type === "compaction")) {
|
||||
outbox.add(id)
|
||||
admitLocal({
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
type: "compaction",
|
||||
delivery: "steer",
|
||||
payload: {},
|
||||
})
|
||||
}
|
||||
// Compaction admission can coalesce onto a different ID. Retire the
|
||||
// speculative row on an echo, and remember consumed IDs until the POST
|
||||
// settles so its older response cannot resurrect a queued row.
|
||||
const observed = new Set<string>()
|
||||
const request = sendAdmission(input.sessionID, async () => {
|
||||
if (input.model) await api().session.switchModel({ sessionID: input.sessionID, model: input.model })
|
||||
return api().session.compact({ sessionID: input.sessionID, id })
|
||||
})
|
||||
.then((item) => {
|
||||
batch(() => {
|
||||
outbox.delete(id)
|
||||
if (item.id !== id) removePending(input.sessionID, id)
|
||||
if (!observed.has(item.id) && !messageIndex.get(input.sessionID)?.has(item.id)) admitLocal(item)
|
||||
})
|
||||
return item
|
||||
})
|
||||
.catch((error) => {
|
||||
if (outbox.delete(id)) removePending(input.sessionID, id)
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
if (compacting.get(input.sessionID)?.request === request) compacting.delete(input.sessionID)
|
||||
})
|
||||
compacting.set(input.sessionID, { id, observed, request })
|
||||
return request
|
||||
},
|
||||
// Optimistic prompt admission: render the prompt immediately under a
|
||||
// client-minted ID, send it, and let the durable inbox.enqueued echo
|
||||
// upsert that same ID with the server's payload. Server admission is
|
||||
// idempotent per ID, so retrying with the identical payload cannot
|
||||
// double-admit.
|
||||
prompt(input: SessionPromptInput & { gate?: Promise<unknown>; prepare?: () => Promise<unknown> }) {
|
||||
const { gate, prepare, ...request } = input
|
||||
prompt(input: SessionPromptInput & { gate?: Promise<unknown> }) {
|
||||
const { gate, ...request } = input
|
||||
const id = request.id ?? SessionMessage.ID.create()
|
||||
// A retry may reuse an ID that is already rendered — and possibly
|
||||
// already durable. Admit optimistically only for new IDs so a failed
|
||||
@@ -1453,15 +1377,25 @@ export function createData(config: CreateDataInput) {
|
||||
},
|
||||
})
|
||||
}
|
||||
return sendAdmission(
|
||||
// Wrapped so even a synchronous client failure reaches the rollback.
|
||||
// The POST additionally waits for the caller's gate, for any
|
||||
// in-flight optimistic create of this session, and for the previous
|
||||
// prompt's POST: the row renders now, the send happens once the
|
||||
// session exists server-side and earlier prompts are admitted.
|
||||
const previous = sending.get(request.sessionID)
|
||||
const send = Promise.resolve()
|
||||
.then(() => Promise.all([gate, creating.get(request.sessionID), previous]))
|
||||
.then(() => api().session.prompt({ ...request, id }))
|
||||
track(
|
||||
sending,
|
||||
request.sessionID,
|
||||
async () => {
|
||||
await prepare?.()
|
||||
return api().session.prompt({ ...request, id })
|
||||
},
|
||||
gate,
|
||||
).catch((error) => {
|
||||
// Roll back only rows this call admitted and the server has not
|
||||
send.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
)
|
||||
return send.catch((error) => {
|
||||
// Roll back only rows this call admitted and the echo has not
|
||||
// acknowledged: anything else is server state.
|
||||
if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id)
|
||||
throw error
|
||||
@@ -1530,70 +1464,20 @@ export function createData(config: CreateDataInput) {
|
||||
loading(sessionID: string) {
|
||||
return store.session.messageLoading[sessionID] ?? false
|
||||
},
|
||||
async loadMore(
|
||||
sessionID: string,
|
||||
options?: {
|
||||
all?: boolean
|
||||
signal?: AbortSignal
|
||||
/** Runs synchronously inside the store-publication batch. */
|
||||
beforePublish?: () => void
|
||||
},
|
||||
) {
|
||||
const signal = options?.signal
|
||||
if (signal?.aborted) return
|
||||
while (messageLoads.has(sessionID)) {
|
||||
const published = await (() => {
|
||||
const pending = messageLoads.get(sessionID)
|
||||
if (!signal) return pending
|
||||
const aborted = Promise.withResolvers<void>()
|
||||
const cancel = () => aborted.resolve()
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
return Promise.race([pending, aborted.promise])
|
||||
.catch((error) => {
|
||||
if (!signal.aborted) throw error
|
||||
})
|
||||
.finally(() => signal.removeEventListener("abort", cancel))
|
||||
})()
|
||||
if ((!options?.all && published) || signal?.aborted) return
|
||||
}
|
||||
async loadMore(sessionID: string) {
|
||||
const cursor = store.session.messageCursor[sessionID]
|
||||
if (!cursor || signal?.aborted) return
|
||||
if (!cursor || store.session.messageLoading[sessionID]) return
|
||||
setStore("session", "messageLoading", sessionID, true)
|
||||
const request = (async () => {
|
||||
const fetched: SessionMessageInfo[] = []
|
||||
let next: string | undefined = cursor
|
||||
do {
|
||||
const response = await api().message.list(
|
||||
{
|
||||
sessionID,
|
||||
limit: options?.all ? 200 : messagePageLimit,
|
||||
cursor: next,
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
if (signal?.aborted) return
|
||||
fetched.push(...response.data)
|
||||
next = response.cursor.next ?? undefined
|
||||
if (!options?.all) break
|
||||
} while (next)
|
||||
// A jump through history publishes once, not once per page of offscreen messages.
|
||||
const existing = store.session.message[sessionID] ?? []
|
||||
const ids = new Set(existing.map((item) => item.id))
|
||||
const messages = [...fetched.reverse().filter((item) => !ids.has(item.id)), ...existing]
|
||||
batch(() => {
|
||||
options?.beforePublish?.()
|
||||
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
setStore("session", "messageCursor", sessionID, next)
|
||||
})
|
||||
return true
|
||||
})()
|
||||
.catch((error) => {
|
||||
if (!signal?.aborted) throw error
|
||||
})
|
||||
const response = await api()
|
||||
.message.list({ sessionID, limit: messagePageLimit, cursor })
|
||||
.finally(() => setStore("session", "messageLoading", sessionID, false))
|
||||
track(messageLoads, sessionID, request)
|
||||
await request
|
||||
const older = response.data.toReversed()
|
||||
const existing = store.session.message[sessionID] ?? []
|
||||
const ids = new Set(existing.map((item) => item.id))
|
||||
const messages = [...older.filter((item) => !ids.has(item.id)), ...existing]
|
||||
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
@@ -14,7 +13,6 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
expect(Client.Agent).toBe(Agent)
|
||||
expect(Client.Config).toBe(Config)
|
||||
expect(Client.Model).toBe(Model)
|
||||
expect(Client.Plugin).toBe(Plugin)
|
||||
expect(Client.Session).toBe(Session)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Location,
|
||||
Model,
|
||||
OpenCode,
|
||||
Plugin,
|
||||
Prompt,
|
||||
Session,
|
||||
SessionMessage,
|
||||
@@ -28,40 +27,6 @@ test("health.get decodes the readiness response", async () => {
|
||||
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
|
||||
})
|
||||
|
||||
test("plugin.check decodes package status and its declared error", async () => {
|
||||
const status = { installed: "1.0.0", available: "1.1.0", mutable: true }
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
expect(request.method).toBe("POST")
|
||||
expect(request.url).toBe("http://localhost:3000/api/plugin/check")
|
||||
expect(Object.fromEntries(request.urlParams.params)).toEqual({ "location[directory]": "/tmp/project" })
|
||||
const body =
|
||||
request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : undefined
|
||||
expect(Object.keys(body)).toEqual(["target"])
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
body.target === "missing"
|
||||
? Response.json({ _tag: "PluginCheckError", message: "Not a configured plugin source" }, { status: 400 })
|
||||
: Response.json({
|
||||
location: {
|
||||
directory: "/tmp/project",
|
||||
project: { id: "proj_test", directory: "/tmp/project", canonical: "/tmp/project" },
|
||||
},
|
||||
data: status,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
const location = { directory: "/tmp/project" }
|
||||
expect((yield* client.plugin.check({ target: "fixture-plugin@^1", location })).data).toEqual(status)
|
||||
const error = yield* client.plugin.check({ target: "missing", location }).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Plugin.CheckError)
|
||||
expect(error.message).toBe("Not a configured plugin source")
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
})
|
||||
|
||||
test("session.get returns the decoded Effect projection", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { isPluginCheckError, isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
|
||||
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
|
||||
|
||||
test("exposes every standard HTTP API group", () => {
|
||||
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
@@ -55,34 +55,6 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
expect(Object.keys(client.plugin)).toEqual(["list", "check"])
|
||||
})
|
||||
|
||||
test("plugin.check uses the inspection contract and retains declared errors", async () => {
|
||||
const status = { installed: "1.0.0", available: "1.1.0", mutable: true }
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
expect(request.method).toBe("POST")
|
||||
expect(request.url).toBe("http://localhost:3000/api/plugin/check?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
const body = await request.json()
|
||||
expect(Object.keys(body)).toEqual(["target"])
|
||||
if (body.target === "missing")
|
||||
return Response.json({ _tag: "PluginCheckError", message: "Not a configured plugin source" }, { status: 400 })
|
||||
expect(body.target).toBe("fixture-plugin@^1")
|
||||
return Response.json({ location: { directory: "/tmp/project" }, data: status })
|
||||
},
|
||||
})
|
||||
expect(
|
||||
(await client.plugin.check({ target: "fixture-plugin@^1", location: { directory: "/tmp/project" } })).data,
|
||||
).toEqual(status)
|
||||
const error = await client.plugin
|
||||
.check({ target: "missing", location: { directory: "/tmp/project" } })
|
||||
.catch((cause: unknown) => cause)
|
||||
expect(isPluginCheckError(error)).toBe(true)
|
||||
if (!isPluginCheckError(error)) throw new Error("Expected PluginCheckError")
|
||||
expect(error.message).toBe("Not a configured plugin source")
|
||||
})
|
||||
|
||||
test("config.get returns ordered config entries for a location", async () => {
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInboxCompaction, type SessionInboxInfo } from "../src/promise"
|
||||
|
||||
test("admits compaction before model setup and serializes the following prompt", async () => {
|
||||
using fixture = setup()
|
||||
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
|
||||
const proposed = fixture.data.session.pending.list(sessionID)[0]
|
||||
expect(proposed).toMatchObject({ type: "compaction", sessionID })
|
||||
expect(fixture.calls).toEqual([])
|
||||
expect(fixture.data.session.message.list(sessionID)).toEqual([])
|
||||
expect(fixture.data.session.status(sessionID)).toBe("idle")
|
||||
|
||||
const prompt = fixture.data.session.prompt({ sessionID, text: "Follow up" })
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
|
||||
await wait(() => fixture.calls.length === 1)
|
||||
expect(fixture.calls).toEqual(["model"])
|
||||
fixture.model.resolve()
|
||||
await wait(() => fixture.calls.length === 2)
|
||||
expect(fixture.calls).toEqual(["model", "compact"])
|
||||
fixture.response.resolve(Response.json({ data: item(proposed.id) }))
|
||||
await Promise.all([compact, prompt])
|
||||
expect(fixture.calls).toEqual(["model", "compact", "prompt"])
|
||||
expect(fixture.proposals).toEqual([proposed.id])
|
||||
})
|
||||
|
||||
test("coalesces duplicate gestures until the admission request settles", async () => {
|
||||
using fixture = setup()
|
||||
const first = fixture.data.session.compact({ sessionID })
|
||||
expect(fixture.data.session.compact({ sessionID })).toBe(first)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toHaveLength(1)
|
||||
await wait(() => fixture.calls.length === 1)
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await first
|
||||
expect(fixture.calls).toEqual(["compact"])
|
||||
const next = fixture.data.session.compact({ sessionID })
|
||||
expect(next).not.toBe(first)
|
||||
await next
|
||||
expect(fixture.calls).toEqual(["compact", "compact"])
|
||||
expect(new Set(fixture.proposals).size).toBe(2)
|
||||
expect(fixture.proposals).not.toContain("msg_canonical")
|
||||
})
|
||||
|
||||
test("substitutes the canonical response ID and reconciles its later echo", async () => {
|
||||
using fixture = setup()
|
||||
const request = fixture.data.session.compact({ sessionID })
|
||||
const proposed = fixture.data.session.pending.list(sessionID)[0].id
|
||||
await fixture.data.session.pending.sync(sessionID)
|
||||
expect(fixture.data.session.pending.list(sessionID).map((row) => row.id)).toEqual([proposed])
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await request
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_canonical")])
|
||||
fixture.enqueue("msg_canonical", 20)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_canonical", 20)])
|
||||
expect(fixture.data.session.input.list(sessionID)).toEqual([])
|
||||
})
|
||||
|
||||
test.each(["proposed", "canonical"])("adopts the %s echo before the response without duplicating it", async (kind) => {
|
||||
using fixture = setup()
|
||||
const request = fixture.data.session.compact({ sessionID })
|
||||
const id = kind === "proposed" ? fixture.data.session.pending.list(sessionID)[0].id : "msg_canonical"
|
||||
fixture.enqueue(id, 20)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id, 20)])
|
||||
fixture.response.resolve(Response.json({ data: item(id) }))
|
||||
await request
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id, 20)])
|
||||
})
|
||||
|
||||
test.each(["started", "cancelled", "failed"])(
|
||||
"does not resurrect a canonical item already %s before the response",
|
||||
async (kind) => {
|
||||
using fixture = setup()
|
||||
const request = fixture.data.session.compact({ sessionID })
|
||||
fixture.enqueue("msg_canonical")
|
||||
if (kind === "started")
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.started",
|
||||
data: { sessionID, inputID: "msg_canonical", reason: "manual" },
|
||||
})
|
||||
if (kind === "cancelled")
|
||||
fixture.emit({ ...event, type: "session.inbox.cancelled", data: { sessionID, inboxID: "msg_canonical" } })
|
||||
if (kind === "failed")
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.failed",
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: "msg_canonical",
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Cancelled" },
|
||||
},
|
||||
})
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await request
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
if (kind === "started") {
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.ended",
|
||||
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
|
||||
})
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
|
||||
{ type: "compaction", status: "completed", summary: "Summary" },
|
||||
])
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["model", "compact"])("rolls back a rejected %s RPC and releases the following prompt", async (rpc) => {
|
||||
using fixture = setup()
|
||||
const request = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
|
||||
const failed = request.catch((error: unknown) => error)
|
||||
const prompt = fixture.data.session.prompt({ sessionID, text: "Follow up" })
|
||||
if (rpc === "model") fixture.model.reject(new Error("Model setup failed"))
|
||||
if (rpc === "compact") {
|
||||
fixture.model.resolve()
|
||||
fixture.response.resolve(new Response("Admission failed", { status: 500 }))
|
||||
}
|
||||
expect(await failed).toBeInstanceOf(Error)
|
||||
await prompt
|
||||
expect(fixture.data.session.pending.list(sessionID).map((row) => row.type)).toEqual(["user"])
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
|
||||
})
|
||||
|
||||
test.each(["proposed", "canonical", "existing"])(
|
||||
"preserves acknowledged %s compaction after an HTTP error",
|
||||
async (kind) => {
|
||||
using fixture = setup()
|
||||
if (kind === "existing") fixture.enqueue("msg_canonical")
|
||||
const request = fixture.data.session.compact({ sessionID })
|
||||
const failed = request.catch((error: unknown) => error)
|
||||
const id = kind === "proposed" ? fixture.data.session.pending.list(sessionID)[0].id : "msg_canonical"
|
||||
if (kind !== "existing") fixture.enqueue(id)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id)])
|
||||
fixture.response.resolve(new Response("Lost response", { status: 500 }))
|
||||
expect(await failed).toBeInstanceOf(Error)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id)])
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
},
|
||||
)
|
||||
|
||||
test("uses a fresh control ID when the known pending compaction starts during model setup", async () => {
|
||||
const proposed = Promise.withResolvers<string>()
|
||||
using fixture = setup(async (request) => {
|
||||
if (!request.url.endsWith("/compact")) return undefined
|
||||
const body = await request.json()
|
||||
proposed.resolve(body.id)
|
||||
if (body.id === "msg_existing") return Response.json({ message: "Control ID already consumed" }, { status: 409 })
|
||||
return Response.json({ data: item(body.id) })
|
||||
})
|
||||
fixture.enqueue("msg_existing")
|
||||
const request = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
|
||||
const result = request.catch((error: unknown) => error)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_existing")])
|
||||
await wait(() => fixture.calls.includes("model"))
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.started",
|
||||
data: { sessionID, inputID: "msg_existing", reason: "manual" },
|
||||
})
|
||||
fixture.model.resolve()
|
||||
expect(await proposed.promise).not.toBe("msg_existing")
|
||||
expect(await result).toEqual(item(await proposed.promise))
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(await proposed.promise)])
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
|
||||
{ id: "msg_existing", type: "compaction", status: "running" },
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["compaction", "canonical compaction", "user"])(
|
||||
"preserves a fetched durable %s when SSE is delayed and HTTP fails",
|
||||
async (type) => {
|
||||
using fixture = setup(async (request) => {
|
||||
if (request.url.endsWith("/prompt")) return fixture.response.promise
|
||||
return undefined
|
||||
})
|
||||
const request =
|
||||
type === "user"
|
||||
? fixture.data.session.prompt({ sessionID, text: "Follow up" })
|
||||
: fixture.data.session.compact({ sessionID })
|
||||
const result = request.catch((error: unknown) => error)
|
||||
const id = type === "canonical compaction" ? "msg_canonical" : fixture.data.session.pending.list(sessionID)[0].id
|
||||
const durable: SessionInboxInfo =
|
||||
type === "user" ? { ...item(id, 20), type: "user", payload: { text: "Follow up" } } : item(id, 20)
|
||||
fixture.pending.push(durable)
|
||||
await fixture.data.session.pending.sync(sessionID)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([durable])
|
||||
fixture.response.resolve(new Response("Lost response", { status: 500 }))
|
||||
expect(await result).toBeInstanceOf(Error)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([durable])
|
||||
if (type === "user")
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ id, type: "user", text: "Follow up" }])
|
||||
},
|
||||
)
|
||||
|
||||
test("keeps one event listener and removes it when the data owner is disposed during a gate", async () => {
|
||||
using fixture = setup()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const first = fixture.data.session.prompt({ sessionID, text: "First", gate: gate.promise })
|
||||
const compact = fixture.data.session.compact({ sessionID })
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
fixture.dispose()
|
||||
expect(fixture.listeners.size).toBe(0)
|
||||
gate.resolve()
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await Promise.all([first, compact])
|
||||
expect(fixture.listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
test("routes concurrent compaction observations by session through one listener", async () => {
|
||||
const firstResponse = Promise.withResolvers<Response>()
|
||||
const secondResponse = Promise.withResolvers<Response>()
|
||||
using fixture = setup(async (request) => {
|
||||
if (!request.url.endsWith("/compact")) return undefined
|
||||
return request.url.includes(`/session/${sessionID}/`) ? firstResponse.promise : secondResponse.promise
|
||||
})
|
||||
const first = fixture.data.session.compact({ sessionID })
|
||||
const second = fixture.data.session.compact({ sessionID: "ses_other" })
|
||||
const firstID = fixture.data.session.pending.list(sessionID)[0].id
|
||||
const secondID = fixture.data.session.pending.list("ses_other")[0].id
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
fixture.emit({ ...event, type: "session.inbox.cancelled", data: { sessionID, inboxID: firstID } })
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
expect(fixture.data.session.pending.list("ses_other").map((row) => row.id)).toEqual([secondID])
|
||||
|
||||
firstResponse.resolve(Response.json({ data: item(firstID) }))
|
||||
secondResponse.resolve(Response.json({ data: { ...item(secondID), sessionID: "ses_other" } }))
|
||||
await Promise.all([first, second])
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
expect(fixture.data.session.pending.list("ses_other")).toEqual([{ ...item(secondID), sessionID: "ses_other" }])
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
})
|
||||
|
||||
test.each(["gate", "prepare"])(
|
||||
"a preceding prompt's failed %s does not block compaction or following model preparation",
|
||||
async (kind) => {
|
||||
using fixture = setup()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const prepared: string[] = []
|
||||
const first = fixture.data.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
id: "msg_first",
|
||||
text: "First",
|
||||
gate: kind === "gate" ? gate.promise : undefined,
|
||||
prepare: () => {
|
||||
prepared.push("first")
|
||||
return gate.promise
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "first" } })
|
||||
const following = fixture.data.session.prompt({
|
||||
sessionID,
|
||||
text: "Follow up",
|
||||
prepare: () => {
|
||||
prepared.push("following")
|
||||
return fixture.api.session.switchModel({ sessionID, model: { providerID: "demo", id: "second" } })
|
||||
},
|
||||
})
|
||||
if (kind === "prepare") await wait(() => prepared.includes("first"))
|
||||
gate.reject(new Error("Preparation failed"))
|
||||
expect(await first).toBeInstanceOf(Error)
|
||||
await wait(() => fixture.calls.includes("model"))
|
||||
expect(prepared).toEqual(kind === "prepare" ? ["first"] : [])
|
||||
fixture.model.resolve()
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await Promise.all([compact, following])
|
||||
expect(fixture.calls).toEqual(["model", "compact", "model", "prompt"])
|
||||
expect(prepared.at(-1)).toBe("following")
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
|
||||
},
|
||||
)
|
||||
|
||||
test("creation failure rejects gated prompt, compaction, and following preparation without sending their RPCs", async () => {
|
||||
const creation = Promise.withResolvers<Response>()
|
||||
const requested = Promise.withResolvers<void>()
|
||||
using fixture = setup(async (request) => {
|
||||
if (!request.url.endsWith("/api/session")) return undefined
|
||||
requested.resolve()
|
||||
return creation.promise
|
||||
})
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const prepared: string[] = []
|
||||
const created = fixture.data.session.create({ id: sessionID })
|
||||
const first = fixture.data.session.prompt({ sessionID, text: "First", gate: gate.promise })
|
||||
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
|
||||
const following = fixture.data.session.prompt({
|
||||
sessionID,
|
||||
text: "Follow up",
|
||||
prepare: async () => {
|
||||
prepared.push("following")
|
||||
},
|
||||
})
|
||||
const results = Promise.allSettled([created.request, first, compact, following])
|
||||
await requested.promise
|
||||
creation.resolve(new Response("Creation failed", { status: 500 }))
|
||||
expect((await results).map((result) => result.status)).toEqual(["rejected", "rejected", "rejected", "rejected"])
|
||||
expect(fixture.calls).toEqual([])
|
||||
expect(prepared).toEqual([])
|
||||
expect(fixture.data.session.get(sessionID)).toBeUndefined()
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
gate.resolve()
|
||||
})
|
||||
|
||||
const sessionID = "ses_compact"
|
||||
const event = { id: "evt_compact", created: 10, durable: { aggregateID: sessionID, seq: 1, version: 1 } }
|
||||
const item = (id: string, timeCreated = 10): SessionInboxCompaction => ({
|
||||
id,
|
||||
sessionID,
|
||||
timeCreated,
|
||||
type: "compaction",
|
||||
delivery: "steer",
|
||||
payload: {},
|
||||
})
|
||||
|
||||
function setup(override?: (request: Request) => Promise<Response | undefined>) {
|
||||
const model = Promise.withResolvers<void>()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls: string[] = []
|
||||
const proposals: string[] = []
|
||||
const pending: SessionInboxInfo[] = []
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const overridden = await override?.(request)
|
||||
if (overridden) return overridden
|
||||
const rpc = new URL(request.url).pathname.split("/").at(-1)
|
||||
if (rpc === "inbox") return Response.json({ data: pending })
|
||||
if (rpc === "model") {
|
||||
calls.push(rpc)
|
||||
await model.promise
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (rpc === "compact") {
|
||||
calls.push(rpc)
|
||||
proposals.push((await request.json()).id)
|
||||
return (await response.promise).clone()
|
||||
}
|
||||
if (rpc === "prompt") {
|
||||
calls.push(rpc)
|
||||
return Response.json({
|
||||
data: { ...item((await request.json()).id), type: "user", payload: { text: "Follow up" } },
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected request: ${request.url}`)
|
||||
},
|
||||
})
|
||||
const root = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const emit = (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details }))
|
||||
return {
|
||||
data: root.data,
|
||||
api,
|
||||
dispose: root.dispose,
|
||||
[Symbol.dispose]: root.dispose,
|
||||
model,
|
||||
response,
|
||||
calls,
|
||||
proposals,
|
||||
pending,
|
||||
listeners,
|
||||
emit,
|
||||
enqueue(id: string, created = 10) {
|
||||
emit({
|
||||
...event,
|
||||
created,
|
||||
type: "session.inbox.enqueued",
|
||||
data: { sessionID, inboxID: id, item: { type: "compaction", delivery: "steer", payload: {} } },
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function wait(predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (predicate()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error("Timed out waiting for request")
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { getEventListeners } from "node:events"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"
|
||||
@@ -415,120 +414,6 @@ test("loads bounded message pages", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["success", "failure", "cancel", "cancel-retry", "cancel-page", "join-cancel", "join-failure"])(
|
||||
"bulk history (%s)",
|
||||
async (mode) => {
|
||||
const messages = [1, 2, 3].map((index) => ({
|
||||
id: `msg_${index}`,
|
||||
type: "user",
|
||||
text: `Message ${index}`,
|
||||
time: { created: index },
|
||||
}))
|
||||
const release = Promise.withResolvers<void>()
|
||||
const controller = new AbortController()
|
||||
const requests: URL[] = []
|
||||
const publications: string[][] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||
requests.push(url)
|
||||
const cursor = url.searchParams.get("cursor")
|
||||
if (!cursor) return Response.json({ data: [messages[2]], cursor: { next: "recent" } })
|
||||
if (cursor === "recent") {
|
||||
if (mode.startsWith("join")) await release.promise
|
||||
if (mode === "join-failure") return Response.json({ message: "offline" }, { status: 503 })
|
||||
return Response.json({ data: [messages[2], messages[1]], cursor: { next: "oldest" } })
|
||||
}
|
||||
if (cursor === "oldest") return Response.json({ data: [messages[0]], cursor: { next: "empty" } })
|
||||
expect(init?.signal).toBe(requests.length === 4 ? controller.signal : undefined)
|
||||
await release.promise
|
||||
if (mode === "failure") return Response.json({ message: "offline" }, { status: 503 })
|
||||
return Response.json({ data: [], cursor: {} })
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => {
|
||||
const data = createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
})
|
||||
return { data, dispose }
|
||||
})
|
||||
|
||||
try {
|
||||
await setup.data.session.message.sync("ses_refresh")
|
||||
const newest = setup.data.session.message.get("ses_refresh", "msg_3")
|
||||
const load = setup.data.session.message.loadMore(
|
||||
"ses_refresh",
|
||||
mode.startsWith("join")
|
||||
? undefined
|
||||
: {
|
||||
all: true,
|
||||
signal: controller.signal,
|
||||
beforePublish: () => {
|
||||
publications.push(setup.data.session.message.list("ses_refresh").map((message) => message.id))
|
||||
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
|
||||
},
|
||||
},
|
||||
)
|
||||
const joined = setup.data.session.message.loadMore("ses_refresh", { all: true, signal: controller.signal })
|
||||
const settled = Promise.allSettled([load, joined])
|
||||
if (mode.startsWith("join")) {
|
||||
await wait(() => requests.length === 2)
|
||||
expect(getEventListeners(controller.signal, "abort")).toHaveLength(1)
|
||||
controller.abort()
|
||||
let cancelled = false
|
||||
void joined.then(() => {
|
||||
cancelled = true
|
||||
})
|
||||
await wait(() => cancelled)
|
||||
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
|
||||
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
|
||||
release.resolve()
|
||||
expect((await settled).map((result) => result.status)).toEqual(
|
||||
mode === "join-failure" ? ["rejected", "fulfilled"] : ["fulfilled", "fulfilled"],
|
||||
)
|
||||
expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(setup.data.session.message.more("ses_refresh")).toBe(true)
|
||||
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
|
||||
mode === "join-failure" ? ["msg_3"] : ["msg_2", "msg_3"],
|
||||
)
|
||||
return
|
||||
}
|
||||
await wait(() => requests.length === 4)
|
||||
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
|
||||
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_3"])
|
||||
expect(requests.slice(1).map((url) => url.searchParams.get("limit"))).toEqual(["200", "200", "200"])
|
||||
if (mode.startsWith("cancel")) controller.abort()
|
||||
const retry =
|
||||
mode === "cancel-retry" || mode === "cancel-page"
|
||||
? setup.data.session.message.loadMore("ses_refresh", mode === "cancel-retry" ? { all: true } : undefined)
|
||||
: undefined
|
||||
release.resolve()
|
||||
expect((await settled).map((result) => result.status)).toEqual(
|
||||
mode === "failure" ? ["rejected", "rejected"] : ["fulfilled", "fulfilled"],
|
||||
)
|
||||
await retry
|
||||
const success = mode === "success" || mode === "cancel-retry"
|
||||
expect(setup.data.session.message.loading("ses_refresh")).toBe(false)
|
||||
expect(setup.data.session.message.more("ses_refresh")).toBe(!success)
|
||||
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
|
||||
success ? ["msg_1", "msg_2", "msg_3"] : mode === "cancel-page" ? ["msg_2", "msg_3"] : ["msg_3"],
|
||||
)
|
||||
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
|
||||
expect(requests).toHaveLength(mode === "cancel-retry" ? 7 : mode === "cancel-page" ? 5 : 4)
|
||||
if (mode === "cancel-page") expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
|
||||
expect(publications).toEqual(mode === "success" ? [["msg_3"]] : [])
|
||||
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
|
||||
} finally {
|
||||
release.resolve()
|
||||
setup.dispose()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("preserves assistant content replacement events across an active message read", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
|
||||
@@ -84,7 +84,10 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...mapGoogleOptions(input.settings),
|
||||
...mapGoogleOptions(
|
||||
input.settings,
|
||||
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
|
||||
),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
@@ -293,7 +296,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
|
||||
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
|
||||
}
|
||||
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
|
||||
const input = settings.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
|
||||
@@ -308,6 +311,7 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
|
||||
...extra,
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
@@ -341,21 +345,28 @@ function mapOpenRouter(
|
||||
}
|
||||
|
||||
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
return mapProviderOptions(settings, [
|
||||
"apiKey",
|
||||
"api_keys",
|
||||
"appName",
|
||||
"appUrl",
|
||||
"authToken",
|
||||
"baseURL",
|
||||
"chunkTimeout",
|
||||
"compatibility",
|
||||
"extraBody",
|
||||
"fetch",
|
||||
"headers",
|
||||
"promptCacheKey",
|
||||
"timeout",
|
||||
])
|
||||
const options = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) =>
|
||||
![
|
||||
"apiKey",
|
||||
"api_keys",
|
||||
"appName",
|
||||
"appUrl",
|
||||
"authToken",
|
||||
"baseURL",
|
||||
"chunkTimeout",
|
||||
"compatibility",
|
||||
"extraBody",
|
||||
"fetch",
|
||||
"headers",
|
||||
"promptCacheKey",
|
||||
"timeout",
|
||||
].includes(key),
|
||||
),
|
||||
)
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
|
||||
|
||||
+154
-148
@@ -294,156 +294,162 @@ export function configured(options?: Options) {
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const durable = definition.durable
|
||||
if (!durable) return yield* Effect.void
|
||||
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||
if (typeof aggregateID !== "string")
|
||||
return yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Expected string aggregate field ${durable.aggregate}`,
|
||||
}),
|
||||
)
|
||||
if (input && input.aggregateID !== aggregateID) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const committed = yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<string, unknown>
|
||||
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && input.seq <= latest) {
|
||||
if (!persist) return
|
||||
const stored = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (
|
||||
stored?.id === event.id &&
|
||||
stored.type === versionedType(definition.type, durable.version) &&
|
||||
stored.created === (event.created ?? 0) &&
|
||||
isDeepStrictEqual(stored.data, encoded)
|
||||
) {
|
||||
if (input.ownerID && row?.ownerID == null) {
|
||||
yield* db
|
||||
.update(EventSequenceTable)
|
||||
.set({ owner_id: input.ownerID })
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
return
|
||||
}
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
return
|
||||
}
|
||||
const seq = input?.seq ?? latest + 1
|
||||
if (input && seq !== latest + 1) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (persist) {
|
||||
const stored = yield* db
|
||||
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, event.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (stored)
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const committed = {
|
||||
...event,
|
||||
durable: { aggregateID, seq, version: durable.version },
|
||||
} as Event.Payload
|
||||
const route = yield* prepareRoutes([committed])
|
||||
for (const projector of list) {
|
||||
yield* projector(committed)
|
||||
}
|
||||
if (commit) yield* commit(seq)
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: {
|
||||
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
|
||||
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if (persist)
|
||||
yield* db
|
||||
.insert(EventTable)
|
||||
.values([
|
||||
{
|
||||
id: event.id,
|
||||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
created: event.created ?? 0,
|
||||
type: versionedType(definition.type, durable.version),
|
||||
data: encoded,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return { aggregateID, seq, event: committed, route }
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (committed) {
|
||||
committed.route()
|
||||
yield* Effect.forEach(
|
||||
pubsub.durable.get(committed.aggregateID) ?? [],
|
||||
(wake) => PubSub.publish(wake, undefined),
|
||||
{ discard: true },
|
||||
if (durable) {
|
||||
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||
if (typeof aggregateID !== "string") {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Expected string aggregate field ${durable.aggregate}`,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
if (input && input.aggregateID !== aggregateID) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return committed
|
||||
}),
|
||||
)
|
||||
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const committed = yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && input.seq <= latest) {
|
||||
if (!persist) return
|
||||
const stored = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (
|
||||
stored?.id === event.id &&
|
||||
stored.type === versionedType(definition.type, durable.version) &&
|
||||
stored.created === (event.created ?? 0) &&
|
||||
isDeepStrictEqual(stored.data, encoded)
|
||||
) {
|
||||
if (input.ownerID && row?.ownerID == null) {
|
||||
yield* db
|
||||
.update(EventSequenceTable)
|
||||
.set({ owner_id: input.ownerID })
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
return
|
||||
}
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
return
|
||||
}
|
||||
const seq = input?.seq ?? latest + 1
|
||||
if (input && seq !== latest + 1) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (persist) {
|
||||
const stored = yield* db
|
||||
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, event.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (stored)
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const committed = {
|
||||
...event,
|
||||
durable: { aggregateID, seq, version: durable.version },
|
||||
} as Event.Payload
|
||||
const route = yield* prepareRoutes([committed])
|
||||
for (const projector of list) {
|
||||
yield* projector(committed)
|
||||
}
|
||||
if (commit) yield* commit(seq)
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: {
|
||||
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
|
||||
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if (persist)
|
||||
yield* db
|
||||
.insert(EventTable)
|
||||
.values([
|
||||
{
|
||||
id: event.id,
|
||||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
created: event.created ?? 0,
|
||||
type: versionedType(definition.type, durable.version),
|
||||
data: encoded,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return { aggregateID, seq, event: committed, route }
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (committed) {
|
||||
committed.route()
|
||||
yield* Effect.forEach(
|
||||
pubsub.durable.get(committed.aggregateID) ?? [],
|
||||
(wake) => PubSub.publish(wake, undefined),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
return committed
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export * as CommandInvocation from "./invocation.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import type { Command } from "../command.js"
|
||||
import { Location } from "../location.js"
|
||||
import { ShellSelect } from "../shell/select.js"
|
||||
|
||||
// Invocation for configured template commands; source loading and registration stay with the caller.
|
||||
export const make = Effect.fnUntraced(function* (ctx: Pick<Plugin.Context, "agent" | "session">) {
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
return Effect.fn("CommandInvocation.invoke")(function* (command: ConfigCommand.Info, input: Command.Invocation) {
|
||||
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||
})
|
||||
const model =
|
||||
command.model === undefined
|
||||
? commandAgent?.model
|
||||
: {
|
||||
id: Model.ID.make(command.model.model),
|
||||
providerID: Provider.ID.make(command.model.providerID),
|
||||
...(command.model.variant === undefined ? {} : { variant: Model.VariantID.make(command.model.variant) }),
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, { location, processes, shell }),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function evaluateTemplate(
|
||||
template: string,
|
||||
input: string,
|
||||
services: {
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const args = parseArguments(input)
|
||||
const placeholders = template.match(placeholderRegex) ?? []
|
||||
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
||||
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
||||
const position = Number(index)
|
||||
const argIndex = position - 1
|
||||
if (argIndex >= args.length) return ""
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
: withArguments.trim()
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.resolve({ priority: "config" })
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
const source = match[1] ?? ""
|
||||
return services.processes
|
||||
.run(
|
||||
ChildProcess.make(shell, ShellSelect.args(shell, source), {
|
||||
cwd: services.location.directory,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{ combineOutput: true },
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError(
|
||||
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const iterator = outputs[Symbol.iterator]()
|
||||
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||
}
|
||||
|
||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
const shellRegex = /!`([^`]+)`/g
|
||||
@@ -0,0 +1,150 @@
|
||||
export * as ConfigFile from "./file.js"
|
||||
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import {
|
||||
applyEdits,
|
||||
createScanner,
|
||||
findNodeAtLocation,
|
||||
modify,
|
||||
parseTree,
|
||||
type Node,
|
||||
type ParseError,
|
||||
} from "jsonc-parser"
|
||||
|
||||
export class UpdateError extends Schema.TaggedError<UpdateError>()("ConfigFile.UpdateError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
const isJson = Schema.is(Schema.MutableJson)
|
||||
const isDocument = (value: unknown): value is Schema.MutableJsonObject => isRecord(value) && isJson(value)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
/**
|
||||
* Edits an existing JSON(C) file using raw source values, not resolved Config.Info.
|
||||
* The synchronous callback mutates a source clone; its return value is ignored.
|
||||
* Validates JSON only; normalization and substitution remain the reader's job.
|
||||
* Does not discover files, start watchers, or refresh Config state.
|
||||
* Read-modify-write calls are serialized within this process.
|
||||
*/
|
||||
export const update = Effect.fn("ConfigFile.update")(
|
||||
function* (
|
||||
filepath: string,
|
||||
mutate: (draft: Schema.MutableJsonObject) => void,
|
||||
): Effect.fn.Return<Schema.JsonObject, UpdateError, FSUtil.Service> {
|
||||
const fs = yield* FSUtil.Service
|
||||
const text = yield* fs
|
||||
.readFileString(filepath)
|
||||
.pipe(Effect.mapError((cause) => new UpdateError({ message: `Failed to read config: ${filepath}`, cause })))
|
||||
const errors: ParseError[] = []
|
||||
const current = parseSource(text, errors)
|
||||
if (errors.length || !isDocument(current))
|
||||
return yield* Effect.fail(new UpdateError({ message: `Invalid config file: ${filepath}` }))
|
||||
|
||||
const next = yield* Effect.try({
|
||||
try: () => {
|
||||
const draft = structuredClone(current)
|
||||
mutate(draft)
|
||||
return draft
|
||||
},
|
||||
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
|
||||
})
|
||||
if (!isDocument(next))
|
||||
return yield* Effect.fail(new UpdateError({ message: `Config update must produce a JSON object: ${filepath}` }))
|
||||
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return next
|
||||
const updated = yield* Effect.try({
|
||||
try: () => edits.reduce(patch, text),
|
||||
catch: (cause) => new UpdateError({ message: `Failed to patch config: ${filepath}`, cause }),
|
||||
})
|
||||
// Duplicate keys can make parse choose the last value while modify edits the first.
|
||||
const written = parseSource(updated, errors)
|
||||
if (errors.length || !isDeepStrictEqual(written, next))
|
||||
return yield* Effect.fail(
|
||||
new UpdateError({ message: `Config patch does not match the requested update: ${filepath}` }),
|
||||
)
|
||||
const temporary = filepath + ".tmp"
|
||||
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
|
||||
Effect.andThen(fs.rename(temporary, filepath)),
|
||||
Effect.mapError((cause) => new UpdateError({ message: `Failed to write config: ${filepath}`, cause })),
|
||||
)
|
||||
return next
|
||||
},
|
||||
(effect) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
|
||||
|
||||
function parseSource(text: string, errors: ParseError[]) {
|
||||
const root = parseTree(text, errors, { allowTrailingComma: true })
|
||||
if (!root || errors.length) return undefined
|
||||
// parse() assigns onto {}, invoking the __proto__ setter instead of retaining
|
||||
// an own JSON key. Construct object entries from the AST without those setters.
|
||||
const value = (node: Node): unknown => {
|
||||
if (node.type === "array") return (node.children ?? []).map(value)
|
||||
if (node.type === "object")
|
||||
return Object.fromEntries(
|
||||
(node.children ?? []).map((property) => {
|
||||
const child = property.children?.[1]
|
||||
return [property.children?.[0]?.value, child && value(child)]
|
||||
}),
|
||||
)
|
||||
return node.value
|
||||
}
|
||||
return value(root)
|
||||
}
|
||||
|
||||
function patch(text: string, edit: Edit) {
|
||||
if (edit.value !== undefined)
|
||||
return applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
)
|
||||
|
||||
const tree = parseTree(text)
|
||||
const node = tree && findNodeAtLocation(tree, edit.path)
|
||||
if (!node) return text
|
||||
// jsonc-parser removes adjacent comments along with the separator. Remove only
|
||||
// the property/element itself and one comma, leaving surrounding comments intact.
|
||||
const target = node.parent?.type === "property" ? node.parent : node
|
||||
const siblings = target.parent?.children ?? []
|
||||
const previous = siblings[siblings.indexOf(target) - 1]
|
||||
const scanner = createScanner(text, true)
|
||||
scanner.setPosition(target.offset + target.length)
|
||||
scanner.scan()
|
||||
const following = text[scanner.getTokenOffset()] === ","
|
||||
if (!following && previous) {
|
||||
scanner.setPosition(previous.offset + previous.length)
|
||||
scanner.scan()
|
||||
}
|
||||
return applyEdits(text, [
|
||||
{ offset: target.offset, length: target.length, content: "" },
|
||||
...(following || previous ? [{ offset: scanner.getTokenOffset(), length: 1, content: "" }] : []),
|
||||
])
|
||||
}
|
||||
|
||||
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
|
||||
if (isDeepStrictEqual(before, after)) return []
|
||||
if (Array.isArray(before) && Array.isArray(after)) {
|
||||
return [
|
||||
...after.flatMap((value, index) => changes(before[index], value, [...path, index])),
|
||||
// Remove from the end so earlier deletions cannot shift later paths.
|
||||
...before
|
||||
.slice(after.length)
|
||||
.map((_, index) => ({ path: [...path, after.length + index], value: undefined }))
|
||||
.toReversed(),
|
||||
]
|
||||
}
|
||||
if (isRecord(before) && isRecord(after)) {
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||
if (!Object.hasOwn(after, key)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!Object.hasOwn(before, key)) return [{ path: [...path, key], value: after[key] }]
|
||||
return changes(before[key], after[key], [...path, key])
|
||||
})
|
||||
}
|
||||
return [{ path, value: after }]
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
export * as ConfigCommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { CommandInvocation } from "../../command/invocation.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
|
||||
@@ -29,9 +23,7 @@ export const Plugin = define({
|
||||
const commands = yield* loadDirectory(fs, entry.path)
|
||||
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
|
||||
})
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const invoke = yield* CommandInvocation.make(ctx)
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
@@ -63,37 +55,7 @@ export const Plugin = define({
|
||||
draft.add({
|
||||
name,
|
||||
description: command.description,
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||
})
|
||||
const model =
|
||||
command.model === undefined
|
||||
? commandAgent?.model
|
||||
: {
|
||||
id: Model.ID.make(command.model.model),
|
||||
providerID: Provider.ID.make(command.model.providerID),
|
||||
...(command.model.variant === undefined
|
||||
? {}
|
||||
: { variant: Model.VariantID.make(command.model.variant) }),
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
}),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
execute: (input) => invoke(command, input),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -146,66 +108,3 @@ function decode(directory: string, filepath: string, content: string) {
|
||||
info,
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateTemplate(
|
||||
template: string,
|
||||
input: string,
|
||||
services: {
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const args = parseArguments(input)
|
||||
const placeholders = template.match(placeholderRegex) ?? []
|
||||
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
||||
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
||||
const position = Number(index)
|
||||
const argIndex = position - 1
|
||||
if (argIndex >= args.length) return ""
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
: withArguments.trim()
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.resolve({ priority: "config" })
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
const source = match[1] ?? ""
|
||||
return services.processes
|
||||
.run(
|
||||
ChildProcess.make(shell, ShellSelect.args(shell, source), {
|
||||
cwd: services.location.directory,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{ combineOutput: true },
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError((error) =>
|
||||
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const iterator = outputs[Symbol.iterator]()
|
||||
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||
}
|
||||
|
||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
const shellRegex = /!`([^`]+)`/g
|
||||
|
||||
@@ -212,7 +212,7 @@ const nativeLayer = (config: Config) =>
|
||||
: Layer.effect(
|
||||
Sqlite.Native,
|
||||
Effect.die(
|
||||
"workerd sqlite cannot open a database from a path; use Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })))",
|
||||
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -275,22 +275,22 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
if (paired.has(item.row.id)) return []
|
||||
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
|
||||
if (item.value.role === "user") {
|
||||
const compaction = owned.find((part): part is SessionV1.CompactionPart => part.type === "compaction")
|
||||
if (compaction) {
|
||||
const compaction = owned.find((part) => part.type === "compaction")
|
||||
if (compaction?.type === "compaction") {
|
||||
const pairedSummary = messages.find(
|
||||
(candidate): candidate is (typeof messages)[number] & { value: SessionV1.Assistant } =>
|
||||
(candidate) =>
|
||||
candidate.value.role === "assistant" &&
|
||||
candidate.value.parentID === item.row.id &&
|
||||
candidate.value.summary === true,
|
||||
candidate.value.summary,
|
||||
)
|
||||
if (!pairedSummary) return []
|
||||
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
|
||||
paired.add(pairedSummary.row.id)
|
||||
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
|
||||
const summary = pairedSummary
|
||||
const summaryText = (byMessage.get(summary.row.id) ?? [])
|
||||
.map((part) => part.value)
|
||||
.filter((part): part is SessionV1.TextPart => part.type === "text" && part.text.length > 0)
|
||||
.map((part) => part.text)
|
||||
.filter((part) => part.type === "text" && part.text.length > 0)
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.join("\n\n")
|
||||
const tailIndex = compaction.tail_start_id
|
||||
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
|
||||
@@ -313,14 +313,16 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
]
|
||||
}
|
||||
const subtasks = owned.filter((part) => part.type === "subtask")
|
||||
const visible = owned.filter((part): part is SessionV1.TextPart => part.type === "text" && !part.ignored)
|
||||
const files = owned.filter((part): part is SessionV1.FilePart => part.type === "file")
|
||||
const agents = owned.filter((part): part is SessionV1.AgentPart => part.type === "agent")
|
||||
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
|
||||
const files = owned.filter((part) => part.type === "file")
|
||||
const agents = owned.filter((part) => part.type === "agent")
|
||||
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
|
||||
const ordinary = visible.filter((part) => !part.synthetic)
|
||||
const synthetic = visible.filter((part) => part.synthetic)
|
||||
const attachments = files.flatMap((part) => migrateFile(part))
|
||||
const unavailable = files.flatMap((part) => (!part.url.startsWith("data:") ? [unavailableFile(part)] : []))
|
||||
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
|
||||
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
|
||||
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
|
||||
const unavailable = files.flatMap((part) =>
|
||||
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
|
||||
)
|
||||
const text = owned
|
||||
.flatMap((part) => {
|
||||
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
|
||||
@@ -328,12 +330,16 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
return []
|
||||
})
|
||||
.join("\n\n")
|
||||
const agentAttachments = agents.map((part) => ({
|
||||
name: part.name,
|
||||
...(part.source
|
||||
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
|
||||
: {}),
|
||||
}))
|
||||
const agentAttachments = agents.map((part) =>
|
||||
part.type === "agent"
|
||||
? {
|
||||
name: part.name,
|
||||
...(part.source
|
||||
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
|
||||
: {}),
|
||||
}
|
||||
: { name: "" },
|
||||
)
|
||||
if (
|
||||
ordinary.length === 0 &&
|
||||
unavailable.length === 0 &&
|
||||
@@ -345,7 +351,7 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
row(item.row, {
|
||||
id: item.row.id,
|
||||
type: "synthetic",
|
||||
text: synthetic.map((part) => part.text).join("\n\n"),
|
||||
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
|
||||
time: { created: item.row.time_created },
|
||||
}),
|
||||
]
|
||||
@@ -363,7 +369,7 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
row(item.row, {
|
||||
id: syntheticID(item.row.id, used),
|
||||
type: "synthetic",
|
||||
text: synthetic.map((part) => part.text).join("\n\n"),
|
||||
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
|
||||
time: { created: item.row.time_created },
|
||||
}),
|
||||
]
|
||||
@@ -437,6 +443,7 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
})
|
||||
.map((item, seq) => ({ ...item, seq }))
|
||||
const assistants = messages
|
||||
.filter((item) => item.value.role === "assistant")
|
||||
.map((item) => item.value)
|
||||
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
|
||||
const latestUser = messages.findLast((item) => {
|
||||
|
||||
@@ -62,8 +62,9 @@ export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
|
||||
const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
||||
|
||||
/**
|
||||
* Mutation locking is process-local and serializes cooperating OpenCode
|
||||
* changes; external writes can still race.
|
||||
* Serialize file changes by absolute target. Conditional writes compare and
|
||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||
* not overwrite changes made from the same stale content.
|
||||
*/
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -128,6 +129,7 @@ export const node = makeLocationNode({ service: Service, layer, deps: [Environme
|
||||
/**
|
||||
* Deferred until the corresponding integrations exist.
|
||||
*/
|
||||
// TODO: Add formatter integration after formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||
// TODO: Add snapshots / undo after snapshot design exists.
|
||||
// TODO: Notify LSP and collect diagnostics after LSP runtime exists.
|
||||
|
||||
@@ -135,7 +135,7 @@ export const fffLayer = Layer.effect(
|
||||
find: () => Effect.succeed([]),
|
||||
})
|
||||
}
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
|
||||
return Service.of({
|
||||
find: (input) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -213,7 +213,7 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) {
|
||||
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
|
||||
}
|
||||
@@ -346,7 +346,8 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
if (result.info && result.backgrounded) yield* Deferred.succeed(result.backgrounded, result.info)
|
||||
if (result.info && result.backgrounded)
|
||||
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
|
||||
return result.info
|
||||
})
|
||||
|
||||
@@ -395,7 +396,7 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) yield* Scope.close(result.scope, Exit.void)
|
||||
return result.info
|
||||
})
|
||||
|
||||
@@ -10,14 +10,17 @@ import {
|
||||
CallToolResultSchema,
|
||||
ElicitationCompleteNotificationSchema,
|
||||
ElicitRequestSchema,
|
||||
GetPromptResultSchema,
|
||||
type Implementation,
|
||||
type ElicitRequestFormParams,
|
||||
type ElicitRequestParams,
|
||||
type ElicitRequestURLParams,
|
||||
type ElicitResult,
|
||||
ListPromptsResultSchema,
|
||||
ListRootsRequestSchema,
|
||||
ListToolsResultSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
PromptSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
type LoggingMessageNotification,
|
||||
LoggingMessageNotificationSchema,
|
||||
@@ -38,6 +41,10 @@ const toError = (error: unknown) => (error instanceof Error ? error : new Error(
|
||||
const TolerantListToolsResult = ListToolsResultSchema.extend({
|
||||
tools: ToolSchema.omit({ outputSchema: true }).array(),
|
||||
})
|
||||
const TolerantListPromptsResult = ListPromptsResultSchema.extend({
|
||||
prompts: PromptSchema.array(),
|
||||
})
|
||||
|
||||
export class NeedsAuthError extends Schema.TaggedError<NeedsAuthError>()("MCP.NeedsAuthError", {
|
||||
server: Schema.String,
|
||||
}) {
|
||||
@@ -294,8 +301,12 @@ export const connect = Effect.fnUntraced(function* (
|
||||
const prompts = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
paginate(
|
||||
(cursor) =>
|
||||
client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
|
||||
async (cursor) => {
|
||||
const params = cursor === undefined ? undefined : { cursor }
|
||||
return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, {
|
||||
timeout: catalogTimeout,
|
||||
})
|
||||
},
|
||||
(result) => result.prompts,
|
||||
),
|
||||
catch: toError,
|
||||
@@ -385,7 +396,11 @@ export const connect = Effect.fnUntraced(function* (
|
||||
prompt: (input) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
client.getPrompt({ name: input.name, arguments: input.args ?? {} }, { signal, timeout: executionTimeout }),
|
||||
client.request(
|
||||
{ method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } },
|
||||
GetPromptResultSchema,
|
||||
{ signal, timeout: executionTimeout },
|
||||
),
|
||||
catch: toError,
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
|
||||
@@ -473,11 +473,11 @@ export const layer = (options?: Options) =>
|
||||
Effect.gen(function* () {
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
yield* stopServer(name, entry)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}),
|
||||
),
|
||||
)
|
||||
connection.onLog((message) => fork(serverLog(name, message)))
|
||||
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
||||
connection.onToolsChanged(() =>
|
||||
live(
|
||||
refreshTools(name, entry, connection).pipe(
|
||||
@@ -512,7 +512,7 @@ export const layer = (options?: Options) =>
|
||||
// Announce the handshake so connect() and credential reconnects don't show a stale
|
||||
// disabled/failed status for the duration of the connection attempt.
|
||||
entry.status = { status: "pending" }
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.scope = scope
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
@@ -543,9 +543,9 @@ export const layer = (options?: Options) =>
|
||||
// Announce the new tool set so the tool registry registers it. A server that finishes connecting
|
||||
// after the initial registration sweep and emits no list-changed notification would otherwise
|
||||
// stay invisible to the model.
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
whenLive(name, entry, result.value.connection)(refreshPrompts(name, entry, result.value.connection))
|
||||
return
|
||||
}
|
||||
@@ -557,7 +557,7 @@ export const layer = (options?: Options) =>
|
||||
? { status: "needs_auth" }
|
||||
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}).pipe(Effect.ensuring(entry.startup.open))
|
||||
|
||||
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
@@ -568,9 +568,9 @@ export const layer = (options?: Options) =>
|
||||
entry.tools = undefined
|
||||
entry.prompts = undefined
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name })
|
||||
yield* bus.publish(PromptsChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(PromptsChanged, { server: name }).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
@@ -592,7 +592,7 @@ export const layer = (options?: Options) =>
|
||||
yield* register(name, entry)
|
||||
if (serverConfig.disabled) {
|
||||
entry.status = { status: "disabled" }
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
return
|
||||
}
|
||||
yield* startServer(name, entry)
|
||||
@@ -608,7 +608,7 @@ export const layer = (options?: Options) =>
|
||||
yield* disposeServer(name, entry)
|
||||
// Credentials are keyed by name + URL and intentionally survive removal for a later re-add.
|
||||
entries.delete(name)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
|
||||
@@ -631,7 +631,7 @@ export const layer = (options?: Options) =>
|
||||
if (entry.config.disabled) {
|
||||
entry.status = { status: "disabled" }
|
||||
entry.startup.openUnsafe()
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
continue
|
||||
}
|
||||
fork(startServer(name, entry).pipe(locks.withLock(name)))
|
||||
@@ -673,6 +673,7 @@ export const layer = (options?: Options) =>
|
||||
bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => owned.has(event.data.integrationID)),
|
||||
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
const state = State.create<Data, Draft>({
|
||||
@@ -737,7 +738,7 @@ export const layer = (options?: Options) =>
|
||||
const target = yield* requireServer(name)
|
||||
yield* stopServer(name, target.entry)
|
||||
target.entry.status = { status: "disabled" }
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}).pipe(locks.withLock(name))
|
||||
}),
|
||||
remove: Effect.fn("MCP.remove")(function* (server) {
|
||||
|
||||
@@ -213,11 +213,20 @@ export const authorize = (input: {
|
||||
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
|
||||
})
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
|
||||
// The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step.
|
||||
if (result === "AUTHORIZED") {
|
||||
return {
|
||||
url: input.config.url,
|
||||
instructions: `Connected to ${input.name}.`,
|
||||
mode: "auto" as const,
|
||||
callback: finalize,
|
||||
}
|
||||
}
|
||||
if (!authorizationUrl)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { CheckError, Event, ID, Info, PackageStatus, Source } from "@opencode-ai/schema/plugin"
|
||||
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
@@ -37,7 +37,6 @@ export interface Interface {
|
||||
|
||||
export type Versioned = PluginDefinition & {
|
||||
readonly version: string
|
||||
readonly revision?: string
|
||||
readonly source?: Plugin.Source
|
||||
}
|
||||
|
||||
@@ -112,7 +111,7 @@ const layer = Layer.effect(
|
||||
for (const definition of definitions) {
|
||||
const previous = active.get(definition.id)
|
||||
active.delete(definition.id)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
|
||||
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
@@ -120,22 +119,18 @@ const layer = Layer.effect(
|
||||
nextInventory.push(activeInfo(definition))
|
||||
continue
|
||||
}
|
||||
const failed = {
|
||||
nextInventory.push({
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
...(definition.revision === undefined ? {} : { revision: definition.revision }),
|
||||
status: "failed",
|
||||
error: loaded.error,
|
||||
tui: definition.tui ?? false,
|
||||
} satisfies Plugin.Info
|
||||
nextInventory.push(failed)
|
||||
})
|
||||
|
||||
if (!previous) continue
|
||||
const restored = yield* load(previous.plugin)
|
||||
if (restored.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
|
||||
failed.revision = previous.plugin.revision
|
||||
if (failed.revision === undefined) delete failed.revision
|
||||
continue
|
||||
}
|
||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||
@@ -147,7 +142,7 @@ const layer = Layer.effect(
|
||||
.filter(([id]) => !ids.has(id))
|
||||
.toReversed()
|
||||
removed.forEach(([id]) => active.delete(id))
|
||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
|
||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
|
||||
discard: true,
|
||||
})
|
||||
inventory = [...nextInventory, ...failures]
|
||||
@@ -180,7 +175,6 @@ function activeInfo(plugin: Versioned): Plugin.Info {
|
||||
return {
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" },
|
||||
...(plugin.revision === undefined ? {} : { revision: plugin.revision }),
|
||||
status: "active",
|
||||
tui: plugin.tui ?? false,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as PluginHost from "./host.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { CheckError } from "@opencode-ai/schema/plugin"
|
||||
import { ServerConfig } from "@opencode-ai/schema/mcp"
|
||||
import { App } from "../app.js"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
@@ -99,7 +98,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
list: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.agent.list(ref)
|
||||
return response(agents.list())
|
||||
return agents.list().pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
},
|
||||
reload: agents.reload,
|
||||
transform: (callback) =>
|
||||
@@ -333,12 +332,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
},
|
||||
plugin: {
|
||||
list: () => response(plugin.list()),
|
||||
check: () =>
|
||||
Effect.fail(
|
||||
new CheckError({
|
||||
message: "Plugin package checks are available through an external client or the Plugins dialog.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
reference: {
|
||||
list: () => response(reference.list()),
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as PluginModule from "./module.js"
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
import { Effect, Predicate, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import type { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
@@ -34,16 +34,13 @@ const Definition = Schema.Struct({
|
||||
]),
|
||||
})
|
||||
|
||||
const revisions = new WeakMap<object, string | undefined>()
|
||||
|
||||
export const load = Effect.fn("PluginModule.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
) {
|
||||
const npm = yield* Npm.Service
|
||||
const resolved = path.isAbsolute(operation.target)
|
||||
? { entrypoint: pathToFileURL(operation.target).href, revision: undefined }
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })
|
||||
const entrypoint = resolved.entrypoint
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
|
||||
@@ -51,16 +48,12 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => importModule(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
|
||||
const exported = Predicate.hasProperty(mod, "default") && Predicate.isObject(mod.default) ? mod.default : undefined
|
||||
// Keep the import-time revision on the original export, not copied namespaces or decoded records.
|
||||
if (exported && !revisions.has(exported)) revisions.set(exported, resolved.revision)
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
return {
|
||||
id: plugin.id,
|
||||
tui: plugin.tui,
|
||||
vcs: plugin.vcs,
|
||||
version: JSON.stringify(operation),
|
||||
revision: exported ? revisions.get(exported) : undefined,
|
||||
source: path.isAbsolute(operation.target)
|
||||
? { type: "local" as const, path: operation.target }
|
||||
: { type: "package" as const, package: operation.target },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
type MantleSDK = {
|
||||
languageModel: (modelID: string) => LanguageModelV3
|
||||
chat: (modelID: string) => LanguageModelV3
|
||||
responses: (modelID: string) => LanguageModelV3
|
||||
}
|
||||
|
||||
@@ -58,8 +58,10 @@ export const CloudflareAIGatewayPlugin = define({
|
||||
const config = gatewayConfig(evt.options)
|
||||
if (!config) return
|
||||
const metadata = gatewayMetadata(evt.options)
|
||||
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider"))
|
||||
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified"))
|
||||
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")).pipe(Effect.orDie)
|
||||
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
const gateway = createAiGateway({
|
||||
accountId: config.accountId,
|
||||
gateway: config.gatewayId,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { loadSDKFactory } from "./sdk-factory.js"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export const DynamicProviderPlugin = define({
|
||||
id: "opencode.provider.dynamic",
|
||||
@@ -12,7 +13,18 @@ export const DynamicProviderPlugin = define({
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
|
||||
evt.sdk = ((yield* loadSDKFactory(npm, evt.package)) as (options: any) => any)(evt.options)
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
|
||||
|
||||
const mod = (yield* Effect.promise(() =>
|
||||
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
|
||||
).pipe(Effect.orDie)) as Record<string, (options: any) => any>
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) throw new Error(`Package ${evt.package} has no provider factory export`)
|
||||
|
||||
evt.sdk = mod[match](evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -40,7 +40,7 @@ export const GitLabPlugin = define({
|
||||
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
|
||||
const id = evt.model.modelID ?? evt.model.id
|
||||
if (id.startsWith("duo-workflow-")) {
|
||||
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider"))
|
||||
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie)
|
||||
const workflowRef =
|
||||
typeof evt.model.settings?.workflowRef === "string" ? evt.model.settings.workflowRef : undefined
|
||||
const workflowDefinition =
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { loadSDKFactory } from "./sdk-factory.js"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export const SapAICorePlugin = define({
|
||||
id: "opencode.provider.sap.ai.core",
|
||||
@@ -17,7 +18,17 @@ export const SapAICorePlugin = define({
|
||||
(typeof evt.options.serviceKey === "string" ? evt.options.serviceKey : undefined)
|
||||
if (serviceKey && !process.env.AICORE_SERVICE_KEY) process.env.AICORE_SERVICE_KEY = serviceKey
|
||||
|
||||
const factory = yield* loadSDKFactory(npm, evt.package)
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) return yield* Effect.die(new Error(`Package ${evt.package} has no import entrypoint`))
|
||||
|
||||
const mod = (yield* Effect.promise(() =>
|
||||
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
|
||||
)) as Record<string, unknown>
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) return yield* Effect.die(new Error(`Package ${evt.package} has no provider factory export`))
|
||||
const factory = mod[match]
|
||||
if (typeof factory !== "function")
|
||||
return yield* Effect.die(new Error(`Package ${evt.package} provider factory export is not callable`))
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export const loadSDKFactory = Effect.fnUntraced(function* (npm: Npm.Interface, packageName: string) {
|
||||
const installedPath = packageName.startsWith("file://")
|
||||
? packageName
|
||||
: (yield* npm.add(packageName).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) return yield* Effect.die(new Error(`Package ${packageName} has no import entrypoint`))
|
||||
|
||||
const mod = (yield* Effect.promise(() =>
|
||||
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
|
||||
)) as Record<string, unknown>
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) return yield* Effect.die(new Error(`Package ${packageName} has no provider factory export`))
|
||||
return mod[match]
|
||||
})
|
||||
@@ -50,7 +50,7 @@ export const Plugin = define({
|
||||
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* (
|
||||
app: Context["app"],
|
||||
) {
|
||||
const plugins = yield* configuredPlugins()
|
||||
const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
|
||||
return [
|
||||
ReportContent,
|
||||
"",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as PluginSupervisor from "./supervisor-service.js"
|
||||
|
||||
import { Context, Effect } from "effect"
|
||||
import type { Plugin } from "@opencode-ai/schema/plugin"
|
||||
|
||||
/**
|
||||
* Dependency-only supervisor seam. Keep this module free of implementation
|
||||
@@ -10,7 +9,6 @@ import type { Plugin } from "@opencode-ai/schema/plugin"
|
||||
export interface Interface {
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
readonly check: (target: string) => Effect.Effect<Plugin.PackageStatus, Plugin.CheckError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
@@ -89,7 +89,6 @@ export const layer = Layer.effect(
|
||||
const instance = yield* InstancePlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const npm = yield* Npm.Service
|
||||
const ready = yield* Latch.make()
|
||||
let observed = 0
|
||||
|
||||
@@ -137,29 +136,7 @@ export const layer = Layer.effect(
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({
|
||||
flush: ready.await,
|
||||
check: Effect.fn("PluginSupervisor.check")(function* (target: string) {
|
||||
yield* ready.await
|
||||
const inventory = yield* registry.list()
|
||||
if (!inventory.some((plugin) => plugin.source.type === "package" && plugin.source.package === target)) {
|
||||
return yield* new Plugin.CheckError({
|
||||
message: `Plugin package is not in the current server inventory: ${target}`,
|
||||
})
|
||||
}
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) {
|
||||
return yield* new Plugin.CheckError({ message: `Unsupported plugin package source: ${target}` })
|
||||
}
|
||||
return yield* npm.check(target).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new Plugin.CheckError({
|
||||
message: `Failed to check plugin package ${target}: ${error.cause instanceof Error ? error.cause.message : error.message}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
return Service.of({ flush: ready.await })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -137,10 +137,11 @@ const layer = Layer.effect(
|
||||
strategy: project.vcs.type === "git" ? "git" : undefined,
|
||||
})
|
||||
// A missing directory row means this directory's resolution is a new durable
|
||||
// fact. The row insert commits atomically with the event, so a crash between
|
||||
// checks retries on the next resolve instead of stranding the announcement.
|
||||
// The in-flight set keeps concurrent resolves from publishing the same fact
|
||||
// twice.
|
||||
// fact (copy.ts registers copy directories directly; those never strand
|
||||
// sessions and never announce). The row insert commits atomically with the
|
||||
// event, so a crash between checks retries on the next resolve instead of
|
||||
// stranding the announcement. The in-flight set keeps concurrent resolves
|
||||
// from publishing the same fact twice.
|
||||
for (const item of directories) {
|
||||
const key = item.projectID + "\u0000" + item.directory
|
||||
if (announcing.has(key)) continue
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "eff
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Workspace } from "./workspace.js"
|
||||
import { Model } from "./model.js"
|
||||
import { Location } from "./location.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
|
||||
@@ -17,7 +17,7 @@ import { SessionProjector } from "./session/projector.js"
|
||||
import { SessionMessageTable, SessionTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Agent } from "./agent.js"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
|
||||
@@ -204,6 +204,7 @@ export const layer = (options?: Options) =>
|
||||
yield* jobs.background(background.id)
|
||||
yield* jobs.wait({ id: background.id }).pipe(
|
||||
Effect.flatMap((result) => (result.info ? notify(result.info) : Effect.void)),
|
||||
Effect.ignore,
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Model } from "../model.js"
|
||||
import { Project } from "../project.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import type { SessionTable } from "./sql.js"
|
||||
import { SessionTable } from "./sql.js"
|
||||
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@ export const commit = Effect.fn("InstructionState.commit")(function* (
|
||||
observation: Observation,
|
||||
) {
|
||||
if (!observation.initial && Object.keys(observation.delta).length === 0) return
|
||||
// The rendered text is frozen into the durable event because re-rendering it
|
||||
// later would require the original Location-scoped instruction sources.
|
||||
// The rendered text is frozen into the durable event: replaying it later would
|
||||
// require the Location-scoped registry that produced it.
|
||||
const text = observation.initial ? "" : yield* renderUpdateText(db, instructions, observation)
|
||||
yield* bus.publish(
|
||||
SessionEvent.InstructionsUpdated,
|
||||
|
||||
@@ -4,17 +4,21 @@ import { SessionEvent } from "./event.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
|
||||
export interface Adapter {
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined>
|
||||
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"]>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
|
||||
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
|
||||
readonly getShell: (shellID: SessionMessage.Shell["shellID"]) => Effect.Effect<SessionMessage.Shell | undefined>
|
||||
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
|
||||
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void>
|
||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void>
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"], never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getAssistant: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getShell: (
|
||||
shellID: SessionMessage.Shell["shellID"],
|
||||
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
|
||||
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
|
||||
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
|
||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
|
||||
}
|
||||
|
||||
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
|
||||
@@ -34,14 +38,16 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
|
||||
const created = DateTime.makeUnsafe(event.created)
|
||||
|
||||
const latestTool = (assistant: DraftAssistant, id: string) =>
|
||||
assistant.content.findLast((item): item is DraftTool => item.type === "tool" && item.id === id)
|
||||
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
|
||||
assistant?.content.findLast(
|
||||
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id),
|
||||
)
|
||||
|
||||
const latestText = (assistant: DraftAssistant) =>
|
||||
assistant.content.findLast((item): item is DraftText => item.type === "text")
|
||||
const latestText = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftText => item.type === "text")
|
||||
|
||||
const latestReasoning = (assistant: DraftAssistant) =>
|
||||
assistant.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
|
||||
const latestReasoning = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
|
||||
|
||||
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -6,13 +6,13 @@ import path from "path"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Model } from "../model.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionMessageUpdater } from "./message-updater.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
@@ -26,10 +26,8 @@ import type { SessionSchema } from "./schema.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type MessageEvent = Exclude<
|
||||
SessionEvent.DurableEvent,
|
||||
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
|
||||
>
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
@@ -55,16 +53,16 @@ const forkTitle = (value?: string) => {
|
||||
return `${value} (fork #1)`
|
||||
}
|
||||
|
||||
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage) {
|
||||
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage, sign = 1) {
|
||||
return db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
cost: sql`${SessionTable.cost} + ${value.cost}`,
|
||||
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input}`,
|
||||
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output}`,
|
||||
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning}`,
|
||||
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read}`,
|
||||
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write}`,
|
||||
cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
|
||||
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
|
||||
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
|
||||
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
|
||||
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
|
||||
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
@@ -75,7 +73,7 @@ function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usa
|
||||
const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"],
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({
|
||||
|
||||
@@ -143,7 +143,7 @@ const layer = Layer.effect(
|
||||
entering && !continuing ? promotable : "steer",
|
||||
)
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
if (promoted > 0) step = 1
|
||||
|
||||
@@ -221,15 +221,10 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
}
|
||||
|
||||
// After durable output, recovery continues instead of replaying: the
|
||||
// partial assistant message is already persisted history. Any failure
|
||||
// the pre-output gate would retry is continued here, plus interrupted
|
||||
// streams, whose read failures may carry delivery states the retry
|
||||
// policy rejects for full resends.
|
||||
if (
|
||||
llmFailure &&
|
||||
llmError &&
|
||||
(isInterruptedStream(llmFailure) || SessionRunnerRetry.isRetryable(llmFailure)) &&
|
||||
isInterruptedStream(llmFailure) &&
|
||||
record.outputStarted &&
|
||||
tools.declines.length === 0 &&
|
||||
!tools.interrupted
|
||||
|
||||
@@ -5,10 +5,10 @@ import { ProjectTable } from "../project/sql.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { SessionInbox } from "./inbox.js"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import type { PermissionV1 } from "@opencode-ai/schema/permission-v1"
|
||||
import type { Project } from "@opencode-ai/schema/project"
|
||||
import { PermissionV1 } from "../v1/permission.js"
|
||||
import { Project } from "../project.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
import type { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { Timestamps } from "../database/schema.sql.js"
|
||||
import type { Instruction } from "@opencode-ai/schema/instruction"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
@@ -255,7 +255,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
Effect.tap((rows) =>
|
||||
Effect.sync(() => {
|
||||
rows.forEach((row) => {
|
||||
addToolStatus(toolTotals, row.status)
|
||||
addToolStatus(toolTotals, row.status, 1)
|
||||
if (!row.name) return
|
||||
const tool = tools.get(row.name) ?? {
|
||||
name: row.name,
|
||||
@@ -266,7 +266,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
durations: [],
|
||||
}
|
||||
tools.set(row.name, tool)
|
||||
addToolStatus(tool, row.status)
|
||||
addToolStatus(tool, row.status, 1)
|
||||
if (row.duration !== null) tool.durations.push(row.duration)
|
||||
})
|
||||
}),
|
||||
@@ -385,17 +385,18 @@ function tokenTotal(tokens: Tokens) {
|
||||
function addToolStatus(
|
||||
target: { calls: number; succeeded: number; failed: number; unfinished: number },
|
||||
status: string | null,
|
||||
count: number,
|
||||
) {
|
||||
target.calls++
|
||||
target.calls += count
|
||||
if (status === "completed") {
|
||||
target.succeeded++
|
||||
target.succeeded += count
|
||||
return
|
||||
}
|
||||
if (status === "error") {
|
||||
target.failed++
|
||||
target.failed += count
|
||||
return
|
||||
}
|
||||
target.unfinished++
|
||||
target.unfinished += count
|
||||
}
|
||||
|
||||
function makeDateKey(timezone = "UTC") {
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as SessionUsage from "./usage.js"
|
||||
import type { Usage } from "@opencode-ai/ai"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Model } from "../model.js"
|
||||
|
||||
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
|
||||
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
|
||||
|
||||
@@ -305,7 +305,7 @@ const layer = () =>
|
||||
}),
|
||||
)
|
||||
yield* outputDone.open
|
||||
}),
|
||||
}).pipe(Effect.catch(() => outputDone.open)),
|
||||
)
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
@@ -356,6 +356,7 @@ const layer = () =>
|
||||
Effect.flatMap(() =>
|
||||
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
|
||||
),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import path from "path"
|
||||
import type { Node } from "web-tree-sitter"
|
||||
import { shellParserWasm } from "#shell-parser-wasm"
|
||||
import { ShellSelect } from "./select.js"
|
||||
import { lazy } from "../util/lazy.js"
|
||||
import { Wildcard } from "../util/wildcard.js"
|
||||
|
||||
type Part = { type: string; text: string }
|
||||
@@ -356,7 +355,10 @@ function resolve(asset: string) {
|
||||
return fileURLToPath(new URL(asset, import.meta.url))
|
||||
}
|
||||
|
||||
const load = lazy(initialize)
|
||||
const load = (() => {
|
||||
let loading: ReturnType<typeof initialize> | undefined
|
||||
return () => (loading ??= initialize())
|
||||
})()
|
||||
|
||||
async function initialize() {
|
||||
const { Parser, Language } = await import("web-tree-sitter")
|
||||
|
||||
@@ -133,34 +133,36 @@ const layer = Layer.effect(
|
||||
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const comparison = {
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
}
|
||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: repo.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
input: comparison,
|
||||
files,
|
||||
ignored,
|
||||
source: repo.source,
|
||||
input: {
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
const comparison = yield* compare("files", input)
|
||||
return comparison.files.filter((file) => !comparison.ignored.has(file))
|
||||
const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: comparison.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
return files.filter((file) => !ignored.has(file))
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
const comparison = yield* compare("diff", input)
|
||||
const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: comparison.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
return yield* git.tree
|
||||
.diff({
|
||||
...comparison.input,
|
||||
context: input.context,
|
||||
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
|
||||
paths: (input.paths ?? files).filter((file) => !ignored.has(file)),
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- Plugin authors get schema-derived input types at the `ToolDraft.add` boundary through `Tool`.
|
||||
- The heterogeneous Core registry deliberately erases registered definitions to `Tool.Info`. Use `any` at this internal boundary; do not replace it with `unknown`, JSON-value plumbing, casts, or compiled wrapper types solely to preserve type safety after registration.
|
||||
- Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same runtime shape after registration.
|
||||
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and normalizes model content and images.
|
||||
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
|
||||
- Built-in tool plugins live in `tool/plugin`.
|
||||
|
||||
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
|
||||
@@ -53,9 +53,9 @@ Tool filtering is catalog visibility, not execution authorization. A call still
|
||||
|
||||
## Output
|
||||
|
||||
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary. Generic output bounding is applied by the Session runner after execution.
|
||||
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary.
|
||||
|
||||
Producer capture remains local to producers. Shell stores combined process output in its backing file and returns a bounded tail with the full-output path when truncated.
|
||||
Producer capture limits remain local to producers. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss.
|
||||
|
||||
## Current Gaps
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
export * as EditTool from "./edit.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
@@ -108,7 +108,7 @@ const findLineOccurrences = (content: string, search: string) => {
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.edit",
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as GlobTool from "./glob.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
@@ -41,7 +41,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
|
||||
/** Glob leaf that defaults its filesystem root to the active Location. */
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.glob",
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as GrepTool from "./grep.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
@@ -57,7 +57,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
|
||||
/** Grep leaf that defaults its filesystem root to the active Location. */
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.grep",
|
||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PatchTool from "./patch.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Effect, Result, Schema } from "effect"
|
||||
@@ -65,7 +65,7 @@ type Prepared =
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as QuestionTool from "./question.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../../form.js"
|
||||
@@ -47,7 +47,7 @@ export const toModelContent = (questions: ReadonlyArray<Question.Prompt>, answer
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.question",
|
||||
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const forms = yield* Form.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ReadTool from "./read.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { basename, dirname, join } from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
@@ -29,7 +29,7 @@ const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.read",
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const reader = yield* ReadToolFileSystem.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* Permission.Service
|
||||
@@ -169,7 +169,7 @@ export const toModelContent = (path: string, offset: number | undefined, output:
|
||||
] as const
|
||||
|
||||
if (output.type === "list-page") {
|
||||
const start = offset || 1
|
||||
const start = offset ?? 1
|
||||
const content = [
|
||||
output.entries.length === 0
|
||||
? `Read directory ${path}, 0 entries`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ShellTool from "./shell.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
@@ -102,7 +102,7 @@ const backgroundResult = (shellID: string, file: string) => ({
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.shell",
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const environment = yield* Environment.Service
|
||||
@@ -280,8 +280,7 @@ export const Plugin = {
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const job = yield* runtime.job.start({
|
||||
// CodeMode children share a tool-call ID, but each shell must own its job.
|
||||
id: info.id,
|
||||
id: context.id,
|
||||
type: name,
|
||||
title: info.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
@@ -296,7 +295,7 @@ export const Plugin = {
|
||||
|
||||
if (input.background === true) {
|
||||
yield* runtime.job.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
|
||||
return backgroundResult(info.id, info.file)
|
||||
}
|
||||
|
||||
@@ -305,7 +304,7 @@ export const Plugin = {
|
||||
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* shell.timeout(info.id, 0)
|
||||
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
|
||||
return backgroundResult(info.id, info.file)
|
||||
}
|
||||
if (result?.info.status === "error")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SkillTool from "./skill.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -31,7 +31,7 @@ const unableToLoad = (name: string, error?: unknown) =>
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.skill",
|
||||
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const skills = yield* Skill.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SubagentTool from "./subagent.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
@@ -52,7 +52,7 @@ export const description = [
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.subagent",
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebFetchTool from "./webfetch.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, type HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
@@ -102,7 +102,7 @@ const convert = (content: string, contentType: string, format: Format) => {
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.webfetch",
|
||||
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebSearchTool from "./websearch.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
@@ -26,7 +26,7 @@ const Output = Schema.Struct({
|
||||
})
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.websearch",
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const permission = yield* Permission.Service
|
||||
const forms = yield* Form.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
export * as WriteTool from "./write.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
@@ -45,7 +45,7 @@ export const toModelContent = (output: Output) =>
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.write",
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
|
||||
@@ -5,7 +5,9 @@ export abstract class NamedError extends Error {
|
||||
abstract toObject(): { name: string; data: unknown }
|
||||
|
||||
static hasName(error: unknown, name: string): boolean {
|
||||
return typeof error === "object" && error !== null && "name" in error && error.name === name
|
||||
return (
|
||||
typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
|
||||
)
|
||||
}
|
||||
|
||||
static create<Name extends string, Fields extends Schema.Struct.Fields>(
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { DateTime, Effect, Layer } from "effect"
|
||||
import { CommandInvocation } from "@opencode-ai/core/command/invocation"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const shell = ShellSelect.Service.of({
|
||||
resolve: (input) =>
|
||||
Effect.sync(() => {
|
||||
expect(input).toEqual({ priority: "config" })
|
||||
return "sh"
|
||||
}),
|
||||
transform: () => Effect.die("unused shell.transform"),
|
||||
reload: () => Effect.die("unused shell.reload"),
|
||||
})
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(AppNodeBuilder.build(AppProcess.node), tempLocationLayer, Layer.succeed(ShellSelect.Service, shell)),
|
||||
)
|
||||
const sessionID = Session.ID.make("ses_command_invocation")
|
||||
|
||||
describe("CommandInvocation", () => {
|
||||
it.effect("expands arguments without changing unconfigured session defaults or prompt attachments", () =>
|
||||
Effect.gen(function* () {
|
||||
const prompts: unknown[] = []
|
||||
const invoke = yield* CommandInvocation.make(promptHost(prompts))
|
||||
const files = [{ uri: "file:///context.md", name: "context" }]
|
||||
for (const [template, text, expected] of [
|
||||
[
|
||||
"$2 / $1 / $2",
|
||||
`"alpha beta" 'gamma delta' [Image 3] tail`,
|
||||
"gamma delta [Image 3] tail / alpha beta / gamma delta [Image 3] tail",
|
||||
],
|
||||
["[$1][$3]", "one two", "[one][]"],
|
||||
["raw [$ARGUMENTS]", `"alpha beta" 'gamma delta'`, `raw ["alpha beta" 'gamma delta']`],
|
||||
[" Review ", " details ", "Review \n\n details"],
|
||||
[" Review ", " ", "Review"],
|
||||
]) {
|
||||
expect(
|
||||
yield* invoke(new ConfigCommand.Info({ template }), {
|
||||
sessionID,
|
||||
prompt: { text, files },
|
||||
delivery: "queue",
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(prompts.at(-1)).toEqual({ sessionID, text: expected, files, delivery: "queue" })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("switches agents before applying command or agent model defaults and admitting the prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: unknown[] = []
|
||||
const ctx = promptHost(calls)
|
||||
const location = yield* Location.Service
|
||||
const reviewer = Agent.ID.make("reviewer")
|
||||
const agentModel = { id: Model.ID.make("agent-model"), providerID: Provider.ID.make("example") }
|
||||
const commandModel = {
|
||||
model: Model.ID.make("command-model"),
|
||||
providerID: Provider.ID.make("example"),
|
||||
variant: Model.VariantID.make("careful"),
|
||||
}
|
||||
const session = Session.Info.make({
|
||||
id: sessionID,
|
||||
projectID: location.project.id,
|
||||
agent: Agent.ID.make("build"),
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: { directory: location.directory },
|
||||
})
|
||||
for (const testCase of [
|
||||
{
|
||||
currentAgent: session.agent,
|
||||
agentModel,
|
||||
command: new ConfigCommand.Info({ template: "Review", agent: reviewer, model: commandModel }),
|
||||
expected: [
|
||||
["session.get", { sessionID }],
|
||||
["switchAgent", { sessionID, agent: reviewer }],
|
||||
["agent.get", { agentID: reviewer }],
|
||||
["switchModel", { sessionID, model: { id: "command-model", providerID: "example", variant: "careful" } }],
|
||||
],
|
||||
},
|
||||
{
|
||||
currentAgent: reviewer,
|
||||
agentModel,
|
||||
command: new ConfigCommand.Info({ template: "Review", agent: reviewer }),
|
||||
expected: [
|
||||
["session.get", { sessionID }],
|
||||
["agent.get", { agentID: reviewer }],
|
||||
["switchModel", { sessionID, model: agentModel }],
|
||||
],
|
||||
},
|
||||
{
|
||||
currentAgent: session.agent,
|
||||
agentModel: undefined,
|
||||
command: new ConfigCommand.Info({ template: "Review", agent: reviewer }),
|
||||
expected: [
|
||||
["session.get", { sessionID }],
|
||||
["switchAgent", { sessionID, agent: reviewer }],
|
||||
["agent.get", { agentID: reviewer }],
|
||||
],
|
||||
},
|
||||
{
|
||||
currentAgent: session.agent,
|
||||
agentModel,
|
||||
command: new ConfigCommand.Info({
|
||||
template: "Review",
|
||||
model: { model: commandModel.model, providerID: commandModel.providerID },
|
||||
}),
|
||||
expected: [["switchModel", { sessionID, model: { id: "command-model", providerID: "example" } }]],
|
||||
},
|
||||
]) {
|
||||
calls.length = 0
|
||||
const invoke = yield* CommandInvocation.make(
|
||||
host({
|
||||
agent: {
|
||||
...ctx.agent,
|
||||
get: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(["agent.get", input])
|
||||
return { location, data: { ...Agent.Info.default(reviewer), model: testCase.agentModel } }
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
...ctx.session,
|
||||
get: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(["session.get", input])
|
||||
return { ...session, agent: testCase.currentAgent }
|
||||
}),
|
||||
switchAgent: (input) => Effect.sync(() => calls.push(["switchAgent", input])),
|
||||
switchModel: (input) => Effect.sync(() => calls.push(["switchModel", input])),
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* invoke(testCase.command, {
|
||||
sessionID,
|
||||
prompt: { text: "" },
|
||||
delivery: "steer",
|
||||
})
|
||||
expect(calls).toEqual([...testCase.expected, { sessionID, text: "Review", delivery: "steer" }])
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("interpolates in source order using the location, closed stdin and nonzero-exit output", () =>
|
||||
Effect.gen(function* () {
|
||||
const prompts: unknown[] = []
|
||||
const location = yield* Location.Service
|
||||
yield* Effect.promise(() => Bun.write(path.join(location.directory, "context.txt"), "context"))
|
||||
const invoke = yield* CommandInvocation.make(promptHost(prompts))
|
||||
yield* invoke(
|
||||
new ConfigCommand.Info({
|
||||
template:
|
||||
'first=!`read value || printf closed-; cat context.txt; sleep 0.05; printf "%s" "-stderr" >&2; exit 7`; second=!`printf "%s" "$1"`',
|
||||
}),
|
||||
{ sessionID, prompt: { text: "argument" }, delivery: "steer" },
|
||||
)
|
||||
expect(prompts).toEqual([{ sessionID, text: "first=closed-context-stderr; second=argument", delivery: "steer" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("wraps process failures with the shell source and does not admit a prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const prompts: unknown[] = []
|
||||
const location = yield* Location.Service
|
||||
const missing = path.join(location.directory, "missing-shell")
|
||||
const invoke = yield* CommandInvocation.make(promptHost(prompts)).pipe(
|
||||
Effect.provideService(ShellSelect.Service, { ...shell, resolve: () => Effect.succeed(missing) }),
|
||||
)
|
||||
const error = yield* invoke(new ConfigCommand.Info({ template: '!`printf "hello"`' }), {
|
||||
sessionID,
|
||||
prompt: { text: "" },
|
||||
delivery: "steer",
|
||||
}).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(String(error)).toContain('Shell interpolation failed for "printf \\"hello\\"": Command failed:')
|
||||
expect(String(error)).toContain(missing)
|
||||
expect(prompts).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function promptHost(prompts: unknown[]) {
|
||||
return host({
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push(input)
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_command_invocation"),
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(0),
|
||||
type: "user",
|
||||
payload: { text: input.text },
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { ConfigFile } from "@opencode-ai/core/config/file"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { withTempDir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
// No Config, Location, Watcher, Credential, or WellKnown services are provided.
|
||||
const it = testEffect(LayerNode.compile(FSUtil.node))
|
||||
|
||||
describe("ConfigFile", () => {
|
||||
it.live("edits the explicit target and preserves comments and unrelated fields", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = path.join(tmp.path, "global", "opencode.jsonc")
|
||||
const target = path.join(tmp.path, "project", "custom.jsonc")
|
||||
const text = '{\n // Keep this comment.\n "shell": "project",\n "custom": { "value": 1 },\n}\n'
|
||||
yield* fs.writeWithDirs(global, '{ "shell": "global" }')
|
||||
yield* fs.writeWithDirs(target, text)
|
||||
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "updated"
|
||||
})
|
||||
|
||||
expect(updated).toEqual({ shell: "updated", custom: { value: 1 } })
|
||||
expect(yield* fs.readFileString(target)).toBe(text.replace('"project"', '"updated"'))
|
||||
expect(yield* fs.readFileString(global)).toBe('{ "shell": "global" }')
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("leaves raw substitutions, model shorthand, and legacy shapes unresolved", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
const text = `{
|
||||
"model": "{env:OPENCODE_TEST_CONFIG_MODEL}",
|
||||
"shell": "{file:missing-shell.txt}",
|
||||
"skills": { "paths": ["./skills"] },
|
||||
"agent": { "review": { "model": "acme/reasoner" } },
|
||||
"username": "before"
|
||||
}
|
||||
`
|
||||
yield* fs.writeFileString(target, text)
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
expect(draft.model).toBe("{env:OPENCODE_TEST_CONFIG_MODEL}")
|
||||
expect(draft.shell).toBe("{file:missing-shell.txt}")
|
||||
expect(draft.skills).toEqual({ paths: ["./skills"] })
|
||||
draft.username = "after"
|
||||
})
|
||||
|
||||
expect(yield* fs.readFileString(target)).toBe(text.replace('"before"', '"after"'))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("patches nested source fields and deletes legacy keys without migrating them", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
yield* fs.writeFileString(
|
||||
target,
|
||||
`{
|
||||
"agent": {
|
||||
"review": { "description": "before", "hidden": true },
|
||||
// Keep the other definition.
|
||||
"build": { "description": "unchanged" }
|
||||
},
|
||||
"snapshot": true
|
||||
}
|
||||
`,
|
||||
)
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
const agent: unknown = draft.agent
|
||||
if (!isRecord(agent) || !isRecord(agent.review)) throw new Error("Missing fixture agent")
|
||||
agent.review.description = "after"
|
||||
agent.review.color = "blue"
|
||||
delete agent.review.hidden
|
||||
delete draft.snapshot
|
||||
})
|
||||
|
||||
expect(updated).toEqual({
|
||||
agent: { review: { description: "after", color: "blue" }, build: { description: "unchanged" } },
|
||||
})
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
|
||||
expect(yield* fs.readFileString(target)).toContain("// Keep the other definition.")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("patches array elements without rewriting untouched comments", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
const text = `{
|
||||
"plugins": [
|
||||
// Keep the first plugin.
|
||||
"first",
|
||||
"second",
|
||||
// Keep the third plugin.
|
||||
"third",
|
||||
"fourth"
|
||||
]
|
||||
}
|
||||
`
|
||||
yield* fs.writeFileString(target, text)
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins[1] = "updated"
|
||||
})
|
||||
expect(yield* fs.readFileString(target)).toBe(text.replace('"second"', '"updated"'))
|
||||
|
||||
const shortened = yield* ConfigFile.update(target, (draft) => {
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins.splice(1, 3)
|
||||
})
|
||||
expect(shortened.plugins).toEqual(["first"])
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(shortened)
|
||||
|
||||
const extended = yield* ConfigFile.update(target, (draft) => {
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins.push("added", "last")
|
||||
})
|
||||
expect(extended.plugins).toEqual(["first", "added", "last"])
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(extended)
|
||||
expect(yield* fs.readFileString(target)).toContain("// Keep the first plugin.")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves adjacent comments when deleting properties and array elements", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
yield* fs.writeFileString(
|
||||
target,
|
||||
`{
|
||||
"shell": "remove",
|
||||
// Keep the model explanation.
|
||||
"model": "acme/reasoner",
|
||||
"plugins": ["first", "second", /* Keep the plugin explanation. */ "third"],
|
||||
"skills": [/* Keep the source explanation. */ "remove",],
|
||||
}
|
||||
`,
|
||||
)
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
delete draft.shell
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins.splice(1, 1)
|
||||
draft.skills = []
|
||||
})
|
||||
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
|
||||
expect(updated).toEqual({ model: "acme/reasoner", plugins: ["first", "third"], skills: [] })
|
||||
expect(yield* fs.readFileString(target)).toContain("// Keep the model explanation.")
|
||||
expect(yield* fs.readFileString(target)).toContain("/* Keep the plugin explanation. */")
|
||||
expect(yield* fs.readFileString(target)).toContain("/* Keep the source explanation. */")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("deletes own JSON keys that also exist on Object.prototype", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(
|
||||
target,
|
||||
'{ "\\u005f_proto__": "remove", "constructor": "remove", "toString": "remove", "shell": "keep" }',
|
||||
)
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
;["__proto__", "constructor", "toString"].forEach((key) => {
|
||||
delete draft[key]
|
||||
})
|
||||
})
|
||||
|
||||
expect(updated).toEqual({ shell: "keep" })
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves and edits object-valued __proto__ source keys", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, '{ "__proto__": { "value": "before" }, "shell": "keep" }')
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
expect(Object.hasOwn(draft, "__proto__")).toBe(true)
|
||||
const entry: unknown = draft["__proto__"]
|
||||
if (!isRecord(entry)) throw new Error("Missing fixture entry")
|
||||
entry.value = "after"
|
||||
})
|
||||
|
||||
expect(updated).toEqual({ ["__proto__"]: { value: "after" }, shell: "keep" })
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
expect(Object.getPrototypeOf(updated)).toBe(Object.prototype)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a duplicate-key patch that would not change the effective value", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "first", "shell": "second" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "after"
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Config patch does not match the requested update: ${target}`)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rereads the selected file for consecutive edits without a watcher", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, '{ "shell": "first" }')
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "second"
|
||||
})
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
expect(draft.shell).toBe("second")
|
||||
draft.username = "added"
|
||||
})
|
||||
expect(yield* fs.readJson(target)).toEqual({ shell: "second", username: "added" })
|
||||
|
||||
yield* fs.writeFileString(target, '{ "shell": "external", "username": "added" }')
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
expect(draft.shell).toBe("external")
|
||||
draft.snapshots = false
|
||||
})
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
expect(updated).toEqual({ shell: "external", username: "added", snapshots: false })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent read-modify-write calls", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, '{ "count": 0 }')
|
||||
const increment = ConfigFile.update(target, (draft) => {
|
||||
if (typeof draft.count !== "number") throw new Error("Missing fixture count")
|
||||
draft.count++
|
||||
})
|
||||
yield* Effect.all([increment, increment, increment], { concurrency: "unbounded" })
|
||||
|
||||
expect(yield* fs.readJson(target)).toEqual({ count: 3 })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not rewrite no-op or structurally equal edits", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{\r\n "plugins": ["first"]\r\n}'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const before = yield* fs.stat(target)
|
||||
yield* ConfigFile.update(target, () => {})
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
draft.plugins = ["first"]
|
||||
})
|
||||
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect((yield* fs.stat(target)).ino).toEqual(before.ino)
|
||||
expect((yield* fs.stat(target)).mtime).toEqual(before.mtime)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("leaves the file unchanged when a callback throws and permits a later edit", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "before" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const cause = new Error("Rejected config update")
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "discarded"
|
||||
throw cause
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe("Config update failed")
|
||||
expect(error.cause).toBe(cause)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "recovered"
|
||||
})
|
||||
expect(yield* fs.readJson(target)).toEqual({ shell: "recovered" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("ignores callback return values instead of replacing the document", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, "{}")
|
||||
|
||||
expect(yield* ConfigFile.update(target, () => new Date(0))).toEqual({})
|
||||
expect(yield* fs.readFileString(target)).toBe("{}")
|
||||
|
||||
const updated = yield* ConfigFile.update(target, (draft) => (draft.shell = "updated"))
|
||||
expect(updated).toEqual({ shell: "updated" })
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects non-JSON mutations before writing", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "before" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.invalid = Number.NaN
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Config update must produce a JSON object: ${target}`)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
;["", "{", "[]", "null"].forEach((text) => {
|
||||
it.live(`rejects invalid or non-object source ${JSON.stringify(text)}`, () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, text)
|
||||
const error = yield* ConfigFile.update(target, () => {
|
||||
throw new Error("Callback must not run")
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Invalid config file: ${target}`)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("reports a missing target without creating it", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "missing.json")
|
||||
const error = yield* ConfigFile.update(target, () => {}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Failed to read config: ${target}`)
|
||||
expect(error.cause).toBeDefined()
|
||||
expect(yield* fs.exists(target)).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports write failures without replacing the target", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "before" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
yield* fs.makeDirectory(target + ".tmp")
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "discarded"
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Failed to write config: ${target}`)
|
||||
expect(error.cause).toBeDefined()
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||
@@ -12,7 +12,6 @@ import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
@@ -29,6 +28,27 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
|
||||
@@ -31,10 +31,7 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
|
||||
const configLayer = Config.testLayer()
|
||||
const pluginNode = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
|
||||
),
|
||||
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
@@ -316,7 +313,7 @@ describe("LocationWatcher subscriptions", () => {
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
yield* policy.transform((draft) => draft.add([".git"]))
|
||||
return PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })
|
||||
return PluginSupervisor.Service.of({ flush: Effect.void })
|
||||
}),
|
||||
),
|
||||
deps: [LocationWatcherPolicy.node],
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { $ } from "bun"
|
||||
import { execFile } from "child_process"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { promisify } from "util"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { Effect } from "effect"
|
||||
import { tmpdir } from "./tmpdir"
|
||||
|
||||
const exec = promisify(execFile)
|
||||
|
||||
@@ -32,30 +29,6 @@ export async function gitRemote(root: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(async () => {
|
||||
const root = await tmpdir()
|
||||
return { root, fixture: await gitRemote(root.path) }
|
||||
}),
|
||||
(input) => body(input.fixture),
|
||||
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
|
||||
)
|
||||
}
|
||||
|
||||
export function read(file: string) {
|
||||
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
|
||||
}
|
||||
|
||||
export async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
||||
await $`git config user.name Test`.cwd(directory).quiet()
|
||||
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
export async function commit(source: string, content: string, message: string) {
|
||||
await fs.writeFile(path.join(source, "README.md"), content)
|
||||
await git(source, "add", "README.md")
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { spawn } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
const root = path.join(import.meta.dir, "../..")
|
||||
|
||||
export function runLockWorker(entrypoint: string, payload: unknown) {
|
||||
return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
|
||||
const proc = spawn(process.execPath, [entrypoint, JSON.stringify(payload)], { cwd: root })
|
||||
const stdout: Buffer[] = []
|
||||
const stderr: Buffer[] = []
|
||||
proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
||||
proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
||||
proc.on("close", (code) => {
|
||||
resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function spawnLockWorker(entrypoint: string, payload: unknown) {
|
||||
return spawn(process.execPath, [entrypoint, JSON.stringify(payload)], {
|
||||
cwd: root,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
}
|
||||
|
||||
export async function stopLockWorker(proc: ReturnType<typeof spawnLockWorker>) {
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) return
|
||||
|
||||
const closed = new Promise<void>((resolve) => proc.once("close", () => resolve()))
|
||||
if (process.platform !== "win32" || !proc.pid) {
|
||||
proc.kill()
|
||||
await closed
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
|
||||
killProc.on("close", () => {
|
||||
proc.kill()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
await closed
|
||||
}
|
||||
|
||||
export async function waitForFile(file: string, timeout = 3_000) {
|
||||
const stop = Date.now() + timeout
|
||||
while (Date.now() < stop) {
|
||||
if (await Bun.file(file).exists()) return
|
||||
await Bun.sleep(20)
|
||||
}
|
||||
throw new Error(`Timed out waiting for file: ${file}`)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import { GetPromptRequestSchema, ListPromptsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const server = new Server({ name: "prompts", version: "1.0.0" }, { capabilities: { prompts: {} } })
|
||||
|
||||
server.setRequestHandler(ListPromptsRequestSchema, ({ params }) =>
|
||||
Promise.resolve(
|
||||
params?.cursor === "page-2"
|
||||
? { prompts: [{ name: "second", description: "Second prompt" }] }
|
||||
: {
|
||||
prompts: [
|
||||
{
|
||||
name: "first",
|
||||
description: "First prompt",
|
||||
arguments: [{ name: "topic", description: "Topic to explain", required: true }],
|
||||
},
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
server.setRequestHandler(GetPromptRequestSchema, ({ params }) =>
|
||||
Promise.resolve({
|
||||
messages: [{ role: "user", content: { type: "text", text: params.arguments?.topic ?? "missing" } }],
|
||||
}),
|
||||
)
|
||||
|
||||
await server.connect(new StdioServerTransport())
|
||||
@@ -12,10 +12,7 @@ export const promptLocationLayer = Layer.effect(
|
||||
() =>
|
||||
Layer.merge(
|
||||
LayerNode.compile(PluginHooks.node),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.void,
|
||||
check: () => Effect.succeed({ mutable: false }),
|
||||
}),
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
) as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
|
||||
import { branch, commit, gitRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -75,6 +75,30 @@ describe("Git", () => {
|
||||
)
|
||||
})
|
||||
|
||||
function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(async () => {
|
||||
const root = await tmpdir()
|
||||
return { root, fixture: await gitRemote(root.path) }
|
||||
}),
|
||||
(input) => body(input.fixture),
|
||||
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
|
||||
)
|
||||
}
|
||||
|
||||
function read(file: string) {
|
||||
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
|
||||
}
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
||||
await $`git config user.name Test`.cwd(directory).quiet()
|
||||
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
describe("Git worktrees", () => {
|
||||
it.live("creates, lists, and removes linked worktrees", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -85,7 +109,9 @@ describe("Git worktrees", () => {
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const worktree = AbsolutePath.make(`${root.path}-git-worktree`)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const git = yield* Git.Service
|
||||
const repo = yield* git.repo.discover(directory)
|
||||
if (!repo) throw new Error("Repository not found")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { convertToOpenAICompatibleChatMessages } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages"
|
||||
import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages"
|
||||
import { describe, test, expect } from "bun:test"
|
||||
|
||||
describe("system messages", () => {
|
||||
test("should convert system message content to string", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant with AGENTS.md instructions.",
|
||||
@@ -21,7 +21,7 @@ describe("system messages", () => {
|
||||
|
||||
describe("user messages", () => {
|
||||
test("should convert messages with only a text part to a string content", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
@@ -32,7 +32,7 @@ describe("user messages", () => {
|
||||
})
|
||||
|
||||
test("should convert messages with image parts", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -61,7 +61,7 @@ describe("user messages", () => {
|
||||
})
|
||||
|
||||
test("should convert messages with image parts from Uint8Array", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -90,7 +90,7 @@ describe("user messages", () => {
|
||||
})
|
||||
|
||||
test("should handle URL-based images", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -117,7 +117,7 @@ describe("user messages", () => {
|
||||
})
|
||||
|
||||
test("should handle multiple text parts without flattening", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -141,7 +141,7 @@ describe("user messages", () => {
|
||||
|
||||
describe("assistant messages", () => {
|
||||
test("should convert assistant text messages", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello back!" }],
|
||||
@@ -160,7 +160,7 @@ describe("assistant messages", () => {
|
||||
})
|
||||
|
||||
test("should handle assistant message with null content when only tool calls", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -195,7 +195,7 @@ describe("assistant messages", () => {
|
||||
})
|
||||
|
||||
test("should concatenate multiple text parts", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -211,7 +211,7 @@ describe("assistant messages", () => {
|
||||
|
||||
describe("tool calls", () => {
|
||||
test("should stringify arguments to tool calls", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -262,7 +262,7 @@ describe("tool calls", () => {
|
||||
})
|
||||
|
||||
test("should handle text output type in tool results", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
@@ -286,7 +286,7 @@ describe("tool calls", () => {
|
||||
})
|
||||
|
||||
test("should handle multiple tool results as separate messages", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
@@ -320,7 +320,7 @@ describe("tool calls", () => {
|
||||
})
|
||||
|
||||
test("should handle text plus multiple tool calls", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -373,7 +373,7 @@ describe("tool calls", () => {
|
||||
|
||||
describe("reasoning (copilot-specific)", () => {
|
||||
test("should omit reasoning_text without reasoning_opaque", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -395,7 +395,7 @@ describe("reasoning (copilot-specific)", () => {
|
||||
})
|
||||
|
||||
test("should include reasoning_opaque from providerOptions", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -423,7 +423,7 @@ describe("reasoning (copilot-specific)", () => {
|
||||
})
|
||||
|
||||
test("should include reasoning_opaque from text part providerOptions", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -450,7 +450,7 @@ describe("reasoning (copilot-specific)", () => {
|
||||
})
|
||||
|
||||
test("should handle reasoning-only assistant message", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -479,7 +479,7 @@ describe("reasoning (copilot-specific)", () => {
|
||||
|
||||
describe("full conversation", () => {
|
||||
test("should convert a multi-turn conversation with reasoning", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant.",
|
||||
|
||||
@@ -42,7 +42,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolDefinitions } from "./lib/tool"
|
||||
import { toolDefinitions, waitForTool } from "./lib/tool"
|
||||
import { Database } from "../src/database/database"
|
||||
import { Bus } from "../src/bus"
|
||||
import { Reference } from "../src/reference"
|
||||
@@ -701,9 +701,25 @@ describe("LocationServiceMap", () => {
|
||||
yield* Reference.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
const registry = yield* Tool.Service
|
||||
// Tool plugins register during the forked PluginSupervisor boot; wait for
|
||||
// every expected tool rather than relying on batch ordering.
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"question",
|
||||
"read",
|
||||
"shell",
|
||||
"skill",
|
||||
"subagent",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
"write",
|
||||
],
|
||||
(name) => waitForTool(registry, name),
|
||||
)
|
||||
return {
|
||||
providers: yield* catalog.provider.all(),
|
||||
tools: yield* toolDefinitions(registry),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { auth, refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { McpOAuth } from "@opencode-ai/core/mcp/oauth"
|
||||
import { Effect } from "effect"
|
||||
@@ -28,96 +27,6 @@ const authorize = (redirect_uri?: string) =>
|
||||
)
|
||||
|
||||
describe("MCP OAuth", () => {
|
||||
test("completes interactive authorization through the loopback callback", async () => {
|
||||
const tokenRequests: URLSearchParams[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (request.method !== "POST" || url.pathname !== "/token") return new Response(null, { status: 404 })
|
||||
tokenRequests.push(new URLSearchParams(await request.text()))
|
||||
return Response.json({
|
||||
access_token: "access",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "refresh",
|
||||
expires_in: 3600,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const credential = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const authorization = yield* McpOAuth.authorize({
|
||||
name: "test",
|
||||
config: new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
url: server.url.href,
|
||||
oauth: { client_id: "client" },
|
||||
}),
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
})
|
||||
const authorizationUrl = new URL(authorization.url)
|
||||
const redirectValue = authorizationUrl.searchParams.get("redirect_uri")
|
||||
const state = authorizationUrl.searchParams.get("state")
|
||||
if (!redirectValue || !state) throw new Error("Missing OAuth redirect parameters")
|
||||
const redirect = new URL(redirectValue)
|
||||
redirect.searchParams.set("code", "accepted")
|
||||
redirect.searchParams.set("state", state)
|
||||
expect((yield* Effect.promise(() => fetch(redirect))).status).toBe(200)
|
||||
return yield* authorization.callback
|
||||
}),
|
||||
),
|
||||
).finally(() => server.stop(true))
|
||||
|
||||
expect(credential.access).toBe("access")
|
||||
expect(credential.refresh).toBe("refresh")
|
||||
expect(tokenRequests).toHaveLength(1)
|
||||
expect(tokenRequests[0]?.get("grant_type")).toBe("authorization_code")
|
||||
expect(tokenRequests[0]?.get("code")).toBe("accepted")
|
||||
expect(tokenRequests[0]?.get("code_verifier")).not.toBeNull()
|
||||
})
|
||||
|
||||
test("refreshes tokens loaded from a persisted credential", async () => {
|
||||
const tokenRequests: URLSearchParams[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (request.method !== "POST" || url.pathname !== "/token") return new Response(null, { status: 404 })
|
||||
tokenRequests.push(new URLSearchParams(await request.text()))
|
||||
return Response.json({ access_token: "next", token_type: "Bearer" })
|
||||
},
|
||||
})
|
||||
const store = McpOAuth.memoryStore()
|
||||
await store.saveTokens(
|
||||
McpOAuth.toTokens(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
access: "expired",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() - 1000,
|
||||
metadata: { serverUrl: server.url.href, tokenType: "Bearer" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const oauthProvider = McpOAuth.provider({
|
||||
redirectUrl: "http://127.0.0.1/callback",
|
||||
client: { id: "client" },
|
||||
onRedirect: () => undefined,
|
||||
store,
|
||||
})
|
||||
|
||||
const result = await auth(oauthProvider, { serverUrl: server.url.href }).finally(() => server.stop(true))
|
||||
|
||||
expect(result).toBe("AUTHORIZED")
|
||||
expect(await store.tokens()).toEqual({ access_token: "next", token_type: "Bearer", refresh_token: "refresh" })
|
||||
expect(tokenRequests).toHaveLength(1)
|
||||
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
|
||||
expect(tokenRequests[0]?.get("refresh_token")).toBe("refresh")
|
||||
})
|
||||
|
||||
test("shares concurrent refreshes for the same token", async () => {
|
||||
let requests = 0
|
||||
const pending = Promise.withResolvers<void>()
|
||||
|
||||
@@ -44,7 +44,7 @@ import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { location } from "./fixture/location"
|
||||
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
|
||||
|
||||
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, Permission.Error> = Effect.void
|
||||
@@ -474,37 +474,6 @@ test("retains output schemas across paginated MCP discovery", async () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("lists paginated prompts and invokes them through the MCP client", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* connect(
|
||||
"prompts",
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-prompts.ts")],
|
||||
}),
|
||||
import.meta.dir,
|
||||
)
|
||||
return {
|
||||
prompts: yield* connection.prompts(),
|
||||
result: yield* connection.prompt({ name: "first", args: { topic: "Effect" } }),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.prompts).toEqual([
|
||||
{
|
||||
name: "first",
|
||||
description: "First prompt",
|
||||
arguments: [{ name: "topic", description: "Topic to explain", required: true }],
|
||||
},
|
||||
{ name: "second", description: "Second prompt", arguments: undefined },
|
||||
])
|
||||
expect(result.result).toEqual({ messages: [{ role: "user", content: { type: "text", text: "Effect" } }] })
|
||||
})
|
||||
|
||||
test("spawns local MCP servers through the location environment", async () => {
|
||||
const spawns: Array<ChildProcess.Command> = []
|
||||
const cwd = path.join(import.meta.dir, "fixture")
|
||||
@@ -1594,9 +1563,7 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
|
||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
|
||||
|
||||
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
|
||||
@@ -1615,11 +1582,7 @@ it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
|
||||
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.status")).toBe(true)
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.status")
|
||||
|
||||
const execution = yield* toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_content_only"),
|
||||
@@ -1642,8 +1605,7 @@ it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
yield* waitForTool(registry, "direct_lookup")
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const execute = definitions.find((tool) => tool.name === "execute")
|
||||
|
||||
@@ -1659,8 +1621,7 @@ it.effect("fails the call when MCP reports isError", () =>
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
yield* waitForTool(registry, "direct_fail")
|
||||
|
||||
const execution = yield* executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_mcp_is_error"),
|
||||
@@ -1678,8 +1639,7 @@ it.effect("preserves MCP text and media content for the model", () =>
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
yield* waitForTool(registry, "direct_media")
|
||||
|
||||
const execution = yield* executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_mcp_media"),
|
||||
@@ -1702,10 +1662,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
||||
const permission = yield* Deferred.make<void>()
|
||||
decision = Deferred.await(permission)
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
|
||||
const fiber = yield* toolSet
|
||||
.execute({
|
||||
@@ -1746,10 +1703,7 @@ it.effect("does not call MCP when permission is blocked", () =>
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.fail(new Permission.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
|
||||
const execution = yield* toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_blocked"),
|
||||
|
||||
@@ -55,62 +55,6 @@ async function createGitFixture(directory: string) {
|
||||
return { repository, commit }
|
||||
}
|
||||
|
||||
async function createRegistryFixture(directory: string) {
|
||||
const tarballs = new Map<string, Uint8Array>()
|
||||
for (const version of ["1.0.0", "1.1.0"]) {
|
||||
const root = path.join(directory, version)
|
||||
await fs.mkdir(path.join(root, "package"), { recursive: true })
|
||||
await writePackage(path.join(root, "package"), {
|
||||
name: "@fixture/registry-plugin",
|
||||
version,
|
||||
exports: "./index.js",
|
||||
})
|
||||
await Bun.write(path.join(root, "package", "index.js"), `export const version = "${version}"\n`)
|
||||
await Bun.$`tar -czf package.tgz package`.cwd(root)
|
||||
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
|
||||
}
|
||||
const state = { latest: "1.0.0", offline: false, requests: 0, tarballRequests: 0 }
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
state.requests++
|
||||
if (state.offline) return new Response("offline", { status: 503 })
|
||||
const url = new URL(request.url)
|
||||
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
|
||||
return Response.json({
|
||||
name: "@fixture/registry-plugin",
|
||||
"dist-tags": { latest: state.latest },
|
||||
versions: Object.fromEntries(
|
||||
[...tarballs.keys()].map((version) => [
|
||||
version,
|
||||
{ name: "@fixture/registry-plugin", version, dist: { tarball: `${url.origin}/${version}.tgz` } },
|
||||
]),
|
||||
),
|
||||
})
|
||||
state.tarballRequests++
|
||||
const tarball = tarballs.get(url.pathname.slice(1).replace(".tgz", ""))
|
||||
return tarball ? new Response(tarball) : new Response("missing", { status: 404 })
|
||||
},
|
||||
})
|
||||
return {
|
||||
state,
|
||||
tarball: `${server.url}1.0.0.tgz`,
|
||||
async configure(cache: string, spec: string) {
|
||||
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(root, ".npmrc"),
|
||||
`@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
|
||||
)
|
||||
return root
|
||||
},
|
||||
async [Symbol.asyncDispose]() {
|
||||
await server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("Npm.sanitize", () => {
|
||||
test("keeps normal scoped package specs unchanged", () => {
|
||||
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
|
||||
@@ -187,7 +131,6 @@ describe("Npm.add", () => {
|
||||
|
||||
expect(entry.directory).toBe(directory)
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
expect(entry.revision).toBe("1.0.0")
|
||||
})
|
||||
|
||||
test("falls back to the original spec when parsing fails", async () => {
|
||||
@@ -255,7 +198,6 @@ describe("Npm.add", () => {
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
|
||||
expect(entries.added.entrypoint).toEndWith("/index.js")
|
||||
expect(entries.added.revision).toBe(fixture.commit)
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
expect(entries.resolved).toEqual(entries.added)
|
||||
expect(
|
||||
@@ -276,7 +218,6 @@ describe("Npm.add", () => {
|
||||
|
||||
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
expect(entry.revision).toBe(fixture.commit)
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
@@ -295,27 +236,11 @@ describe("Npm.add", () => {
|
||||
const npm = yield* Npm.Service
|
||||
const mutableEntry = yield* npm.add(mutable, { refresh: true })
|
||||
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
|
||||
const nextCommit = yield* Effect.promise(async () => {
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
|
||||
await Bun.$`git -C ${fixture.repository} add .`
|
||||
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
|
||||
return Bun.$`git -C ${fixture.repository} rev-parse HEAD`.text().then((value) => value.trim())
|
||||
})
|
||||
const lock = path.join(cache, "packages", yield* Effect.promise(() => Npm.cacheKey(mutable)), "package-lock.json")
|
||||
const before = yield* Effect.promise(() => Bun.file(lock).bytes())
|
||||
expect((yield* npm.resolve(mutable)).revision).toBe(fixture.commit)
|
||||
expect(yield* npm.check(mutable)).toEqual({ installed: fixture.commit, available: nextCommit, mutable: true })
|
||||
expect(yield* npm.check(pinned)).toEqual({ installed: fixture.commit, available: fixture.commit, mutable: false })
|
||||
expect(yield* Effect.promise(() => Bun.file(lock).bytes())).toEqual(before)
|
||||
expect(yield* npm.add(mutable)).toEqual(mutableEntry)
|
||||
expect(mutableEntry.revision).toBe(fixture.commit)
|
||||
|
||||
const uncached = `git+${repository}#HEAD`
|
||||
expect(yield* npm.check(uncached)).toEqual({ installed: undefined, available: nextCommit, mutable: true })
|
||||
expect(yield* Effect.promise(() => fs.readdir(path.join(cache, "packages")))).not.toContain(
|
||||
yield* Effect.promise(() => Npm.cacheKey(uncached)),
|
||||
)
|
||||
expect((yield* npm.resolve(uncached)).entrypoint).toBeUndefined()
|
||||
yield* npm.add(mutable, { refresh: true })
|
||||
return { mutable: mutableEntry, pinned: pinnedEntry }
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
@@ -335,8 +260,6 @@ describe("Npm.add", () => {
|
||||
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
|
||||
const offline = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
expect((yield* npm.resolve(mutable)).revision).toBe(second.mutable.revision)
|
||||
expect((yield* npm.check(mutable).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
|
||||
return yield* npm.add(mutable, { refresh: true })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
|
||||
@@ -368,92 +291,5 @@ describe("Npm.resolve", () => {
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(resolved.entrypoint).toEndWith("/tui.js")
|
||||
expect(resolved.revision).toBe("1.0.0")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.check and revision metadata", () => {
|
||||
test("checks registry tags, ranges, and exact pins without changing or installing packages", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using registry = await createRegistryFixture(tmp.path)
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const spec = "@fixture/registry-plugin@latest"
|
||||
const range = "@fixture/registry-plugin@^1.0.0"
|
||||
const pinned = "@fixture/registry-plugin@1.0.0"
|
||||
const alias = "alias@npm:@fixture/registry-plugin@latest"
|
||||
const root = await registry.configure(cache, spec)
|
||||
const rangeRoot = await registry.configure(cache, range)
|
||||
const pinnedRoot = await registry.configure(cache, pinned)
|
||||
await registry.configure(cache, alias)
|
||||
await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
for (const unsupported of [registry.tarball, alias]) {
|
||||
const error = yield* npm.check(unsupported).pipe(Effect.flip)
|
||||
expect(error._tag).toBe("NpmInstallFailedError")
|
||||
expect(error.cause).toEqual(new Error("Package checks only support registry and Git package specs"))
|
||||
}
|
||||
expect(registry.state.requests).toBe(0)
|
||||
expect(registry.state.tarballRequests).toBe(0)
|
||||
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
|
||||
const loaded = yield* npm.add(spec)
|
||||
expect(loaded.revision).toBe("1.0.0")
|
||||
const files = [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"node_modules/@fixture/registry-plugin/package.json",
|
||||
"node_modules/@fixture/registry-plugin/index.js",
|
||||
]
|
||||
const before = yield* Effect.promise(() =>
|
||||
Promise.all(files.map((file) => Bun.file(path.join(root, file)).bytes())),
|
||||
)
|
||||
const requests = registry.state.tarballRequests
|
||||
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.0.0", mutable: true })
|
||||
registry.state.latest = "1.1.0"
|
||||
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.1.0", mutable: true })
|
||||
expect(yield* npm.check(range)).toEqual({ installed: undefined, available: "1.1.0", mutable: true })
|
||||
expect(yield* npm.check(pinned)).toEqual({ installed: undefined, available: "1.0.0", mutable: false })
|
||||
expect(yield* npm.add(spec)).toEqual(loaded)
|
||||
expect(yield* npm.resolve(spec)).toEqual(loaded)
|
||||
expect(
|
||||
yield* Effect.promise(() => Promise.all(files.map((file) => Bun.file(path.join(root, file)).bytes()))),
|
||||
).toEqual(before)
|
||||
expect(registry.state.tarballRequests).toBe(requests)
|
||||
expect(yield* Effect.promise(() => fs.readdir(rangeRoot))).toEqual([".npmrc"])
|
||||
expect(yield* Effect.promise(() => fs.readdir(pinnedRoot))).toEqual([".npmrc"])
|
||||
|
||||
registry.state.offline = true
|
||||
expect((yield* npm.check(spec).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
|
||||
expect((yield* npm.resolve(spec)).revision).toBe("1.0.0")
|
||||
expect(yield* npm.add(spec)).toEqual(loaded)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
}, 30_000)
|
||||
|
||||
test("leaves unknown installed revisions undefined and prefers the wrapper Git lock", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const commit = "0123456789abcdef0123456789abcdef01234567"
|
||||
const spec = `git+https://example.com/fixture.git#${commit}`
|
||||
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
|
||||
const directory = path.join(root, "node_modules", "fixture")
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await writePackage(root, { dependencies: { fixture: spec } })
|
||||
await writePackage(directory, { name: "fixture", exports: "./index.js" })
|
||||
await Bun.write(path.join(directory, "index.js"), "export default {}\n")
|
||||
await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
|
||||
expect((yield* npm.add(spec)).revision).toBeUndefined()
|
||||
const lock = (revision: string) =>
|
||||
JSON.stringify({
|
||||
packages: { "node_modules/fixture": { resolved: `git+https://example.com/fixture.git#${revision}` } },
|
||||
})
|
||||
yield* Effect.promise(() => Bun.write(path.join(root, "node_modules", ".package-lock.json"), lock(commit)))
|
||||
expect((yield* npm.resolve(spec)).revision).toBe(commit)
|
||||
const installed = "f".repeat(40)
|
||||
yield* Effect.promise(() => Bun.write(path.join(root, "package-lock.json"), lock(installed)))
|
||||
expect((yield* npm.resolve(spec)).revision).toBe(installed)
|
||||
yield* Effect.promise(() => fs.rm(directory, { recursive: true }))
|
||||
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -450,23 +450,19 @@ describe("Plugin", () => {
|
||||
}),
|
||||
})
|
||||
|
||||
for (const revision of ["1.0.0", undefined]) {
|
||||
yield* plugins.activate([{ ...versioned(previous, revision ?? "unknown"), revision }])
|
||||
yield* plugins.activate([{ ...versioned(replacement, "2"), revision: "2.0.0" }])
|
||||
yield* plugins.activate([versioned(previous)])
|
||||
yield* plugins.activate([versioned(replacement, "2")])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("managed"),
|
||||
source: { type: "builtin" },
|
||||
...(revision === undefined ? {} : { revision }),
|
||||
status: "failed",
|
||||
error: expect.stringContaining("replacement failed"),
|
||||
tui: false,
|
||||
},
|
||||
])
|
||||
if (revision === undefined) expect((yield* plugins.list())[0]).not.toHaveProperty("revision")
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
|
||||
}
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("managed"),
|
||||
source: { type: "builtin" },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("replacement failed"),
|
||||
tui: false,
|
||||
},
|
||||
])
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import path from "node:path"
|
||||
import fs from "node:fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { PackageStatus } from "@opencode-ai/schema/plugin"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { it, testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const pluginIt = testEffect(PluginTestLayer)
|
||||
|
||||
// Bun.build inside bun test panics on Windows; the Node import boundary is platform-independent.
|
||||
describe.skipIf(process.platform === "win32")("Node plugin revisions", () => {
|
||||
it.live("keeps the loaded revision across Node CommonJS namespace wrappers", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-node-revision-")))
|
||||
const entrypoint = path.join(tmp.path, "plugin.cjs")
|
||||
const harness = path.join(tmp.path, "harness.ts")
|
||||
yield* Effect.promise(() =>
|
||||
fs.symlink(path.join(import.meta.dir, "../../../util/node_modules"), path.join(tmp.path, "node_modules")),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
harness,
|
||||
`
|
||||
import assert from "node:assert/strict"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { PluginModule } from ${JSON.stringify(path.join(import.meta.dir, "../../src/plugin/module.ts"))}
|
||||
import { Npm } from ${JSON.stringify(path.join(import.meta.dir, "../../../util/src/npm.ts"))}
|
||||
const entrypoint = process.argv[2]
|
||||
const source = (tui) => 'module.exports = { id: "node-revision-plugin", tui: ' + tui + ', effect: () => {} }'
|
||||
const load = (revision) => Effect.runPromise(PluginModule.load({ type: "add", target: "inspection-plugin@latest", options: {} }).pipe(
|
||||
Effect.provideService(Npm.Service, {
|
||||
add: () => Effect.succeed({ directory: "", entrypoint, revision }),
|
||||
resolve: () => Effect.die("unused resolve"),
|
||||
check: () => Effect.die("unused check"),
|
||||
which: () => Effect.die("unused which"),
|
||||
}),
|
||||
))
|
||||
await writeFile(entrypoint, source(false))
|
||||
const first = await load("1.0.0")
|
||||
assert.deepEqual({ tui: first.tui, revision: first.revision }, { tui: false, revision: "1.0.0" })
|
||||
await writeFile(entrypoint, source(true))
|
||||
const second = await load("2.0.0")
|
||||
assert.deepEqual({ tui: second.tui, revision: second.revision }, { tui: false, revision: "1.0.0" })
|
||||
`,
|
||||
),
|
||||
)
|
||||
const result = yield* Effect.promise(() =>
|
||||
Bun.build({
|
||||
entrypoints: [harness],
|
||||
outdir: tmp.path,
|
||||
naming: "[name].mjs",
|
||||
target: "node",
|
||||
conditions: ["node"],
|
||||
plugins: [
|
||||
{
|
||||
name: "externalize-dependencies",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^[^.#/]/ }, (args) =>
|
||||
args.path.startsWith("@opencode-ai/") ? undefined : { path: args.path, external: true },
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
const child = Bun.spawn(["node", path.join(tmp.path, "harness.mjs"), entrypoint], {
|
||||
cwd: tmp.path,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: tmp.path,
|
||||
XDG_CONFIG_HOME: path.join(tmp.path, "config"),
|
||||
XDG_DATA_HOME: path.join(tmp.path, "data"),
|
||||
XDG_CACHE_HOME: path.join(tmp.path, "cache"),
|
||||
XDG_STATE_HOME: path.join(tmp.path, "state"),
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => child.kill()))
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]),
|
||||
),
|
||||
).toEqual([0, expect.any(String), expect.any(String)])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
pluginIt.live("keeps loaded revision tied to cached module code rather than refreshed disk metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-revision-")))
|
||||
const entrypoint = path.join(tmp.path, "plugin.ts")
|
||||
const source = (tui: boolean) =>
|
||||
`import { Effect } from ${JSON.stringify(import.meta.resolve("effect"))}\nexport default { id: "revision-plugin", tui: ${tui}, effect: () => Effect.void }\n`
|
||||
yield* Effect.promise(() => Bun.write(entrypoint, source(false)))
|
||||
const npm = yield* Npm.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const operation = { type: "add" as const, target: "inspection-plugin@latest", options: {} }
|
||||
const load = (revision: string, options: Record<string, unknown>) =>
|
||||
PluginModule.load({ ...operation, options }).pipe(
|
||||
Effect.provideService(Npm.Service, {
|
||||
...npm,
|
||||
add: () => Effect.succeed({ directory: tmp.path, entrypoint, revision }),
|
||||
}),
|
||||
)
|
||||
const first = yield* load("1.0.0", {})
|
||||
yield* plugins.activate([first])
|
||||
expect(first.version).toBe(JSON.stringify(operation))
|
||||
expect(first.source).toEqual({ type: "package", package: operation.target })
|
||||
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
|
||||
|
||||
yield* Effect.promise(() => Bun.write(entrypoint, source(true)))
|
||||
const second = yield* load("2.0.0", { changed: true })
|
||||
expect(second.tui).toBe(false)
|
||||
expect(second.revision).toBe("1.0.0")
|
||||
yield* plugins.activate([second])
|
||||
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
|
||||
yield* plugins.activate([{ ...second, version: "failed", effect: () => Effect.die("setup failed") }])
|
||||
expect((yield* plugins.list())[0]).toMatchObject({ status: "failed", revision: "1.0.0" })
|
||||
expect(Plugin.PackageStatus).toBe(PackageStatus)
|
||||
}),
|
||||
)
|
||||
|
||||
pluginIt.effect("rejects in-process checks in Effect and Promise setup without waiting for activation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "effect-check",
|
||||
version: "1",
|
||||
effect: (host) =>
|
||||
host.plugin.check({ target: "inspection-plugin@latest" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.tap((error) =>
|
||||
Effect.sync(() =>
|
||||
expect(error).toMatchObject({
|
||||
_tag: "PluginCheckError",
|
||||
message: expect.stringContaining("external client"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.orDie,
|
||||
Effect.asVoid,
|
||||
),
|
||||
},
|
||||
{
|
||||
...PluginPromise.fromPromise({
|
||||
id: "promise-check",
|
||||
setup: async (host) => {
|
||||
await expect(host.plugin.check({ target: "inspection-plugin@latest" })).rejects.toThrow("external client")
|
||||
},
|
||||
}),
|
||||
version: "1",
|
||||
},
|
||||
])
|
||||
expect((yield* plugins.list()).map((plugin) => plugin.status)).toEqual(["active", "active"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("checks configured package sources without installing, writing config, or updating plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-check-")))
|
||||
const target = "inspection-plugin@latest"
|
||||
const unsupported = "https://example.com/plugin.tgz"
|
||||
const config = JSON.stringify({ plugins: ["-*", target, unsupported] })
|
||||
const configPath = path.join(tmp.path, "opencode.json")
|
||||
yield* Effect.promise(() => Bun.write(configPath, config))
|
||||
let adds = 0
|
||||
let checks = 0
|
||||
let fail = false
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationServiceMap.node, Bus.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[
|
||||
Npm.node,
|
||||
Layer.succeed(Npm.Service, {
|
||||
add: () => {
|
||||
adds++
|
||||
return Effect.fail(new Npm.InstallFailedError({ dir: tmp.path }))
|
||||
},
|
||||
resolve: () => Effect.succeed({ directory: tmp.path }),
|
||||
which: () => Effect.undefined,
|
||||
check: () => {
|
||||
checks++
|
||||
if (fail)
|
||||
return Effect.fail(
|
||||
new Npm.InstallFailedError({ dir: tmp.path, cause: new Error("registry unavailable") }),
|
||||
)
|
||||
return Effect.succeed({ available: "2.0.0", mutable: true })
|
||||
},
|
||||
}),
|
||||
],
|
||||
]),
|
||||
)
|
||||
const locations = yield* LocationServiceMap.Service.pipe(Effect.provide(context))
|
||||
const bus = yield* Bus.Service.pipe(Effect.provide(context))
|
||||
yield* Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* supervisor.flush
|
||||
const inventory = yield* plugins.list()
|
||||
const initialAdds = adds
|
||||
let updates = 0
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === Plugin.Event.Updated.type) updates++
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
expect(yield* supervisor.check(target)).toEqual({ available: "2.0.0", mutable: true })
|
||||
expect(yield* supervisor.check("not-configured@latest").pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "PluginCheckError",
|
||||
message: expect.stringContaining("server inventory"),
|
||||
})
|
||||
expect(yield* supervisor.check(unsupported).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "PluginCheckError",
|
||||
message: `Unsupported plugin package source: ${unsupported}`,
|
||||
})
|
||||
expect(checks).toBe(1)
|
||||
fail = true
|
||||
expect(yield* supervisor.check(target).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "PluginCheckError",
|
||||
message: expect.stringContaining("registry unavailable"),
|
||||
})
|
||||
expect(adds).toBe(initialAdds)
|
||||
expect(yield* plugins.list()).toEqual(inventory)
|
||||
expect(updates).toBe(0)
|
||||
expect(yield* Effect.promise(() => Bun.file(configPath).text())).toBe(config)
|
||||
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))))
|
||||
}),
|
||||
)
|
||||
@@ -38,7 +38,6 @@ const npmLayer = Layer.succeed(
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
which: () => Effect.undefined,
|
||||
check: () => Effect.succeed({ mutable: false }),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
},
|
||||
plugin: overrides.plugin ?? {
|
||||
list: () => Effect.die("unused plugin.list"),
|
||||
check: () => Effect.die("unused plugin.check"),
|
||||
},
|
||||
reference: overrides.reference ?? {
|
||||
list: () => Effect.die("unused reference.list"),
|
||||
|
||||
@@ -14,7 +14,6 @@ import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { catalogHost, host, integrationHost } from "./host"
|
||||
@@ -30,6 +29,27 @@ const it = testEffect(layer)
|
||||
const models = (file: string) =>
|
||||
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
|
||||
|
||||
function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ModelsDevPlugin", () => {
|
||||
it.effect("projects normalized models.dev snapshots into the catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -16,10 +15,32 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* CloudflareAIGatewayPlugin.effect(host)
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
fx,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const aiGatewayCalls: Record<string, unknown>[] = []
|
||||
const unifiedCalls: string[] = []
|
||||
const gatewayModelCalls: unknown[] = []
|
||||
@@ -87,8 +108,9 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
|
||||
expect(
|
||||
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
|
||||
).toContainEqual({
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
form: [
|
||||
@@ -110,6 +132,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
@@ -130,6 +153,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv(), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -174,6 +198,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv(), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -203,6 +228,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv(), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -240,6 +266,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -271,6 +298,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -293,6 +321,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -316,6 +345,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -345,6 +375,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
@@ -353,11 +384,9 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
})
|
||||
expect(
|
||||
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
|
||||
).toContainEqual({ type: "key", label: "Gateway API token" })
|
||||
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
@@ -379,6 +408,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv(), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -410,6 +440,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv(), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ function npmEntrypoint(entrypoint?: string) {
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
which: () => Effect.undefined,
|
||||
check: () => Effect.succeed({ mutable: false }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,6 +156,7 @@ describe("DynamicProviderPlugin", () => {
|
||||
|
||||
itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
|
||||
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
|
||||
@@ -176,6 +176,7 @@ describe("DynamicProviderPlugin", () => {
|
||||
|
||||
itWithAISDK.effect("uses the model modelID for the default language model", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const language = yield* aisdk.language(
|
||||
|
||||
@@ -21,6 +21,7 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* GithubCopilotPlugin.effect(host)
|
||||
})
|
||||
@@ -56,8 +57,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("registers GitHub Copilot device OAuth", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("github-copilot")))?.methods).toContainEqual({
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("github-copilot")))?.methods).toContainEqual({
|
||||
id: Integration.MethodID.make("device"),
|
||||
type: "oauth",
|
||||
label: "Login with GitHub Copilot",
|
||||
@@ -124,8 +124,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("adds Copilot authentication to native Anthropic requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "http.request", {
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("claude-sonnet-4.5") }),
|
||||
@@ -146,8 +145,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("classifies title generation as a background interaction", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "http.request", {
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_title"),
|
||||
agent: Agent.ID.make("title"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }),
|
||||
@@ -160,8 +158,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("classifies compaction requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "http.request", {
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_compaction"),
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
|
||||
@@ -173,6 +170,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const ignored = yield* aisdk.runSDK({
|
||||
@@ -223,6 +221,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("selects languageModel when responses and chat are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
@@ -241,6 +240,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("selects languageModel with the API model ID when responses and chat are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
@@ -259,6 +259,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("uses responses for gpt-5 models except gpt-5-mini", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
@@ -319,6 +320,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("uses advertised Copilot endpoint metadata before model ID fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
@@ -348,6 +350,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("uses the API model ID when selecting responses or chat", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
@@ -414,6 +417,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("ignores non-Copilot providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -15,10 +15,32 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* GitLabPlugin.effect(host)
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
void mock.module("gitlab-ai-provider", () => ({
|
||||
VERSION: "test-version",
|
||||
createGitLab: (options: Record<string, unknown>) => {
|
||||
@@ -42,6 +64,7 @@ describe("GitLabPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
@@ -79,6 +102,7 @@ describe("GitLabPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
@@ -104,6 +128,7 @@ describe("GitLabPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
@@ -145,6 +170,7 @@ describe("GitLabPlugin", () => {
|
||||
it.effect("ignores non-GitLab SDK packages", () =>
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
@@ -163,6 +189,7 @@ describe("GitLabPlugin", () => {
|
||||
|
||||
it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: [string, unknown][] = []
|
||||
yield* addPlugin()
|
||||
@@ -196,6 +223,7 @@ describe("GitLabPlugin", () => {
|
||||
|
||||
it.effect("uses exact static workflow model ids when the provider recognizes them", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: [string, unknown][] = []
|
||||
yield* addPlugin()
|
||||
@@ -223,6 +251,7 @@ describe("GitLabPlugin", () => {
|
||||
|
||||
it.effect("uses provider feature flags instead of model settings feature flags", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: [string, unknown][] = []
|
||||
yield* addPlugin()
|
||||
@@ -249,6 +278,7 @@ describe("GitLabPlugin", () => {
|
||||
|
||||
it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: [string, unknown][] = []
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
@@ -18,9 +18,9 @@ const addPlugin = Effect.fn(function* () {
|
||||
})
|
||||
|
||||
describe("KiloPlugin", () => {
|
||||
test("is registered so legacy referer headers can be applied", () => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")
|
||||
})
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to Kilo endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
@@ -15,13 +15,14 @@ const it = testEffect(PluginTestLayer)
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* LLMGatewayPlugin.effect(host)
|
||||
const integration = yield* Integration.Service
|
||||
yield* LLMGatewayPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
|
||||
})
|
||||
|
||||
describe("LLMGatewayPlugin", () => {
|
||||
test("is registered so legacy referer headers can be applied", () => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.llmgateway")
|
||||
})
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.llmgateway")),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { LMStudioPlugin, make } from "@opencode-ai/core/plugin/provider/lmstudio
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -38,10 +38,12 @@ function eventually<A>(
|
||||
}
|
||||
|
||||
describe("LMStudioPlugin", () => {
|
||||
test("is registered as a built-in provider plugin", () => {
|
||||
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
|
||||
})
|
||||
it.effect("is registered as a built-in provider plugin", () =>
|
||||
Effect.sync(() => {
|
||||
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers local language models with their capabilities and effective context", () =>
|
||||
Effect.acquireUseRelease(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
@@ -18,9 +18,9 @@ const addPlugin = Effect.fn(function* () {
|
||||
})
|
||||
|
||||
describe("NvidiaPlugin", () => {
|
||||
test("is registered so legacy referer headers can be applied", () => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.nvidia")
|
||||
})
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.nvidia")),
|
||||
)
|
||||
|
||||
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -28,7 +28,8 @@ const it = testEffect(PluginTestLayer)
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* OpenAIPlugin.effect(host)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
|
||||
})
|
||||
|
||||
const addGithubCopilotPlugin = Effect.fn(function* () {
|
||||
@@ -64,8 +65,7 @@ describe("OpenAIPlugin", () => {
|
||||
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("openai")))?.methods).toEqual([
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("chatgpt-browser"),
|
||||
type: "oauth",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user