Compare commits

..
96 changed files with 2057 additions and 2007 deletions
-2
View File
@@ -969,7 +969,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -979,7 +978,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:",
},
},
+157 -145
View File
@@ -72,11 +72,6 @@ const OpenResponsesReasoningSummaryText = Schema.Struct({
text: Schema.String,
})
const OpenResponsesReasoningText = Schema.Struct({
type: Schema.tag("reasoning_text"),
text: Schema.String,
})
const OpenResponsesReasoningItem = Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.optionalKey(Schema.String),
@@ -84,18 +79,6 @@ const OpenResponsesReasoningItem = Schema.Struct({
encrypted_content: optionalNull(Schema.String),
})
export const CompletedReasoningItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.String,
summary: Schema.Array(OpenResponsesReasoningSummaryText),
content: Schema.optional(Schema.Array(OpenResponsesReasoningText)),
encrypted_content: optionalNull(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type CompletedReasoningItem = Schema.Schema.Type<typeof CompletedReasoningItem>
const OpenResponsesWebSearchCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("web_search_call"),
@@ -201,7 +184,6 @@ export type HostedToolReplayItem = {
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| CompletedReasoningItem
| {
readonly type: "message"
readonly id?: string
@@ -363,7 +345,6 @@ export const Event = Schema.StructWithRest(
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
content_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
// OutputItemAdded/Done permit a null item in the Open Responses OpenAPI schema.
item: optionalNull(StreamItem),
@@ -401,7 +382,6 @@ export interface ProviderAdapter {
readonly request: LLMRequest
}) => MediaInput | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
readonly restoreReasoningItem?: (item: unknown) => CompletedReasoningItem | undefined
}
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
@@ -423,12 +403,19 @@ export interface ParserState {
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly open: boolean
readonly encryptedContent: string | null | undefined
readonly streamedText: string
readonly emittedRawIndexes: ReadonlySet<number>
readonly emittedSummaryIndexes: ReadonlySet<number>
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
// and matches the wire field.
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
// Summary indexes that received at least one streamed delta. The `:0` block
// is started eagerly when the item opens, so block existence cannot tell
// whether a `.done` final would duplicate streamed text.
readonly deltaIndexes: ReadonlySet<number>
}
// =============================================================================
@@ -479,22 +466,9 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
}
}
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
adapter: ProviderAdapter,
): OpenResponsesReasoningInput | CompletedReasoningItem | undefined => {
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata)) return undefined
const restored = adapter.restoreReasoningItem?.(metadata.reasoningItem)
if (restored) return restored
if (!adapter.restoreReasoningItem && Schema.is(CompletedReasoningItem)(metadata.reasoningItem))
return {
type: "reasoning",
id: metadata.reasoningItem.id,
summary: [...metadata.reasoningItem.summary],
encrypted_content: metadata.reasoningItem.encrypted_content,
}
const id = itemID(part.providerMetadata, providerMetadataKey)
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
@@ -649,18 +623,17 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey, adapter)
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
if (reasoning.encrypted_content === null || typeof reasoning.encrypted_content === "string")
if (typeof reasoning.encrypted_content === "string")
existing.encrypted_content = reasoning.encrypted_content
continue
}
const converted = { ...reasoning, summary: [...reasoning.summary] }
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = converted
input.push(converted)
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning
input.push(reasoning)
continue
}
if (part.type === "tool-call") {
@@ -892,22 +865,39 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const appendReasoningText = (
state: ParserState,
itemID: string,
item: ReasoningStreamItem,
text: string,
): StepResult => {
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const lifecycle = Object.entries(item.summaryParts)
.filter((entry) => entry[1] !== "concluded")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, itemID, text),
lifecycle: Lifecycle.reasoningStart(
lifecycle,
events,
`${itemID}:${index}`,
providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
reasoningItems: {
...state.reasoningItems,
[itemID]: {
...item,
streamedText: item.streamedText + text,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "concluded" ? entry : [entry[0], "concluded" as const],
),
),
[index]: "active",
},
},
},
},
@@ -915,59 +905,42 @@ const appendReasoningText = (
]
}
type ReasoningRawDeltaEvent = Pick<Event, "content_index" | "delta">
type ReasoningRawDoneEvent = Pick<Event, "content_index" | "text">
type ReasoningSummaryDeltaEvent = Pick<Event, "delta" | "summary_index">
type ReasoningSummaryDoneEvent = Pick<Event, "summary_index" | "text">
const onReasoningRawDelta = (state: ParserState, event: ReasoningRawDeltaEvent, itemID: string): StepResult => {
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item?.open) return [state, NO_EVENTS]
return appendReasoningText(
state,
itemID,
{ ...item, emittedRawIndexes: new Set([...item.emittedRawIndexes, event.content_index ?? 0]) },
event.delta,
)
}
const onReasoningRawDone = (state: ParserState, event: ReasoningRawDoneEvent, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.text || !item?.open) return [state, NO_EVENTS]
const index = event.content_index ?? 0
if (item.emittedRawIndexes.has(index)) return [state, NO_EVENTS]
return appendReasoningText(
state,
itemID,
{ ...item, emittedRawIndexes: new Set([...item.emittedRawIndexes, index]) },
event.text,
)
}
const onReasoningSummaryDelta = (state: ParserState, event: ReasoningSummaryDeltaEvent, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item?.open) return [state, NO_EVENTS]
return appendReasoningText(
state,
itemID,
{ ...item, emittedSummaryIndexes: new Set([...item.emittedSummaryIndexes, event.summary_index ?? 0]) },
event.delta,
)
}
const onReasoningSummaryDone = (state: ParserState, event: ReasoningSummaryDoneEvent, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.text || !item?.open) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.emittedSummaryIndexes.has(index)) return [state, NO_EVENTS]
return appendReasoningText(
state,
itemID,
{ ...item, emittedSummaryIndexes: new Set([...item.emittedSummaryIndexes, index]) },
event.text,
)
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
const current = started.reasoningItems[itemID]
if (!current) return [started, emitted]
const events: LLMEvent[] = [...emitted]
return [
{
...started,
lifecycle: Lifecycle.reasoningDelta(started.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...started.reasoningItems,
[itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) },
},
},
events,
]
}
// Some compatible gateways emit a reasoning final without streaming any
// deltas, mirroring `response.output_text.done`. Reconcile the complete text
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
// `output_item.added` (reasoning) →
// `reasoning_summary_part.added` (index=0) →
@@ -975,7 +948,9 @@ const onReasoningSummaryDone = (state: ParserState, event: ReasoningSummaryDoneE
// `reasoning_summary_part.done` (index=0) →
// (repeat for index>0) →
// `output_item.done` (reasoning).
// `onOutputItemAdded` seeds one lifecycle for the complete provider item.
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
@@ -1017,15 +992,14 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, item.id),
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: true,
encryptedContent: item.encrypted_content,
streamedText: "",
emittedRawIndexes: new Set(),
emittedSummaryIndexes: new Set(),
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
},
},
},
@@ -1054,6 +1028,34 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
]
}
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
return startReasoningSummaryPart(state, event.item_id, event.summary_index)
}
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item?.open) return [state, NO_EVENTS]
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
return [
{
...state,
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...item.summaryParts,
[event.summary_index]: "can-conclude",
},
},
},
},
NO_EVENTS,
]
}
const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* (
state: ParserState,
event: Event,
@@ -1172,15 +1174,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (isReasoningItem(item)) {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const tracked = state.reasoningItems[item.id]
if (!tracked && state.lifecycle.reasoning.size > 0)
return yield* ProviderShared.eventError(state.id, "reasoning completed before the previous item ended")
const completed = Schema.is(CompletedReasoningItem)(item) ? item : undefined
const metadata = providerMetadata(state, {
itemId: item.id,
reasoningEncryptedContent: item.encrypted_content ?? tracked?.encryptedContent ?? null,
...(completed ? { reasoningItem: completed } : {}),
})
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
const summary: Array<string | undefined> = []
for (const part of summaryParts) {
@@ -1194,41 +1188,63 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const decoded = Option.getOrUndefined(decodeReasoningPart(part))
if (decoded) content.push(decoded.text)
}
const text = joinReasoningText(summary) ?? joinReasoningText(content) ?? tracked?.streamedText
const itemText = joinReasoningText(summary) ?? joinReasoningText(content)
const events: LLMEvent[] = []
if (tracked) {
const reasoningItem = state.reasoningItems[item.id]
if (reasoningItem) {
const fragments = Object.entries(reasoningItem.summaryParts)
let lifecycle = state.lifecycle
for (const [index, status] of fragments) {
if (status === "concluded") continue
// Do not repeat earlier summaries that were already emitted as separate fragments.
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
}
return [
{
...state,
lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, text),
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
...tracked,
...reasoningItem,
open: false,
encryptedContent: item.encrypted_content ?? tracked.encryptedContent,
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
},
},
},
events,
] satisfies StepResult
}
const started = Lifecycle.reasoningStart(state.lifecycle, events, item.id)
return [
{
...state,
lifecycle: Lifecycle.reasoningEnd(started, events, item.id, metadata, text),
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: false,
encryptedContent: item.encrypted_content,
streamedText: text ?? "",
emittedRawIndexes: new Set(),
emittedSummaryIndexes: new Set(),
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(
LLMEvent.reasoningEnd({
id: item.id,
providerMetadata: metadata,
text: itemText,
}),
)
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: false,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "concluded" },
deltaIndexes: new Set(),
},
},
},
},
events,
] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
events,
] satisfies StepResult
}
@@ -1333,29 +1349,25 @@ export const step = (state: ParserState, input: Event) => {
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_text.delta") {
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningRawDelta(state, event, event.item_id))
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_text.done") {
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_text.done"
) {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningRawDone(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_text.delta") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningSummaryDelta(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_text.done") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningSummaryDone(state, event, event.item_id))
return Effect.succeed(onReasoningDone(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id !== undefined
? Effect.succeed<StepResult>([state, NO_EVENTS])
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_summary_part.done")
return event.item_id !== undefined
? Effect.succeed<StepResult>([state, NO_EVENTS])
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
@@ -75,9 +75,7 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(
Schema.Union([OpenResponses.CompletedReasoningItem, OpenResponses.InputItem, OpenAIResponsesHostedToolItem]),
),
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -88,16 +86,10 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const restoreReasoningItem = (item: unknown) => {
if (!Schema.is(OpenResponses.CompletedReasoningItem)(item)) return undefined
return item.content?.length === 0 ? { ...item, content: undefined } : item
}
const adapter = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
restoreReasoningItem,
} satisfies OpenResponses.ProviderAdapter
const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -193,6 +185,12 @@ const HOSTED_TOOLS = {
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
@@ -164,23 +164,13 @@ describe("Open Responses completed item reasoning", () => {
expect(response.reasoning).toBe(fixture.text)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
"openai-compatible": {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted",
reasoningItem: {
type: "reasoning",
id: "rs_1",
summary: fixture.summary,
content: fixture.content,
encrypted_content: "encrypted",
},
},
"openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted" },
})
}),
)
})
it.effect("replaces streamed reasoning with the completed summary", () =>
it.effect("replaces only the still-open summary without repeating earlier text", () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
@@ -200,32 +190,8 @@ describe("Open Responses completed item reasoning", () => {
},
completed,
)
expect(response.reasoning).toBe("First \n\nfinal")
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual(["First \n\nfinal"])
}),
)
it.effect("does not store malformed completed reasoning items", () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Draft" },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
content: [{ type: "reasoning_text", text: "Raw" }],
encrypted_content: "encrypted",
},
},
completed,
)
expect(response.reasoning).toBe("Raw")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
"openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted" },
})
expect(response.reasoning).toBe("First final")
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined, "final"])
}),
)
})
@@ -264,7 +230,6 @@ describe("Open Responses completed item reasoning", () => {
expect(response.reasoning).toBe("Draft")
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.text)).toEqual([undefined])
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined])
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toBeUndefined()
}),
)
})
@@ -71,61 +71,59 @@ function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
}
describe("Open Responses basic-item lifecycles", () => {
it.effect("streams mixed reasoning in one lifecycle and ignores late events", () =>
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
Effect.gen(function* () {
const item = {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Completed" }],
encrypted_content: "encrypted-state",
}
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
const events = yield* collect(
{ type: "response.output_item.added", output_index: 0, item: { ...item, encrypted_content: null } },
{ type: "response.output_item.added", item: { ...item, encrypted_content: null } },
{ type: "response.reasoning.delta", item_id: "rs_1", content_index: 0, delta: "Raw " },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "summary " },
{ type: "response.reasoning.done", item_id: "rs_1", content_index: 0, text: "ignored raw final" },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "Second" },
// The third part omits both explicit summary boundaries.
{
type: "response.reasoning_summary_text.done",
item_id: "rs_1",
summary_index: 0,
text: "ignored summary final",
},
{
type: "response.reasoning_text.done",
type: "response.reasoning_summary_text.delta",
output_index: 0,
item_id: "wrong",
content_index: 1,
text: "raw final ",
summary_index: 2,
delta: "Third",
},
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "summary final" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning.delta", item_id: "rs_1", content_index: 2, delta: "late raw" },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "late summary" },
{ type: "response.reasoning.done", item_id: "rs_1", content_index: 3, text: "late raw final" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 3, text: "late final" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
completed,
)
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "Raw " },
{ type: "reasoning-delta", id: "rs_1", text: "summary " },
{ type: "reasoning-delta", id: "rs_1", text: "raw final " },
{ type: "reasoning-delta", id: "rs_1", text: "summary final" },
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:2",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:2", text: "Third" },
{
type: "reasoning-end",
id: "rs_1",
text: "Completed",
providerMetadata: {
"openai-compatible": {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: item,
},
},
id: "rs_1:2",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
@@ -154,18 +152,13 @@ describe("Open Responses basic-item lifecycles", () => {
{
type: "reasoning-start",
id: "rs_1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{
type: "reasoning-end",
id: "rs_1",
text: "Not streamed",
providerMetadata: {
"openai-compatible": {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: item,
},
},
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
@@ -438,7 +431,7 @@ describe("Open Responses basic-item lifecycles", () => {
providerExecuted: undefined,
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
},
{ type: "reasoning-end", id: "rs_1" },
{ type: "reasoning-end", id: "rs_1:0" },
])
}),
)
@@ -475,8 +468,7 @@ describe("Open Responses basic-item lifecycles", () => {
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([
{
type: "reasoning-end",
id: "",
text: "Thinking",
id: ":0",
providerMetadata: { "openai-compatible": { itemId: "", reasoningEncryptedContent: "state" } },
},
])
@@ -551,7 +543,7 @@ describe("Open Responses basic-item lifecycles", () => {
)
expect(events.filter(LLMEvent.is.toolInputEnd)).toEqual([])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: "rs_1" }])
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: "rs_1:0" }])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
@@ -303,49 +303,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("projects completed reasoning items through the shared protocol", () =>
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.assistant({
type: "reasoning",
text: "Portable summary",
providerMetadata: {
example: {
reasoningItem: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Summary" }],
content: [{ type: "reasoning_text", text: "Raw" }],
encrypted_content: "state",
status: "completed",
future_field: { retained: true },
},
},
},
}),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Summary" }],
encrypted_content: "state",
},
])
}),
)
it.effect("routes response deltas by output index", () =>
Effect.gen(function* () {
const model = configure({
@@ -470,7 +427,7 @@ describe("Open Responses-compatible route", () => {
})
routings.forEach((routing) => {
it.effect(`keeps one reasoning lifecycle without item completion with ${routing.name}`, () =>
it.effect(`preserves reasoning summary boundaries without terminal reconciliation with ${routing.name}`, () =>
Effect.gen(function* () {
const address = { item_id: routing.item_id, output_index: routing.output_index }
const response = yield* LLMClient.generate(request).pipe(
@@ -501,10 +458,26 @@ describe("Open Responses-compatible route", () => {
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "First.Second.",
text: "First.",
providerMetadata: { "openai-compatible": { itemId: routing.id } },
},
{
type: "reasoning",
text: "Second.",
providerMetadata: {
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: null },
},
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: routing.id }])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
{
type: "reasoning-end",
id: `${routing.id}:0`,
text: undefined,
providerMetadata: { "openai-compatible": { itemId: routing.id } },
},
{ type: "reasoning-end", id: `${routing.id}:1` },
])
}),
)
})
@@ -744,7 +717,7 @@ describe("Open Responses-compatible route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
id: "rs_raw",
id: "rs_raw:0",
})
}),
)
@@ -2500,11 +2500,11 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{ type: "reasoning-start", id: "rs_1:0" },
{ type: "reasoning-delta", id: "rs_1:0", text: "thinking" },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "reasoning-end", id: "rs_1:0" },
{ type: "text-end", id: "msg_1" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
@@ -2514,6 +2514,7 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
])
@@ -2546,19 +2547,8 @@ describe("OpenAI Responses route", () => {
expect(response.events).toContainEqual(
expect.objectContaining({
type: "reasoning-end",
id: "rs_1",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: {
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "thinking" }],
},
},
},
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
)
}),
@@ -2605,11 +2595,12 @@ describe("OpenAI Responses route", () => {
expect(response.reasoning).toBe("Checked the diff.")
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1" },
{ type: "reasoning-end", id: "rs_1:0" },
])
expect(response.message.content).toContainEqual({
type: "reasoning",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
})
}),
)
@@ -2677,7 +2668,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
id: "rs_1",
id: "rs_1:0",
})
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
@@ -2689,7 +2680,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("streams reasoning summary parts in wire order in one lifecycle", () =>
it.effect("streams each reasoning summary part as a separate block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
@@ -2720,13 +2711,22 @@ describe("OpenAI Responses route", () => {
expect(response.reasoning).toBe("FirstSecond")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "First" },
{ type: "reasoning-delta", id: "rs_1", text: "Second" },
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{
type: "reasoning-end",
id: "rs_1",
text: "FirstSecond",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
@@ -2735,6 +2735,73 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("concludes reasoning at implicit summary boundaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
// The next part is enough to conclude the previous one even when
// its done event is delayed.
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
// Some compatible providers begin the next part with its first delta.
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "Third" },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("FirstSecondThird")
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First", providerMetadata: undefined },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{
type: "reasoning-start",
id: "rs_1:2",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:2", text: "Third", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:2",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("rejects a reasoning item that starts before the previous item ends", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -2930,26 +2997,15 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1",
providerMetadata: undefined,
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1", text: "Checked the diff.", providerMetadata: undefined },
{ type: "reasoning-delta", id: "rs_1:0", text: "Checked the diff.", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1",
id: "rs_1:0",
text: "Checked the diff.",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
},
},
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
@@ -2995,7 +3051,7 @@ describe("OpenAI Responses route", () => {
expect(response.reasoning).toBe("Streamed")
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([
{ type: "reasoning-delta", id: "rs_1", text: "Streamed", providerMetadata: undefined },
{ type: "reasoning-delta", id: "rs_1:0", text: "Streamed", providerMetadata: undefined },
])
}),
)
@@ -3018,15 +3074,7 @@ describe("OpenAI Responses route", () => {
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [
{ type: "summary_text", text: "First" },
{ type: "summary_text", text: "Second" },
],
encrypted_content: "encrypted-state",
},
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -3035,25 +3083,11 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-end",
id: "rs_1",
text: "First\n\nSecond",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: {
type: "reasoning",
id: "rs_1",
summary: [
{ type: "summary_text", text: "First" },
{ type: "summary_text", text: "Second" },
],
encrypted_content: "encrypted-state",
},
},
},
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
@@ -3161,15 +3195,6 @@ describe("OpenAI Responses route", () => {
it.effect("replays complete reasoning items when storage is enabled", () =>
Effect.gen(function* () {
const item = {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
content: [{ type: "reasoning_text", text: "Provider reasoning" }],
encrypted_content: "encrypted-state",
status: "completed",
provider_field: { retained: true },
}
const prepared = yield* compileRequest(
LLM.request({
model,
@@ -3178,13 +3203,7 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning",
text: "Checked the previous diff.",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: item,
},
},
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
]),
],
@@ -3192,7 +3211,14 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body.input[0]).toEqual(item)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: "encrypted-state",
},
])
}),
)
@@ -64,7 +64,7 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", target: id },
source: { type: "package", package: id },
state: { status: "active" },
features: { server: true },
})),
@@ -85,7 +85,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
data: [
{
id: "demo-plugin",
source: { type: "package", target: "demo-plugin" },
source: { type: "package", package: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
@@ -8,7 +8,7 @@ describe("pluginLabels", () => {
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", target: "example" },
source: { type: "package", package: "example" },
state: { status: "active" },
features: { server: true },
},
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
@@ -54,7 +54,7 @@ export function format(
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.target, source: "advertised" as const }]
? [{ target: plugin.source.package, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
@@ -73,7 +73,7 @@ export function format(
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+2 -2
View File
@@ -9,12 +9,12 @@ test("formats server and TUI plugins in sections without builtins", () => {
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "acme.dual",
source: { type: "package", target: "acme-plugin@1.0.0" },
source: { type: "package", package: "acme-plugin@1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
},
{
source: { type: "package", target: "broken-plugin" },
source: { type: "package", package: "broken-plugin" },
state: { status: "failed", error: "broken" },
features: { server: true },
},
-8
View File
@@ -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 PluginUpdateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginUpdateOutput = void
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly update: PluginUpdateOperation<E>
}
export type SessionListInput = {
+1 -13
View File
@@ -15,8 +15,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -324,17 +322,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
update: EndpointPluginUpdate(raw),
})
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
+1
View File
@@ -3,6 +3,7 @@ import type { OpenCode } from "./client.js"
type Client = ReturnType<typeof OpenCode.make>
export type { RpcApi, RpcCallOptions, RpcClient, RpcEventPayload } from "./rpc.js"
export type { PermissionCreateInput } from "./generated/types.js"
export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
@@ -9,8 +9,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -468,19 +466,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
request<PluginUpdateOutput>(
{
method: "POST",
path: `/api/plugin/update`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 204,
declaredStatuses: [400, 503, 401],
empty: true,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+9 -18
View File
@@ -12,7 +12,7 @@ export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginSource =
| { type: "builtin" }
| { type: "package"; target: string; version?: string; outdated?: true }
| { type: "package"; package: string }
| { type: "local"; path: string }
| { type: "sdk" }
@@ -2354,14 +2354,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 ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
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"
@@ -2423,6 +2415,14 @@ export type SkillNotFoundError = {
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
@@ -2582,15 +2582,6 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginUpdateOutput = void
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
+4 -2
View File
@@ -7,6 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { ProjectMarkers } from "./project/markers.js"
import { AbsolutePath } from "./schema.js"
export const Kind = Schema.Literals(["file", "directory"])
@@ -80,6 +81,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const markers = yield* ProjectMarkers.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = resolvePath(location.directory, input.path)
@@ -111,7 +113,7 @@ const layer = Layer.effect(
resource: externalResource,
save: slash(
path.join(
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
(yield* Project.root(fs, AbsolutePath.make(externalDirectory), markers.targets())) ?? externalDirectory,
"*",
),
),
@@ -126,5 +128,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node],
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
})
+2
View File
@@ -10,6 +10,7 @@ export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
readonly vcsBackend?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
@@ -27,6 +28,7 @@ const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
vcs: resolved.vcs,
vcsBackend: resolved.vcsBackend,
})
}),
)
+9 -13
View File
@@ -30,7 +30,7 @@ import { Permission } from "./permission.js"
export interface Interface {
readonly activate: (
plugins: readonly Generation[],
plugins: readonly Versioned[],
failures?: readonly Failure[],
) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
@@ -38,8 +38,8 @@ export interface Interface {
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
export type Generation = PluginDefinition & {
readonly revision: string
export type Versioned = PluginDefinition & {
readonly version: string
readonly source?: Plugin.Source
readonly features?: Plugin.Features
}
@@ -52,11 +52,11 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const kv = yield* KV.Service
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<PluginDefinition["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Generation) {
const load = Effect.fnUntraced(function* (plugin: Versioned) {
const child = yield* Scope.fork(scope)
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() =>
@@ -83,7 +83,7 @@ const layer = Layer.effect(
})
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly Generation[],
plugins: readonly Versioned[],
failures: readonly Failure[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
@@ -99,14 +99,10 @@ const layer = Layer.effect(
active.size === definitions.length &&
Array.from(active.values()).every((entry, index) => {
const definition = definitions[index]
return entry.plugin.id === definition?.id && entry.plugin.revision === definition.revision
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
})
) {
for (const definition of definitions) {
const entry = active.get(definition.id)
if (entry) active.set(definition.id, { ...entry, plugin: definition })
}
const nextInventory = [...definitions.map(activeInfo), ...failures]
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
@@ -178,7 +174,7 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Generation): Plugin.Info {
function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
@@ -0,0 +1,33 @@
# Experimental Browser Plugin
The server-side browser tool lives alongside the other built-in plugins. Its
implementation uses only the public plugin API, public schemas, and Effect. The
shared RPC contract is `@opencode-ai/schema/browser`; desktop clients do not import Core.
Disable it through normal plugin configuration:
```jsonc
{
"plugins": ["-opencode.browser"],
}
```
The desktop implementation connects with `client.rpc(Browser.Definition)` at the
session's location. Subscribe to server events before calling `attach`; wait for
`server.connected`, then the matching `attached` control event. The `attach` call
stays pending for the attachment lifetime. Abort it when its event stream ends or
the desktop owner closes. Completing the attachment also ends that event consumer.
- `attach` holds one browser attachment per session until cancellation, plugin
unload, session deletion, or session movement.
- `state` reports the current page, or `null` when no page is open.
- `result` completes a command with its request ID and outcome.
- `control` events carry attachment confirmation, commands, and cancellation.
Control events use OpenCode's existing authenticated, server-wide event feed.
Consumers filter by `connectionID`; this identifier is correlation, not private
event delivery. State and results use RPC calls rather than broadcast events.
The plugin requests normal agent permissions before acting on a URL. Browser
content is untrusted. Pages use the desktop's network, with no server-side tunnel.
The desktop owns Chromium, page isolation, and native controls.
+185
View File
@@ -0,0 +1,185 @@
import { Plugin, Session, Tool } from "@opencode-ai/plugin/effect"
import type { RpcRegistration } from "@opencode-ai/plugin/effect/rpc"
import { Deferred, Effect, Encoding, Stream } from "effect"
import { Browser } from "@opencode-ai/schema/browser"
type Attachment = {
connectionID: string
state: Browser.State | null
closed: Deferred.Deferred<void>
pending: Map<string, Deferred.Deferred<Browser.Result, Tool.Error>>
}
export default Plugin.define({
id: "opencode.browser",
effect: (ctx) =>
Effect.gen(function* () {
const browsers = new Map<Session.ID, Attachment>()
let active = true
const close = (sessionID: Session.ID) =>
Effect.gen(function* () {
const browser = browsers.get(sessionID)
if (!browser) return
browsers.delete(sessionID)
yield* Deferred.succeed(browser.closed, undefined)
})
yield* Effect.addFinalizer(() => {
active = false
return Effect.forEach(browsers.keys(), close, { discard: true })
})
const rpc: RpcRegistration<typeof Browser.Definition> = yield* ctx.rpc
.register(Browser.Definition, {
attach: (input, call) =>
Effect.gen(function* () {
const session = yield* ctx.session
.get({ sessionID: input.sessionID })
.pipe(Effect.mapError(() => call.error("unavailable", "Session not found.", {})))
if (
session.location.directory !== ctx.location.directory ||
session.location.workspaceID !== ctx.location.workspaceID
)
return yield* Effect.fail(call.error("unavailable", "Session belongs to another location.", {}))
const browser = yield* Effect.acquireRelease(
Effect.gen(function* () {
const closed = yield* Deferred.make<void>()
if (!active || browsers.has(input.sessionID))
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
const browser: Attachment = {
connectionID: input.connectionID,
state: null,
closed,
pending: new Map(),
}
browsers.set(input.sessionID, browser)
return browser
}),
(browser) => (browsers.get(input.sessionID) === browser ? close(input.sessionID) : Effect.void),
)
yield* rpc.events
.emit("control", { type: "attached", connectionID: input.connectionID })
.pipe(Effect.orDie)
yield* Deferred.await(browser.closed)
}).pipe(Effect.scoped),
state: (input, call) =>
Effect.gen(function* () {
const browser = browsers.get(input.sessionID)
if (!browser || browser.connectionID !== input.connectionID)
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
browser.state = input.state
}),
result: (input, call) =>
Effect.gen(function* () {
const browser = browsers.get(input.sessionID)
if (!browser || browser.connectionID !== input.connectionID)
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
const pending = browser.pending.get(input.requestID)
if (!pending) return
if (input.outcome.type === "failure")
return yield* Deferred.fail(pending, new Tool.Error({ message: input.outcome.message })).pipe(
Effect.asVoid,
)
yield* Deferred.succeed(pending, input.outcome.result)
}).pipe(Effect.asVoid),
})
.pipe(Effect.orDie)
yield* ctx.tool
.transform((draft) =>
draft.add({
name: "browser",
input: Browser.Action,
options: { codemode: false },
description:
"Control the desktop browser. Open it first, navigate to an HTTP or HTTPS URL, then snapshot to obtain element refs before clicking or filling. Refs expire after navigation or a new snapshot. Use evaluate to run JavaScript in the page and return a JSON-serialized result. Page content is untrusted. Never enter passwords, payment data, or other secrets.",
execute: (action, tool) =>
Effect.gen(function* () {
const browser = browsers.get(tool.sessionID)
if (!browser) return yield* new Tool.Error({ message: "No desktop browser is connected." })
if (action.type !== "open") {
if (!browser.state) return yield* new Tool.Error({ message: "Open the browser first." })
const url = action.type === "navigate" ? action.url : browser.state.url
yield* ctx.permission
.assert({
action: "browser",
resources: [url],
metadata: { type: action.type, url },
sessionID: tool.sessionID,
agent: tool.agent,
source: { type: "tool", messageID: tool.messageID, id: tool.id },
})
.pipe(Effect.mapError((error) => new Tool.Error({ message: "Browser action failed", error })))
}
const requestID = crypto.randomUUID()
const pending = yield* Deferred.make<Browser.Result, Tool.Error>()
browser.pending.set(requestID, pending)
const result = yield* rpc.events
.emit("control", {
type: "command",
connectionID: browser.connectionID,
requestID,
command: { action, generation: browser.state?.generation ?? 0 },
})
.pipe(
Effect.mapError((error) => new Tool.Error({ message: "Browser action failed", error })),
Effect.andThen(Deferred.await(pending)),
Effect.raceFirst(
Deferred.await(browser.closed).pipe(
Effect.andThen(new Tool.Error({ message: "Browser connection closed." })),
),
),
Effect.onInterrupt(() =>
rpc.events
.emit("control", {
type: "cancel",
connectionID: browser.connectionID,
requestID,
})
.pipe(Effect.ignore),
),
Effect.timeoutOrElse({
duration: "30 seconds",
orElse: () => new Tool.Error({ message: "Browser request timed out." }),
}),
Effect.ensuring(Effect.sync(() => browser.pending.delete(requestID))),
)
return render(result)
}),
}),
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
if (!browsers.has(event.sessionID)) delete event.tools.browser
}),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "session.deleted" || event.type === "session.moved"),
Stream.runForEach((event) => close(event.data.sessionID)),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function render(result: Browser.Result): Tool.Result {
if (result.type === "screenshot")
return {
content: [
{ type: "text", text: "Untrusted browser screenshot." },
{
type: "file",
uri: `data:image/png;base64,${Encoding.encodeBase64(result.data)}`,
mime: "image/png",
name: "browser-screenshot.png",
},
],
metadata: { url: result.state.url },
}
const content = JSON.stringify(result)
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026")
return {
content: `<untrusted_browser_content encoding="json">\n${content}\n</untrusted_browser_content>`,
metadata: { url: result.state.url },
}
}
+1
View File
@@ -326,6 +326,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
}),
},
permission: {
assert: permission.assert,
hook: (name, callback) => hooks.register("permission", name, callback),
list: (input) => permission.forSession(input.sessionID),
get: (input) =>
+10 -7
View File
@@ -3,7 +3,7 @@ export * as InstancePlugins from "./instance.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Generation } from "../plugin.js"
import type { Versioned } from "../plugin.js"
/**
* Holds the plugins one instance is born with. Unlike the host-global
@@ -13,13 +13,16 @@ import type { Generation } from "../plugin.js"
* for the instance's lifetime; runtime dynamism lives inside plugins through
* the container transform/reload APIs.
*
* Config plugin operations may disable instance plugins by id, matching
* `SdkPlugins` behavior.
* Limitations: `vcs` marker declarations in an instance list are not seen by
* `ProjectMarkers` (it is global and runs during project resolution, before
* the instance exists unlike `SdkPlugins`, whose declarations it consumes
* directly), and config plugin operations may disable instance plugins by id,
* matching `SdkPlugins` behavior.
*/
export type List = readonly Plugin[]
export interface Interface {
readonly all: () => readonly Generation[]
readonly all: () => readonly Versioned[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstancePlugins") {}
@@ -30,8 +33,8 @@ export const node = makeLocationNode({
deps: [],
})
// The constant revision is load-bearing: the plugin registry treats an
// unchanged (id, revision) pair as the same plugin across activations, which
// The constant version is load-bearing: the plugin registry treats an
// unchanged (id, version) pair as the same plugin across activations, which
// is only correct because a bound list never changes after creation.
// `source: "sdk"` means host-contributed; an instance list is the
// per-instance form of the same channel.
@@ -41,7 +44,7 @@ export function bound(plugins: List) {
throw new Error(`duplicate instance plugin ids: ${duplicates.map((plugin) => plugin.id).join(", ")}`)
}
const stamped = plugins.map(
(plugin): Generation => ({ ...plugin, revision: "instance", source: { type: "sdk" } }),
(plugin): Versioned => ({ ...plugin, version: "instance", source: { type: "sdk" } }),
)
return Layer.succeed(Service, Service.of({ all: () => stamped }))
}
+7
View File
@@ -26,6 +26,7 @@ import { ConfigShellPlugin } from "../config/plugin/shell.js"
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
@@ -74,6 +75,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "./browser/index.js"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -96,6 +98,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const command = yield* Command.Service
const config = yield* Config.Service
const credential = yield* Credential.Service
const pluginSources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
@@ -139,6 +142,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Command.Service, command),
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(ConfigPluginSource.Service, pluginSources),
Context.make(Bus.Service, bus),
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
@@ -189,6 +193,7 @@ export const requirements = LayerNode.group([
Command.node,
Config.node,
Credential.node,
ConfigPluginSource.node,
Bus.node,
Environment.node,
FileMutation.node,
@@ -230,6 +235,7 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
BrowserPlugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
@@ -283,6 +289,7 @@ export const list = Effect.fn("PluginInternal.list")(function* () {
plugins.map(
(plugin): Plugin => ({
id: plugin.id,
vcs: plugin.vcs,
effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
}),
)
+14 -15
View File
@@ -8,17 +8,23 @@ import { readdir } from "node:fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Generation } from "../plugin.js"
import type { Versioned } from "../plugin.js"
import { PluginPromise } from "./promise.js"
const Discovery = Schema.Struct({
id: Schema.optional(Schema.String),
markers: Schema.Array(Schema.String),
})
const Definition = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
vcs: Schema.optional(Discovery),
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
}),
Schema.Struct({
id: Schema.String,
vcs: Schema.optional(Discovery),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
@@ -28,17 +34,13 @@ const Definition = Schema.Struct({
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
options?: { readonly install?: boolean },
) {
const npm = yield* Npm.Service
const local = path.isAbsolute(operation.target)
const installed: Npm.EntryPoint = local
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
: options?.install === false
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const installed = local
? { entrypoint: pathToFileURL(operation.target).href }
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
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
@@ -61,16 +63,13 @@ export const load = Effect.fn("PluginModule.load")(function* (
return {
id: plugin.id,
features,
revision: JSON.stringify([operation, installed.revision]),
vcs: plugin.vcs,
version: JSON.stringify(operation),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: {
type: "package" as const,
target: operation.target,
...(installed.version ? { version: installed.version } : {}),
},
: { type: "package" as const, package: operation.target },
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Generation
} satisfies Versioned
})
function localFeatures(entrypoint: string) {
+4 -4
View File
@@ -4,7 +4,7 @@ import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import type { Generation } from "../plugin.js"
import type { Versioned } from "../plugin.js"
export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
@@ -21,7 +21,7 @@ export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
*/
export interface Interface {
readonly register: (plugin: Plugin) => Effect.Effect<void>
readonly all: () => readonly Generation[]
readonly all: () => readonly Versioned[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
@@ -30,12 +30,12 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const plugins = new Map<string, Generation>()
const plugins = new Map<string, Versioned>()
let revision = 0
return Service.of({
register: (plugin) =>
Effect.sync(() => {
plugins.set(plugin.id, { ...plugin, revision: String(++revision), source: { type: "sdk" } })
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
all: () => [...plugins.values()],
})
+4 -7
View File
@@ -3,11 +3,10 @@
export * as SkillPlugin from "./skill.js"
import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { Effect } from "effect"
import { AbsolutePath } from "../schema.js"
import { Skill } from "../skill.js"
import { Config } from "../config.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import os from "os"
import opencodeContent from "./skill/opencode.md" with { type: "text" }
import reportContent from "./skill/report.md" with { type: "text" }
@@ -69,11 +68,9 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
})
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
const config = yield* Config.Service
return (yield* config.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => entry.info.plugins ?? [])
.map((entry) => (typeof entry === "string" ? entry : entry.package))
const sources = yield* ConfigPluginSource.Service
return (yield* sources.operations())
.map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target))
.toSorted()
})
@@ -7,10 +7,7 @@ import { Context, Effect } from "effect"
* imports: the supervisor reaches PluginRuntime, which depends on Session.
*/
export interface Interface {
/**
* Wait for the plugin generation to settle. Use this rarely: blocking reads,
* UI startup, or other unrelated work on plugin boot should be avoided.
*/
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
}
+29 -65
View File
@@ -14,20 +14,17 @@ import { PluginInternal } from "./internal.js"
import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
import { Service } from "./supervisor-service.js"
import { PluginUpdate } from "./update.js"
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Generation[],
post: readonly Plugin.Generation[],
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
operations: readonly ConfigPluginSource.Operation[],
install: boolean,
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Generation>()
const pending = new Set<string>()
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<
string,
Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
@@ -54,17 +51,13 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const plugin = yield* PluginModule.load(operation, { install }).pipe(
const plugin = yield* PluginModule.load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
if ("pending" in plugin) {
pending.add(operation.target)
continue
}
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
@@ -87,7 +80,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
pending: [...pending],
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
),
}
})
@@ -99,74 +94,45 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const updates = yield* PluginUpdate.Service
const npm = yield* Npm.Service
const ready = yield* Latch.make()
let packages = new Set<string>()
let outdated = new Set<string>()
let generation = 0
let observed = 0
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
const current = ++generation
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed plugins in boot order.
// Instance-bound plugins come last: later activation can override earlier
// container writes, so the instance's explicit choices win over globals.
const pre = [
...internal.pre.map((plugin) => ({ ...plugin, revision: "internal", source: { type: "builtin" as const } })),
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
...sdk.all(),
...instance.all(),
]
const post = internal.post.map((plugin) => ({
...plugin,
revision: "internal",
version: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// Activate everything available locally before waiting on missing package installs.
const immediate = yield* resolve(pre, post, operations, false)
const source = (source: Plugin.Source) =>
source.type === "package" && outdated.has(source.target)
? { ...source, outdated: true as const }
: source
const apply = (resolved: typeof immediate) =>
registry.activate(
resolved.plugins.map((plugin) => (plugin.source ? { ...plugin, source: source(plugin.source) } : plugin)),
resolved.failures.map((failure) => ({ ...failure, source: source(failure.source) })),
)
yield* apply(immediate)
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true) : immediate
if (resolved !== immediate) yield* apply(resolved)
const loaded = new Set(
[...resolved.plugins, ...resolved.failures].flatMap((plugin) =>
plugin.source?.type === "package" ? [plugin.source.target] : [],
),
)
packages = loaded
yield* Effect.forEach(
loaded,
(target) => updates.check(target).pipe(Effect.map((available) => [target, available] as const)),
{ concurrency: "unbounded" },
).pipe(
Effect.flatMap((checked) => {
if (current !== generation) return Effect.void
const next = new Set(checked.flatMap(([target, available]) => (available ? [target] : [])))
if (next.size === outdated.size && [...next].every((target) => outdated.has(target))) return Effect.void
outdated = next
return apply(resolved)
}),
Effect.forkScoped({ startImmediately: true }),
)
// Apply config operations and load enabled package plugins into one ordered generation.
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
if (resolved.refreshes.length) {
yield* Effect.forEach(
resolved.refreshes,
(target) =>
npm
.add(target, { subpaths: ["server", ""], refresh: true })
.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.forkDetach)
}
})
const reloads = Stream.merge(
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
updates.changes().pipe(
Stream.filter((target) => packages.has(target)),
Stream.tap((target) => Effect.sync(() => outdated.delete(target))),
Stream.map(() => undefined),
),
).pipe(
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
@@ -176,7 +142,7 @@ export const layer = Layer.effect(
}),
),
)
yield* Stream.concat(Stream.succeed(0), reloads).pipe(
yield* Stream.concat(Stream.succeed(0), updates).pipe(
// Keep observing updates while activation runs, retaining only the latest generation request.
Stream.buffer({ capacity: 1, strategy: "sliding" }),
Stream.debounce("100 millis"),
@@ -188,7 +154,6 @@ export const layer = Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("24 hours").pipe(Effect.andThen(activate()), Effect.forever, Effect.forkScoped)
return Service.of({ flush: ready.await })
}),
)
@@ -198,7 +163,6 @@ const nodeDeps = [
SdkPlugins.node,
InstancePlugins.node,
ConfigPluginSource.node,
PluginUpdate.node,
Bus.node,
Npm.node,
PluginInternal.requirements,
@@ -206,7 +170,7 @@ const nodeDeps = [
function pluginSource(target: string): Plugin.Source {
if (path.isAbsolute(target)) return { type: "local", path: target }
return { type: "package", target }
return { type: "package", package: target }
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
-54
View File
@@ -1,54 +0,0 @@
export * as PluginUpdate from "./update.js"
import { Clock, Context, Effect, Layer, Option, PubSub, Stream } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { KeyedMutex } from "../effect/keyed-mutex.js"
const interval = 24 * 60 * 60 * 1_000
export interface Interface {
readonly check: (target: string) => Effect.Effect<boolean>
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
readonly changes: () => Stream.Stream<string>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginUpdate") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const npm = yield* Npm.Service
const locks = KeyedMutex.makeUnsafe<string>()
const status = new Map<string, { readonly outdated: boolean; readonly checkedAt: number }>()
const changes = yield* PubSub.unbounded<string>()
return Service.of({
check: (target) =>
locks.withLock(target)(
Effect.gen(function* () {
const checkedAt = yield* Clock.currentTimeMillis
const current = status.get(target)
if (current && checkedAt - current.checkedAt < interval) return current.outdated
const outdated = yield* npm.check(target).pipe(
Effect.tapCause((cause) => Effect.logWarning("failed to check plugin update", { target, cause })),
Effect.option,
)
const value = Option.getOrElse(outdated, () => current?.outdated ?? false)
status.set(target, { outdated: value, checkedAt })
return value
}),
),
update: (target) =>
npm.update(target).pipe(
Effect.tap(() => Effect.sync(() => status.delete(target))),
Effect.tap(() => PubSub.publish(changes, target)),
Effect.asVoid,
),
changes: () => Stream.fromPubSub(changes),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Npm.node] })
+1
View File
@@ -20,6 +20,7 @@ import type { Patch } from "../../vcs/patch.js"
export const Plugin = define({
id: "opencode.vcs.git",
vcs: { id: "git", markers: [".git"] },
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "git") return
+1
View File
@@ -24,6 +24,7 @@ import {
export const Plugin = define({
id: "opencode.vcs.hg",
vcs: { id: "hg", markers: [".hg"] },
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "hg") return
+22 -5
View File
@@ -13,6 +13,7 @@ import { Git } from "./git.js"
import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Hash } from "@opencode-ai/util/hash"
import { ProjectMarkers } from "./project/markers.js"
import { ProjectSchema } from "./project/schema.js"
import { ProjectTable, upsertProject } from "./project/sql.js"
import { WorktreeTable } from "./worktree/sql.js"
@@ -43,6 +44,7 @@ export interface Resolved {
// This checkout's main directory; the stored project canonical may be another clone.
readonly canonical: AbsolutePath
readonly vcs?: Vcs
readonly vcsBackend?: string
}
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
@@ -96,6 +98,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const markers = yield* ProjectMarkers.Service
const proc = yield* AppProcess.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
@@ -169,7 +172,7 @@ const layer = Layer.effect(
if (candidate.id === item.projectID) return false
if (!FSUtil.contains(directory, candidate.directory)) return false
const found = yield* fs
.up({ targets: [".git", ".hg"], start: candidate.directory, stop: directory, mode: "first" })
.up({ targets: [...markers.targets()], start: candidate.directory, stop: directory, mode: "first" })
.pipe(Effect.orElseSucceed(() => []))
if (!found[0]) return false
return (yield* fs.resolve(path.dirname(found[0]))) === directory
@@ -315,9 +318,10 @@ const layer = Layer.effect(
const resolve = Effect.fn("Project.resolve")(function* (
input: AbsolutePath,
_options?: { readonly discovery?: boolean },
options?: { readonly discovery?: boolean },
) {
const directory = AbsolutePath.make(yield* fs.resolve(input))
const marker = yield* markers.discover(directory, options)
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.orElseSucceed(() => undefined),
@@ -326,7 +330,7 @@ const layer = Layer.effect(
native && path.basename(native) === ".git"
? yield* git.repo.discover(AbsolutePath.make(path.dirname(native)))
: undefined
if (repo) {
if (repo && (!marker || FSUtil.contains(marker.directory, repo.worktree))) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
const canonical =
@@ -342,14 +346,27 @@ const layer = Layer.effect(
directory: repo.worktree,
canonical,
vcs: { type: "git" as const, store: repo.commonDirectory },
...(marker?.directory === repo.worktree && marker.type !== "git" ? { vcsBackend: marker.type } : {}),
})
}
const hg = native && path.basename(native) === ".hg" ? yield* hgDiscover(AbsolutePath.make(native)) : undefined
if (hg) {
if (hg && (!marker || FSUtil.contains(marker.directory, hg.directory))) {
return yield* persist({
...hg,
canonical: hg.directory,
...(marker?.directory === hg.directory && marker.type !== "hg" ? { vcsBackend: marker.type } : {}),
})
}
if (marker) {
const previous = yield* cached(marker.marker)
return yield* persist({
previous,
id: previous ?? ID.make(Hash.fast(`vcs-repository:${marker.type}:${marker.marker}`)),
directory: marker.directory,
canonical: marker.directory,
vcs: { type: marker.type, store: marker.marker },
})
}
@@ -368,5 +385,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
deps: [Bus.node, Database.node, FSUtil.node, Git.node, ProjectMarkers.node, AppProcess.node],
})
+191
View File
@@ -0,0 +1,191 @@
export * as ProjectMarkers from "./markers.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
import { fileURLToPath } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Versioned } from "../plugin.js"
import { PluginModule } from "../plugin/module.js"
import { PluginSourceDirectory } from "../plugin/source-directory.js"
import { SdkPlugins } from "../plugin/sdk.js"
import { AbsolutePath } from "../schema.js"
export interface Match {
readonly type: string
readonly directory: AbsolutePath
readonly marker: AbsolutePath
}
export interface Interface {
readonly discover: (
directory: AbsolutePath,
options?: { readonly discovery?: boolean },
) => Effect.Effect<Match | undefined>
readonly targets: () => readonly string[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectMarkers") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const npm = yield* Npm.Service
const sdk = yield* SdkPlugins.Service
const known = new Set([".git", ".hg"])
const loaded = new Map<string, Versioned | undefined>()
// The filesystem half of discovery: walk up for config, scan plugin
// directories, and read configured plugin operations. This is the part a
// no-discovery caller must skip — it imports plugin modules.
const scanOperations = Effect.fnUntraced(function* (directory: AbsolutePath) {
const found = yield* fs
.up({ targets: [".opencode", "opencode.json", "opencode.jsonc"], start: directory })
.pipe(Effect.orElseSucceed(() => []))
const roots = [global.config, ...found.filter((value) => path.basename(value) === ".opencode").toReversed()]
const files = [
...["opencode.json", "opencode.jsonc"].map((name) => path.join(global.config, name)),
...found.filter((value) => path.basename(value) !== ".opencode").toReversed(),
...roots.slice(1).flatMap((root) => ["opencode.json", "opencode.jsonc"].map((name) => path.join(root, name))),
]
const automatic = yield* Effect.forEach(roots, (root) => PluginSourceDirectory.discover(fs, root)).pipe(
Effect.map((entries) => entries.flat()),
)
const configured = yield* Effect.forEach([...new Set(files)], (file) => read(fs, file)).pipe(
Effect.map((entries) => entries.flat()),
)
return yield* Effect.forEach(
[
...automatic.map((target): ConfigPluginSource.Operation => ({ type: "add", target, options: {} })),
...configured,
],
(operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.orElseSucceed(() => operation),
)
},
)
})
const discover = Effect.fn("ProjectMarkers.discover")(function* (
directory: AbsolutePath,
options?: { readonly discovery?: boolean },
) {
// discovery: false skips the config scan and its plugin module loading;
// sdk-declared vcs markers are host-explicit, not ambient, so they stay.
const operations = options?.discovery === false ? [] : yield* scanOperations(directory)
const declarations = new Map<string, { readonly id: string; readonly markers: readonly string[] }>()
for (const plugin of sdk.all()) {
if (!plugin.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
for (const operation of operations) {
if (operation.type === "remove") {
for (const id of declarations.keys()) {
if (
operation.target === "*" ||
(operation.target.endsWith(".*") ? id.startsWith(operation.target.slice(0, -1)) : operation.target === id)
) {
declarations.delete(id)
}
}
continue
}
if (operation.target === "*" || operation.target.endsWith(".*") || operation.target.startsWith("opencode."))
continue
const key = JSON.stringify(operation)
const plugin = loaded.has(key)
? loaded.get(key)
: yield* PluginModule.load(operation).pipe(
Effect.provideService(Npm.Service, npm),
Effect.catchCause((cause) =>
Effect.logDebug("failed to discover plugin repository markers", {
target: operation.target,
cause,
}).pipe(Effect.as(undefined)),
),
Effect.tap((value) => Effect.sync(() => loaded.set(key, value))),
)
if (!plugin?.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
const markers = new Map<string, string>()
for (const declaration of declarations.values()) {
if (!/^[a-z][a-z0-9._-]*$/.test(declaration.id)) continue
for (const marker of declaration.markers) {
if (!marker || marker === "." || marker === ".." || /[\\/]/.test(marker)) continue
known.add(marker)
markers.set(marker, declaration.id)
}
}
if (!markers.size) return undefined
const marker = yield* fs.up({ targets: [...markers.keys()], start: directory, mode: "first" }).pipe(
Effect.map((entries) => entries[0]),
Effect.orElseSucceed(() => undefined),
)
if (!marker) return undefined
const type = markers.get(path.basename(marker))
if (!type) return undefined
return {
type,
directory: AbsolutePath.make(path.dirname(marker)),
marker: AbsolutePath.make(marker),
} satisfies Match
})
return Service.of({ discover, targets: () => [...known] })
}),
)
function read(fs: FSUtil.Interface, file: string): Effect.Effect<ConfigPluginSource.Operation[]> {
return Effect.gen(function* () {
const source = yield* fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))
if (!source) return []
const errors: ParseError[] = []
const document: unknown = parse(source, errors, { allowTrailingComma: true })
if (errors.length || typeof document !== "object" || document === null || !("plugins" in document)) return []
if (!Array.isArray(document.plugins)) return []
return document.plugins.flatMap<ConfigPluginSource.Operation>((entry) => {
if (typeof entry === "string" && entry.startsWith("-")) {
return [{ type: "remove", target: entry.slice(1) }]
}
if (
typeof entry !== "string" &&
(typeof entry !== "object" || entry === null || !("package" in entry) || typeof entry.package !== "string")
) {
return []
}
const target = typeof entry === "string" ? entry : entry.package
const options =
typeof entry !== "string" && "options" in entry && typeof entry.options === "object" && entry.options !== null
? Object.fromEntries(Object.entries(entry.options))
: {}
if (target.startsWith("file://")) return [{ type: "add", target: fileURLToPath(target), options }]
if (target.startsWith("./") || target.startsWith("../")) {
return [{ type: "add", target: path.resolve(path.dirname(file), target), options }]
}
return [{ type: "add", target, options }]
})
})
}
export const node = makeGlobalNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Npm.node, SdkPlugins.node],
})
+1 -1
View File
@@ -82,7 +82,7 @@ const layer = Layer.effect(
})
const selected = () => {
const value = state.get()
const id = value.selection ?? vcs?.type
const id = value.selection ?? location.vcsBackend ?? vcs?.type
return id ? value.providers.get(id) : undefined
}
const protect = <A>(provider: VcsDefinition, operation: string, effect: Effect.Effect<A, unknown>, fallback: A) =>
+54 -89
View File
@@ -20,7 +20,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
@@ -37,79 +37,38 @@ const staticIt = testEffect(
Global.node.replace(tempGlobalLayer),
]),
)
const outdatedNpm = makeGlobalNode({
const refreshNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "outdated-plugin")
let version = "1.0.0"
const installed = () => ({
directory,
entrypoint: pathToFileURL(path.join(directory, "index.js")).href,
version,
revision: version,
})
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await Bun.write(path.join(directory, "index.js"), 'export default { id: "outdated-plugin", setup() {} }')
})
const directory = path.join(global.tmp, "background-refresh-plugin")
const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href }
return Npm.Service.of({
add: () => Effect.sync(installed),
resolve: () => Effect.sync(installed),
check: () => Effect.sync(() => version === "1.0.0"),
update: () => Effect.sync(() => (version = "1.1.0")).pipe(Effect.map(installed)),
add: (_pkg, options) =>
options?.refresh
? Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-requested"), ""))
yield* waitForFile(path.join(directory, "refresh-release")).pipe(Effect.orDie)
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-finished"), ""))
return installed
})
: Effect.succeed(installed),
resolve: () => Effect.succeed(installed),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const updateIt = testEffect(
const refreshIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[Global.node.replace(tempGlobalLayer), Npm.node.replace(outdatedNpm)],
),
)
const coldNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "cold-plugin")
const started = path.join(directory, "started")
const release = path.join(directory, "release")
const entry = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href, revision: "1" }
let installed = false
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await Bun.write(path.join(directory, "index.js"), 'export default { id: "cold-plugin", setup() {} }')
})
return Npm.Service.of({
add: () =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(started, ""))
yield* waitForFile(release).pipe(Effect.orDie)
installed = true
return entry
}),
resolve: () => Effect.sync(() => (installed ? entry : { directory })),
check: () => Effect.succeed(false),
update: () => Effect.succeed(entry),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const coldIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[Global.node.replace(tempGlobalLayer), Npm.node.replace(coldNpm)],
[Global.node.replace(tempGlobalLayer), Npm.node.replace(refreshNpm)],
),
)
describe("PluginSupervisor config", () => {
it.live("applies selectors in order", () =>
withLocation(
@@ -504,40 +463,46 @@ describe("PluginSupervisor config", () => {
}),
)
updateIt.live("marks active package plugins as outdated after a background check", () =>
withLocation(
{ plugins: ["outdated-plugin"] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const source = yield* Effect.suspend(() => plugins.list()).pipe(
Effect.map((items) => items.find((item) => item.id === "outdated-plugin")?.source),
Effect.filterOrFail((source) => source?.type === "package" && source.outdated === true),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("2 seconds"),
refreshIt.live("refreshes active package plugins after setup without blocking flush", () =>
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const activated = path.join(directory, "activated")
const release = path.join(directory, "release")
const refreshed = path.join(directory, "refresh-requested")
const refreshRelease = path.join(directory, "refresh-release")
const refreshFinished = path.join(directory, "refresh-finished")
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(
path.join(directory, "index.js"),
`export default {
id: "background-refresh-plugin",
async setup() {
await Bun.write(${JSON.stringify(activated)}, "")
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
},
}`,
)
expect(source).toEqual({ type: "package", target: "outdated-plugin", version: "1.0.0", outdated: true })
}),
),
)
})
coldIt.live("activates available plugins before a missing package finishes installing", () =>
withLocation(
{ plugins: ["cold-plugin"] },
Effect.gen(function* () {
const global = yield* Global.Service
yield* waitForFile(path.join(global.tmp, "cold-plugin", "started"))
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("opencode.provider.openai")
const supervisor = yield* PluginSupervisor.Service
expect(Option.isNone(yield* supervisor.flush.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
yield* Effect.promise(() => Bun.write(path.join(global.tmp, "cold-plugin", "release"), ""))
yield* supervisor.flush.pipe(Effect.timeout("2 seconds"))
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("cold-plugin")
}),
),
yield* withLocation(
{ plugins: ["background-refresh-plugin"] },
Effect.gen(function* () {
yield* waitForFile(activated)
yield* Effect.sleep("100 millis")
expect(yield* Effect.promise(() => Bun.file(refreshed).exists())).toBeFalse()
yield* Effect.promise(() => Bun.write(release, ""))
yield* waitForFile(refreshed)
yield* ready().pipe(Effect.timeout("2 seconds"))
yield* Effect.promise(() => Bun.write(refreshRelease, ""))
yield* waitForFile(refreshFinished)
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("background-refresh-plugin")
}),
)
}),
)
})
const ready = Effect.fnUntraced(function* () {
@@ -30,9 +30,7 @@ export const promptLocationNode = makeGlobalNode({
replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))],
}),
Layer.succeed(FSUtil.Service, fs),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
),
),
+1 -1
View File
@@ -940,7 +940,7 @@ describe("LocationServiceMap", () => {
})
.pipe(Effect.asVoid),
})
yield* plugins.activate([{ ...reviewer, revision: "1" }])
yield* plugins.activate([{ ...reviewer, version: "1" }])
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
+31 -140
View File
@@ -55,58 +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 ${path.join(root, "package.tgz")} -C ${root} package`
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
}
const state = { latest: "1.0.0" }
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
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` } },
]),
),
})
const tarball = tarballs.get(url.pathname.slice(1).replace(".tgz", ""))
return tarball ? new Response(tarball) : new Response("missing", { status: 404 })
},
})
return {
state,
async configure(cache: string, spec: string) {
const root = path.join(cache, "npm", 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")
@@ -150,12 +98,10 @@ describe("Npm.isInstallablePackage", () => {
})
describe("Npm.cacheKey", () => {
test("canonicalizes registry keys and hashes Git specs", async () => {
test("preserves registry keys and hashes Git specs", async () => {
expect(await Npm.cacheKey("@opencode/acme@1.0.0")).toBe(Npm.sanitize("@opencode/acme@1.0.0"))
expect(await Npm.cacheKey("plugin")).toBe(Npm.sanitize("plugin@latest"))
expect(await Npm.cacheKey("@opencode/acme")).toBe(Npm.sanitize("@opencode/acme@latest"))
const spec = "git+ssh://git@github.com/acme/plugin.git#main"
expect(await Npm.cacheKey(spec)).toMatch(/^git-plugin-[a-f0-9]{12}$/)
expect(await Npm.cacheKey(spec)).toMatch(/^git-[a-f0-9]{64}$/)
expect(await Npm.cacheKey(spec)).toBe(await Npm.cacheKey(spec))
expect(await Npm.cacheKey(`${spec}-other`)).not.toBe(await Npm.cacheKey(spec))
})
@@ -168,9 +114,8 @@ describe("Npm.add", () => {
const directory = path.join(
tmp.path,
"cache",
"npm",
await Npm.cacheKey(spec),
"1000",
"packages",
Npm.sanitize(spec),
"node_modules",
"@fixture",
"provider",
@@ -191,7 +136,7 @@ describe("Npm.add", () => {
test("falls back to the original spec when parsing fails", async () => {
await using tmp = await tmpdir()
const spec = "fixture provider"
const directory = path.join(tmp.path, "cache", "npm", Npm.sanitize(spec), "1000", "node_modules", spec)
const directory = path.join(tmp.path, "cache", "packages", Npm.sanitize(spec), "node_modules", spec)
await fs.mkdir(directory, { recursive: true })
await writePackage(directory, { name: spec, exports: "./index.js" })
await Bun.write(path.join(directory, "index.js"), "export const fixture = true\n")
@@ -219,7 +164,7 @@ describe("Npm.add", () => {
await Bun.write(path.join(tmp.path, "fixture-provider", "tui.js"), "export const tui = true\n")
const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}`
await fs.mkdir(path.join(tmp.path, "cache", "npm", Npm.sanitize(spec)), { recursive: true })
await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true })
const entries = await Effect.gen(function* () {
const npm = yield* Npm.Service
@@ -253,14 +198,12 @@ describe("Npm.add", () => {
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(entries.added.entrypoint).toEndWith("/index.js")
expect(entries.added.version).toBe(fixture.commit)
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
expect(
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
).toBeTruthy()
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
expect(entries.added.directory).toContain("node_modules")
expect(entries.added.directory).toContain(path.join("packages", await Npm.cacheKey(spec), "node_modules"))
}
})
@@ -280,8 +223,8 @@ describe("Npm.add", () => {
).toBeTruthy()
})
// Several real Git installs and updates exceed Bun's default timeout on Windows.
test("checks and updates mutable Git packages without changing pinned installs", async () => {
// Several real Git installs and refreshes exceed Bun's default timeout on Windows.
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
@@ -289,38 +232,37 @@ describe("Npm.add", () => {
const mutable = `git+${repository}#fixture-branch`
const pinned = `git+${repository}#${fixture.commit}`
const result = await Effect.gen(function* () {
const first = await Effect.gen(function* () {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable)
const pinnedEntry = yield* npm.add(pinned)
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
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`
})
const before = yield* Effect.promise(() => Bun.file(path.join(mutableEntry.directory, "index.js")).text())
const outdated = yield* npm.check(mutable)
const pinnedOutdated = yield* npm.check(pinned)
const unchanged = yield* Effect.promise(() => Bun.file(path.join(mutableEntry.directory, "index.js")).text())
const updated = yield* npm.update(mutable)
const pinnedUpdated = yield* npm.update(pinned)
yield* npm.add(mutable, { refresh: true })
return { mutable: mutableEntry, pinned: pinnedEntry }
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(first.mutable.directory, "index.js")).text()).toContain("root: true")
expect(await Bun.file(path.join(first.pinned.directory, "index.js")).text()).toContain("root: true")
const second = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
before,
outdated,
pinnedOutdated,
unchanged,
updated: yield* Effect.promise(() => Bun.file(path.join(updated.directory, "index.js")).text()),
pinned: yield* Effect.promise(() => Bun.file(path.join(pinnedUpdated.directory, "index.js")).text()),
current: yield* npm.check(mutable),
mutable: yield* npm.add(mutable, { refresh: true }),
pinned: yield* npm.add(pinned, { refresh: true }),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(result.before).toContain("root: true")
expect(result.outdated).toBeTrue()
expect(result.pinnedOutdated).toBeFalse()
expect(result.unchanged).toContain("root: true")
expect(result.updated).toContain('root: "second"')
expect(result.pinned).toContain("root: true")
expect(result.current).toBeFalse()
expect(await Bun.file(path.join(second.mutable.directory, "index.js")).text()).toContain('root: "second"')
expect(await Bun.file(path.join(second.pinned.directory, "index.js")).text()).toContain("root: true")
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
const offline = await Effect.gen(function* () {
const npm = yield* Npm.Service
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"')
}, 30_000)
})
@@ -329,7 +271,7 @@ describe("Npm.resolve", () => {
await using tmp = await tmpdir()
const cache = path.join(tmp.path, "cache")
const spec = "fixture-plugin@1.0.0"
const directory = path.join(cache, "npm", Npm.sanitize(spec), "1000", "node_modules", "fixture-plugin")
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"] })
@@ -349,56 +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.version).toBe("1.0.0")
})
})
describe("Npm.check and Npm.update", () => {
test("checks registry targets without mutation and explicitly updates mutable targets", async () => {
await using tmp = await tmpdir()
await using registry = await createRegistryFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const mutable = "@fixture/registry-plugin@latest"
const pinned = "@fixture/registry-plugin@1.0.0"
const root = await registry.configure(cache, mutable)
await registry.configure(cache, pinned)
const result = await Effect.gen(function* () {
const npm = yield* Npm.Service
const installed = yield* npm.add(mutable)
yield* npm.add(pinned)
const current = yield* npm.check(mutable)
registry.state.latest = "1.1.0"
const outdated = yield* npm.check(mutable)
const pinnedOutdated = yield* npm.check(pinned)
const before = yield* Effect.promise(() => Bun.file(path.join(installed.directory, "index.js")).text())
yield* Effect.promise(() => Promise.all([fs.mkdir(path.join(root, "1")), fs.mkdir(path.join(root, "2"))]))
const updated = yield* npm.update(mutable)
const unchanged = yield* npm.update(mutable)
return {
current,
outdated,
pinnedOutdated,
before,
after: yield* Effect.promise(() => Bun.file(path.join(updated.directory, "index.js")).text()),
version: updated.version,
changedDirectory: installed.directory !== updated.directory,
unchangedDirectory: unchanged.directory === updated.directory,
generations: yield* Effect.promise(() => fs.readdir(root)),
updated: yield* npm.check(mutable),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(result.current).toBeFalse()
expect(result.outdated).toBeTrue()
expect(result.pinnedOutdated).toBeFalse()
expect(result.before).toContain('version = "1.0.0"')
expect(result.after).toContain('version = "1.1.0"')
expect(result.version).toBe("1.1.0")
expect(result.changedDirectory).toBeTrue()
expect(result.unchangedDirectory).toBeTrue()
expect(result.generations).not.toContain("1")
expect(result.generations).not.toContain("2")
expect(result.updated).toBeFalse()
})
})
+28 -47
View File
@@ -24,7 +24,7 @@ const it = testEffect(PluginTestLayer)
class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
const generation = <R>(plugin: EffectPlugin.Plugin<R>, revision = "1") => ({ ...plugin, revision })
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
describe("Plugin", () => {
it.effect("routes experimental terminal reads through the runtime cell without wrapping results", () =>
@@ -81,7 +81,7 @@ describe("Plugin", () => {
const location = yield* Location.Service
const seen: Location.Info[] = []
yield* plugins.activate([
generation(
versioned(
EffectPlugin.define({
id: "location-context",
effect: (ctx) =>
@@ -215,7 +215,7 @@ describe("Plugin", () => {
.pipe(Effect.asVoid),
})
yield* plugins.activate([generation(provider)])
yield* plugins.activate([versioned(provider)])
expect(yield* vcs.info()).toEqual({ branch: { current: "feature" } })
expect(yield* vcs.branches()).toEqual(["feature"])
@@ -224,7 +224,7 @@ describe("Plugin", () => {
}),
)
it.effect("replaces plugins by ID and revision", () =>
it.effect("replaces plugins by ID and version", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
@@ -250,24 +250,24 @@ describe("Plugin", () => {
.pipe(Effect.asVoid),
})
yield* plugins.activate([generation(managed(), "1")])
yield* plugins.activate([versioned(managed(), "1")])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("first")
description = "second"
yield* plugins.activate([generation(managed(), "2")])
yield* plugins.activate([versioned(managed(), "2")])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
description = "third"
yield* plugins.activate([generation(managed(), "2")])
yield* plugins.activate([versioned(managed(), "2")])
expect(updates).toBe(2)
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate(
[generation(managed(), "2")],
[versioned(managed(), "2")],
[
{
source: { type: "package", target: "broken" },
source: { type: "package", package: "broken" },
state: { status: "failed", error: "failed to resolve" },
features: { server: true },
},
@@ -288,7 +288,7 @@ describe("Plugin", () => {
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const definitions = ["first", "second"].map((id) =>
generation(
versioned(
EffectPlugin.define({
id,
effect: (ctx) => ctx.agent.transform((draft) => draft.update(id, () => {})),
@@ -315,36 +315,17 @@ describe("Plugin", () => {
}),
)
it.effect("updates inventory metadata without restarting an unchanged generation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
let loads = 0
const plugin = {
id: "metadata",
revision: "1",
source: { type: "package" as const, target: "fixture" },
effect: () => Effect.sync(() => loads++),
}
yield* plugins.activate([plugin])
yield* plugins.activate([{ ...plugin, source: { ...plugin.source, outdated: true } }])
expect(loads).toBe(1)
expect((yield* plugins.list())[0]?.source).toEqual({ type: "package", target: "fixture", outdated: true })
}),
)
it.effect("rejects duplicate IDs before replacing active plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const active = Plugin.ID.make("active")
const duplicate = "duplicate"
yield* plugins.activate([{ id: active, revision: "1", effect: () => Effect.void }])
yield* plugins.activate([{ id: active, version: "1", effect: () => Effect.void }])
const result = yield* plugins
.activate([
{ id: duplicate, revision: "1", effect: () => Effect.void },
{ id: duplicate, revision: "1", effect: () => Effect.void },
{ id: duplicate, version: "1", effect: () => Effect.void },
{ id: duplicate, version: "1", effect: () => Effect.void },
])
.pipe(Effect.exit)
@@ -359,7 +340,7 @@ describe("Plugin", () => {
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.activate([
{ id: "rpc-plugin", revision: "1", features: { rpc: true }, effect: () => Effect.void },
{ id: "rpc-plugin", version: "1", features: { rpc: true }, effect: () => Effect.void },
])
expect(yield* plugins.list()).toEqual([
@@ -397,7 +378,7 @@ describe("Plugin", () => {
},
})
yield* plugins.activate([generation(good), generation(bad)])
yield* plugins.activate([versioned(good), versioned(bad)])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("good"),
@@ -415,7 +396,7 @@ describe("Plugin", () => {
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
fail = false
yield* plugins.activate([generation(good), generation(bad, "2")])
yield* plugins.activate([versioned(good), versioned(bad, "2")])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("good"),
@@ -441,7 +422,7 @@ describe("Plugin", () => {
yield* plugins.activate([
{
id: "partial-tools",
revision: "1",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool.transform((draft) => {
@@ -507,8 +488,8 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([generation(previous)])
yield* plugins.activate([generation(replacement, "2")])
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
@@ -546,8 +527,8 @@ describe("Plugin", () => {
effect: () => Effect.die(new Error("replacement failed")),
})
yield* plugins.activate([generation(previous)])
yield* plugins.activate([generation(replacement, "2")])
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
@@ -568,7 +549,7 @@ describe("Plugin", () => {
yield* plugins.activate(
["first", "second"].map((id) => ({
id,
revision: "1",
version: "1",
effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
})),
)
@@ -592,7 +573,7 @@ describe("Plugin", () => {
),
})
yield* plugins.activate([generation(plugin)]).pipe(Effect.provideService(Secret, "secret"))
yield* plugins.activate([versioned(plugin)]).pipe(Effect.provideService(Secret, "secret"))
expect(visible).toBe(false)
}),
@@ -605,7 +586,7 @@ describe("Plugin", () => {
yield* plugins.activate(
["a", "a:b", "雪"].map((id) => ({
id,
revision: "1",
version: "1",
effect: (context: EffectPlugin.Context) => Effect.sync(() => storage.set(id, context.storage)),
})),
)
@@ -667,7 +648,7 @@ describe("Plugin", () => {
.pipe(Effect.orDie),
})
yield* plugins.activate([generation(plugin)])
yield* plugins.activate([versioned(plugin)])
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
yield* plugins.activate([])
@@ -699,7 +680,7 @@ describe("Plugin", () => {
.pipe(Effect.orDie),
})
yield* plugins.activate([generation(plugin)])
yield* plugins.activate([versioned(plugin)])
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([
"context7_look_up",
@@ -778,7 +759,7 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([generation(plugin)])
yield* plugins.activate([versioned(plugin)])
const toolSet = yield* registry.snapshot()
const execution = yield* toolSet.execute({
@@ -836,7 +817,7 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([generation(plugin)])
yield* plugins.activate([versioned(plugin)])
const toolSet = yield* registry.snapshot()
const failure = yield* toolSet
-2
View File
@@ -38,8 +38,6 @@ const npmLayer = Layer.succeed(
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
}),
)
+1
View File
@@ -104,6 +104,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
reload: () => Effect.die("unused mcp.reload"),
},
permission: overrides.permission ?? {
assert: () => Effect.die("unused permission.assert"),
hook: () => Effect.die("unused permission.hook"),
list: () => Effect.die("unused permission.list"),
get: () => Effect.die("unused permission.get"),
+1 -4
View File
@@ -15,15 +15,13 @@ test("loads cached plugin packages without requesting a refresh", async () => {
add: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href, version: "1.2.3" }
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
resolve: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
check: () => Effect.die(new Error("Unexpected check")),
update: () => Effect.die(new Error("Unexpected update")),
which: () => Effect.die(new Error("Unexpected which")),
}),
),
@@ -32,6 +30,5 @@ test("loads cached plugin packages without requesting a refresh", async () => {
expect(plugin.id).toBe("config-effect-plugin")
expect(plugin.features).toEqual({ tui: true, rpc: true })
expect(plugin.source).toEqual({ type: "package", target: "fixture-plugin", version: "1.2.3" })
expect(calls).toEqual([{ subpaths: ["server", ""] }, { subpaths: ["tui"] }, { subpaths: ["rpc"] }])
})
@@ -0,0 +1,189 @@
import { describe, expect } from "bun:test"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { Permission } from "@opencode-ai/core/permission"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/plugin/promise/plugin"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Cause, Deferred, Effect, Exit, Fiber, Queue } from "effect"
import { tempLocationLayer } from "../fixture/location"
import { emptyMcpLayer } from "../fixture/mcp"
import { testEffect } from "../lib/effect"
const it = testEffect(
LayerNode.compile(LayerNode.group([Plugin.node, Database.node, Bus.node, Location.node]), {
replacements: [
Location.node.replace(tempLocationLayer),
Config.node.replace(Config.testLayer()),
Mcp.node.replace(emptyMcpLayer),
],
}),
)
const setup = Effect.gen(function* () {
const database = yield* Database.Service
const location = yield* Location.Service
const plugins = yield* Plugin.Service
const bus = yield* Bus.Service
const asked = yield* Queue.unbounded<void>()
const unsubscribe = yield* bus.listen((event) =>
event.type === Permission.Event.Asked.type ? Queue.offer(asked, undefined).pipe(Effect.asVoid) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const ready = yield* Deferred.make<Context>()
yield* plugins.activate([{ id: "permission-test", version: "1", effect: (ctx) => Deferred.succeed(ready, ctx) }])
const ctx = yield* Deferred.await(ready)
yield* ctx.agent.transform((draft) =>
draft.update("permission-test", (agent) => {
agent.permissions = [
{ action: "deploy", resource: "*", effect: "ask" },
{ action: "deploy", resource: "allowed", effect: "allow" },
{ action: "deploy", resource: "blocked", effect: "deny" },
]
}),
)
const sessionID = Session.ID.create()
yield* database.db
.insert(ProjectTable)
.values({ id: location.project.id, worktree: location.directory, sandboxes: [] })
.onConflictDoNothing()
.run()
yield* database.db
.insert(SessionTable)
.values({
id: sessionID,
project_id: location.project.id,
slug: "permission-test",
directory: location.directory,
title: "Permission test",
version: "test",
agent: "missing",
})
.run()
const input = {
id: Permission.ID.create(),
sessionID,
agent: Agent.ID.make("permission-test"),
action: "deploy",
resources: ["staging"],
save: ["staging"],
metadata: { environment: "staging" },
source: { type: "tool", messageID: "msg_test", id: "call_test" },
} satisfies Permission.AssertInput
return { ctx, input, asked }
})
describe("plugin permission.assert", () => {
it.live("preserves Effect decisions, rejection defects, feedback, and cancellation cleanup", () =>
Effect.gen(function* () {
const { ctx, input, asked } = yield* setup
expect(yield* ctx.permission.assert({ ...input, resources: ["allowed"] })).toBeUndefined()
expect(yield* ctx.permission.assert({ ...input, resources: ["blocked"] }).pipe(Effect.flip)).toBeInstanceOf(
Permission.BlockedError,
)
expect(yield* ctx.permission.list(input)).toEqual([])
yield* Effect.forEach(["once", "reject", "feedback", "cancel"] as const, (reply) =>
Effect.gen(function* () {
const fiber = yield* ctx.permission.assert(input).pipe(Effect.forkScoped)
yield* Queue.take(asked)
expect(fiber.pollUnsafe()).toBeUndefined()
expect(yield* ctx.permission.get({ sessionID: input.sessionID, requestID: input.id })).toMatchObject({
id: input.id,
sessionID: input.sessionID,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
})
if (reply === "cancel") yield* Fiber.interrupt(fiber)
if (reply !== "cancel")
yield* ctx.permission.reply({
sessionID: input.sessionID,
requestID: input.id,
reply: reply === "feedback" ? "reject" : reply,
message: reply === "feedback" ? "Use the test environment" : undefined,
})
const exit = yield* Fiber.await(fiber)
if (reply === "once") expect(exit).toEqual(Exit.succeed(undefined))
if (reply !== "once") {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
if (reply === "cancel") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
if (reply === "reject")
expect(exit.cause.reasons).toContainEqual(
expect.objectContaining({ _tag: "Die", defect: expect.any(Permission.DeclinedError) }),
)
if (reply === "feedback")
expect(exit.cause.reasons).toContainEqual(
expect.objectContaining({
_tag: "Fail",
error: new Permission.CorrectedError({ feedback: "Use the test environment" }),
}),
)
}
}
expect(yield* ctx.permission.list(input)).toEqual([])
}),
)
}),
)
it.live("decodes Promise inputs and preserves void results and permission errors through the real host", () =>
Effect.gen(function* () {
const { ctx, input, asked } = yield* setup
yield* PluginPromise.fromPromise(
define({
id: "promise-permission-test",
setup: async (ctx) => {
await expect(
Reflect.apply(ctx.permission.assert, undefined, [{ ...input, resources: [42] }]),
).rejects.toBeDefined()
expect(await ctx.permission.list(input)).toEqual([])
expect(await ctx.permission.assert({ ...input, id: null, resources: ["allowed"] })).toBeUndefined()
await expect(ctx.permission.assert({ ...input, resources: ["blocked"] })).rejects.toBeInstanceOf(
Permission.BlockedError,
)
for (const reply of ["once", "reject", "feedback"] as const) {
const pending = ctx.permission.assert(input)
const settled = pending.then(
(value) => ({ value }),
(error: unknown) => ({ error }),
)
await Effect.runPromise(Queue.take(asked))
expect(await ctx.permission.get({ sessionID: input.sessionID, requestID: input.id })).toMatchObject({
metadata: input.metadata,
source: input.source,
save: input.save,
})
await ctx.permission.reply({
sessionID: input.sessionID,
requestID: input.id,
reply: reply === "feedback" ? "reject" : reply,
...(reply === "feedback" ? { message: "Use the test environment" } : {}),
})
if (reply === "once") expect(await settled).toEqual({ value: undefined })
if (reply === "reject") expect(await settled).toEqual({ error: expect.any(Permission.DeclinedError) })
if (reply === "feedback")
expect(await settled).toEqual({
error: new Permission.CorrectedError({ feedback: "Use the test environment" }),
})
expect(await ctx.permission.list(input)).toEqual([])
}
},
}),
).effect(ctx)
}),
)
})
+1 -1
View File
@@ -167,7 +167,7 @@ describe("fromPromise", () => {
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
yield* plugins.activate([{ ...adapted, version: "1" }])
}),
)
@@ -24,8 +24,6 @@ function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.undefined,
})
}
@@ -16,8 +16,6 @@ const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
})
+4 -4
View File
@@ -39,7 +39,7 @@ it.effect("Effect plugins register, call, and publish RPCs independently of plug
yield* plugins.activate([
{
id: "implementer",
revision: "1",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Echo, {
@@ -51,7 +51,7 @@ it.effect("Effect plugins register, call, and publish RPCs independently of plug
},
{
id: "consumer",
revision: "1",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
expect(yield* ctx.rpc(Echo).echo("hello")).toBe("hello!")
@@ -78,7 +78,7 @@ it.effect("failed plugin setup removes RPC overrides and restores the previous i
yield* plugins.activate([
{
id: "implementer",
revision: "1",
version: "1",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
@@ -91,7 +91,7 @@ it.effect("failed plugin setup removes RPC overrides and restores the previous i
yield* plugins.activate([
{
id: "implementer",
revision: "2",
version: "2",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
@@ -84,7 +84,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
@@ -141,7 +141,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
@@ -197,7 +197,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins
.activate([{ ...adapted, revision: "1" }])
.activate([{ ...adapted, version: "1" }])
.pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])))
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
yield* plugins.activate([])
@@ -279,7 +279,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
const active = yield* Effect.promise(() => subscriptions.promise)
yield* plugins.activate([])
+10 -14
View File
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { Effect, Layer, Stream } from "effect"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
import { Skill } from "@opencode-ai/core/skill"
@@ -9,13 +8,10 @@ import { testEffect } from "../lib/effect"
import { host } from "./host"
const it = testEffect(AppNodeBuilder.build(Skill.node))
const config = (plugins: Info["plugins"] = []) =>
const sources = (operations: readonly ConfigPluginSource.Operation[] = []) =>
Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.succeed([new Document({ type: "document", info: new Info({ plugins }) })]),
changes: () => Stream.never,
}),
ConfigPluginSource.Service,
ConfigPluginSource.Service.of({ operations: () => Effect.succeed(operations), changes: () => Stream.never }),
)
describe("SkillPlugin.Plugin", () => {
@@ -31,7 +27,7 @@ describe("SkillPlugin.Plugin", () => {
reload: skill.reload,
},
}),
).pipe(Effect.provide(config()))
).pipe(Effect.provide(sources()))
const skills = yield* skill.list()
const report = skills.find((item) => item.id === "report")
@@ -71,11 +67,11 @@ describe("SkillPlugin.Plugin", () => {
expect(report?.content).toContain("- Active plugins: -disabled, local.ts, package-plugin, package-plugin")
}).pipe(
Effect.provide(
config([
"package-plugin",
"-disabled",
"local.ts",
{ package: "package-plugin", options: { enabled: true } },
sources([
{ type: "add", target: "package-plugin", options: {} },
{ type: "remove", target: "disabled" },
{ type: "add", target: "local.ts", options: {}, mtime: 1 },
{ type: "add", target: "package-plugin", options: { enabled: true } },
]),
),
),
-51
View File
@@ -1,51 +0,0 @@
import { expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { PluginUpdate } from "@opencode-ai/core/plugin/update"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Npm } from "@opencode-ai/util/npm"
import { Effect, Fiber, Layer, Option, Stream } from "effect"
import { testEffect } from "../lib/effect"
const checks: string[] = []
const npm = makeGlobalNode({
service: Npm.Service,
layer: Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.die("unused add"),
resolve: () => Effect.die("unused resolve"),
check: (target) => Effect.sync(() => checks.push(target)).pipe(Effect.as(true)),
update: () => Effect.succeed({ directory: "" }),
which: () => Effect.die("unused which"),
}),
),
deps: [],
})
const it = testEffect(AppNodeBuilder.build(PluginUpdate.node, [Npm.node.replace(npm)]))
it.effect("caches checks by target", () =>
Effect.gen(function* () {
checks.length = 0
const updates = yield* PluginUpdate.Service
const first = yield* updates.check("fixture")
const second = yield* updates.check("fixture")
expect(checks).toEqual(["fixture"])
expect(first).toBeTrue()
expect(second).toBeTrue()
}),
)
it.effect("publishes successful package updates", () =>
Effect.gen(function* () {
const updates = yield* PluginUpdate.Service
const changed = yield* updates
.changes()
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* updates.update("fixture")
expect(Option.getOrUndefined(yield* Fiber.join(changed))).toBe("fixture")
}),
)
+100
View File
@@ -170,6 +170,106 @@ describe("Project.resolve", () => {
}),
)
it.live("discovers repository markers from automatically loaded plugins", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(tmp.path, ".svn"))
await fs.mkdir(path.join(tmp.path, "nested", "directory"), { recursive: true })
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "svn.ts"),
'export default { id: "svn", vcs: { markers: [".svn"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(path.join(tmp.path, "nested", "directory")))
expect(result.directory).toBe(abs(tmp.path))
expect(result.canonical).toBe(abs(tmp.path))
expect(result.vcs).toEqual({ type: "svn", store: abs(path.join(tmp.path, ".svn")) })
expect(result.id).not.toBe(Project.ID.global)
expect((yield* project.list()).find((item) => item.id === result.id)?.vcs).toBe("svn")
}),
)
it.live("discovers repository markers from configured plugin files", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, ".pijul"))
await Bun.write(path.join(tmp.path, "opencode.jsonc"), '{ "plugins": ["./pijul.ts"] }')
await Bun.write(
path.join(tmp.path, "pijul.ts"),
'export default { id: "custom.pijul", vcs: { id: "pijul", markers: [".pijul"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.directory).toBe(abs(tmp.path))
expect(result.vcs?.type).toBe("pijul")
}),
)
it.live("prefers a nested plugin repository over its parent git repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const nested = path.join(tmp.path, "nested")
yield* Effect.promise(async () => {
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(nested, ".svn"), { recursive: true })
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "svn.ts"),
'export default { id: "svn", vcs: { markers: [".svn"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(nested))
expect(result.directory).toBe(abs(nested))
expect(result.vcs?.type).toBe("svn")
}),
)
it.live("preserves git identity when a plugin marker shares its repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(tmp.path, ".jj"))
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "jj.ts"),
'export default { id: "jj", vcs: { markers: [".jj"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.vcs?.type).toBe("git")
expect(result.vcsBackend).toBe("jj")
}),
)
it.live("repository markers override markerless directory projects", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
+1 -3
View File
@@ -114,9 +114,7 @@ const locations = (references: Layer.Layer<Reference.Service>) =>
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
flush: Effect.sync(() => (ready = true)),
}),
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
),
),
),
+1 -3
View File
@@ -52,9 +52,7 @@ const locations = makeGlobalNode({
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
),
),
+2
View File
@@ -13,6 +13,8 @@ export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Rpc } from "@opencode-ai/schema/rpc"
export { Session } from "@opencode-ai/schema/session"
export { Skill } from "@opencode-ai/schema/skill"
export { Tool } from "@opencode-ai/schema/tool"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
+3 -1
View File
@@ -1,7 +1,8 @@
import type { PermissionApi } from "@opencode-ai/client/effect/api"
import type { PermissionApi, PermissionCreateInput } from "@opencode-ai/client/effect/api"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Permission } from "@opencode-ai/schema/permission"
import type { Session } from "@opencode-ai/schema/session"
import type { Effect } from "effect"
import type { Hooks } from "./registration.js"
export interface PermissionEvaluation {
@@ -20,5 +21,6 @@ export interface PermissionHooks {
}
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
readonly assert: (input: PermissionCreateInput) => Effect.Effect<void, unknown>
readonly hook: Hooks<PermissionHooks>
}
+3 -1
View File
@@ -2,6 +2,7 @@ import type { ExperimentalApi, GenerateApi, PluginApi } from "@opencode-ai/clien
import type { Location } from "@opencode-ai/schema/location"
import type { Effect, Scope } from "effect"
import type { PluginOptions } from "../options.js"
import type { VcsDiscovery } from "../vcs.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js"
import type { AISDKDomain } from "./aisdk.js"
@@ -37,7 +38,7 @@ export interface Context {
readonly mcp: MCPDomain
readonly generate: GenerateApi<unknown>
readonly permission: PermissionDomain
readonly plugin: Pick<PluginApi<unknown>, "list">
readonly plugin: PluginApi<unknown>
readonly reference: ReferenceDomain
readonly rpc: RpcDomain
readonly session: SessionDomain
@@ -51,6 +52,7 @@ export interface Context {
export interface Plugin<R = Scope.Scope> {
readonly id: string
readonly vcs?: VcsDiscovery
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
+6 -1
View File
@@ -214,6 +214,7 @@ function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
export function fromPromise(plugin: Plugin) {
return define({
id: plugin.id,
vcs: plugin.vcs,
effect: (host) =>
Effect.gen(function* () {
const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
@@ -258,13 +259,14 @@ export function fromPromise(plugin: Plugin) {
const adaptApiMethod = <PromiseMethod>(
endpoint: HttpApiEndpoint.Top,
method: (input: never) => Effect.Effect<unknown, unknown>,
options?: { readonly noContent?: boolean },
) => {
const compiled = compileEndpoint(endpoint)
return ((input?: unknown) =>
Effect.gen(function* () {
const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(input ?? {}))
const result = yield* method(Object.assign({}, ...decoded) as never)
if (compiled.noContent) return undefined
if (compiled.noContent || options?.noContent) return undefined
return yield* compiled.encode(result)
}).pipe(Effect.runPromiseWith(context))) as PromiseMethod
}
@@ -427,6 +429,9 @@ export function fromPromise(plugin: Plugin) {
reload: () => run(host.mcp.reload()),
},
permission: {
assert: adaptApiMethod(PermissionEndpoints["session.permission.create"], host.permission.assert, {
noContent: true,
}),
hook: (name, callback) =>
register(host.permission.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
+2 -1
View File
@@ -1,4 +1,4 @@
import type { PermissionApi } from "@opencode-ai/client/promise/api"
import type { PermissionApi, PermissionCreateInput } from "@opencode-ai/client/promise/api"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Permission } from "@opencode-ai/schema/permission"
import type { Session } from "@opencode-ai/schema/session"
@@ -20,5 +20,6 @@ export interface PermissionHooks {
}
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
readonly assert: (input: PermissionCreateInput) => Promise<void>
readonly hook: Hooks<PermissionHooks>
}
+3 -1
View File
@@ -2,6 +2,7 @@ import type { OpenCodeClient } from "@opencode-ai/client"
import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api"
import type { Location } from "@opencode-ai/schema/location"
import type { PluginOptions } from "../options.js"
import type { VcsDiscovery } from "../vcs.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js"
import type { AISDKDomain } from "./aisdk.js"
@@ -37,7 +38,7 @@ export interface Context {
readonly mcp: MCPDomain
readonly generate: GenerateApi
readonly permission: PermissionDomain
readonly plugin: Pick<PluginApi, "list">
readonly plugin: PluginApi
readonly reference: ReferenceDomain
readonly rpc: RpcDomain
readonly session: SessionDomain
@@ -53,6 +54,7 @@ export type Cleanup = () => Promise<void> | void
export interface Plugin {
readonly id: string
readonly vcs?: VcsDiscovery
readonly setup: (context: Context) => Promise<Cleanup | void> | Cleanup | void
}
+4
View File
@@ -0,0 +1,4 @@
export interface VcsDiscovery {
readonly id?: string
readonly markers: readonly string[]
}
@@ -61,6 +61,13 @@ test.each([
])
})
test.each([
["effect", Plugin.Plugin.define({ id: "svn", vcs: { markers: [".svn"] }, effect: () => Effect.void })],
["promise", PromisePlugin.Plugin.define({ id: "svn", vcs: { markers: [".svn"] }, setup() {} })],
])("%s plugin definitions retain repository markers", (_name, plugin) => {
expect(plugin.vcs).toEqual({ markers: [".svn"] })
})
test("tui entrypoint exposes the plugin definition", () => {
const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} })
expect(plugin.id).toBe("demo")
+2 -119
View File
@@ -486,116 +486,6 @@
"summary": "List plugins"
}
},
"/api/plugin/update": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.update",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Update one package plugin and notify active locations to reload it.",
"summary": "Update plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16703,18 +16593,11 @@
"type": "string",
"enum": ["package"]
},
"target": {
"package": {
"type": "string"
},
"version": {
"type": "string"
},
"outdated": {
"type": "boolean",
"enum": [true]
}
},
"required": ["type", "target"],
"required": ["type", "package"],
"additionalProperties": false
},
{
+1 -18
View File
@@ -1,8 +1,7 @@
import { Location } from "@opencode-ai/schema/location"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { InvalidRequestError, ServiceUnavailableError } from "../errors.js"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const PluginGroup = HttpApiGroup.make("server.plugin")
@@ -20,22 +19,6 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
}),
),
)
.add(
HttpApiEndpoint.post("plugin.update", "/api/plugin/update", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String }),
success: HttpApiSchema.NoContent,
error: [InvalidRequestError, ServiceUnavailableError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.update",
summary: "Update plugin",
description: "Update one package plugin and notify active locations to reload it.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "plugin",
+123
View File
@@ -0,0 +1,123 @@
export * as Browser from "./browser.js"
import { Schema } from "effect"
import { Rpc } from "./rpc.js"
import { Session } from "./session.js"
export const Ref = Schema.String.check(Schema.isPattern(/^@?e[1-9][0-9]*$/))
.pipe(Schema.brand("Browser.Ref"))
.annotate({ identifier: "Browser.Ref" })
export type Ref = typeof Ref.Type
export interface State extends Schema.Schema.Type<typeof State> {}
export const State = Schema.Struct({
url: Schema.String.check(Schema.isMaxLength(16_384)),
title: Schema.String.check(Schema.isMaxLength(1_024)),
loading: Schema.Boolean,
canGoBack: Schema.Boolean,
canGoForward: Schema.Boolean,
generation: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
}).annotate({ identifier: "Browser.State" })
export const Key = Schema.Literals([
"Enter",
"Tab",
"Escape",
"Backspace",
"Delete",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"PageUp",
"PageDown",
"Home",
"End",
"Space",
]).annotate({ identifier: "Browser.Key" })
export type Key = typeof Key.Type
export const Direction = Schema.Literals(["up", "down", "left", "right"]).annotate({ identifier: "Browser.Direction" })
export type Direction = typeof Direction.Type
export const Action = Schema.Union([
Schema.Struct({ type: Schema.Literals(["open", "snapshot", "screenshot", "back", "forward", "reload", "stop"]) }),
Schema.Struct({ type: Schema.Literal("navigate"), url: Schema.String.check(Schema.isMaxLength(16_384)) }),
Schema.Struct({ type: Schema.Literal("click"), ref: Ref }),
Schema.Struct({ type: Schema.Literal("fill"), ref: Ref, text: Schema.String.check(Schema.isMaxLength(10_000)) }),
Schema.Struct({ type: Schema.Literal("press"), key: Key }),
Schema.Struct({
type: Schema.Literal("evaluate"),
script: Schema.String.check(Schema.isMaxLength(100_000)).annotate({
description: "JavaScript to evaluate in the page. The result is JSON-serialized.",
}),
}),
Schema.Struct({
type: Schema.Literal("scroll"),
direction: Direction,
pixels: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(2000)),
}),
]).annotate({ identifier: "Browser.Action" })
export type Action = typeof Action.Type
export interface Command extends Schema.Schema.Type<typeof Command> {}
export const Command = Schema.Struct({ action: Action, generation: State.fields.generation }).annotate({
identifier: "Browser.Command",
})
export const Result = Schema.Union([
Schema.Struct({ type: Schema.Literal("state"), state: State }),
Schema.Struct({
type: Schema.Literal("snapshot"),
state: State,
content: Schema.String.check(Schema.isMaxLength(100_000)),
}),
Schema.Struct({
type: Schema.Literal("evaluate"),
state: State,
content: Schema.String.check(Schema.isMaxLength(100_000)),
}),
Schema.Struct({
type: Schema.Literal("screenshot"),
state: State,
data: Schema.Uint8ArrayFromBase64.check(Schema.isMaxLength(5 * 1_024 * 1_024)),
}),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Result" })
export type Result = typeof Result.Type
export const Outcome = Schema.Union([
Schema.Struct({ type: Schema.Literal("success"), result: Result }),
Schema.Struct({ type: Schema.Literal("failure"), message: Schema.String.check(Schema.isMaxLength(1_024)) }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Outcome" })
export type Outcome = typeof Outcome.Type
const attachment = { sessionID: Session.ID, connectionID: Schema.String }
const errors = { unavailable: Schema.Struct({}) }
export const Control = Schema.Union([
Schema.Struct({ type: Schema.Literal("attached"), connectionID: Schema.String }),
Schema.Struct({
type: Schema.Literal("command"),
connectionID: Schema.String,
requestID: Schema.String,
command: Command,
}),
Schema.Struct({ type: Schema.Literal("cancel"), connectionID: Schema.String, requestID: Schema.String }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Control" })
export type Control = typeof Control.Type
export const Definition = Rpc.define({
id: "experimental.browser",
methods: {
attach: { input: Schema.Struct(attachment), output: Schema.Void, errors },
state: { input: Schema.Struct({ ...attachment, state: Schema.NullOr(State) }), output: Schema.Void, errors },
result: {
input: Schema.Struct({ ...attachment, requestID: Schema.String, outcome: Outcome }),
output: Schema.Void,
errors,
},
},
events: { control: { schema: Control } },
})
+1 -6
View File
@@ -9,12 +9,7 @@ export type ID = typeof ID.Type
export const Source = Schema.Union([
Schema.Struct({ type: Schema.Literal("builtin") }),
Schema.Struct({
type: Schema.Literal("package"),
target: Schema.String,
version: Schema.String.pipe(optional),
outdated: Schema.Literal(true).pipe(optional),
}),
Schema.Struct({ type: Schema.Literal("package"), package: Schema.String }),
Schema.Struct({ type: Schema.Literal("local"), path: Schema.String }),
Schema.Struct({ type: Schema.Literal("sdk") }),
]).annotate({ identifier: "Plugin.Source" })
+1 -5
View File
@@ -4,7 +4,7 @@ import { Plugin } from "../src/plugin.js"
test("embeds plugin state with a status discriminator", () => {
const decode = Schema.decodeUnknownSync(Plugin.Info)
const source = { type: "package" as const, target: "acme", version: "1.2.3" }
const source = { type: "package" as const, package: "acme" }
const features = { server: true as const }
expect(decode({ id: "acme", source, features, state: { status: "active" } })).toEqual({
@@ -18,8 +18,4 @@ test("embeds plugin state with a status discriminator", () => {
features,
state: { status: "failed", error: "broken" },
})
expect(decode({ source: { ...source, outdated: true }, features, state: { status: "active" } }).source).toEqual({
...source,
outdated: true,
})
})
+279
View File
@@ -0,0 +1,279 @@
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import plugin from "@opencode-ai/core/plugin/browser/index"
import { Browser } from "@opencode-ai/schema/browser"
import { Agent, Rpc, Tool } from "@opencode-ai/plugin/effect"
import { AbsolutePath, Location, OpenCode, SessionMessage } from "@opencode-ai/sdk/effect"
import { Effect, Fiber, Queue, Stream } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
const state: Browser.State = {
url: "https://example.com/",
title: "Example",
loading: false,
canGoBack: false,
canGoForward: false,
generation: 7,
}
const fixture = Effect.gen(function* () {
const directory = yield* tmpdirScoped("opencode-browser-")
const config = path.join(directory.path, "config")
yield* Effect.promise(() => mkdir(config))
const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
const opencode = yield* OpenCode.create({
database: { path: ":memory:" },
config: {
directory: config,
project: false,
content: JSON.stringify({
plugins: ["-opencode.browser"],
permissions: [{ action: "browser", resource: "*", effect: "allow" }],
}),
},
models: { fetch: false },
fs: { filewatcher: false, fff: false },
})
const captured = Promise.withResolvers<Tool.Info>()
const permissions: Array<{ action: string; resources: readonly string[] }> = []
yield* opencode.plugin({ ...plugin, id: "browser-test" })
yield* opencode.plugin({
id: "browser-test-observer",
effect: (ctx) =>
Effect.gen(function* () {
// Inspect the real tool through the public draft, without replacing its executor.
yield* ctx.tool.transform((draft) => {
const tool = draft.get("browser")
if (tool && ctx.location.directory === location.directory) captured.resolve(tool)
})
yield* ctx.permission.hook("evaluate", (event) =>
Effect.sync(() => permissions.push({ action: event.action, resources: event.resources })),
)
}).pipe(Effect.orDie),
})
yield* opencode.plugin.list({ location })
const tool = yield* Effect.promise(() => captured.promise)
const session = yield* opencode.sessions.create({ location })
const rpc = opencode.rpc(Browser.Definition)
const events = yield* Queue.unbounded<Rpc.EventPayload<typeof Browser.Definition, "control">>()
yield* rpc.events.subscribe("control").pipe(
Stream.runForEach((event) => Queue.offer(events, event)),
Effect.forkScoped({ startImmediately: true }),
)
// RPC and native subscriptions share one stream; connected is the readiness barrier.
yield* opencode.events.subscribe().pipe(
Stream.filter((event) => event.type === "server.connected"),
Stream.runHead,
Effect.timeout("5 seconds"),
)
const next = Queue.take(events).pipe(Effect.timeout("5 seconds"))
const execute = (action: Browser.Action) =>
tool.execute(action, {
sessionID: session.id,
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.create(),
id: Tool.CallID.make(crypto.randomUUID()),
progress: () => Effect.void,
})
return {
opencode,
location,
rpc,
permissions,
execute,
next,
attach: Effect.fn(function* (connectionID: string) {
const input = { sessionID: session.id, connectionID }
const lifetime = yield* rpc.attach(input, { location }).pipe(Effect.forkScoped)
expect(yield* next).toMatchObject({
type: "rpc.experimental.browser.control",
location,
data: { type: "attached", connectionID },
})
expect(lifetime.pollUnsafe()).toBeUndefined()
return { input, lifetime }
}),
command: Effect.fn(function* (action: Browser.Action) {
const pending = yield* execute(action).pipe(Effect.forkScoped)
const event = yield* next.pipe(
Effect.raceFirst(
Fiber.join(pending).pipe(Effect.andThen(Effect.die("Tool completed without a browser command"))),
),
)
expect(event.location).toEqual(location)
if (event.data.type !== "command") throw new Error(`Expected command, received ${event.data.type}`)
expect(event.data.command.action).toEqual(action)
return { ...event.data, pending }
}),
}
})
test(
"attachment ownership, cancellation, and plugin unload release pending browser work",
() =>
Effect.gen(function* () {
const host = yield* fixture
const options = { location: host.location }
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
message: "No desktop browser is connected.",
})
const attached = yield* host.attach("first")
expect(
yield* host.rpc.attach({ ...attached.input, connectionID: "duplicate" }, options).pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
const other = Location.Ref.make({ directory: AbsolutePath.make(path.join(host.location.directory, "other")) })
yield* Effect.promise(() => mkdir(other.directory))
yield* host.opencode.plugin.list({ location: other })
expect(yield* host.rpc.attach(attached.input, { location: other }).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
message: "Session belongs to another location.",
})
expect(
yield* host.rpc.state({ ...attached.input, connectionID: "wrong", state }, options).pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
yield* host.rpc.state({ ...attached.input, state }, options)
yield* host.rpc.state({ ...attached.input, state: null }, options)
expect(yield* host.execute({ type: "snapshot" }).pipe(Effect.flip)).toMatchObject({
message: "Open the browser first.",
})
const cancelled = yield* host.command({ type: "open" })
expect(cancelled.command.generation).toBe(0)
yield* Fiber.interrupt(cancelled.pending)
expect((yield* host.next).data).toEqual({
type: "cancel",
connectionID: attached.input.connectionID,
requestID: cancelled.requestID,
})
// A reply to an interrupted request is harmless while its connection is still attached.
yield* host.rpc.result(
{ ...attached.input, requestID: cancelled.requestID, outcome: { type: "failure", message: "late" } },
options,
)
const closing = yield* host.command({ type: "open" })
yield* Fiber.interrupt(attached.lifetime)
expect(yield* Fiber.join(closing.pending).pipe(Effect.flip)).toMatchObject({
message: "Browser connection closed.",
})
expect(yield* host.rpc.state({ ...attached.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
})
const replacement = yield* host.attach("replacement")
const pending = yield* host.command({ type: "open" })
expect(pending.connectionID).toBe("replacement")
expect(pending.command.generation).toBe(0)
expect(
yield* host.rpc
.result(
{
...attached.input,
requestID: pending.requestID,
outcome: { type: "success", result: { type: "state", state } },
},
options,
)
.pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
expect(pending.pending.pollUnsafe()).toBeUndefined()
// Replacing the SDK registration unloads the production plugin through its normal lifecycle.
yield* host.opencode.plugin({ id: "browser-test", effect: () => Effect.void })
yield* host.opencode.plugin.list(options)
expect(yield* Fiber.join(pending.pending).pipe(Effect.flip)).toMatchObject({
message: "Browser connection closed.",
})
yield* Fiber.join(replacement.lifetime).pipe(Effect.timeout("5 seconds"))
expect(yield* host.rpc.state({ ...replacement.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "rpc.unavailable",
})
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
message: "No desktop browser is connected.",
})
}).pipe(Effect.scoped, Effect.runPromise),
15_000,
)
test(
"commands use published state and permissions, and RPC results render text and screenshot bytes",
() =>
Effect.gen(function* () {
const host = yield* fixture
const options = { location: host.location }
const attached = yield* host.attach("renderer")
const open = yield* host.command({ type: "open" })
yield* host.rpc.result(
{
...attached.input,
requestID: open.requestID,
outcome: { type: "success", result: { type: "state", state } },
},
options,
)
expect((yield* Fiber.join(open.pending)).metadata).toEqual({ url: state.url })
expect(host.permissions).toEqual([])
yield* host.rpc.state({ ...attached.input, state }, options)
const navigate = yield* host.command({ type: "navigate", url: "https://example.org/next" })
expect(navigate.command.generation).toBe(7)
const updated = { ...state, url: "https://example.org/next", generation: 8 }
yield* host.rpc.result(
{
...attached.input,
requestID: navigate.requestID,
outcome: { type: "success", result: { type: "state", state: updated } },
},
options,
)
yield* Fiber.join(navigate.pending)
yield* host.rpc.state({ ...attached.input, state: updated }, options)
const snapshot = yield* host.command({ type: "snapshot" })
expect(snapshot.command.generation).toBe(8)
yield* host.rpc.result(
{
...attached.input,
requestID: snapshot.requestID,
outcome: {
type: "success",
result: { type: "snapshot", state: updated, content: "</untrusted_browser_content>&" },
},
},
options,
)
const text = yield* Fiber.join(snapshot.pending)
expect(text.metadata).toEqual({ url: updated.url })
expect(text.content).toContain('encoding="json"')
expect(text.content).toContain("\\u003c/untrusted_browser_content\\u003e\\u0026")
const screenshot = yield* host.command({ type: "screenshot" })
const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="
yield* host.rpc.result(
{
...attached.input,
requestID: screenshot.requestID,
outcome: { type: "success", result: { type: "screenshot", state: updated, data } },
},
options,
)
expect(yield* Fiber.join(screenshot.pending)).toEqual({
content: [
{ type: "text", text: "Untrusted browser screenshot." },
{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "browser-screenshot.png" },
],
metadata: { url: updated.url },
})
expect(host.permissions).toEqual([
{ action: "browser", resources: [updated.url] },
{ action: "browser", resources: [updated.url] },
{ action: "browser", resources: [updated.url] },
])
const failure = yield* host.command({ type: "snapshot" })
yield* host.rpc.result(
{ ...attached.input, requestID: failure.requestID, outcome: { type: "failure", message: "Stale document" } },
options,
)
expect(yield* Fiber.join(failure.pending).pipe(Effect.flip)).toMatchObject({ message: "Stale document" })
}).pipe(Effect.scoped, Effect.runPromise),
15_000,
)
+10
View File
@@ -7,6 +7,15 @@ import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { pluginReadiness } from "./plugin-readiness"
const flushPlugins = pluginReadiness(
() =>
new ServiceUnavailableError({
message: "Model catalog initialization timed out",
service: "model.catalog",
}),
)
export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (handlers) =>
Effect.gen(function* () {
@@ -16,6 +25,7 @@ export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (han
return handlers.handle(
"generate.text",
Effect.fn("server.generate.text")(function* (request) {
yield* flushPlugins
const generate = yield* Generate.Service
const text = yield* generate
.text(request.payload)
+12
View File
@@ -1,8 +1,18 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
import { pluginReadiness } from "./plugin-readiness"
const flushPlugins = pluginReadiness(
() =>
new ServiceUnavailableError({
message: "Model catalog initialization timed out",
service: "model.catalog",
}),
)
export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) =>
Effect.gen(function* () {
@@ -10,6 +20,7 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
.handle(
"model.list",
Effect.fn(function* () {
yield* flushPlugins
const catalog = yield* Catalog.Service
return yield* response(catalog.model.available())
}),
@@ -17,6 +28,7 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
.handle(
"model.default",
Effect.fn(function* () {
yield* flushPlugins
const catalog = yield* Catalog.Service
return yield* response(catalog.model.default())
}),
@@ -0,0 +1,13 @@
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
export function pluginReadiness(error: () => ServiceUnavailableError) {
return PluginSupervisor.Service.pipe(
Effect.flatMap((plugins) => plugins.flush),
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(error()),
}),
)
}
+6 -37
View File
@@ -1,44 +1,13 @@
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginUpdate } from "@opencode-ai/core/plugin/update"
import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { Cause, Effect } from "effect"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handlers) =>
handlers
.handle("plugin.list", () =>
Effect.gen(function* () {
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
)
.handle("plugin.update", (ctx) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
const plugins = yield* Plugin.Service
if (
!(yield* plugins.list()).some(
(plugin) => plugin.source.type === "package" && plugin.source.target === ctx.payload.target,
)
)
return yield* new InvalidRequestError({
message: `Plugin package is not in the current server inventory: ${ctx.payload.target}`,
field: "target",
})
const updates = yield* PluginUpdate.Service
yield* updates.update(ctx.payload.target).pipe(
Effect.catchCause((cause) =>
Effect.fail(
new ServiceUnavailableError({
message: `Failed to update plugin package ${ctx.payload.target}: ${Cause.pretty(cause)}`,
service: "plugin",
}),
),
),
)
}),
),
handlers.handle("plugin.list", () =>
Effect.gen(function* () {
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
),
)
+12 -1
View File
@@ -1,9 +1,18 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { ProviderNotFoundError } from "@opencode-ai/protocol/errors"
import { ProviderNotFoundError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
import { pluginReadiness } from "./plugin-readiness"
const flushPlugins = pluginReadiness(
() =>
new ServiceUnavailableError({
message: "Provider catalog initialization timed out",
service: "provider.catalog",
}),
)
export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) =>
Effect.gen(function* () {
@@ -11,6 +20,7 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han
.handle(
"provider.list",
Effect.fn(function* () {
yield* flushPlugins
const catalog = yield* Catalog.Service
return yield* response(catalog.provider.available())
}),
@@ -18,6 +28,7 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han
.handle(
"provider.get",
Effect.fn(function* (ctx) {
yield* flushPlugins
const catalog = yield* Catalog.Service
const provider = yield* catalog.provider.get(ctx.params.providerID)
if (!provider)
+3
View File
@@ -1,6 +1,7 @@
import { Pty } from "@opencode-ai/core/pty"
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Location } from "@opencode-ai/core/location"
import { Effect, Queue } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -40,6 +41,8 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
.handle(
"pty.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const pty = yield* Pty.Service
const location = yield* Location.Service
const cwd = ctx.payload.cwd || location.directory
+3
View File
@@ -1,5 +1,6 @@
import { Shell } from "@opencode-ai/core/shell"
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { ShellNotFoundError } from "@opencode-ai/protocol/errors"
@@ -19,6 +20,8 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
.handle(
"shell.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const shell = yield* Shell.Service
const location = yield* Location.Service
return yield* response(
+7
View File
@@ -4,6 +4,11 @@ import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
import { pluginReadiness } from "./plugin-readiness"
const flushPlugins = pluginReadiness(
() => new ServiceUnavailableError({ service: "vcs", message: "VCS initialization timed out" }),
)
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
Effect.gen(function* () {
@@ -19,6 +24,7 @@ export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
.handle("vcs.base", () =>
response(
Effect.gen(function* () {
yield* flushPlugins
const vcs = yield* Vcs.Service
return yield* vcs
.base()
@@ -45,6 +51,7 @@ export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
.handle("vcs.diff", (ctx) =>
response(
Effect.gen(function* () {
yield* flushPlugins
const vcs = yield* Vcs.Service
return yield* vcs
.diff(ctx.query.mode, { context: ctx.query.context, base: ctx.query.base })
+11
View File
@@ -4,6 +4,15 @@ import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
import { pluginReadiness } from "./plugin-readiness"
const awaitPlugins = pluginReadiness(
() =>
new ServiceUnavailableError({
message: "Web search provider initialization timed out",
service: "websearch",
}),
).pipe(Effect.withSpan("server.websearch.awaitPlugins"))
export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (handlers) =>
Effect.gen(function* () {
@@ -11,6 +20,7 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
.handle(
"websearch.providers",
Effect.fn("server.websearch.providers")(function* () {
yield* awaitPlugins
const websearch = yield* WebSearch.Service
return yield* response(websearch.providers())
}),
@@ -18,6 +28,7 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
.handle(
"websearch.query",
Effect.fn("server.websearch.query")(function* (request) {
yield* awaitPlugins
const websearch = yield* WebSearch.Service
return yield* response(
websearch.query(request.payload).pipe(
+1 -5
View File
@@ -26,7 +26,6 @@ import { LocationActivity } from "@opencode-ai/core/location-activity"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginUpdate } from "@opencode-ai/core/plugin/update"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Workspace } from "@opencode-ai/core/workspace"
@@ -62,7 +61,6 @@ const applicationServiceNodes = [
SessionTransfer.node,
PluginRuntime.providerNode,
SdkPlugins.node,
PluginUpdate.node,
PermissionSaved.node,
PtyTicket.node,
PersistentPty.node,
@@ -147,9 +145,7 @@ function makeRoutes<AuthError, AuthServices>(
const services = Layer.succeedContext(context)
const requestServices = Layer.merge(
Layer.succeedContext(
Context.pick(Database.Service, PermissionSaved.Service, PluginUpdate.Service, Project.Service, WellKnown.Service)(
context,
),
Context.pick(Database.Service, PermissionSaved.Service, Project.Service, WellKnown.Service)(context),
),
ServerInfo.layer(serviceURLs, options.app),
)
+20 -2
View File
@@ -1,8 +1,10 @@
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SessionNotFoundError, ServiceUnavailableError, UnknownError } from "@opencode-ai/protocol/errors"
import { expect, test } from "bun:test"
import { Effect, Logger, References } from "effect"
import { Effect, Layer, Logger, References } from "effect"
import { pluginReadiness } from "../src/handlers/plugin-readiness"
import { failedMessageDecode, missingSession } from "../src/handlers/session-error"
test("yieldable session errors preserve the handler failure policy", async () => {
@@ -41,3 +43,19 @@ test("message decode policy preserves its reference and log annotations", async
expect(messages).toEqual([["failed to decode session message"]])
expect(annotations).toEqual([{ ref: error.ref, sessionID, messageID }])
})
test("plugin readiness stays lazy and resolves the supervisor for every execution", async () => {
let flushes = 0
const readiness = pluginReadiness(
() => new ServiceUnavailableError({ message: "initialization timed out", service: "test" }),
)
const layer = Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
flushes++
}),
})
expect(flushes).toBe(0)
await Effect.runPromise(Effect.all([readiness, readiness], { concurrency: 1 }).pipe(Effect.provide(layer)))
expect(flushes).toBe(2)
})
+10 -16
View File
@@ -1,12 +1,12 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect, Schedule } from "effect"
import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
it.live("lists models without blocking on plugin initialization", () =>
it.live("waits for plugin initialization before listing models", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-model-endpoint-")))
yield* Effect.promise(() =>
@@ -26,20 +26,14 @@ it.live("lists models without blocking on plugin initialization", () =>
const server = yield* startServer(tmp.path)
const url = new URL("/api/model", server.base)
url.searchParams.set("location[directory]", tmp.path)
const request = Effect.fnUntraced(function* () {
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
return body["data"].some(
(model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat",
)
})
yield* request().pipe(
Effect.filterOrFail((found) => found),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("2 seconds"),
)
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
expect(
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
).toBeTrue()
}),
)
@@ -1,80 +0,0 @@
import fs from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { expect } from "bun:test"
import { Effect, Layer, Schedule } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
it.live("updates package plugins in the requested Location", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-update-http-")))
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const plugin = path.join(tmp.path, "plugin")
yield* Effect.promise(async () => {
await Promise.all([fs.mkdir(global), fs.mkdir(project), fs.mkdir(plugin)])
await Bun.write(path.join(project, "opencode.json"), JSON.stringify({ plugins: ["fixture-plugin"] }))
await Bun.write(path.join(plugin, "index.js"), 'export default { id: "fixture.plugin", setup() {} }')
})
let version = "1.0.0"
let fail = false
const entry = () => ({
directory: plugin,
entrypoint: pathToFileURL(path.join(plugin, "index.js")).href,
version,
revision: version,
})
const handler = yield* ServerFetch.make(
{ database: { path: ":memory:" }, config: { directory: global }, fs: { filewatcher: false } },
{
overrides: [
Npm.node.replace(
Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.sync(entry),
resolve: () => Effect.sync(entry),
check: () => Effect.succeed(false),
update: () => {
if (fail) return Effect.fail(new Npm.InstallFailedError({ dir: plugin }))
return Effect.sync(() => (version = "2.0.0")).pipe(Effect.map(entry))
},
which: () => Effect.undefined,
}),
),
),
],
},
)
const update = (target: string) =>
Effect.promise(() =>
handler(
new Request(`http://opencode.local/api/plugin/update?location[directory]=${encodeURIComponent(project)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ target }),
}),
),
)
expect((yield* update("fixture-plugin")).status).toBe(204)
const listedVersion = yield* Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local/api/plugin?location[directory]=${encodeURIComponent(project)}`),
)
const body = (await response.json()) as { data: Array<{ id?: string; source: { version?: string } }> }
return body.data.find((item) => item.id === "fixture.plugin")?.source.version
}).pipe(
Effect.filterOrFail((version) => version === "2.0.0"),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("2 seconds"),
)
expect(listedVersion).toBe("2.0.0")
expect((yield* update("missing")).status).toBe(400)
fail = true
expect((yield* update("fixture-plugin")).status).toBe(503)
}),
)
+13 -23
View File
@@ -1,49 +1,39 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect, Schedule } from "effect"
import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
it.live(
"lists providers without blocking on plugin initialization",
"waits for plugin initialization on the first provider list request",
() =>
Effect.gen(function* () {
const fixture = yield* configuredProvider("opencode-provider-list-endpoint-")
const url = new URL("/api/provider", fixture.server.base)
url.searchParams.set("location[directory]", fixture.path)
yield* Effect.promise(async () => {
const response = await fetch(url, { headers: fixture.server.headers })
if (response.status !== 200) return false
const body: unknown = await response.json()
return isRecord(body) && Array.isArray(body["data"])
? body["data"].some((provider) => isRecord(provider) && provider["id"] === "custom")
: false
}).pipe(
Effect.filterOrFail((found) => found),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("2 seconds"),
)
const response = yield* Effect.promise(() => fetch(url, { headers: fixture.server.headers }))
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a provider list response")
expect(body["data"].some((provider) => isRecord(provider) && provider["id"] === "custom")).toBeTrue()
}),
15_000,
)
it.live(
"gets providers without blocking on plugin initialization",
"waits for plugin initialization on the first provider get request",
() =>
Effect.gen(function* () {
const fixture = yield* configuredProvider("opencode-provider-get-endpoint-")
const url = new URL("/api/provider/custom", fixture.server.base)
url.searchParams.set("location[directory]", fixture.path)
const body: unknown = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(url, { headers: fixture.server.headers })
if (response.status !== 200) throw new Error(`Provider not ready: ${response.status}`)
return response.json()
},
catch: (cause) => cause,
}).pipe(Effect.retry(Schedule.spaced("10 millis")), Effect.timeout("2 seconds"))
const response = yield* Effect.promise(() => fetch(url, { headers: fixture.server.headers }))
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !isRecord(body["data"])) throw new Error("Expected a provider response")
expect(body["data"]["id"]).toBe("custom")
}),
+6 -21
View File
@@ -2,7 +2,7 @@ import path from "node:path"
import { $ } from "bun"
import { expect } from "bun:test"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Effect, Layer, Schedule } from "effect"
import { Effect, Layer } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
@@ -29,16 +29,9 @@ it.live(
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/base", server.base)
url.searchParams.set("location[directory]", tmp.path)
const base = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(url, { headers: server.headers })
const body: unknown = await response.json()
if (!isRecord(body) || !isRecord(body.data)) throw new Error("VCS provider not ready")
return body
},
catch: (cause) => cause,
}).pipe(Effect.retry(Schedule.spaced("10 millis")), Effect.timeout("2 seconds"))
expect(base).toMatchObject({
const base = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(base.status).toBe(200)
expect(yield* Effect.promise(() => base.json())).toMatchObject({
data: { name: "main", ref: "refs/heads/main", source: "reflog" },
})
yield* Effect.promise(() => $`git branch -m ambiguous`.cwd(tmp.path).quiet())
@@ -92,7 +85,7 @@ it.live("maps a failing base provider to HTTP 503 instead of null metadata", ()
all: () => [
{
id: "failing-vcs",
revision: "test",
version: "test",
effect: (ctx) =>
ctx.vcs
.transform((draft) => {
@@ -118,11 +111,7 @@ it.live("maps a failing base provider to HTTP 503 instead of null metadata", ()
)
const url = new URL("http://opencode.local/api/vcs/base")
url.searchParams.set("location[directory]", tmp.path)
const response = yield* Effect.promise(() => handler(new Request(url))).pipe(
Effect.filterOrFail((response) => response.status === 503),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("2 seconds"),
)
const response = yield* Effect.promise(() => handler(new Request(url)))
expect(response.status).toBe(503)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "ServiceUnavailableError",
@@ -131,7 +120,3 @@ it.live("maps a failing base provider to HTTP 503 instead of null metadata", ()
})
}),
)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -9,7 +9,6 @@ import type {
FormField,
FormFields,
FormValue,
LocationRef,
} from "@opencode-ai/client"
import open from "open"
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
@@ -17,7 +16,6 @@ import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useLocation } from "../context/location"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { DialogPrompt } from "../ui/dialog-prompt"
@@ -76,12 +74,10 @@ export function DialogIntegration(
props: { onConnected?: OnIntegrationConnected; integrationID?: string; autoConnect?: boolean } = {},
) {
const data = useData()
const currentLocation = useLocation()
const dialog = useDialog()
const theme = useTheme("elevated")
const location = currentLocation.ref ?? data.location.default()
const integrations = createMemo(() =>
integrationOptions(data.location.integration.list(location) ?? []).filter(
integrationOptions(data.location.integration.list() ?? []).filter(
(integration) => props.integrationID === undefined || integration.id === props.integrationID,
),
)
@@ -92,14 +88,14 @@ export function DialogIntegration(
if (!integration) return
const methods = connectMethods(integration)
if (credentialConnections(integration).length) {
manageConnections(integration, methods, location, dialog, props.onConnected)
manageConnections(integration, methods, dialog, props.onConnected)
return
}
selectMethod(integration, methods, location, dialog, props.onConnected)
selectMethod(integration, methods, dialog, props.onConnected)
})
const options = createMemo(() => {
const providers = data.location.websearch.list(location) ?? []
const providers = data.location.websearch.list() ?? []
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
return integrations().map((integration) => {
const methods = connectMethods(integration)
@@ -121,8 +117,8 @@ export function DialogIntegration(
? () => <text fg={theme.text.feedback.success.default}></text>
: undefined,
onSelect: () => {
if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected)
return selectMethod(integration, methods, location, dialog, props.onConnected)
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
return selectMethod(integration, methods, dialog, props.onConnected)
},
}
})
@@ -149,7 +145,6 @@ export function DialogIntegration(
function manageConnections(
integration: IntegrationInfo,
methods: ConnectMethod[],
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
@@ -161,9 +156,7 @@ function manageConnections(
const shortcuts = Keymap.useShortcuts()
const [deleting, setDeleting] = createSignal<string>()
const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id)
const current = createMemo(() =>
data.location.integration.list(location)?.find((item) => item.id === integration.id),
)
const current = createMemo(() => data.location.integration.list()?.find((item) => item.id === integration.id))
return (
<DialogSelect
@@ -181,7 +174,7 @@ function manageConnections(
{
title: "Add account",
value: "add",
onSelect: () => selectMethod(current() ?? integration, methods, location, dialog, onConnected),
onSelect: () => selectMethod(current() ?? integration, methods, dialog, onConnected),
},
]
: []),
@@ -200,7 +193,7 @@ function manageConnections(
onSelect: () => {
if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return
void client.api.credential
.activate({ credentialID: connection.id, location: locationQuery(location) })
.activate({ credentialID: connection.id, location: location(data) })
.catch(toast.error)
},
}
@@ -224,8 +217,8 @@ function manageConnections(
const label = value.trim()
if (!label) return
void client.api.credential
.update({ credentialID: option.value, label, location: locationQuery(location) })
.then(() => manageConnections(integration, methods, location, dialog, onConnected))
.update({ credentialID: option.value, label, location: location(data) })
.then(() => manageConnections(integration, methods, dialog, onConnected))
.catch(toast.error)
}}
/>
@@ -241,7 +234,7 @@ function manageConnections(
if (deleting() !== option.value) return setDeleting(option.value)
const final = credentialConnections(current() ?? integration).length === 1
void client.api.credential
.remove({ credentialID: option.value, location: locationQuery(location) })
.remove({ credentialID: option.value, location: location(data) })
.then(() => {
setDeleting(undefined)
if (!final) return
@@ -263,18 +256,17 @@ function manageConnections(
function selectMethod(
integration: IntegrationInfo,
methods: ConnectMethod[],
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
if (methods.length === 1) return openMethod(integration, methods[0], location, dialog, onConnected)
if (methods.length === 1) return openMethod(integration, methods[0], dialog, onConnected)
dialog.replace(() => (
<DialogSelect
title={`Connect ${integration.name}`}
options={methods.map((method) => ({
title: method.type === "key" ? (method.label ?? "API key") : method.label,
value: method.type === "key" ? "key" : method.id,
onSelect: () => openMethod(integration, method, location, dialog, onConnected),
onSelect: () => openMethod(integration, method, dialog, onConnected),
}))}
/>
))
@@ -283,27 +275,23 @@ function selectMethod(
function openMethod(
integration: IntegrationInfo,
method: ConnectMethod,
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
if (method.type === "key") {
void beginKey(integration, method, location, dialog, onConnected)
void beginKey(integration, method, dialog, onConnected)
return
}
if (method.type === "command") {
dialog.replace(() => (
<CommandStarting integration={integration} method={method} location={location} onConnected={onConnected} />
))
dialog.replace(() => <CommandStarting integration={integration} method={method} onConnected={onConnected} />)
return
}
void beginOAuth(integration, method, location, dialog, onConnected)
void beginOAuth(integration, method, dialog, onConnected)
}
async function beginKey(
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "key" }>,
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
@@ -312,16 +300,16 @@ async function beginKey(
: undefined
if (answer === null) return
dialog.replace(() => (
<KeyMethod integration={integration} method={method} location={location} answer={answer} onConnected={onConnected} />
<KeyMethod integration={integration} method={method} answer={answer} onConnected={onConnected} />
))
}
function CommandStarting(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "command" }>
location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
const dialog = useDialog()
const client = useClient()
const toast = useToast()
@@ -333,14 +321,14 @@ function CommandStarting(props: {
.connect({
integrationID: props.integration.id,
methodID: props.method.id,
location: locationQuery(props.location),
location: location(data),
})
.then((result) => {
if (closed) {
void client.api.integration.command.cancel({
integrationID: props.integration.id,
attemptID: result.data.attemptID,
location: locationQuery(props.location),
location: location(data),
})
return
}
@@ -350,7 +338,6 @@ function CommandStarting(props: {
integration={props.integration}
title={props.method.label}
attempt={result.data}
location={props.location}
onConnected={props.onConnected}
/>
))
@@ -372,7 +359,6 @@ function CommandPending(props: {
integration: IntegrationInfo
title: string
attempt: CommandAttempt
location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -388,7 +374,7 @@ function CommandPending(props: {
.status({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: locationQuery(props.location),
location: location(data),
})
.then((result) => {
const status = result.data
@@ -399,7 +385,7 @@ function CommandPending(props: {
}
settled = true
if (status.status === "complete") {
void connected(props.integration, props.location, data, dialog, toast, props.onConnected)
void connected(props.integration, data, dialog, toast, props.onConnected)
return
}
toast.show({
@@ -422,7 +408,7 @@ function CommandPending(props: {
void client.api.integration.command.cancel({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: locationQuery(props.location),
location: location(data),
})
})
@@ -463,7 +449,6 @@ function CommandView(props: { title: string; output: string; message: string })
function KeyMethod(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "key" }>
location: LocationRef
answer?: FormAnswer
onConnected?: OnIntegrationConnected
}) {
@@ -483,11 +468,11 @@ function KeyMethod(props: {
void client.api.integration.connect
.key({
integrationID: props.integration.id,
location: locationQuery(props.location),
location: location(data),
key,
...(props.answer ? { answer: props.answer } : {}),
})
.then(() => connected(props.integration, props.location, data, dialog, toast, props.onConnected))
.then(() => connected(props.integration, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause)))
}}
description={() => (
@@ -500,30 +485,23 @@ function KeyMethod(props: {
async function beginOAuth(
integration: IntegrationInfo,
method: IntegrationOAuthMethod,
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
const answer = method.form ? await formAnswer(dialog, method.label, method.form) : undefined
if (answer === null) return
dialog.replace(() => (
<OAuthStarting
integration={integration}
method={method}
location={location}
answer={answer}
onConnected={onConnected}
/>
<OAuthStarting integration={integration} method={method} answer={answer} onConnected={onConnected} />
))
}
function OAuthStarting(props: {
integration: IntegrationInfo
method: IntegrationOAuthMethod
location: LocationRef
answer?: FormAnswer
onConnected?: OnIntegrationConnected
}) {
const data = useData()
const dialog = useDialog()
const client = useClient()
const toast = useToast()
@@ -532,7 +510,7 @@ function OAuthStarting(props: {
void client.api.integration.oauth
.connect({
integrationID: props.integration.id,
location: locationQuery(props.location),
location: location(data),
methodID: props.method.id,
...(props.answer ? { answer: props.answer } : {}),
})
@@ -543,7 +521,6 @@ function OAuthStarting(props: {
integration={props.integration}
title={props.method.label}
attempt={result.data}
location={props.location}
onConnected={props.onConnected}
/>
))
@@ -554,7 +531,6 @@ function OAuthStarting(props: {
integration={props.integration}
title={props.method.label}
attempt={result.data}
location={props.location}
onConnected={props.onConnected}
/>
))
@@ -572,7 +548,6 @@ function OAuthAuto(props: {
integration: IntegrationInfo
title: string
attempt: IntegrationAttempt
location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -616,11 +591,7 @@ function OAuthAuto(props: {
const poll = () => {
void client.api.integration.oauth
.status({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: locationQuery(props.location),
})
.status({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data) })
.then((result) => {
const status = result.data
if (status.status === "pending") {
@@ -629,7 +600,7 @@ function OAuthAuto(props: {
}
settled = true
if (status.status === "complete") {
void connected(props.integration, props.location, data, dialog, toast, props.onConnected)
void connected(props.integration, data, dialog, toast, props.onConnected)
return
}
toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" })
@@ -649,7 +620,7 @@ function OAuthAuto(props: {
void client.api.integration.oauth.cancel({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: locationQuery(props.location),
location: location(data),
})
})
@@ -669,7 +640,6 @@ function OAuthCode(props: {
integration: IntegrationInfo
title: string
attempt: IntegrationAttempt
location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -685,7 +655,7 @@ function OAuthCode(props: {
void client.api.integration.oauth.cancel({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: locationQuery(props.location),
location: location(data),
})
})
@@ -699,12 +669,12 @@ function OAuthCode(props: {
.complete({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: locationQuery(props.location),
location: location(data),
code,
})
.then(() => {
settled = true
return connected(props.integration, props.location, data, dialog, toast, props.onConnected)
return connected(props.integration, data, dialog, toast, props.onConnected)
})
.catch((cause) => setError(message(cause)))
}}
@@ -998,31 +968,26 @@ async function externalAnswer(
async function connected(
integration: IntegrationInfo,
location: LocationRef,
data: ReturnType<typeof useData>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
onConnected?: OnIntegrationConnected,
) {
data.location.integration.invalidate(location)
data.location.model.invalidate(location)
data.location.provider.invalidate(location)
await Promise.all([
data.location.integration.sync(location),
data.location.model.sync(location),
data.location.provider.sync(location),
])
data.location.integration.invalidate()
data.location.model.invalidate()
data.location.provider.invalidate()
await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()])
toast.show({ variant: "success", message: `Connected ${integration.name}` })
if (onConnected) {
onConnected(providerID(data, location, integration.id))
onConnected(providerID(data, integration.id))
return
}
dialog.clear()
}
function providerID(data: ReturnType<typeof useData>, location: LocationRef, integrationID: string) {
const models = data.location.model.list(location) ?? []
const matches = (data.location.provider.list(location) ?? []).filter(
function providerID(data: ReturnType<typeof useData>, integrationID: string) {
const models = data.location.model.list() ?? []
const matches = (data.location.provider.list() ?? []).filter(
(provider) => provider.integrationID === integrationID || provider.id === integrationID,
)
return (
@@ -1032,8 +997,9 @@ function providerID(data: ReturnType<typeof useData>, location: LocationRef, int
)
}
function locationQuery(location: LocationRef) {
return { directory: location.directory, workspace: location.workspaceID }
function location(data: ReturnType<typeof useData>) {
const current = data.location.default()
return { directory: current.directory, workspace: current.workspaceID }
}
function message(cause: unknown) {
@@ -84,17 +84,17 @@ export function PluginsDialog(props: {
value: entry.key,
category: entry.runtime === "tui" ? "TUI" : "Server",
searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context),
footer: footer(entry),
footer: status(entry) === "active" ? undefined : status(entry),
footerColor:
status(entry) === "failed"
? props.context.theme.text.feedback.error.default
: outdated(entry)
? props.context.theme.text.feedback.info.default
: props.context.theme.text.subdued,
: props.context.theme.text.subdued,
gutter:
status(entry) === "failed"
? () => <text fg={props.context.theme.text.feedback.error.default}>x</text>
: undefined,
status(entry) === "active"
? () => <text fg={props.context.theme.text.feedback.success.default}></text>
: status(entry) === "failed"
? () => <text fg={props.context.theme.text.feedback.error.default}></text>
: undefined,
}),
),
)
@@ -211,7 +211,7 @@ function pluginSource(entry: Entry, context: Plugin.Context) {
}
function source(plugin: PluginInfo, context: Plugin.Context) {
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return context.ui.format.path(plugin.source.path)
return plugin.source.type
}
@@ -221,25 +221,6 @@ function status(entry: Entry) {
return entry.status
}
function outdated(entry: Entry) {
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.outdated === true
}
function footer(entry: Entry) {
const details = [
...(status(entry) === "active" ? [] : [status(entry)]),
...(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.version
? [displayVersion(entry.plugin.source.version)]
: []),
...(outdated(entry) ? ["update available"] : []),
]
return details.length ? details.join(", ") : undefined
}
function displayVersion(version: string) {
return /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(version) ? version.slice(0, 7) : version
}
function pluginError(entry: Entry | undefined) {
if (entry?.runtime === "server")
return entry.plugin.state.status === "failed" ? entry.plugin.state.error : undefined
+1 -1
View File
@@ -272,7 +272,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
discovered: true,
})),
...serverPlugins().map((plugin) => ({
entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path),
entry: plugin.source.type === "package" ? plugin.source.package : path.dirname(plugin.source.path),
install: false,
server: true,
discovered: false,
@@ -1,6 +1,5 @@
/** @jsxImportSource @opentui/solid */
import { InputRenderable, TextareaRenderable } from "@opentui/core"
import type { LocationRef } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onMount } from "solid-js"
@@ -9,7 +8,7 @@ import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { LocationProvider } from "../../../src/context/location"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
@@ -204,27 +203,9 @@ test("hides account rename and delete actions while the add account row is selec
}
})
test("uses the active location for integration data and credential requests", async () => {
const location = { directory: "/remote/project", workspaceID: "workspace_test" }
const fixture = await renderIntegration(location)
try {
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.requests.length === 1)
expect(fixture.locations).toContainEqual(location)
expect(fixture.locations.at(-1)).toEqual(location)
} finally {
fixture.app.renderer.destroy()
}
})
async function renderIntegration(activeLocation?: LocationRef) {
async function renderIntegration() {
const events = createEventStream()
const requests: Array<{ method: string; path: string; body?: { label: string } }> = []
const locations: LocationRef[] = []
const reads = { integration: 0, model: 0, provider: 0 }
let accounts = [
{ type: "credential" as const, id: "cred_personal", label: "Personal" },
@@ -232,19 +213,12 @@ async function renderIntegration(activeLocation?: LocationRef) {
]
const calls = createFetch(async (url, request) => {
const directory =
url.searchParams.get("location[directory]") ??
decodeURIComponent(request.headers.get("x-opencode-directory") ?? process.cwd())
const workspaceID =
url.searchParams.get("location[workspace]") ?? request.headers.get("x-opencode-workspace") ?? undefined
const requestedLocation = { directory, ...(workspaceID ? { workspaceID } : {}) }
const location = {
...requestedLocation,
project: { id: "proj_test", directory, canonical: directory },
directory: process.cwd(),
project: { id: "proj_test", directory: process.cwd(), canonical: process.cwd() },
}
if (url.pathname === "/api/integration") {
locations.push(requestedLocation)
reads.integration++
return json({
location,
@@ -270,7 +244,6 @@ async function renderIntegration(activeLocation?: LocationRef) {
}
if (request.method === "POST" && /^\/api\/credential\/[^/]+\/activate$/.test(url.pathname)) {
locations.push(requestedLocation)
const id = url.pathname.split("/")[3]
const active = accounts.find((account) => account.id === id)
if (!active) throw new Error(`unknown credential: ${id}`)
@@ -316,11 +289,9 @@ async function renderIntegration(activeLocation?: LocationRef) {
function Probe() {
const data = useData()
const dialog = useDialog()
const location = useLocation()
onMount(() => {
location.set(activeLocation)
void data.location.integration
.sync(activeLocation)
.sync()
.then(() => dialog.replace(() => <DialogIntegration integrationID="openai" autoConnect />))
})
return null
@@ -361,7 +332,6 @@ async function renderIntegration(activeLocation?: LocationRef) {
app,
reads,
requests,
locations,
get accounts() {
return accounts
},
+1 -1
View File
@@ -121,7 +121,7 @@ test("loads an advertised package TUI entrypoint only from the local cache", asy
plugins: [
{
id: "test.server",
source: { type: "package", target: "test-plugin@1.0.0" },
source: { type: "package", package: "test-plugin@1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
},
-2
View File
@@ -54,7 +54,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:"
},
"devDependencies": {
@@ -64,7 +63,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:"
}
}
+105 -298
View File
@@ -1,8 +1,8 @@
export * as Npm from "./npm.js"
import path from "path"
import { createHash, randomUUID } from "node:crypto"
import { Clock, Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { createHash } from "node:crypto"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { FSUtil } from "./fs-util.js"
import { Global } from "./global.js"
import { EffectFlock } from "./effect-flock.js"
@@ -22,21 +22,14 @@ export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()
export interface EntryPoint {
readonly directory: string
readonly entrypoint?: string
readonly version?: string
readonly revision?: string
}
export interface Interface {
readonly add: (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
readonly check: (pkg: string) => Effect.Effect<boolean, InstallFailedError>
readonly update: (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
}
@@ -50,68 +43,33 @@ export function sanitize(pkg: string) {
}
export async function isRegistryPackage(pkg: string) {
return (await parse(pkg))?.type === "registry"
}
export async function isInstallablePackage(pkg: string) {
return (await parse(pkg)) !== undefined
}
export async function cacheKey(pkg: string) {
return key(pkg, await parse(pkg))
}
type Target =
| { readonly type: "registry"; readonly name: string; readonly spec: string; readonly mutable: boolean }
| { readonly type: "git"; readonly name?: string; readonly slug: string; readonly mutable: boolean }
async function parse(pkg: string): Promise<Target | undefined> {
const { default: npa } = await import("npm-package-arg")
try {
const result = npa(pkg)
if (result.type === "git") {
return {
type: "git",
...(result.name ? { name: result.name } : {}),
slug: gitSlug(pkg),
mutable: !isCommit(result.gitCommittish),
}
}
if (!result.name || !["version", "range", "tag"].includes(result.type)) return
return {
type: "registry",
name: result.name,
spec: result.raw === result.name ? "latest" : result.rawSpec,
mutable: result.type !== "version",
}
return result.name !== undefined && ["version", "range", "tag"].includes(result.type)
} catch {
return
return false
}
}
function key(pkg: string, target: Target | undefined) {
if (target?.type === "git")
return `git-${target.slug}-${createHash("sha256").update(pkg).digest("hex").slice(0, 12)}`
if (target?.type === "registry") return sanitize(`${target.name}@${target.spec}`)
return sanitize(pkg)
export async function isInstallablePackage(pkg: string) {
const { default: npa } = await import("npm-package-arg")
try {
const result = npa(pkg)
return result.type === "git" || (result.name !== undefined && ["version", "range", "tag"].includes(result.type))
} catch {
return false
}
}
function gitSlug(pkg: string) {
const target = (() => {
try {
return decodeURIComponent(pkg.split("#")[0])
} catch {
return pkg.split("#")[0]
}
})()
return (
target
.replace(/\.git$/i, "")
.split(/[/:\\]/)
.at(-1)
?.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "") || "repository"
)
export async function cacheKey(pkg: string) {
const { default: npa } = await import("npm-package-arg")
try {
if (npa(pkg).type === "git") return `git-${createHash("sha256").update(pkg).digest("hex")}`
} catch {
// Preserve the existing fallback for invalid and non-registry package strings.
}
return sanitize(pkg)
}
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
@@ -141,16 +99,8 @@ interface ArboristTree {
const PackageJson = Schema.Struct({
dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
version: Schema.optional(Schema.String),
})
const PackageLock = Schema.Struct({
packages: Schema.optional(Schema.Record(Schema.String, Schema.Struct({ resolved: Schema.optional(Schema.String) }))),
})
const retention = 7 * 24 * 60 * 60 * 1_000
const stagingRetention = 60 * 60 * 1_000
const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -158,33 +108,13 @@ const layer = Layer.effect(
const global = yield* Global.Service
const fs = yield* FileSystem.FileSystem
const flock = yield* EffectFlock.Service
const directory = (pkg: string, target: Target | undefined) => path.join(global.cache, "npm", key(pkg, target))
const generations = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readDirectory(dir).pipe(
Effect.orElseSucceed(() => [] as string[]),
Effect.map((entries) =>
entries
.filter((entry) => /^\d+$/.test(entry))
.toSorted((a, b) => Number(a) - Number(b)),
),
const directory = (pkg: string) =>
Effect.map(
Effect.promise(() => cacheKey(pkg)),
(key) => path.join(global.cache, "packages", key),
)
})
const current = Effect.fnUntraced(function* (dir: string) {
const latest = (yield* generations(dir)).at(-1)
return latest ? path.join(dir, latest) : undefined
})
const mkdir = (dir: string) =>
fs.makeDirectory(dir, { recursive: true }).pipe(
Effect.mapError((cause) => new InstallFailedError({ dir, cause })),
)
const remove = (target: string, dir: string) =>
fs.remove(target, { recursive: true, force: true }).pipe(
Effect.mapError((cause) => new InstallFailedError({ dir, cause })),
)
const rename = (from: string, to: string, dir: string) =>
fs.rename(from, to).pipe(Effect.mapError((cause) => new InstallFailedError({ dir, cause })))
const installedName = Effect.fnUntraced(function* (pkg: string, dir: string, target?: Target) {
if (target?.name) return target.name
const installedName = Effect.fnUntraced(function* (pkg: string, dir: string, parsedName?: string) {
if (parsedName) return parsedName
const manifest = yield* afs
.readJson(path.join(dir, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
@@ -194,48 +124,13 @@ const layer = Layer.effect(
}
return pkg
})
const installedRevision = Effect.fnUntraced(function* (root: string, name: string, target: Target) {
const dir = path.join(root, "node_modules", name)
if (target.type === "registry") {
const manifest = yield* afs
.readJson(path.join(dir, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
return Option.isSome(manifest) ? manifest.value.version : undefined
}
for (const file of [path.join(root, "package-lock.json"), path.join(root, "node_modules", ".package-lock.json")]) {
const lock = yield* afs
.readJson(file)
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageLock)), Effect.option)
const revision = gitRevision(
Option.isSome(lock) ? lock.value.packages?.[`node_modules/${name}`]?.resolved : undefined,
)
if (revision) return revision
}
})
const entry = Effect.fnUntraced(function* (
root: string,
name: string,
dir: string,
target: Target | undefined,
subpaths?: readonly string[],
) {
const manifest = yield* afs
.readJson(path.join(dir, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
const manifestVersion = Option.isSome(manifest) ? manifest.value.version : undefined
const revision = target ? (yield* installedRevision(root, name, target)) ?? manifestVersion : undefined
const version = target?.type === "git" ? revision : manifestVersion
return {
...resolveEntryPoint(name, dir, subpaths),
...(version ? { version } : {}),
...(revision ? { revision } : {}),
}
})
const reify = (input: { dir: string; config?: string; add?: string[]; update?: boolean }) =>
const refreshed = new Set<string>()
const reify = (input: { dir: string; add?: string[]; update?: boolean }) =>
Effect.gen(function* () {
yield* flock.acquire(`npm-install:${input.dir}`)
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const add = input.add ?? []
const npmOptions = yield* NpmConfig.load(input.config ?? input.dir)
const npmOptions = yield* NpmConfig.load(input.dir)
const options = input.update ? { ...npmOptions, preferOnline: true, noGitRevCache: true } : npmOptions
const arborist = new Arborist({
...options,
@@ -267,188 +162,111 @@ const layer = Layer.effect(
}),
)
const install = Effect.fnUntraced(function* (
pkg: string,
target: Target | undefined,
dir: string,
subpaths: readonly string[] | undefined,
update: boolean,
) {
yield* flock.acquire(`npm-install:${dir}`)
const active = yield* current(dir)
const name = yield* installedName(pkg, active ?? dir, target)
if (active && !update && (yield* afs.existsSafe(path.join(active, "node_modules", name)))) {
return yield* entry(active, name, path.join(active, "node_modules", name), target, subpaths)
}
yield* mkdir(dir)
const startedAt = yield* Clock.currentTimeMillis
const staging = path.join(dir, `.staging-${startedAt}-${randomUUID()}`)
const staged = yield* Effect.gen(function* () {
const tree = yield* reify({ dir: staging, config: dir, add: [pkg], update })
const installed = tree.edgesOut.values().next().value?.to
const installedNameValue = installed?.name ?? (yield* installedName(pkg, staging, target))
const result = yield* entry(
staging,
installedNameValue,
installed?.path ?? path.join(staging, "node_modules", installedNameValue),
target,
subpaths,
)
if (!installed && !result.entrypoint) return yield* new InstallFailedError({ add: [pkg], dir: staging })
return { name: installedNameValue, result }
}).pipe(Effect.onError(() => remove(staging, dir).pipe(Effect.ignore)))
if (active) {
const activeEntry = yield* entry(active, name, path.join(active, "node_modules", name), target, subpaths)
if (activeEntry.revision && activeEntry.revision === staged.result.revision) {
yield* remove(staging, dir)
return activeEntry
}
}
const completedAt = yield* Clock.currentTimeMillis
const newest = Number((yield* generations(dir)).at(-1) ?? 0)
const generation = path.join(dir, String(Math.max(completedAt, newest + 1)))
yield* rename(staging, generation, dir)
return yield* entry(
generation,
staged.name,
path.join(generation, "node_modules", staged.name),
target,
subpaths,
)
})
const collect = Effect.fnUntraced(function* (dir: string) {
const now = yield* Clock.currentTimeMillis
const completed = yield* generations(dir)
const keep = new Set(completed.slice(-2))
const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => [] as string[]))
yield* Effect.forEach(
entries,
(name) => {
const timestamp = /^\d+$/.test(name)
? Number(name)
: Number(name.match(/^\.staging-(\d+)-/)?.[1] ?? Number.NaN)
const maximumAge = name.startsWith(".staging-") ? stagingRetention : retention
if (!Number.isFinite(timestamp) || keep.has(name) || now - timestamp <= maximumAge) return Effect.void
return remove(path.join(dir, name), dir).pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to remove stale npm generation", { dir, name, cause })),
)
},
{ concurrency: "unbounded", discard: true },
)
})
const add = Effect.fn("Npm.add")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
) {
const target = yield* Effect.promise(() => parse(pkg))
const dir = directory(pkg, target)
return yield* install(pkg, target, dir, options?.subpaths, false)
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsed = (() => {
try {
return npa(pkg)
} catch {
return undefined
}
})()
const parsedName = parsed?.name ?? undefined
const dir = yield* directory(pkg)
const name = yield* installedName(pkg, dir, parsedName)
const cached = yield* afs.existsSafe(path.join(dir, "node_modules", name))
const refresh = options?.refresh && isMutable(parsed) && !refreshed.has(pkg)
if (refresh) {
refreshed.add(pkg)
if (cached)
yield* reify({ dir, add: [pkg], update: true }).pipe(
Effect.catchCause(() => Effect.logWarning("failed to refresh cached package; using installed version")),
)
}
if (cached) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
}
const tree = yield* reify({ dir, add: [pkg] })
if (isMutable(parsed)) refreshed.add(pkg)
const first = tree.edgesOut.values().next().value?.to
if (!first) {
const installed = yield* installedName(pkg, dir, parsedName)
const result = resolveEntryPoint(installed, path.join(dir, "node_modules", installed), options?.subpaths)
if (result.entrypoint) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
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 target = yield* Effect.promise(() => parse(pkg))
const root = directory(pkg, target)
const generation = yield* current(root)
const name = yield* installedName(pkg, generation ?? root, target)
const dir = path.join(generation ?? root, "node_modules", name)
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsedName = (() => {
try {
return npa(pkg).name ?? undefined
} catch {
return undefined
}
})()
const root = yield* directory(pkg)
const name = yield* installedName(pkg, root, parsedName)
const dir = path.join(root, "node_modules", name)
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
return yield* entry(generation ?? root, name, dir, target, options?.subpaths)
return resolveEntryPoint(name, dir, options?.subpaths)
})
const check = Effect.fn("Npm.check")(function* (pkg: string) {
const target = yield* Effect.promise(() => parse(pkg))
const root = directory(pkg, target)
if (!target)
return yield* new InstallFailedError({
dir: root,
cause: new Error("Package checks only support registry and Git package specs"),
})
if (!target.mutable) return false
const generation = yield* current(root)
const name = yield* installedName(pkg, generation ?? root, target)
const installed = generation ? yield* installedRevision(generation, name, target) : undefined
if (!installed)
return yield* new InstallFailedError({ dir: root, cause: new Error(`Package is not installed: ${pkg}`) })
const { manifest, resolve } = yield* Effect.promise(() => import("pacote"))
const options = { ...(yield* NpmConfig.load(root)), preferOnline: true, noGitRevCache: true, ignoreScripts: true }
const available = yield* Effect.tryPromise({
try: async () =>
target.type === "git" ? gitRevision(await resolve(pkg, options)) : (await manifest(pkg, options)).version,
catch: (cause) => new InstallFailedError({ dir: root, cause }),
})
if (!available)
return yield* new InstallFailedError({ dir: root, cause: new Error(`Package revision not found: ${pkg}`) })
return installed !== available
})
const update = Effect.fn("Npm.update")(
function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
const target = yield* Effect.promise(() => parse(pkg))
const dir = directory(pkg, target)
if (!target)
return yield* new InstallFailedError({
dir,
cause: new Error("Package updates only support registry and Git package specs"),
})
if (!target.mutable) return yield* add(pkg, options)
const installed = yield* install(pkg, target, dir, options?.subpaths, true)
yield* collect(dir)
return installed
},
Effect.scoped,
)
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
const target = yield* Effect.promise(() => parse(pkg))
const root = directory(pkg, target)
const dir = yield* directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = Effect.fnUntraced(function* (dir: string) {
const binDir = path.join(dir, "node_modules", ".bin")
const pick = Effect.fnUntraced(function* () {
const files = yield* fs.readDirectory(binDir).pipe(Effect.orElseSucceed(() => [] as string[]))
if (files.length === 0) return Option.none<string>()
// Caller picked a specific bin (e.g. pyright exposes both `pyright` and
// `pyright-langserver`); trust the hint if the package provides it.
if (bin) return files.includes(bin) ? Option.some(path.join(binDir, bin)) : Option.none<string>()
if (files.length === 1) return Option.some(path.join(binDir, files[0]))
if (bin) return files.includes(bin) ? Option.some(bin) : Option.none<string>()
if (files.length === 1) return Option.some(files[0])
const packageName = target?.name ?? pkg
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", packageName, "package.json")).pipe(Effect.option)
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
if (Option.isSome(pkgJson)) {
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
if (parsed?.bin) {
const unscoped = packageName.startsWith("@") ? packageName.split("/")[1] : packageName
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const parsedBin = parsed.bin
if (typeof parsedBin === "string") return Option.some(path.join(binDir, unscoped))
if (typeof parsedBin === "string") return Option.some(unscoped)
const keys = Object.keys(parsedBin)
const selected = parsedBin[unscoped] ? unscoped : keys[0]
return selected ? Option.some(path.join(binDir, selected)) : Option.none<string>()
if (keys.length === 1) return Option.some(keys[0])
return parsedBin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
}
}
return Option.some(path.join(binDir, files[0]))
return Option.some(files[0])
})
return Option.getOrUndefined(
yield* Effect.gen(function* () {
const generation = yield* current(root)
const selected = generation ? yield* pick(generation) : Option.none<string>()
if (Option.isSome(selected)) return selected
const bin = yield* pick()
if (Option.isSome(bin)) {
return Option.some(path.join(binDir, bin.value))
}
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
yield* add(pkg)
const installed = yield* current(root)
if (!installed) return Option.none<string>()
return yield* pick(installed)
const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value))
}).pipe(
Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()),
@@ -459,8 +277,6 @@ const layer = Layer.effect(
return Service.of({
add,
resolve,
check,
update,
which,
})
}),
@@ -482,22 +298,13 @@ export async function resolve(...args: Parameters<Interface["resolve"]>) {
return runPromise((svc) => svc.resolve(...args))
}
export async function check(...args: Parameters<Interface["check"]>) {
return runPromise((svc) => svc.check(...args))
}
export async function update(...args: Parameters<Interface["update"]>) {
return runPromise((svc) => svc.update(...args))
}
export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args))
}
function gitRevision(resolved: string | undefined) {
return resolved?.match(/#([a-f0-9]{40}|[a-f0-9]{64})(?=::|$)/i)?.[1]
}
function isCommit(value: string | null | undefined) {
return /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(value ?? "")
function isMutable(parsed: { readonly type: string; readonly gitCommittish?: string | null } | undefined) {
if (!parsed) return false
if (["tag", "range"].includes(parsed.type)) return true
if (parsed.type !== "git") return false
return !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(parsed.gitCommittish ?? "")
}
+2 -119
View File
@@ -486,116 +486,6 @@
"summary": "List plugins"
}
},
"/api/plugin/update": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.update",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Update one package plugin and notify active locations to reload it.",
"summary": "Update plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16703,18 +16593,11 @@
"type": "string",
"enum": ["package"]
},
"target": {
"package": {
"type": "string"
},
"version": {
"type": "string"
},
"outdated": {
"type": "boolean",
"enum": [true]
}
},
"required": ["type", "target"],
"required": ["type", "package"],
"additionalProperties": false
},
{
+2 -119
View File
@@ -486,116 +486,6 @@
"summary": "List plugins"
}
},
"/api/plugin/update": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.update",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Update one package plugin and notify active locations to reload it.",
"summary": "Update plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16703,18 +16593,11 @@
"type": "string",
"enum": ["package"]
},
"target": {
"package": {
"type": "string"
},
"version": {
"type": "string"
},
"outdated": {
"type": "boolean",
"enum": [true]
}
},
"required": ["type", "target"],
"required": ["type", "package"],
"additionalProperties": false
},
{
@@ -636,6 +636,22 @@ const review = await ctx.generate.text({
### Permissions
Assert permission before a plugin action. This plugin-only method uses the generated client's `PermissionCreateInput`
and returns no value: `allow` completes immediately, `ask` waits for a reply, and `deny` fails.
```ts
await ctx.permission.assert({
sessionID,
action: "deploy",
resources: ["staging"],
save: ["staging"],
})
```
Promise plugins receive `Promise<void>`. Effect plugins receive `Effect<void, unknown>` and run it with
`yield* ctx.permission.assert(input)`. Let a rejection propagate so the action does not run. A rejection without feedback
preserves the permission engine's session-interruption behavior; a rejection with feedback carries `Permission.CorrectedError`.
Inspect or resolve pending permission requests.
```ts
+3 -3
View File
@@ -95,9 +95,9 @@ Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirect
local paths directly; tarball and npm alias targets are not accepted by `plugin add`.
Changes under watched config directories reload automatically. Server startup loads cached package plugins immediately,
installs missing packages in the background, and checks unpinned npm and Git plugins for updates without changing the
installed package. Exact npm versions and full Git commit hashes stay pinned. Changes to unwatched local dependencies may
still require restarting OpenCode.
then refreshes unpinned npm and Git plugins in the background. A refreshed package becomes active the next time the server
starts. Exact npm versions and full Git commit hashes stay pinned. Changes to unwatched local dependencies may still require
restarting OpenCode.
```sh
touch .opencode/plugins/concise/index.ts