mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-19 06:30:30 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33567c5792 | |||
| daf3f9ed08 | |||
| d5bf8799c0 | |||
| f6f64d7ece | |||
| 6baad7fc3e | |||
| 0762d63b6a | |||
| 4df0591025 | |||
| 30cb420900 |
@@ -193,7 +193,7 @@ If you find yourself copying a 3-to-5-line snippet between two protocols, lift i
|
||||
|
||||
`LLMRequest.system` is the initial privileged prompt that applies ahead of the conversation. `Message.system(...)` is a separate, provider-neutral chronological operator update inside `LLMRequest.messages`; it applies only from its position in history onward and accepts text content only.
|
||||
|
||||
Native chronological system messages are route/model-specific. Anthropic Messages lowers them natively for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
|
||||
Native chronological system messages are route/model-specific. Open Responses lowers them to standard `developer` messages, while Anthropic Messages lowers them to native system messages for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
|
||||
|
||||
```text
|
||||
<system-update>
|
||||
|
||||
@@ -90,6 +90,7 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
|
||||
export const InputItem = Schema.Union([
|
||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
@@ -140,6 +141,11 @@ export const Tool = Schema.Struct({
|
||||
export const ToolChoice = Schema.Union([
|
||||
Schema.Literals(["auto", "none", "required"]),
|
||||
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("allowed_tools"),
|
||||
mode: Schema.Literals(["auto", "none", "required"]),
|
||||
tools: Schema.Array(Schema.Struct({ type: Schema.tag("function"), name: Schema.String })),
|
||||
}),
|
||||
])
|
||||
|
||||
// Fields shared between the HTTP body and the WebSocket `response.create`
|
||||
@@ -153,6 +159,7 @@ export const coreFields = {
|
||||
tools: optionalArray(Tool),
|
||||
tool_choice: Schema.optional(ToolChoice),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
|
||||
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
|
||||
prompt_cache_key: Schema.optional(Schema.String),
|
||||
include: optionalArray(OpenResponsesOptions.ResponseIncludableSchema),
|
||||
@@ -168,6 +175,8 @@ export const coreFields = {
|
||||
}),
|
||||
),
|
||||
max_output_tokens: Schema.optional(Schema.Number),
|
||||
max_tool_calls: Schema.optional(Schema.Int),
|
||||
parallel_tool_calls: Schema.optional(Schema.Boolean),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
}
|
||||
@@ -439,14 +448,10 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
|
||||
const previous = input.at(-1)
|
||||
if (previous && "role" in previous && previous.role === "user")
|
||||
input[input.length - 1] = {
|
||||
role: "user",
|
||||
content: [...previous.content, { type: "input_text", text: part.text }],
|
||||
}
|
||||
else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] })
|
||||
input.push({
|
||||
role: "developer",
|
||||
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -580,6 +585,19 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
: {}),
|
||||
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
|
||||
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
|
||||
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
|
||||
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
|
||||
...(options.truncation ? { truncation: options.truncation } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const allowedToolChoice = (request: LLMRequest) => {
|
||||
const allowed = OpenResponsesOptions.resolve(request).allowedTools
|
||||
if (!allowed) return undefined
|
||||
return {
|
||||
type: "allowed_tools" as const,
|
||||
mode: allowed.mode,
|
||||
tools: allowed.toolNames.map((name) => ({ type: "function" as const, name })),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +620,9 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
),
|
||||
),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined,
|
||||
tool_choice:
|
||||
allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
|
||||
@@ -121,7 +121,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
: yield* Effect.forEach(request.tools, (tool) =>
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined,
|
||||
tool_choice:
|
||||
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
} satisfies OpenAIResponsesBody
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Schema } from "effect"
|
||||
import { Option, Schema } from "effect"
|
||||
import { TextVerbosity, type LLMRequest } from "../../schema/index.js"
|
||||
|
||||
export const ResponseIncludables = [
|
||||
@@ -11,52 +11,62 @@ export const ResponseIncludables = [
|
||||
"reasoning.encrypted_content",
|
||||
"message.output_text.logprobs",
|
||||
] as const
|
||||
export type ResponseIncludable = (typeof ResponseIncludables)[number]
|
||||
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
|
||||
|
||||
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type ServiceTier = (typeof ServiceTiers)[number]
|
||||
|
||||
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
||||
const INCLUDABLES = new Set<string>(ResponseIncludables)
|
||||
const SERVICE_TIERS = new Set<string>(ServiceTiers)
|
||||
|
||||
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
|
||||
typeof value === "string" && TEXT_VERBOSITY.has(value)
|
||||
|
||||
const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value)
|
||||
export const Truncations = ["auto", "disabled"] as const
|
||||
export type Truncation = (typeof Truncations)[number]
|
||||
|
||||
export const ReasoningEffort = Schema.String
|
||||
export const TextVerbositySchema = TextVerbosity
|
||||
export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables)
|
||||
export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
|
||||
(value): value is ResponseIncludable => typeof value === "string",
|
||||
{ title: "ResponseIncludable" },
|
||||
)
|
||||
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
||||
export const TruncationSchema = Schema.Literals(Truncations)
|
||||
|
||||
export interface Resolved {
|
||||
readonly instructions?: string
|
||||
readonly store?: boolean
|
||||
readonly reasoningEffort?: string
|
||||
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||
readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity>
|
||||
readonly serviceTier?: ServiceTier
|
||||
export const AllowedTools = Schema.Struct({
|
||||
toolNames: Schema.Array(Schema.String),
|
||||
mode: Schema.optional(Schema.Literals(["auto", "none", "required"])),
|
||||
})
|
||||
export type AllowedTools = typeof AllowedTools.Type
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
instructions: Schema.optional(Schema.String),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
reasoningEffort: Schema.optional(ReasoningEffort),
|
||||
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: Schema.optional(TextVerbositySchema),
|
||||
serviceTier: Schema.optional(ServiceTierSchema),
|
||||
truncation: Schema.optional(TruncationSchema),
|
||||
allowedTools: Schema.optional(AllowedTools),
|
||||
maxToolCalls: Schema.optional(Schema.Int),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export type Resolved = Omit<Options, "allowedTools"> & {
|
||||
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
|
||||
}
|
||||
|
||||
const decodeOptions = Schema.decodeUnknownOption(Options)
|
||||
|
||||
export const resolve = (request: LLMRequest): Resolved => {
|
||||
const input = request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]
|
||||
const include = Array.isArray(input?.include)
|
||||
? input.include.filter((entry): entry is ResponseIncludable => INCLUDABLES.has(entry))
|
||||
: []
|
||||
const reasoningSummary = input?.reasoningSummary
|
||||
const input = Option.getOrUndefined(
|
||||
decodeOptions(request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]),
|
||||
)
|
||||
if (!input) return {}
|
||||
return {
|
||||
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
|
||||
store: typeof input?.store === "boolean" ? input.store : undefined,
|
||||
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
|
||||
reasoningSummary:
|
||||
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
|
||||
? reasoningSummary
|
||||
...input,
|
||||
include: input.include?.length ? input.include : undefined,
|
||||
allowedTools:
|
||||
input.allowedTools && input.allowedTools.toolNames.length > 0
|
||||
? { ...input.allowedTools, mode: input.allowedTools.mode ?? "auto" }
|
||||
: undefined,
|
||||
include: include.length > 0 ? include : undefined,
|
||||
textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined,
|
||||
serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import type { ResponseIncludable, ServiceTier } from "../protocols/utils/open-responses-options.js"
|
||||
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema/index.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
import type { ProviderOptions } from "../schema/index.js"
|
||||
|
||||
export interface OpenResponsesOptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly instructions?: string
|
||||
readonly store?: boolean
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||
readonly textVerbosity?: TextVerbosity
|
||||
readonly serviceTier?: ServiceTier
|
||||
}
|
||||
export type OpenResponsesOptionsInput = Options & { readonly [key: string]: unknown }
|
||||
|
||||
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
|
||||
readonly openresponses?: OpenResponsesOptionsInput
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type FinishReasonDetails,
|
||||
type AIError,
|
||||
type LLMRequest,
|
||||
type ProviderMetadata,
|
||||
type UsageInput,
|
||||
} from "./schema/index.js"
|
||||
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
|
||||
@@ -33,13 +34,22 @@ export interface LayerOptions {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
|
||||
|
||||
export const complete = (
|
||||
options: { readonly reason: FinishReasonDetails; readonly usage?: UsageInput },
|
||||
options: {
|
||||
readonly reason: FinishReasonDetails
|
||||
readonly usage?: UsageInput
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
},
|
||||
...events: readonly LLMEvent[]
|
||||
) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
...events,
|
||||
LLMEvent.stepFinish({ index: 0, reason: options.reason, usage: options.usage }),
|
||||
LLMEvent.finish({ reason: options.reason }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: options.reason,
|
||||
usage: options.usage,
|
||||
providerMetadata: options.providerMetadata,
|
||||
}),
|
||||
LLMEvent.finish({ reason: options.reason, providerMetadata: options.providerMetadata }),
|
||||
]
|
||||
|
||||
export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src/index.js"
|
||||
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { OpenAI } from "../../src/providers.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
@@ -56,6 +56,28 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates as standard developer messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "developer", content: "Operator update." },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects OpenAI-native tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
@@ -101,13 +123,36 @@ describe("Open Responses-compatible route", () => {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
|
||||
providerOptions: {
|
||||
openresponses: {
|
||||
reasoningEffort: "low",
|
||||
store: true,
|
||||
truncation: "auto",
|
||||
allowedTools: { toolNames: ["lookup"] },
|
||||
maxToolCalls: 2,
|
||||
parallelToolCalls: false,
|
||||
},
|
||||
},
|
||||
}).model("example-model")
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." }))
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Think.",
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
reasoning: { effort: "low" },
|
||||
store: true,
|
||||
truncation: "auto",
|
||||
tool_choice: {
|
||||
type: "allowed_tools",
|
||||
mode: "auto",
|
||||
tools: [{ type: "function", name: "lookup" }],
|
||||
},
|
||||
max_tool_calls: 2,
|
||||
parallel_tool_calls: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -241,27 +241,18 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
|
||||
it.effect("lowers chronological system updates to developer messages in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Before."),
|
||||
Message.system("Treat </system-update> literally."),
|
||||
Message.assistant("After."),
|
||||
],
|
||||
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "Before." },
|
||||
{ type: "input_text", text: "<system-update>\nTreat </system-update> literally.\n</system-update>" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "developer", content: "Operator update." },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
@@ -1283,11 +1274,20 @@ describe("OpenAI Responses route", () => {
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
|
||||
prompt: "think",
|
||||
promptCacheKey: "session_123",
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
|
||||
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
|
||||
],
|
||||
toolChoice: "none",
|
||||
providerOptions: {
|
||||
openai: {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
truncation: "disabled",
|
||||
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
|
||||
maxToolCalls: 4,
|
||||
parallelToolCalls: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -1298,6 +1298,17 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
|
||||
expect(prepared.body.text).toEqual({ verbosity: "low" })
|
||||
expect(prepared.body.truncation).toBe("disabled")
|
||||
expect(prepared.body.tool_choice).toEqual({
|
||||
type: "allowed_tools",
|
||||
mode: "required",
|
||||
tools: [
|
||||
{ type: "function", name: "read" },
|
||||
{ type: "function", name: "grep" },
|
||||
],
|
||||
})
|
||||
expect(prepared.body.max_tool_calls).toBe(4)
|
||||
expect(prepared.body.parallel_tool_calls).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1323,20 +1334,17 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters unknown includable values out of the include array", () =>
|
||||
it.effect("passes forward-compatible includable values through", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "hi",
|
||||
// The user passed one invalid entry alongside a valid one. Keep the
|
||||
// valid one so the request still succeeds rather than failing on a
|
||||
// typo from upstream config.
|
||||
providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content", "bogus.thing"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1350,13 +1358,13 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats an all-invalid include as no include at all", () =>
|
||||
it.effect("passes an unknown includable value through", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
expect(prepared.body.include).toEqual(["bogus.thing"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useData } from "@/context/server"
|
||||
import { pluginLabels } from "@/utils/plugin"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
import { ExternalLink } from "./external-link"
|
||||
|
||||
type SkillItem = {
|
||||
@@ -102,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const globalPlugins = createMemo(() => pluginLabels(globalPluginList.latest ?? []))
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
|
||||
const projectPlugins = createMemo(() => {
|
||||
const shared = new Set(globalPlugins())
|
||||
return pluginLabels(projectPluginList.latest ?? []).filter((name) => !shared.has(name))
|
||||
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
|
||||
})
|
||||
|
||||
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { pluginLabels } from "@/utils/plugin"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import "./settings-v2.css"
|
||||
@@ -45,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.plugin.list().then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo<PluginRowItem[]>(() => pluginLabels(pluginList.latest ?? []).map((name) => ({ name })))
|
||||
const plugins = createMemo<PluginRowItem[]>(() =>
|
||||
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSdk.connection.status() !== "connected") return
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { pluginLabels } from "@/utils/plugin"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -39,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo(() => pluginLabels(pluginList.latest ?? []))
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import { pluginLabels } from "./plugin"
|
||||
|
||||
describe("pluginLabels", () => {
|
||||
test("omits built-in plugins", () => {
|
||||
const plugins: PluginInfo[] = [
|
||||
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
|
||||
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
|
||||
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
|
||||
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
|
||||
]
|
||||
|
||||
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,3 @@ export function pluginLabel(plugin: PluginInfo) {
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
|
||||
export function pluginLabels(plugins: readonly PluginInfo[]) {
|
||||
return plugins.filter((plugin) => plugin.source.type !== "builtin").map(pluginLabel)
|
||||
}
|
||||
|
||||
@@ -160,7 +160,29 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
}),
|
||||
Spec.make("plugin", {
|
||||
description: "Manage plugins",
|
||||
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||
commands: [
|
||||
Spec.make("list", {
|
||||
description: "List plugins",
|
||||
params: {
|
||||
builtin: Flag.boolean("builtin").pipe(
|
||||
Flag.withDescription("Include built-in server plugins"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("add", {
|
||||
description: "Install a plugin and add it to the global configuration",
|
||||
params: {
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry package specifier")),
|
||||
},
|
||||
}),
|
||||
Spec.make("remove", {
|
||||
description: "Remove a plugin from global configuration",
|
||||
params: {
|
||||
package: Argument.string("package").pipe(Argument.withDescription("configured package specifier")),
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("models", {
|
||||
description: "List all available models",
|
||||
|
||||
@@ -84,8 +84,12 @@ export default Runtime.handler(Commands, (input) =>
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
packages: {
|
||||
resolve: (spec) =>
|
||||
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
|
||||
resolve: (spec, install = true) =>
|
||||
runPromise(
|
||||
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
|
||||
Effect.map((result) => result.entrypoint),
|
||||
),
|
||||
),
|
||||
},
|
||||
environment: requestedServer === undefined ? Env.session() : undefined,
|
||||
terminalHandoff: () => preflight.finish(),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { resolveConfigPath } from "../mcp/add"
|
||||
import { Config } from "../../../config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.add,
|
||||
Effect.fn("cli.plugin.add")(function* (input) {
|
||||
if (!(yield* Effect.promise(() => Npm.isRegistryPackage(input.package))))
|
||||
return yield* Effect.fail(
|
||||
new Error("Plugin target must be an npm registry package name, version, tag, or semver range"),
|
||||
)
|
||||
const npm = yield* Npm.Service
|
||||
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
|
||||
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
|
||||
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
|
||||
if (!target)
|
||||
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
|
||||
|
||||
if (target === "server") {
|
||||
const global = yield* Global.Service
|
||||
const configPath = yield* Effect.promise(() => resolveConfigPath(global.config))
|
||||
const changed = yield* Effect.promise(() => writePluginConfig(configPath, input.package))
|
||||
process.stdout.write(
|
||||
changed
|
||||
? `Plugin "${input.package}" installed and added to ${configPath}${EOL}`
|
||||
: `Plugin "${input.package}" is already configured in ${configPath}${EOL}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const config = yield* Config.Service
|
||||
yield* config.update((draft) => {
|
||||
if (configured(draft.plugins, input.package)) return
|
||||
draft.plugins = [...(draft.plugins ?? []), input.package]
|
||||
})
|
||||
process.stdout.write(`TUI plugin "${input.package}" installed and added to ${config.path}${EOL}`)
|
||||
}),
|
||||
)
|
||||
|
||||
export function configurationTarget(server?: string, tui?: string) {
|
||||
if (server) return "server" as const
|
||||
if (tui) return "tui" as const
|
||||
}
|
||||
|
||||
export async function writePluginConfig(configPath: string, spec: string) {
|
||||
const text = await readFile(configPath, "utf8").catch((error) => {
|
||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return "{}"
|
||||
throw error
|
||||
})
|
||||
const errors: ParseError[] = []
|
||||
const config: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length || typeof config !== "object" || config === null || Array.isArray(config))
|
||||
throw new Error(`Invalid global configuration: ${configPath}`)
|
||||
const plugins = "plugins" in config ? config.plugins : undefined
|
||||
if (plugins !== undefined && !Array.isArray(plugins)) throw new Error(`Invalid plugins configuration: ${configPath}`)
|
||||
if (configured(plugins, spec)) return false
|
||||
|
||||
const updated = applyEdits(
|
||||
text,
|
||||
modify(text, ["plugins"], [...(plugins ?? []), spec], { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
)
|
||||
await mkdir(path.dirname(configPath), { recursive: true })
|
||||
const temporary = configPath + ".tmp"
|
||||
await writeFile(temporary, updated.endsWith("\n") ? updated : updated + "\n", { mode: 0o600 })
|
||||
await rename(temporary, configPath)
|
||||
return true
|
||||
}
|
||||
|
||||
function configured(plugins: readonly unknown[] | undefined, spec: string) {
|
||||
return plugins?.some(
|
||||
(entry) =>
|
||||
entry === spec || (typeof entry === "object" && entry !== null && "package" in entry && entry.package === spec),
|
||||
)
|
||||
}
|
||||
@@ -5,24 +5,69 @@ import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
Effect.fn("cli.plugin.list")(function* () {
|
||||
Effect.fn("cli.plugin.list")(function* (input) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||
if (plugins.length === 0) {
|
||||
process.stdout.write("No plugins loaded" + EOL)
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const info = yield* config.get()
|
||||
const discovered = yield* Effect.promise(() =>
|
||||
tuiPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
|
||||
)
|
||||
const output = format(
|
||||
response.data,
|
||||
[
|
||||
...(info.plugins ?? []).flatMap((entry) => {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
return target.startsWith("-") ? [] : [{ target, source: "configured" as const }]
|
||||
}),
|
||||
...discovered.map((target) => ({ target, source: "discovered" as const })),
|
||||
],
|
||||
input.builtin,
|
||||
)
|
||||
if (!output) {
|
||||
process.stdout.write("No plugins found" + EOL)
|
||||
return
|
||||
}
|
||||
process.stdout.write(plugins.map(name).join(EOL) + EOL)
|
||||
process.stdout.write(output + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
export function format(
|
||||
plugins: readonly PluginInfo[],
|
||||
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
|
||||
builtin = false,
|
||||
) {
|
||||
const server = plugins
|
||||
.filter((plugin) => builtin || plugin.source.type !== "builtin")
|
||||
.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||
.map((plugin) => `${name(plugin)} (${plugin.status})`)
|
||||
const advertised = plugins.flatMap((plugin) =>
|
||||
plugin.status === "active" && plugin.tui && plugin.source.type === "package"
|
||||
? [{ target: plugin.source.package, source: "advertised" as const }]
|
||||
: [],
|
||||
)
|
||||
const targets = [...tui, ...advertised]
|
||||
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
|
||||
.toSorted((a, b) => a.target.localeCompare(b.target))
|
||||
.map((plugin) => `${plugin.target} (${plugin.source})`)
|
||||
return [
|
||||
targets.length ? ["TUI", ...targets].join(EOL) : undefined,
|
||||
server.length ? ["Server", ...server].join(EOL) : undefined,
|
||||
]
|
||||
.filter((section) => section !== undefined)
|
||||
.join(EOL + EOL)
|
||||
}
|
||||
|
||||
function name(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { readFile, rename, writeFile } from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Config } from "../../../config"
|
||||
import { resolveConfigPath } from "../mcp/add"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.remove,
|
||||
Effect.fn("cli.plugin.remove")(function* (input) {
|
||||
const global = yield* Global.Service
|
||||
const configPath = yield* Effect.promise(() => resolveConfigPath(global.config))
|
||||
const server = yield* Effect.promise(() => removePluginConfig(configPath, input.package))
|
||||
const config = yield* Config.Service
|
||||
const info = yield* config.get()
|
||||
const tui = configured(info.plugins, input.package)
|
||||
if (tui)
|
||||
yield* config.update((draft) => {
|
||||
draft.plugins = draft.plugins?.filter((entry) => !matches(entry, input.package))
|
||||
})
|
||||
|
||||
const removed = [server ? configPath : undefined, tui ? config.path : undefined].filter(
|
||||
(file) => file !== undefined,
|
||||
)
|
||||
process.stdout.write(
|
||||
removed.length
|
||||
? `Plugin "${input.package}" removed from ${removed.join(", ")}${EOL}`
|
||||
: `Plugin "${input.package}" is not configured${EOL}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export async function removePluginConfig(configPath: string, spec: string) {
|
||||
const text = await readFile(configPath, "utf8").catch((error) => {
|
||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return undefined
|
||||
throw error
|
||||
})
|
||||
if (text === undefined) return false
|
||||
const errors: ParseError[] = []
|
||||
const config: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length || typeof config !== "object" || config === null || Array.isArray(config))
|
||||
throw new Error(`Invalid global configuration: ${configPath}`)
|
||||
const plugins = "plugins" in config ? config.plugins : undefined
|
||||
if (plugins !== undefined && !Array.isArray(plugins)) throw new Error(`Invalid plugins configuration: ${configPath}`)
|
||||
if (!configured(plugins, spec)) return false
|
||||
|
||||
const updated = applyEdits(
|
||||
text,
|
||||
modify(
|
||||
text,
|
||||
["plugins"],
|
||||
plugins?.filter((entry) => !matches(entry, spec)),
|
||||
{
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
},
|
||||
),
|
||||
)
|
||||
const temporary = configPath + ".tmp"
|
||||
await writeFile(temporary, updated.endsWith("\n") ? updated : updated + "\n", { mode: 0o600 })
|
||||
await rename(temporary, configPath)
|
||||
return true
|
||||
}
|
||||
|
||||
function configured(plugins: readonly unknown[] | undefined, spec: string) {
|
||||
return plugins?.some((entry) => matches(entry, spec)) ?? false
|
||||
}
|
||||
|
||||
function matches(entry: unknown, spec: string) {
|
||||
return entry === spec || (typeof entry === "object" && entry !== null && "package" in entry && entry.package === spec)
|
||||
}
|
||||
@@ -38,6 +38,8 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
},
|
||||
plugin: {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
add: () => import("./commands/handlers/plugin/add"),
|
||||
remove: () => import("./commands/handlers/plugin/remove"),
|
||||
},
|
||||
models: () => import("./commands/handlers/models"),
|
||||
export: () => import("./commands/handlers/export"),
|
||||
|
||||
@@ -80,7 +80,13 @@ async function run(input: RunCommandInput, options: ExecutionOptions) {
|
||||
}
|
||||
|
||||
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const client = OpenCode.make({
|
||||
baseUrl: endpoint.url,
|
||||
headers: Service.headers(endpoint),
|
||||
// Bun's default five-minute deadline terminates the event stream used by long-running sessions.
|
||||
fetch: ((request: RequestInfo | URL, init?: RequestInit) =>
|
||||
fetch(request, { ...init, timeout: false } as BunFetchRequestInit)) as typeof fetch,
|
||||
})
|
||||
const explicit = parseRunModel(input.model)
|
||||
const target = await resolveSessionTarget({
|
||||
client,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { configurationTarget, writePluginConfig } from "../src/commands/handlers/plugin/add"
|
||||
|
||||
test("routes packages according to their exported runtimes", () => {
|
||||
expect(configurationTarget("server.js", "tui.js")).toBe("server")
|
||||
expect(configurationTarget("server.js", undefined)).toBe("server")
|
||||
expect(configurationTarget(undefined, "tui.js")).toBe("tui")
|
||||
expect(configurationTarget(undefined, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("adds a package to global plugin config without replacing unrelated settings", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "opencode.jsonc")
|
||||
await Bun.write(file, '{\n // retained\n "model": "provider/model",\n "plugins": ["first"]\n}\n')
|
||||
|
||||
try {
|
||||
expect(await writePluginConfig(file, "second@1.0.0")).toBe(true)
|
||||
expect(await writePluginConfig(file, "second@1.0.0")).toBe(false)
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// retained")
|
||||
expect(parse(text)).toEqual({
|
||||
model: "provider/model",
|
||||
plugins: ["first", "second@1.0.0"],
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { EOL } from "node:os"
|
||||
import { format } from "../src/commands/handlers/plugin/list"
|
||||
|
||||
test("formats server and TUI plugins in sections without builtins", () => {
|
||||
expect(
|
||||
format(
|
||||
[
|
||||
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
|
||||
{
|
||||
id: "acme.dual",
|
||||
source: { type: "package", package: "acme-plugin@1.0.0" },
|
||||
status: "active",
|
||||
tui: true,
|
||||
},
|
||||
{
|
||||
source: { type: "package", package: "broken-plugin" },
|
||||
status: "failed",
|
||||
error: "broken",
|
||||
tui: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ target: "tui-only", source: "configured" },
|
||||
{ target: "/tmp/local.ts", source: "discovered" },
|
||||
],
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"TUI",
|
||||
"/tmp/local.ts (discovered)",
|
||||
"acme-plugin@1.0.0 (advertised)",
|
||||
"tui-only (configured)",
|
||||
"",
|
||||
"Server",
|
||||
"acme.dual (active)",
|
||||
"broken-plugin (failed)",
|
||||
].join(EOL),
|
||||
)
|
||||
})
|
||||
|
||||
test("includes builtins when requested", () => {
|
||||
expect(
|
||||
format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true),
|
||||
).toBe(["Server", "opencode.agent (active)"].join(EOL))
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { removePluginConfig } from "../src/commands/handlers/plugin/remove"
|
||||
|
||||
test("removes string and object package entries without replacing unrelated settings", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "opencode.jsonc")
|
||||
await Bun.write(
|
||||
file,
|
||||
'{\n // retained\n "model": "provider/model",\n "plugins": ["remove-me", { "package": "remove-me", "options": {} }, "keep-me"]\n}\n',
|
||||
)
|
||||
|
||||
try {
|
||||
expect(await removePluginConfig(file, "remove-me")).toBe(true)
|
||||
expect(await removePluginConfig(file, "remove-me")).toBe(false)
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// retained")
|
||||
expect(parse(text)).toEqual({ model: "provider/model", plugins: ["keep-me"] })
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -579,6 +579,8 @@ export type Endpoint5_31Output =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly rawFinish?: string | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly cost: number & Brand.Brand<"Money.USD">
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
@@ -601,6 +603,9 @@ export type Endpoint5_31Output =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly finish?: "content-filter" | undefined
|
||||
readonly rawFinish?: string | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
|
||||
@@ -1108,6 +1108,8 @@ export type SessionStepEnded = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
rawFinish?: string
|
||||
providerState?: SessionMessageProviderState1
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
snapshot?: string
|
||||
@@ -1145,6 +1147,9 @@ export type SessionStepFailed = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
error: SessionStructuredError
|
||||
finish?: "content-filter"
|
||||
rawFinish?: string
|
||||
providerState?: SessionMessageProviderState1
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
snapshot?: string
|
||||
@@ -1921,6 +1926,8 @@ export type SessionMessageAssistant = {
|
||||
content: Array<SessionMessageAssistantText | SessionMessageAssistantReasoning | SessionMessageAssistantTool>
|
||||
snapshot?: { start?: string; end?: string; files?: Array<string> }
|
||||
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
rawFinish?: string
|
||||
providerState?: SessionMessageProviderState
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
error?: SessionStructuredError
|
||||
@@ -2691,6 +2698,8 @@ export type SessionImportInput = {
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly rawFinish?: string
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -2958,6 +2967,8 @@ export type SessionImportInput = {
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly rawFinish?: string
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -3225,6 +3236,8 @@ export type SessionImportInput = {
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly rawFinish?: string
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
|
||||
@@ -601,6 +601,8 @@ export function createData(config: CreateDataInput) {
|
||||
existing.retry = undefined
|
||||
existing.error = undefined
|
||||
existing.finish = undefined
|
||||
existing.rawFinish = undefined
|
||||
existing.providerState = undefined
|
||||
existing.time.completed = undefined
|
||||
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
|
||||
return
|
||||
@@ -628,6 +630,8 @@ export function createData(config: CreateDataInput) {
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = event.data.finish
|
||||
currentAssistant.rawFinish = event.data.rawFinish
|
||||
currentAssistant.providerState = event.data.providerState
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
if (event.data.snapshot)
|
||||
@@ -640,7 +644,9 @@ export function createData(config: CreateDataInput) {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = "error"
|
||||
currentAssistant.finish = event.data.finish ?? "error"
|
||||
currentAssistant.rawFinish = event.data.rawFinish
|
||||
currentAssistant.providerState = event.data.providerState
|
||||
currentAssistant.error = event.data.error
|
||||
currentAssistant.retry = undefined
|
||||
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
|
||||
|
||||
@@ -195,6 +195,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
draft.retry = undefined
|
||||
draft.error = undefined
|
||||
draft.finish = undefined
|
||||
draft.rawFinish = undefined
|
||||
draft.providerState = undefined
|
||||
draft.time.completed = undefined
|
||||
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
|
||||
}),
|
||||
@@ -228,6 +230,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.time.completed = created
|
||||
draft.finish = event.data.finish
|
||||
draft.rawFinish = event.data.rawFinish
|
||||
draft.providerState = castDraft(event.data.providerState)
|
||||
draft.cost = event.data.cost
|
||||
draft.tokens = event.data.tokens
|
||||
if (event.data.snapshot || event.data.files)
|
||||
@@ -241,7 +245,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.step.failed": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.time.completed = created
|
||||
draft.finish = "error"
|
||||
draft.finish = event.data.finish ?? "error"
|
||||
draft.rawFinish = event.data.rawFinish
|
||||
draft.providerState = castDraft(event.data.providerState)
|
||||
draft.error = castDraft(event.data.error)
|
||||
draft.retry = undefined
|
||||
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
|
||||
|
||||
@@ -326,6 +326,8 @@ const layer = Layer.effect(
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: finish.finish,
|
||||
rawFinish: finish.rawFinish,
|
||||
providerState: finish.providerState,
|
||||
...stepUsage(finish),
|
||||
...end,
|
||||
})
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface StepRecord {
|
||||
/** Present once the provider finished the step normally. */
|
||||
readonly finish?: {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
||||
readonly rawFinish?: string
|
||||
readonly providerState?: SessionMessage.ProviderState
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
}
|
||||
readonly calls: ReadonlyArray<{
|
||||
@@ -364,6 +366,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
error: stepFailure,
|
||||
finish: stepSettlement?.finish === "content-filter" ? stepSettlement.finish : undefined,
|
||||
rawFinish: stepSettlement?.rawFinish,
|
||||
providerState: stepSettlement?.providerState,
|
||||
...details,
|
||||
})
|
||||
})
|
||||
@@ -517,7 +522,12 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
||||
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
|
||||
stepSettlement = {
|
||||
finish: event.reason.normalized,
|
||||
rawFinish: event.reason.raw,
|
||||
providerState: providerState(event.providerMetadata),
|
||||
tokens: SessionUsage.tokens(event.usage),
|
||||
}
|
||||
if (event.reason.normalized === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||
|
||||
@@ -56,8 +56,8 @@ const headers = (format: Format, userAgent: string) => ({
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
})
|
||||
|
||||
const browserUserAgent =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||||
const openCodeUserAgent =
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OpenCode-User/1.0; +https://opencode.ai"
|
||||
|
||||
const isCloudflareChallenge = (error: unknown) => {
|
||||
if (!error || typeof error !== "object" || !("reason" in error)) return false
|
||||
@@ -74,14 +74,14 @@ const isCloudflareChallenge = (error: unknown) => {
|
||||
return response.status === 403 && response.headers["cf-mitigated"] === "challenge"
|
||||
}
|
||||
|
||||
const request = (url: string, format: Format, userAgent = browserUserAgent) =>
|
||||
const request = (url: string, format: Format, userAgent = openCodeUserAgent) =>
|
||||
HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers(format, userAgent)))
|
||||
|
||||
const assertHttpUrl = (url: URL) => {
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("URL must use http:// or https://")
|
||||
}
|
||||
|
||||
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = browserUserAgent) =>
|
||||
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = openCodeUserAgent) =>
|
||||
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
|
||||
|
||||
const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
|
||||
@@ -35,6 +35,17 @@ describe("Npm.sanitize", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.isRegistryPackage", () => {
|
||||
test("accepts registry packages and rejects unsupported install targets", async () => {
|
||||
expect(await Npm.isRegistryPackage("plugin")).toBe(true)
|
||||
expect(await Npm.isRegistryPackage("@acme/plugin@beta")).toBe(true)
|
||||
expect(await Npm.isRegistryPackage("plugin@^1.2.0")).toBe(true)
|
||||
expect(await Npm.isRegistryPackage("./plugin")).toBe(false)
|
||||
expect(await Npm.isRegistryPackage("github:acme/plugin")).toBe(false)
|
||||
expect(await Npm.isRegistryPackage("alias@npm:plugin@1.0.0")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.add", () => {
|
||||
test("resolves cached scoped package specs without reifying", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
@@ -106,3 +117,31 @@ describe("Npm.add", () => {
|
||||
expect(entries.fallback.entrypoint).toEndWith("/index.js")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.resolve", () => {
|
||||
test("resolves a TUI entrypoint only when the package is already cached", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const spec = "fixture-plugin@1.0.0"
|
||||
const directory = path.join(cache, "packages", Npm.sanitize(spec), "node_modules", "fixture-plugin")
|
||||
const missing = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(missing.entrypoint).toBeUndefined()
|
||||
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await writePackage(directory, {
|
||||
name: "fixture-plugin",
|
||||
exports: { ".": "./index.js", "./tui": "./tui.js" },
|
||||
})
|
||||
await Bun.write(path.join(directory, "index.js"), "export default {}\n")
|
||||
await Bun.write(path.join(directory, "tui.js"), "export default {}\n")
|
||||
|
||||
const resolved = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(resolved.entrypoint).toEndWith("/tui.js")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
which: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
|
||||
function npmEntrypoint(entrypoint?: string) {
|
||||
return Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.ur
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const npm = Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
|
||||
|
||||
@@ -353,7 +353,12 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
publisher.publish(
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "content-filter" },
|
||||
reason: { normalized: "content-filter", raw: "refusal" },
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
nonCachedInputTokens: 8,
|
||||
outputTokens: 3,
|
||||
@@ -367,6 +372,10 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
const settlement = publisher.record().finish
|
||||
expect(settlement).toMatchObject({
|
||||
finish: "content-filter",
|
||||
rawFinish: "refusal",
|
||||
providerState: {
|
||||
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
|
||||
},
|
||||
tokens: { input: 8, output: 2, reasoning: 1 },
|
||||
})
|
||||
if (!settlement) throw new Error("Expected content-filter settlement")
|
||||
@@ -381,6 +390,11 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
|
||||
expect(published.at(-1)?.data).toMatchObject({
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
finish: "content-filter",
|
||||
rawFinish: "refusal",
|
||||
providerState: {
|
||||
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
|
||||
},
|
||||
cost: 1.25,
|
||||
tokens: { input: 8, output: 2, reasoning: 1 },
|
||||
snapshot: "tree-end",
|
||||
|
||||
@@ -4161,13 +4161,49 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists raw finish reasons and provider state", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* TestLLM.push(
|
||||
TestLLM.complete(
|
||||
{
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
providerMetadata: { openai: { responseId: "response-1", serviceTier: "priority" } },
|
||||
},
|
||||
LLMEvent.textStart({ id: "answer" }),
|
||||
LLMEvent.textDelta({ id: "answer", text: "Complete" }),
|
||||
LLMEvent.textEnd({ id: "answer" }),
|
||||
),
|
||||
)
|
||||
|
||||
yield* runPrompt(session, "Keep provider finish details")
|
||||
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "stop",
|
||||
rawFinish: "end_turn",
|
||||
providerState: { responseId: "response-1", serviceTier: "priority" },
|
||||
content: [{ type: "text", text: "Complete" }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects content-filter finishes as visible terminal failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* TestLLM.push(
|
||||
TestLLM.complete(
|
||||
{
|
||||
reason: { normalized: "content-filter" },
|
||||
reason: { normalized: "content-filter", raw: "SAFETY" },
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
responseId: "response-blocked",
|
||||
refusal: { category: "safety", explanation: "Prompt blocked" },
|
||||
},
|
||||
},
|
||||
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
|
||||
},
|
||||
LLMEvent.textStart({ id: "partial" }),
|
||||
@@ -4182,7 +4218,12 @@ describe("SessionRunnerLLM", () => {
|
||||
{ type: "user" },
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
finish: "content-filter",
|
||||
rawFinish: "SAFETY",
|
||||
providerState: {
|
||||
responseId: "response-blocked",
|
||||
refusal: { category: "safety", explanation: "Prompt blocked" },
|
||||
},
|
||||
error: { type: "provider.content-filter" },
|
||||
cost: 0,
|
||||
tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } },
|
||||
|
||||
@@ -23,6 +23,8 @@ const webFetchToolNode = makeLocationNode({
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_webfetch_test")
|
||||
const webFetchUserAgent =
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OpenCode-User/1.0; +https://opencode.ai"
|
||||
const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
let respond = (_request: HttpClientRequest.HttpClientRequest) =>
|
||||
@@ -376,7 +378,17 @@ describe("WebFetchTool registration", () => {
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
|
||||
])
|
||||
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
|
||||
expect(requests).toMatchObject([
|
||||
{
|
||||
url,
|
||||
headers: {
|
||||
accept: "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"user-agent": webFetchUserAgent,
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(requests[0]?.headers).not.toHaveProperty("sec-fetch-mode")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -397,15 +409,23 @@ describe("WebFetchTool registration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
live.effect("follows redirects while approving only the requested URL", () =>
|
||||
Effect.acquireUseRelease(
|
||||
live.effect("follows redirects while approving only the requested URL", () => {
|
||||
const received: Array<Record<string, string | null>> = []
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) =>
|
||||
new URL(request.url).pathname === "/redirect"
|
||||
? new Response("", { status: 302, headers: { location: "/target" } })
|
||||
: new Response("redirected", { headers: { "content-type": "text/plain" } }),
|
||||
fetch: (request) => {
|
||||
received.push({
|
||||
accept: request.headers.get("accept"),
|
||||
"accept-language": request.headers.get("accept-language"),
|
||||
"sec-fetch-mode": request.headers.get("sec-fetch-mode"),
|
||||
"user-agent": request.headers.get("user-agent"),
|
||||
})
|
||||
if (new URL(request.url).pathname === "/redirect")
|
||||
return new Response("", { status: 302, headers: { location: "/target" } })
|
||||
return new Response("redirected", { headers: { "content-type": "text/plain" } })
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
@@ -421,10 +441,18 @@ describe("WebFetchTool registration", () => {
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
|
||||
])
|
||||
expect(received).toEqual(
|
||||
Array.from({ length: 2 }, () => ({
|
||||
accept: "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"sec-fetch-mode": null,
|
||||
"user-agent": webFetchUserAgent,
|
||||
})),
|
||||
)
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("rejects non-HTTP schemes before permission or transport", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -549,7 +577,7 @@ describe("WebFetchTool registration", () => {
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
})
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0")
|
||||
expect(requests[0]?.headers["user-agent"]).toBe(webFetchUserAgent)
|
||||
expect(requests[1]?.headers["user-agent"]).toBe("opencode")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -43,14 +43,16 @@ export async function startBackgroundCli(logger: Logger) {
|
||||
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
|
||||
})
|
||||
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
||||
const url = new URL(service.url)
|
||||
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
|
||||
logger.log("v2 CLI background service ready", {
|
||||
username: service.auth.username,
|
||||
version: cli.version,
|
||||
...endpoint(service.url),
|
||||
...endpoint(url.origin),
|
||||
})
|
||||
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
|
||||
return {
|
||||
url: service.url,
|
||||
url: url.origin,
|
||||
username: service.auth.username,
|
||||
password: service.auth.password,
|
||||
version: cli.version,
|
||||
|
||||
@@ -298,6 +298,8 @@ export namespace Step {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
finish: FinishReason,
|
||||
rawFinish: Schema.String.pipe(optional),
|
||||
providerState: SessionMessage.ProviderState.pipe(optional),
|
||||
cost: Money.USD,
|
||||
tokens: TokenUsage.Info,
|
||||
snapshot: Snapshot.ID.pipe(optional),
|
||||
@@ -313,6 +315,9 @@ export namespace Step {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
error: SessionError.Error,
|
||||
finish: Schema.Literals(["content-filter"]).pipe(optional),
|
||||
rawFinish: Schema.String.pipe(optional),
|
||||
providerState: SessionMessage.ProviderState.pipe(optional),
|
||||
cost: Money.USD.pipe(optional),
|
||||
tokens: TokenUsage.Info.pipe(optional),
|
||||
snapshot: Snapshot.ID.pipe(optional),
|
||||
|
||||
@@ -215,6 +215,8 @@ export const Assistant = Schema.Struct({
|
||||
files: Schema.Array(RelativePath).pipe(optional),
|
||||
}).pipe(optional),
|
||||
finish: FinishReason.pipe(optional),
|
||||
rawFinish: Schema.String.pipe(optional),
|
||||
providerState: ProviderState.pipe(optional),
|
||||
cost: Money.USD.pipe(optional),
|
||||
tokens: TokenUsage.Info.pipe(optional),
|
||||
error: SessionError.Error.pipe(optional),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { SessionEvent } from "../src/session-event.js"
|
||||
import { SessionMessage } from "../src/session-message.js"
|
||||
|
||||
const assistant = {
|
||||
id: "msg_terminal",
|
||||
type: "assistant" as const,
|
||||
agent: "build",
|
||||
model: { providerID: "openai", id: "gpt-test" },
|
||||
content: [],
|
||||
time: { created: 0 },
|
||||
}
|
||||
|
||||
test("assistant terminal diagnostics remain optional and round trip", () => {
|
||||
const decode = Schema.decodeUnknownSync(SessionMessage.Assistant)
|
||||
const encode = Schema.encodeSync(SessionMessage.Assistant)
|
||||
|
||||
expect(encode(decode(assistant))).toEqual(assistant)
|
||||
expect(
|
||||
encode(
|
||||
decode({
|
||||
...assistant,
|
||||
finish: "content-filter",
|
||||
rawFinish: "SAFETY",
|
||||
providerState: { promptFeedback: { blockReason: "SAFETY" } },
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
finish: "content-filter",
|
||||
rawFinish: "SAFETY",
|
||||
providerState: { promptFeedback: { blockReason: "SAFETY" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("failed steps only override the assistant finish for content filters", () => {
|
||||
const decode = Schema.decodeUnknownSync(SessionEvent.Step.Failed.data)
|
||||
const input = {
|
||||
sessionID: "ses_terminal",
|
||||
assistantMessageID: "msg_terminal",
|
||||
error: { type: "provider.content-filter", message: "Blocked" },
|
||||
}
|
||||
|
||||
expect(decode(input)).toMatchObject(input)
|
||||
expect(decode({ ...input, finish: "content-filter", rawFinish: "SAFETY" })).toMatchObject({
|
||||
finish: "content-filter",
|
||||
rawFinish: "SAFETY",
|
||||
})
|
||||
expect(() => decode({ ...input, finish: "stop" })).toThrow()
|
||||
})
|
||||
@@ -24,6 +24,7 @@
|
||||
"./context/client": "./src/context/client.tsx",
|
||||
"./context/theme": "./src/context/theme.tsx",
|
||||
"./theme/discovery": "./src/theme/discovery.ts",
|
||||
"./plugin/discovery": "./src/plugin/discovery.ts",
|
||||
"./context/editor": "./src/context/editor.ts",
|
||||
"./context/clipboard": "./src/context/clipboard.tsx",
|
||||
"./attention": "./src/attention.ts",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
|
||||
import {
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
@@ -21,6 +23,8 @@ import { isDeepEqual } from "remeda"
|
||||
import "#runtime-plugin-support"
|
||||
import { useConfig } from "../config"
|
||||
import { useTuiLifecycle } from "../context/runtime"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
|
||||
@@ -28,7 +32,7 @@ import { createSourceWatcher } from "./watch"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
|
||||
|
||||
export interface PackageResolver {
|
||||
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
type State =
|
||||
@@ -90,6 +94,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const [serverPlugins, setServerPlugins] = createSignal<
|
||||
ReadonlyArray<
|
||||
Extract<PluginInfo, { readonly status: "active" }> & { readonly source: { readonly type: "package" } }
|
||||
>
|
||||
>([])
|
||||
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
@@ -230,7 +241,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const npmFailures = new Map<string, string>()
|
||||
const reconcile = async () => {
|
||||
await Promise.all(props.directories.map(watcher.wait))
|
||||
const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])]
|
||||
const entries = [
|
||||
...(await discoverTuiPlugins(props.directories)).map((entry) => ({ entry, install: true, server: false })),
|
||||
...serverPlugins().map((plugin) => ({ entry: plugin.source.package, install: false, server: true })),
|
||||
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, server: false })),
|
||||
]
|
||||
|
||||
// Resolve: fold entries into one desired generation. A source that fails
|
||||
// to import keeps its running previous version and only reports failure.
|
||||
@@ -238,7 +253,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
for (const plugin of builtins)
|
||||
desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
|
||||
const failures: State[] = []
|
||||
for (const entry of entries) {
|
||||
for (const source of entries) {
|
||||
const entry = source.entry
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-")) {
|
||||
for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
|
||||
@@ -259,11 +275,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const memo = local ? undefined : npmFailures.get(target)
|
||||
const resolved = memo
|
||||
? { status: "failed" as const, error: memo }
|
||||
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install).catch((error) => ({
|
||||
status: "failed" as const,
|
||||
error: errorMessage(error),
|
||||
}))
|
||||
if (resolved.status === "unsupported") {
|
||||
if (source.server) continue
|
||||
failures.push({ target, status: "unsupported" })
|
||||
continue
|
||||
}
|
||||
@@ -439,7 +456,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify(config.data.plugins ?? []),
|
||||
() => JSON.stringify([serverPlugins(), config.data.plugins ?? []]),
|
||||
() => {
|
||||
npmFailures.clear()
|
||||
void enqueue(reconcile).then(
|
||||
@@ -449,6 +466,29 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
},
|
||||
),
|
||||
)
|
||||
const syncServerPlugins = () =>
|
||||
client.api.plugin
|
||||
.list({ location: data.location.default() })
|
||||
.then((response) =>
|
||||
setServerPlugins(
|
||||
response.data.filter(
|
||||
(
|
||||
plugin,
|
||||
): plugin is Extract<PluginInfo, { readonly status: "active" }> & {
|
||||
readonly source: { readonly type: "package" }
|
||||
} => plugin.status === "active" && plugin.tui && plugin.source.type === "package",
|
||||
),
|
||||
),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify(data.location.default()),
|
||||
() => void syncServerPlugins(),
|
||||
),
|
||||
)
|
||||
onCleanup(client.event.on("plugin.updated", syncServerPlugins))
|
||||
onCleanup(client.event.on("server.connected", syncServerPlugins))
|
||||
onMount(() => {
|
||||
let disposing: Promise<void> | undefined
|
||||
const dispose = () => {
|
||||
@@ -523,12 +563,13 @@ async function resolvePlugin(
|
||||
options: Readonly<Record<string, any>> | undefined,
|
||||
previous: Registration | undefined,
|
||||
packages: PackageResolver,
|
||||
install: boolean,
|
||||
) {
|
||||
// Package entrypoints never change within a session, so a loaded previous
|
||||
// version needs no re-resolution (which could otherwise hit npm).
|
||||
if (!local && previous && sameOptions(previous.options, options))
|
||||
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
|
||||
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
|
||||
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
|
||||
if (!entrypoint) return { status: "unsupported" as const }
|
||||
// The cache-busted specifier doubles as the version: unique per entrypoint
|
||||
// and mtime, so equal versions mean an identical module.
|
||||
|
||||
@@ -95,6 +95,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } })
|
||||
if (url.pathname === "/api/plugin")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
data: [],
|
||||
})
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Effect, FileSystem } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { createEventStream, createFetch, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
@@ -30,12 +31,26 @@ async function until(read: () => Promise<string>, expected: (value: string | und
|
||||
return value
|
||||
}
|
||||
|
||||
async function bootApp(directory: string) {
|
||||
async function bootApp(
|
||||
directory: string,
|
||||
options?: {
|
||||
plugins?: unknown[]
|
||||
resolve?: (spec: string, install?: boolean) => Promise<string | undefined>
|
||||
},
|
||||
) {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/plugin")
|
||||
return json({
|
||||
location: {
|
||||
directory,
|
||||
project: { id: "proj_test", directory, canonical: directory },
|
||||
},
|
||||
data: options?.plugins ?? [],
|
||||
})
|
||||
if (url.pathname !== "/api/fs/list") return
|
||||
return json({
|
||||
location: {
|
||||
@@ -54,7 +69,7 @@ async function bootApp(directory: string) {
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { resolve: options?.resolve ?? (async () => undefined) },
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(
|
||||
@@ -73,6 +88,40 @@ async function bootApp(directory: string) {
|
||||
}
|
||||
}
|
||||
|
||||
test("loads an advertised package TUI entrypoint only from the local cache", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const marker = path.join(tmp.path, "marker.txt")
|
||||
const entrypoint = path.join(tmp.path, "tui.ts")
|
||||
await writeFile(entrypoint, lifecycleSource(marker, "test.package", "package"))
|
||||
const resolutions: Array<{ spec: string; install?: boolean }> = []
|
||||
|
||||
await using app = await bootApp(tmp.path, {
|
||||
plugins: [
|
||||
{
|
||||
id: "test.server",
|
||||
source: { type: "package", package: "test-plugin@1.0.0" },
|
||||
status: "active",
|
||||
tui: true,
|
||||
},
|
||||
],
|
||||
resolve: async (spec, install) => {
|
||||
resolutions.push({ spec, install })
|
||||
return pathToFileURL(entrypoint).href
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
await until(
|
||||
() => readFile(marker, "utf8"),
|
||||
(value) => value === "package:setup\n",
|
||||
),
|
||||
).toBe("package:setup\n")
|
||||
expect(resolutions).toContainEqual({ spec: "test-plugin@1.0.0", install: false })
|
||||
|
||||
process.emit("SIGHUP")
|
||||
await app.task
|
||||
})
|
||||
|
||||
test("discovers an ancestor TUI plugin directory created after startup", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cwd = path.join(tmp.path, "repo", "packages", "app")
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface Interface {
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
@@ -41,6 +42,16 @@ export function sanitize(pkg: string) {
|
||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
||||
}
|
||||
|
||||
export async function isRegistryPackage(pkg: string) {
|
||||
const { default: npa } = await import("npm-package-arg")
|
||||
try {
|
||||
const result = npa(pkg)
|
||||
return result.name !== undefined && ["version", "range", "tag"].includes(result.type)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
|
||||
const entrypoint = subpaths
|
||||
.map((subpath) => {
|
||||
@@ -134,6 +145,23 @@ const layer = Layer.effect(
|
||||
return resolveEntryPoint(first.name, first.path, options?.subpaths)
|
||||
}, Effect.scoped)
|
||||
|
||||
const resolve = Effect.fn("Npm.resolve")(function* (
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) {
|
||||
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
|
||||
const name = (() => {
|
||||
try {
|
||||
return npa(pkg).name ?? pkg
|
||||
} catch {
|
||||
return pkg
|
||||
}
|
||||
})()
|
||||
const dir = path.join(directory(pkg), "node_modules", name)
|
||||
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
|
||||
return resolveEntryPoint(name, dir, options?.subpaths)
|
||||
})
|
||||
|
||||
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
|
||||
const dir = directory(pkg)
|
||||
const binDir = path.join(dir, "node_modules", ".bin")
|
||||
@@ -187,6 +215,7 @@ const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
add,
|
||||
resolve,
|
||||
which,
|
||||
})
|
||||
}),
|
||||
@@ -204,6 +233,10 @@ export async function add(...args: Parameters<Interface["add"]>) {
|
||||
return runPromise((svc) => svc.add(...args))
|
||||
}
|
||||
|
||||
export async function resolve(...args: Parameters<Interface["resolve"]>) {
|
||||
return runPromise((svc) => svc.resolve(...args))
|
||||
}
|
||||
|
||||
export async function which(...args: Parameters<Interface["which"]>) {
|
||||
return runPromise((svc) => svc.which(...args))
|
||||
}
|
||||
|
||||
+35
-1
@@ -99,6 +99,32 @@ an isolated cache. Package installation does not run lifecycle scripts.
|
||||
Published packages should expose their plugin entrypoint and include every
|
||||
runtime import in `dependencies`.
|
||||
|
||||
Install a package plugin globally with the CLI:
|
||||
|
||||
```sh
|
||||
opencode2 plugin add opencode-acme-plugin@1.2.0
|
||||
```
|
||||
|
||||
This installs and inspects the package before changing configuration. Packages
|
||||
with a server entrypoint are added to global `opencode.json(c)`. Packages that
|
||||
only expose `./tui` are added to global `cli.json` instead.
|
||||
|
||||
The command accepts npm registry package names with an optional version,
|
||||
dist-tag, or semver range. Configure local paths directly instead; Git, tarball,
|
||||
and npm alias targets are not accepted by `plugin add`.
|
||||
|
||||
List configured and active plugins, or remove a package from both global server
|
||||
and TUI configuration:
|
||||
|
||||
```sh
|
||||
opencode2 plugin list
|
||||
opencode2 plugin list --builtin
|
||||
opencode2 plugin remove opencode-acme-plugin@1.2.0
|
||||
```
|
||||
|
||||
Built-in server plugins are hidden from the default list. Removing a plugin
|
||||
keeps its package cache available for later reuse.
|
||||
|
||||
Local files and local package directories are imported directly. OpenCode does
|
||||
**not** install their dependencies. Install dependencies in a `package.json`
|
||||
visible from the plugin file, for example:
|
||||
@@ -397,13 +423,21 @@ manifest is:
|
||||
"name": "opencode-acme-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./tui": "./src/tui.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "beta"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Packages with a TUI entrypoint should set `tui: true` on their server plugin
|
||||
definition. A locally connected TUI loads the package's `./tui` export from the
|
||||
existing OpenCode package cache. A TUI connected to a remote server skips it
|
||||
when that package is not installed locally.
|
||||
|
||||
Use versions compatible with the OpenCode release you target and test the
|
||||
installed package, not only a workspace-linked copy. Because the plugin API is
|
||||
beta, publish compatible plugin updates when V2 entrypoints or contracts
|
||||
|
||||
Reference in New Issue
Block a user