Compare commits

...
84 changed files with 2002 additions and 1213 deletions
+2
View File
@@ -969,6 +969,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -978,6 +979,7 @@
"@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:",
},
},
+143 -155
View File
@@ -72,6 +72,11 @@ 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),
@@ -79,6 +84,18 @@ 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"),
@@ -184,6 +201,7 @@ export type HostedToolReplayItem = {
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| CompletedReasoningItem
| {
readonly type: "message"
readonly id?: string
@@ -345,6 +363,7 @@ 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),
@@ -382,6 +401,7 @@ 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 }
@@ -403,19 +423,12 @@ 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
// 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>
readonly streamedText: string
readonly emittedRawIndexes: ReadonlySet<number>
readonly emittedSummaryIndexes: ReadonlySet<number>
}
// =============================================================================
@@ -466,9 +479,22 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
}
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
adapter: ProviderAdapter,
): OpenResponsesReasoningInput | CompletedReasoningItem | 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
@@ -623,17 +649,18 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey)
const reasoning = lowerReasoning(part, providerMetadataKey, adapter)
if (!reasoning) continue
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
if (typeof reasoning.encrypted_content === "string")
if (reasoning.encrypted_content === null || typeof reasoning.encrypted_content === "string")
existing.encrypted_content = reasoning.encrypted_content
continue
}
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning
input.push(reasoning)
const converted = { ...reasoning, summary: [...reasoning.summary] }
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = converted
input.push(converted)
continue
}
if (part.type === "tool-call") {
@@ -865,39 +892,22 @@ 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 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 appendReasoningText = (
state: ParserState,
itemID: string,
item: ReasoningStreamItem,
text: string,
): StepResult => {
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.reasoningStart(
lifecycle,
events,
`${itemID}:${index}`,
providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, itemID, text),
reasoningItems: {
...state.reasoningItems,
[itemID]: {
...item,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "concluded" ? entry : [entry[0], "concluded" as const],
),
),
[index]: "active",
},
streamedText: item.streamedText + text,
},
},
},
@@ -905,41 +915,58 @@ const startReasoningSummaryPart = (state: ParserState, itemID: string, index: nu
]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
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 => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item?.open) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
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,
]
return appendReasoningText(
state,
itemID,
{ ...item, emittedRawIndexes: new Set([...item.emittedRawIndexes, event.content_index ?? 0]) },
event.delta,
)
}
// 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 onReasoningRawDone = (state: ParserState, event: ReasoningRawDoneEvent, 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)
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 reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
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,
)
}
// Responses APIs normally stream reasoning items in this order:
// `output_item.added` (reasoning) →
@@ -948,9 +975,7 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// `reasoning_summary_part.done` (index=0) →
// (repeat for index>0) →
// `output_item.done` (reasoning).
// `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.
// `onOutputItemAdded` seeds one lifecycle for the complete provider item.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
@@ -992,14 +1017,15 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, item.id),
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: true,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
streamedText: "",
emittedRawIndexes: new Set(),
emittedSummaryIndexes: new Set(),
},
},
},
@@ -1028,34 +1054,6 @@ 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,
@@ -1174,7 +1172,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (isReasoningItem(item)) {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
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 summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
const summary: Array<string | undefined> = []
for (const part of summaryParts) {
@@ -1188,63 +1194,41 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const decoded = Option.getOrUndefined(decodeReasoningPart(part))
if (decoded) content.push(decoded.text)
}
const itemText = joinReasoningText(summary) ?? joinReasoningText(content)
const text = joinReasoningText(summary) ?? joinReasoningText(content) ?? tracked?.streamedText
const events: LLMEvent[] = []
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)
}
if (tracked) {
return [
{
...state,
lifecycle,
lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, text),
reasoningItems: {
...state.reasoningItems,
[item.id]: {
...reasoningItem,
...tracked,
open: false,
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
},
},
},
events,
] satisfies StepResult
}
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(),
encryptedContent: item.encrypted_content ?? tracked.encryptedContent,
},
},
},
events,
] satisfies StepResult
}
const started = Lifecycle.reasoningStart(state.lifecycle, events, item.id)
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
{
...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(),
},
},
},
events,
] satisfies StepResult
}
@@ -1349,25 +1333,29 @@ 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_summary_text.delta") {
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_text.delta") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
return Effect.succeed(onReasoningRawDelta(state, event, event.item_id))
}
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_text.done"
) {
if (event.type === "response.reasoning.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(onReasoningDone(state, event, event.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))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id !== undefined
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
? Effect.succeed<StepResult>([state, NO_EVENTS])
: 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(onReasoningSummaryPartDone(state, event))
? Effect.succeed<StepResult>([state, NO_EVENTS])
: 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,7 +75,9 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
input: Schema.Array(
Schema.Union([OpenResponses.CompletedReasoningItem, OpenResponses.InputItem, OpenAIResponsesHostedToolItem]),
),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -86,10 +88,16 @@ 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) => {
@@ -185,12 +193,6 @@ 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,13 +164,23 @@ 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" },
"openai-compatible": {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted",
reasoningItem: {
type: "reasoning",
id: "rs_1",
summary: fixture.summary,
content: fixture.content,
encrypted_content: "encrypted",
},
},
})
}),
)
})
it.effect("replaces only the still-open summary without repeating earlier text", () =>
it.effect("replaces streamed reasoning with the completed summary", () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
@@ -190,8 +200,32 @@ describe("Open Responses completed item reasoning", () => {
},
completed,
)
expect(response.reasoning).toBe("First final")
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined, "final"])
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" },
})
}),
)
})
@@ -230,6 +264,7 @@ 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,59 +71,61 @@ function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
}
describe("Open Responses basic-item lifecycles", () => {
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
it.effect("streams mixed reasoning in one lifecycle and ignores late events", () =>
Effect.gen(function* () {
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
const item = {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Completed" }],
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_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.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",
type: "response.reasoning_summary_text.done",
item_id: "rs_1",
summary_index: 0,
text: "ignored summary final",
},
{
type: "response.reasoning_text.done",
output_index: 0,
item_id: "wrong",
summary_index: 2,
delta: "Third",
content_index: 1,
text: "raw final ",
},
{ 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_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 },
{ 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" },
completed,
)
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
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-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-end",
id: "rs_1:2",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
id: "rs_1",
text: "Completed",
providerMetadata: {
"openai-compatible": {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: item,
},
},
},
])
}),
@@ -152,13 +154,18 @@ 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" } },
providerMetadata: {
"openai-compatible": {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: item,
},
},
},
])
}),
@@ -431,7 +438,7 @@ describe("Open Responses basic-item lifecycles", () => {
providerExecuted: undefined,
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
},
{ type: "reasoning-end", id: "rs_1:0" },
{ type: "reasoning-end", id: "rs_1" },
])
}),
)
@@ -468,7 +475,8 @@ describe("Open Responses basic-item lifecycles", () => {
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([
{
type: "reasoning-end",
id: ":0",
id: "",
text: "Thinking",
providerMetadata: { "openai-compatible": { itemId: "", reasoningEncryptedContent: "state" } },
},
])
@@ -543,7 +551,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:0" }])
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: "rs_1" }])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
@@ -303,6 +303,49 @@ 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({
@@ -427,7 +470,7 @@ describe("Open Responses-compatible route", () => {
})
routings.forEach((routing) => {
it.effect(`preserves reasoning summary boundaries without terminal reconciliation with ${routing.name}`, () =>
it.effect(`keeps one reasoning lifecycle without item completion 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(
@@ -458,26 +501,10 @@ describe("Open Responses-compatible route", () => {
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "First.",
providerMetadata: { "openai-compatible": { itemId: routing.id } },
},
{
type: "reasoning",
text: "Second.",
providerMetadata: {
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: null },
},
text: "First.Second.",
},
])
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` },
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: routing.id }])
}),
)
})
@@ -717,7 +744,7 @@ describe("Open Responses-compatible route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
id: "rs_raw:0",
id: "rs_raw",
})
}),
)
@@ -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:0" },
{ type: "reasoning-delta", id: "rs_1:0", text: "thinking" },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1:0" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "text-end", id: "msg_1" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
@@ -2514,7 +2514,6 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
])
@@ -2547,8 +2546,19 @@ describe("OpenAI Responses route", () => {
expect(response.events).toContainEqual(
expect.objectContaining({
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
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" }],
},
},
},
}),
)
}),
@@ -2595,12 +2605,11 @@ 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:0" },
{ type: "reasoning-end", id: "rs_1" },
])
expect(response.message.content).toContainEqual({
type: "reasoning",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
})
}),
)
@@ -2668,7 +2677,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
id: "rs_1:0",
id: "rs_1",
})
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
@@ -2680,7 +2689,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("streams each reasoning summary part as a separate block", () =>
it.effect("streams reasoning summary parts in wire order in one lifecycle", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
@@ -2711,22 +2720,13 @@ describe("OpenAI Responses route", () => {
expect(response.reasoning).toBe("FirstSecond")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{
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-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "First" },
{ type: "reasoning-delta", id: "rs_1", text: "Second" },
{
type: "reasoning-end",
id: "rs_1:1",
id: "rs_1",
text: "FirstSecond",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
@@ -2735,73 +2735,6 @@ 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(
@@ -2997,15 +2930,26 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
id: "rs_1",
providerMetadata: undefined,
},
{ type: "reasoning-delta", id: "rs_1:0", text: "Checked the diff.", providerMetadata: undefined },
{ type: "reasoning-delta", id: "rs_1", text: "Checked the diff.", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:0",
id: "rs_1",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
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",
},
},
},
},
])
@@ -3051,7 +2995,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:0", text: "Streamed", providerMetadata: undefined },
{ type: "reasoning-delta", id: "rs_1", text: "Streamed", providerMetadata: undefined },
])
}),
)
@@ -3074,7 +3018,15 @@ 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", encrypted_content: "encrypted-state" },
item: {
type: "reasoning",
id: "rs_1",
summary: [
{ type: "summary_text", text: "First" },
{ type: "summary_text", text: "Second" },
],
encrypted_content: "encrypted-state",
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -3083,11 +3035,25 @@ 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:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
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",
},
},
},
},
])
}),
@@ -3195,6 +3161,15 @@ 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,
@@ -3203,7 +3178,13 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning",
text: "Checked the previous diff.",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: item,
},
},
},
]),
],
@@ -3211,14 +3192,7 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: "encrypted-state",
},
])
expect(prepared.body.input[0]).toEqual(item)
}),
)
@@ -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", package: id },
source: { type: "package", target: 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", package: "demo-plugin" },
source: { type: "package", target: "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", package: "example" },
source: { type: "package", target: "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.package
if (plugin.source.type === "package") return plugin.source.target
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.package, source: "advertised" as const }]
? [{ target: plugin.source.target, 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.package
if (plugin.source.type === "package") return plugin.source.target
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", package: "acme-plugin@1.0.0" },
source: { type: "package", target: "acme-plugin@1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
},
{
source: { type: "package", package: "broken-plugin" },
source: { type: "package", target: "broken-plugin" },
state: { status: "failed", error: "broken" },
features: { server: true },
},
+8
View File
@@ -88,8 +88,16 @@ 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 = {
+13 -1
View File
@@ -15,6 +15,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -322,7 +324,17 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
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 EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
@@ -9,6 +9,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -466,6 +468,19 @@ 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) =>
+18 -9
View File
@@ -12,7 +12,7 @@ export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginSource =
| { type: "builtin" }
| { type: "package"; package: string }
| { type: "package"; target: string; version?: string; outdated?: true }
| { type: "local"; path: string }
| { type: "sdk" }
@@ -2354,6 +2354,14 @@ 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"
@@ -2415,14 +2423,6 @@ 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,6 +2582,15 @@ 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
+2 -4
View File
@@ -7,7 +7,6 @@ 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"])
@@ -81,7 +80,6 @@ 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)
@@ -113,7 +111,7 @@ const layer = Layer.effect(
resource: externalResource,
save: slash(
path.join(
(yield* Project.root(fs, AbsolutePath.make(externalDirectory), markers.targets())) ?? externalDirectory,
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
"*",
),
),
@@ -128,5 +126,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
deps: [FSUtil.node, Location.node],
})
-2
View File
@@ -10,7 +10,6 @@ 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") {}
@@ -28,7 +27,6 @@ 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,
})
}),
)
+13 -9
View File
@@ -30,7 +30,7 @@ import { Permission } from "./permission.js"
export interface Interface {
readonly activate: (
plugins: readonly Versioned[],
plugins: readonly Generation[],
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 Versioned = PluginDefinition & {
readonly version: string
export type Generation = PluginDefinition & {
readonly revision: 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: Versioned; readonly scope: Scope.Closeable }>()
const active = new Map<Plugin.ID, { readonly plugin: Generation; 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: Versioned) {
const load = Effect.fnUntraced(function* (plugin: Generation) {
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 Versioned[],
plugins: readonly Generation[],
failures: readonly Failure[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
@@ -99,10 +99,14 @@ 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.version === definition.version
return entry.plugin.id === definition?.id && entry.plugin.revision === definition.revision
})
) {
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
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]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
@@ -174,7 +178,7 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Versioned): Plugin.Info {
function activeInfo(plugin: Generation): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
+7 -10
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 { Versioned } from "../plugin.js"
import type { Generation } from "../plugin.js"
/**
* Holds the plugins one instance is born with. Unlike the host-global
@@ -13,16 +13,13 @@ import type { Versioned } from "../plugin.js"
* for the instance's lifetime; runtime dynamism lives inside plugins through
* the container transform/reload APIs.
*
* 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.
* Config plugin operations may disable instance plugins by id, matching
* `SdkPlugins` behavior.
*/
export type List = readonly Plugin[]
export interface Interface {
readonly all: () => readonly Versioned[]
readonly all: () => readonly Generation[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstancePlugins") {}
@@ -33,8 +30,8 @@ export const node = makeLocationNode({
deps: [],
})
// The constant version is load-bearing: the plugin registry treats an
// unchanged (id, version) pair as the same plugin across activations, which
// The constant revision is load-bearing: the plugin registry treats an
// unchanged (id, revision) 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.
@@ -44,7 +41,7 @@ export function bound(plugins: List) {
throw new Error(`duplicate instance plugin ids: ${duplicates.map((plugin) => plugin.id).join(", ")}`)
}
const stamped = plugins.map(
(plugin): Versioned => ({ ...plugin, version: "instance", source: { type: "sdk" } }),
(plugin): Generation => ({ ...plugin, revision: "instance", source: { type: "sdk" } }),
)
return Layer.succeed(Service, Service.of({ all: () => stamped }))
}
-5
View File
@@ -26,7 +26,6 @@ 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"
@@ -97,7 +96,6 @@ 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
@@ -141,7 +139,6 @@ 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),
@@ -192,7 +189,6 @@ export const requirements = LayerNode.group([
Command.node,
Config.node,
Credential.node,
ConfigPluginSource.node,
Bus.node,
Environment.node,
FileMutation.node,
@@ -287,7 +283,6 @@ 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)),
}),
)
+15 -14
View File
@@ -8,23 +8,17 @@ import { readdir } from "node:fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Versioned } from "../plugin.js"
import type { Generation } 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",
),
@@ -34,13 +28,17 @@ 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 = local
? { entrypoint: pathToFileURL(operation.target).href }
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
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 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
@@ -63,13 +61,16 @@ export const load = Effect.fn("PluginModule.load")(function* (
return {
id: plugin.id,
features,
vcs: plugin.vcs,
version: JSON.stringify(operation),
revision: JSON.stringify([operation, installed.revision]),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: { type: "package" as const, package: operation.target },
: {
type: "package" as const,
target: operation.target,
...(installed.version ? { version: installed.version } : {}),
},
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Versioned
} satisfies Generation
})
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 { Versioned } from "../plugin.js"
import type { Generation } 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 Versioned[]
readonly all: () => readonly Generation[]
}
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, Versioned>()
const plugins = new Map<string, Generation>()
let revision = 0
return Service.of({
register: (plugin) =>
Effect.sync(() => {
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
plugins.set(plugin.id, { ...plugin, revision: String(++revision), source: { type: "sdk" } })
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
all: () => [...plugins.values()],
})
+7 -4
View File
@@ -3,10 +3,11 @@
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 { ConfigPluginSource } from "../config/plugin/source.js"
import { Config } from "../config.js"
import os from "os"
import opencodeContent from "./skill/opencode.md" with { type: "text" }
import reportContent from "./skill/report.md" with { type: "text" }
@@ -68,9 +69,11 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
})
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
const sources = yield* ConfigPluginSource.Service
return (yield* sources.operations())
.map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target))
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))
.toSorted()
})
@@ -7,7 +7,10 @@ import { Context, Effect } from "effect"
* imports: the supervisor reaches PluginRuntime, which depends on Session.
*/
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
/**
* Wait for the plugin generation to settle. Use this rarely: blocking reads,
* UI startup, or other unrelated work on plugin boot should be avoided.
*/
readonly flush: Effect.Effect<void>
}
+65 -29
View File
@@ -14,17 +14,20 @@ 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.Versioned[],
post: readonly Plugin.Versioned[],
pre: readonly Plugin.Generation[],
post: readonly Plugin.Generation[],
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.Versioned>()
const packages = new Map<string, Plugin.Generation>()
const pending = new Set<string>()
const failures = new Map<
string,
Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
@@ -51,13 +54,17 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const plugin = yield* PluginModule.load(operation).pipe(
const plugin = yield* PluginModule.load(operation, { install }).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),
@@ -80,9 +87,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
),
pending: [...pending],
}
})
@@ -94,45 +99,74 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const updates = yield* PluginUpdate.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, version: "internal", source: { type: "builtin" as const } })),
...internal.pre.map((plugin) => ({ ...plugin, revision: "internal", source: { type: "builtin" as const } })),
...sdk.all(),
...instance.all(),
]
const post = internal.post.map((plugin) => ({
...plugin,
version: "internal",
revision: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// 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)
}
// 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 }),
)
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
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(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
@@ -142,7 +176,7 @@ export const layer = Layer.effect(
}),
),
)
yield* Stream.concat(Stream.succeed(0), updates).pipe(
yield* Stream.concat(Stream.succeed(0), reloads).pipe(
// Keep observing updates while activation runs, retaining only the latest generation request.
Stream.buffer({ capacity: 1, strategy: "sliding" }),
Stream.debounce("100 millis"),
@@ -154,6 +188,7 @@ 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 })
}),
)
@@ -163,6 +198,7 @@ const nodeDeps = [
SdkPlugins.node,
InstancePlugins.node,
ConfigPluginSource.node,
PluginUpdate.node,
Bus.node,
Npm.node,
PluginInternal.requirements,
@@ -170,7 +206,7 @@ const nodeDeps = [
function pluginSource(target: string): Plugin.Source {
if (path.isAbsolute(target)) return { type: "local", path: target }
return { type: "package", package: target }
return { type: "package", target }
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
+54
View File
@@ -0,0 +1,54 @@
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,7 +20,6 @@ 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,7 +24,6 @@ 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
+5 -22
View File
@@ -13,7 +13,6 @@ 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"
@@ -44,7 +43,6 @@ 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.
@@ -98,7 +96,6 @@ 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
@@ -172,7 +169,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: [...markers.targets()], start: candidate.directory, stop: directory, mode: "first" })
.up({ targets: [".git", ".hg"], start: candidate.directory, stop: directory, mode: "first" })
.pipe(Effect.orElseSucceed(() => []))
if (!found[0]) return false
return (yield* fs.resolve(path.dirname(found[0]))) === directory
@@ -318,10 +315,9 @@ 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),
@@ -330,7 +326,7 @@ const layer = Layer.effect(
native && path.basename(native) === ".git"
? yield* git.repo.discover(AbsolutePath.make(path.dirname(native)))
: undefined
if (repo && (!marker || FSUtil.contains(marker.directory, repo.worktree))) {
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
const canonical =
@@ -346,27 +342,14 @@ 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 && (!marker || FSUtil.contains(marker.directory, hg.directory))) {
if (hg) {
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 },
})
}
@@ -385,5 +368,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Bus.node, Database.node, FSUtil.node, Git.node, ProjectMarkers.node, AppProcess.node],
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
})
-191
View File
@@ -1,191 +0,0 @@
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 ?? location.vcsBackend ?? vcs?.type
const id = value.selection ?? vcs?.type
return id ? value.providers.get(id) : undefined
}
const protect = <A>(provider: VcsDefinition, operation: string, effect: Effect.Effect<A, unknown>, fallback: A) =>
+90 -55
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, Schedule, Stream } from "effect"
import { Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
@@ -37,38 +37,79 @@ const staticIt = testEffect(
Global.node.replace(tempGlobalLayer),
]),
)
const refreshNpm = makeGlobalNode({
const outdatedNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href }
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() {} }')
})
return Npm.Service.of({
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),
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)),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const refreshIt = testEffect(
const updateIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[Global.node.replace(tempGlobalLayer), Npm.node.replace(refreshNpm)],
[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)],
),
)
describe("PluginSupervisor config", () => {
it.live("applies selectors in order", () =>
withLocation(
@@ -463,46 +504,40 @@ describe("PluginSupervisor config", () => {
}),
)
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)
},
}`,
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"),
)
})
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")
}),
)
}),
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")
}),
),
)
})
const ready = Effect.fnUntraced(function* () {
@@ -30,7 +30,9 @@ 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, version: "1" }])
yield* plugins.activate([{ ...reviewer, revision: "1" }])
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
+140 -31
View File
@@ -55,6 +55,58 @@ 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")
@@ -98,10 +150,12 @@ describe("Npm.isInstallablePackage", () => {
})
describe("Npm.cacheKey", () => {
test("preserves registry keys and hashes Git specs", async () => {
test("canonicalizes 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-[a-f0-9]{64}$/)
expect(await Npm.cacheKey(spec)).toMatch(/^git-plugin-[a-f0-9]{12}$/)
expect(await Npm.cacheKey(spec)).toBe(await Npm.cacheKey(spec))
expect(await Npm.cacheKey(`${spec}-other`)).not.toBe(await Npm.cacheKey(spec))
})
@@ -114,8 +168,9 @@ describe("Npm.add", () => {
const directory = path.join(
tmp.path,
"cache",
"packages",
Npm.sanitize(spec),
"npm",
await Npm.cacheKey(spec),
"1000",
"node_modules",
"@fixture",
"provider",
@@ -136,7 +191,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", "packages", Npm.sanitize(spec), "node_modules", spec)
const directory = path.join(tmp.path, "cache", "npm", Npm.sanitize(spec), "1000", "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")
@@ -164,7 +219,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", "packages", Npm.sanitize(spec)), { recursive: true })
await fs.mkdir(path.join(tmp.path, "cache", "npm", Npm.sanitize(spec)), { recursive: true })
const entries = await Effect.gen(function* () {
const npm = yield* Npm.Service
@@ -198,12 +253,14 @@ 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("packages", await Npm.cacheKey(spec), "node_modules"))
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
expect(entries.added.directory).toContain("node_modules")
}
})
@@ -223,8 +280,8 @@ describe("Npm.add", () => {
).toBeTruthy()
})
// 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 () => {
// 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 () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
@@ -232,37 +289,38 @@ describe("Npm.add", () => {
const mutable = `git+${repository}#fixture-branch`
const pinned = `git+${repository}#${fixture.commit}`
const first = await Effect.gen(function* () {
const result = await Effect.gen(function* () {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable)
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
const pinnedEntry = yield* npm.add(pinned)
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`
})
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
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)
return {
mutable: yield* npm.add(mutable, { refresh: true }),
pinned: yield* npm.add(pinned, { refresh: true }),
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),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
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"')
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()
}, 30_000)
})
@@ -271,7 +329,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, "packages", Npm.sanitize(spec), "node_modules", "fixture-plugin")
const directory = path.join(cache, "npm", Npm.sanitize(spec), "1000", "node_modules", "fixture-plugin")
const missing = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
@@ -291,5 +349,56 @@ 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()
})
})
+47 -28
View File
@@ -24,7 +24,7 @@ const it = testEffect(PluginTestLayer)
class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
const generation = <R>(plugin: EffectPlugin.Plugin<R>, revision = "1") => ({ ...plugin, revision })
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([
versioned(
generation(
EffectPlugin.define({
id: "location-context",
effect: (ctx) =>
@@ -215,7 +215,7 @@ describe("Plugin", () => {
.pipe(Effect.asVoid),
})
yield* plugins.activate([versioned(provider)])
yield* plugins.activate([generation(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 version", () =>
it.effect("replaces plugins by ID and revision", () =>
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([versioned(managed(), "1")])
yield* plugins.activate([generation(managed(), "1")])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("first")
description = "second"
yield* plugins.activate([versioned(managed(), "2")])
yield* plugins.activate([generation(managed(), "2")])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
description = "third"
yield* plugins.activate([versioned(managed(), "2")])
yield* plugins.activate([generation(managed(), "2")])
expect(updates).toBe(2)
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate(
[versioned(managed(), "2")],
[generation(managed(), "2")],
[
{
source: { type: "package", package: "broken" },
source: { type: "package", target: "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) =>
versioned(
generation(
EffectPlugin.define({
id,
effect: (ctx) => ctx.agent.transform((draft) => draft.update(id, () => {})),
@@ -315,17 +315,36 @@ 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, version: "1", effect: () => Effect.void }])
yield* plugins.activate([{ id: active, revision: "1", effect: () => Effect.void }])
const result = yield* plugins
.activate([
{ id: duplicate, version: "1", effect: () => Effect.void },
{ id: duplicate, version: "1", effect: () => Effect.void },
{ id: duplicate, revision: "1", effect: () => Effect.void },
{ id: duplicate, revision: "1", effect: () => Effect.void },
])
.pipe(Effect.exit)
@@ -340,7 +359,7 @@ describe("Plugin", () => {
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.activate([
{ id: "rpc-plugin", version: "1", features: { rpc: true }, effect: () => Effect.void },
{ id: "rpc-plugin", revision: "1", features: { rpc: true }, effect: () => Effect.void },
])
expect(yield* plugins.list()).toEqual([
@@ -378,7 +397,7 @@ describe("Plugin", () => {
},
})
yield* plugins.activate([versioned(good), versioned(bad)])
yield* plugins.activate([generation(good), generation(bad)])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("good"),
@@ -396,7 +415,7 @@ describe("Plugin", () => {
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
fail = false
yield* plugins.activate([versioned(good), versioned(bad, "2")])
yield* plugins.activate([generation(good), generation(bad, "2")])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("good"),
@@ -422,7 +441,7 @@ describe("Plugin", () => {
yield* plugins.activate([
{
id: "partial-tools",
version: "1",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool.transform((draft) => {
@@ -488,8 +507,8 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
yield* plugins.activate([generation(previous)])
yield* plugins.activate([generation(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
@@ -527,8 +546,8 @@ describe("Plugin", () => {
effect: () => Effect.die(new Error("replacement failed")),
})
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
yield* plugins.activate([generation(previous)])
yield* plugins.activate([generation(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
@@ -549,7 +568,7 @@ describe("Plugin", () => {
yield* plugins.activate(
["first", "second"].map((id) => ({
id,
version: "1",
revision: "1",
effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
})),
)
@@ -573,7 +592,7 @@ describe("Plugin", () => {
),
})
yield* plugins.activate([versioned(plugin)]).pipe(Effect.provideService(Secret, "secret"))
yield* plugins.activate([generation(plugin)]).pipe(Effect.provideService(Secret, "secret"))
expect(visible).toBe(false)
}),
@@ -586,7 +605,7 @@ describe("Plugin", () => {
yield* plugins.activate(
["a", "a:b", "雪"].map((id) => ({
id,
version: "1",
revision: "1",
effect: (context: EffectPlugin.Context) => Effect.sync(() => storage.set(id, context.storage)),
})),
)
@@ -648,7 +667,7 @@ describe("Plugin", () => {
.pipe(Effect.orDie),
})
yield* plugins.activate([versioned(plugin)])
yield* plugins.activate([generation(plugin)])
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
yield* plugins.activate([])
@@ -680,7 +699,7 @@ describe("Plugin", () => {
.pipe(Effect.orDie),
})
yield* plugins.activate([versioned(plugin)])
yield* plugins.activate([generation(plugin)])
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([
"context7_look_up",
@@ -759,7 +778,7 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([versioned(plugin)])
yield* plugins.activate([generation(plugin)])
const toolSet = yield* registry.snapshot()
const execution = yield* toolSet.execute({
@@ -817,7 +836,7 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([versioned(plugin)])
yield* plugins.activate([generation(plugin)])
const toolSet = yield* registry.snapshot()
const failure = yield* toolSet
+2
View File
@@ -38,6 +38,8 @@ 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,
}),
)
+4 -1
View File
@@ -15,13 +15,15 @@ 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 }
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href, version: "1.2.3" }
}),
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")),
}),
),
@@ -30,5 +32,6 @@ 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"] }])
})
+1 -1
View File
@@ -167,7 +167,7 @@ describe("fromPromise", () => {
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
yield* plugins.activate([{ ...adapted, revision: "1" }])
}),
)
@@ -24,6 +24,8 @@ 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,6 +16,8 @@ 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",
version: "1",
revision: "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",
version: "1",
revision: "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",
version: "1",
revision: "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",
version: "2",
revision: "2",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
@@ -84,7 +84,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
yield* plugins.activate([{ ...adapted, revision: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
@@ -141,7 +141,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
yield* plugins.activate([{ ...adapted, revision: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
@@ -197,7 +197,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins
.activate([{ ...adapted, version: "1" }])
.activate([{ ...adapted, revision: "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, version: "1" }])
yield* plugins.activate([{ ...adapted, revision: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
const active = yield* Effect.promise(() => subscriptions.promise)
yield* plugins.activate([])
+14 -10
View File
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { Effect, Layer, Stream } from "effect"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
import { Skill } from "@opencode-ai/core/skill"
@@ -8,10 +9,13 @@ import { testEffect } from "../lib/effect"
import { host } from "./host"
const it = testEffect(AppNodeBuilder.build(Skill.node))
const sources = (operations: readonly ConfigPluginSource.Operation[] = []) =>
const config = (plugins: Info["plugins"] = []) =>
Layer.succeed(
ConfigPluginSource.Service,
ConfigPluginSource.Service.of({ operations: () => Effect.succeed(operations), changes: () => Stream.never }),
Config.Service,
Config.Service.of({
entries: () => Effect.succeed([new Document({ type: "document", info: new Info({ plugins }) })]),
changes: () => Stream.never,
}),
)
describe("SkillPlugin.Plugin", () => {
@@ -27,7 +31,7 @@ describe("SkillPlugin.Plugin", () => {
reload: skill.reload,
},
}),
).pipe(Effect.provide(sources()))
).pipe(Effect.provide(config()))
const skills = yield* skill.list()
const report = skills.find((item) => item.id === "report")
@@ -67,11 +71,11 @@ describe("SkillPlugin.Plugin", () => {
expect(report?.content).toContain("- Active plugins: -disabled, local.ts, package-plugin, package-plugin")
}).pipe(
Effect.provide(
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 } },
config([
"package-plugin",
"-disabled",
"local.ts",
{ package: "package-plugin", options: { enabled: true } },
]),
),
),
+51
View File
@@ -0,0 +1,51 @@
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,106 +170,6 @@ 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(
+3 -1
View File
@@ -114,7 +114,9 @@ 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)),
}),
),
),
),
+3 -1
View File
@@ -52,7 +52,9 @@ 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 }),
),
),
+1 -3
View File
@@ -2,7 +2,6 @@ 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"
@@ -38,7 +37,7 @@ export interface Context {
readonly mcp: MCPDomain
readonly generate: GenerateApi<unknown>
readonly permission: PermissionDomain
readonly plugin: PluginApi<unknown>
readonly plugin: Pick<PluginApi<unknown>, "list">
readonly reference: ReferenceDomain
readonly rpc: RpcDomain
readonly session: SessionDomain
@@ -52,7 +51,6 @@ export interface Context {
export interface Plugin<R = Scope.Scope> {
readonly id: string
readonly vcs?: VcsDiscovery
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
-1
View File
@@ -214,7 +214,6 @@ 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(() =>
+1 -3
View File
@@ -2,7 +2,6 @@ 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"
@@ -38,7 +37,7 @@ export interface Context {
readonly mcp: MCPDomain
readonly generate: GenerateApi
readonly permission: PermissionDomain
readonly plugin: PluginApi
readonly plugin: Pick<PluginApi, "list">
readonly reference: ReferenceDomain
readonly rpc: RpcDomain
readonly session: SessionDomain
@@ -54,7 +53,6 @@ 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
@@ -1,4 +0,0 @@
export interface VcsDiscovery {
readonly id?: string
readonly markers: readonly string[]
}
@@ -61,13 +61,6 @@ 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")
+119 -2
View File
@@ -486,6 +486,116 @@
"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"],
@@ -16593,11 +16703,18 @@
"type": "string",
"enum": ["package"]
},
"package": {
"target": {
"type": "string"
},
"version": {
"type": "string"
},
"outdated": {
"type": "boolean",
"enum": [true]
}
},
"required": ["type", "package"],
"required": ["type", "target"],
"additionalProperties": false
},
{
+18 -1
View File
@@ -1,7 +1,8 @@
import { Location } from "@opencode-ai/schema/location"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { InvalidRequestError, ServiceUnavailableError } from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const PluginGroup = HttpApiGroup.make("server.plugin")
@@ -19,6 +20,22 @@ 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",
+6 -1
View File
@@ -9,7 +9,12 @@ export type ID = typeof ID.Type
export const Source = Schema.Union([
Schema.Struct({ type: Schema.Literal("builtin") }),
Schema.Struct({ type: Schema.Literal("package"), package: Schema.String }),
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("local"), path: Schema.String }),
Schema.Struct({ type: Schema.Literal("sdk") }),
]).annotate({ identifier: "Plugin.Source" })
+5 -1
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, package: "acme" }
const source = { type: "package" as const, target: "acme", version: "1.2.3" }
const features = { server: true as const }
expect(decode({ id: "acme", source, features, state: { status: "active" } })).toEqual({
@@ -18,4 +18,8 @@ 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,
})
})
-10
View File
@@ -7,15 +7,6 @@ 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* () {
@@ -25,7 +16,6 @@ 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,18 +1,8 @@
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* () {
@@ -20,7 +10,6 @@ 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())
}),
@@ -28,7 +17,6 @@ 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())
}),
@@ -1,13 +0,0 @@
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()),
}),
)
}
+37 -6
View File
@@ -1,13 +1,44 @@
import { Plugin } from "@opencode-ai/core/plugin"
import { Effect } from "effect"
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 { 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()))
}),
),
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",
}),
),
),
)
}),
),
)
+1 -12
View File
@@ -1,18 +1,9 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { ProviderNotFoundError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { ProviderNotFoundError } 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* () {
@@ -20,7 +11,6 @@ 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())
}),
@@ -28,7 +18,6 @@ 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,7 +1,6 @@
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"
@@ -41,8 +40,6 @@ 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,6 +1,5 @@
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"
@@ -20,8 +19,6 @@ 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,11 +4,6 @@ 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* () {
@@ -24,7 +19,6 @@ 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()
@@ -51,7 +45,6 @@ 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,15 +4,6 @@ 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* () {
@@ -20,7 +11,6 @@ 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())
}),
@@ -28,7 +18,6 @@ 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(
+5 -1
View File
@@ -26,6 +26,7 @@ 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"
@@ -61,6 +62,7 @@ const applicationServiceNodes = [
SessionTransfer.node,
PluginRuntime.providerNode,
SdkPlugins.node,
PluginUpdate.node,
PermissionSaved.node,
PtyTicket.node,
PersistentPty.node,
@@ -145,7 +147,9 @@ function makeRoutes<AuthError, AuthServices>(
const services = Layer.succeedContext(context)
const requestServices = Layer.merge(
Layer.succeedContext(
Context.pick(Database.Service, PermissionSaved.Service, Project.Service, WellKnown.Service)(context),
Context.pick(Database.Service, PermissionSaved.Service, PluginUpdate.Service, Project.Service, WellKnown.Service)(
context,
),
),
ServerInfo.layer(serviceURLs, options.app),
)
+2 -20
View File
@@ -1,10 +1,8 @@
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SessionNotFoundError, ServiceUnavailableError, UnknownError } from "@opencode-ai/protocol/errors"
import { SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
import { expect, test } from "bun:test"
import { Effect, Layer, Logger, References } from "effect"
import { pluginReadiness } from "../src/handlers/plugin-readiness"
import { Effect, Logger, References } from "effect"
import { failedMessageDecode, missingSession } from "../src/handlers/session-error"
test("yieldable session errors preserve the handler failure policy", async () => {
@@ -43,19 +41,3 @@ 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)
})
+16 -10
View File
@@ -1,12 +1,12 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Schedule } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
it.live("waits for plugin initialization before listing models", () =>
it.live("lists models without blocking on plugin initialization", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-model-endpoint-")))
yield* Effect.promise(() =>
@@ -26,14 +26,20 @@ it.live("waits for plugin initialization before listing models", () =>
const server = yield* startServer(tmp.path)
const url = new URL("/api/model", server.base)
url.searchParams.set("location[directory]", tmp.path)
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()
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"),
)
}),
)
@@ -0,0 +1,80 @@
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)
}),
)
+23 -13
View File
@@ -1,39 +1,49 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Schedule } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
it.live(
"waits for plugin initialization on the first provider list request",
"lists providers without blocking on plugin initialization",
() =>
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)
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()
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"),
)
}),
15_000,
)
it.live(
"waits for plugin initialization on the first provider get request",
"gets providers without blocking on plugin initialization",
() =>
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 response = yield* Effect.promise(() => fetch(url, { headers: fixture.server.headers }))
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
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"))
if (!isRecord(body) || !isRecord(body["data"])) throw new Error("Expected a provider response")
expect(body["data"]["id"]).toBe("custom")
}),
+21 -6
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 } from "effect"
import { Effect, Layer, Schedule } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
@@ -29,9 +29,16 @@ 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.promise(() => fetch(url, { headers: server.headers }))
expect(base.status).toBe(200)
expect(yield* Effect.promise(() => base.json())).toMatchObject({
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({
data: { name: "main", ref: "refs/heads/main", source: "reflog" },
})
yield* Effect.promise(() => $`git branch -m ambiguous`.cwd(tmp.path).quiet())
@@ -85,7 +92,7 @@ it.live("maps a failing base provider to HTTP 503 instead of null metadata", ()
all: () => [
{
id: "failing-vcs",
version: "test",
revision: "test",
effect: (ctx) =>
ctx.vcs
.transform((draft) => {
@@ -111,7 +118,11 @@ 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)))
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"),
)
expect(response.status).toBe(503)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "ServiceUnavailableError",
@@ -120,3 +131,7 @@ 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,6 +9,7 @@ import type {
FormField,
FormFields,
FormValue,
LocationRef,
} from "@opencode-ai/client"
import open from "open"
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
@@ -16,6 +17,7 @@ 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"
@@ -74,10 +76,12 @@ 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() ?? []).filter(
integrationOptions(data.location.integration.list(location) ?? []).filter(
(integration) => props.integrationID === undefined || integration.id === props.integrationID,
),
)
@@ -88,14 +92,14 @@ export function DialogIntegration(
if (!integration) return
const methods = connectMethods(integration)
if (credentialConnections(integration).length) {
manageConnections(integration, methods, dialog, props.onConnected)
manageConnections(integration, methods, location, dialog, props.onConnected)
return
}
selectMethod(integration, methods, dialog, props.onConnected)
selectMethod(integration, methods, location, dialog, props.onConnected)
})
const options = createMemo(() => {
const providers = data.location.websearch.list() ?? []
const providers = data.location.websearch.list(location) ?? []
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
return integrations().map((integration) => {
const methods = connectMethods(integration)
@@ -117,8 +121,8 @@ export function DialogIntegration(
? () => <text fg={theme.text.feedback.success.default}></text>
: undefined,
onSelect: () => {
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
return selectMethod(integration, methods, dialog, props.onConnected)
if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected)
return selectMethod(integration, methods, location, dialog, props.onConnected)
},
}
})
@@ -145,6 +149,7 @@ export function DialogIntegration(
function manageConnections(
integration: IntegrationInfo,
methods: ConnectMethod[],
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
@@ -156,7 +161,9 @@ 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()?.find((item) => item.id === integration.id))
const current = createMemo(() =>
data.location.integration.list(location)?.find((item) => item.id === integration.id),
)
return (
<DialogSelect
@@ -174,7 +181,7 @@ function manageConnections(
{
title: "Add account",
value: "add",
onSelect: () => selectMethod(current() ?? integration, methods, dialog, onConnected),
onSelect: () => selectMethod(current() ?? integration, methods, location, dialog, onConnected),
},
]
: []),
@@ -193,7 +200,7 @@ function manageConnections(
onSelect: () => {
if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return
void client.api.credential
.activate({ credentialID: connection.id, location: location(data) })
.activate({ credentialID: connection.id, location: locationQuery(location) })
.catch(toast.error)
},
}
@@ -217,8 +224,8 @@ function manageConnections(
const label = value.trim()
if (!label) return
void client.api.credential
.update({ credentialID: option.value, label, location: location(data) })
.then(() => manageConnections(integration, methods, dialog, onConnected))
.update({ credentialID: option.value, label, location: locationQuery(location) })
.then(() => manageConnections(integration, methods, location, dialog, onConnected))
.catch(toast.error)
}}
/>
@@ -234,7 +241,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: location(data) })
.remove({ credentialID: option.value, location: locationQuery(location) })
.then(() => {
setDeleting(undefined)
if (!final) return
@@ -256,17 +263,18 @@ 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], dialog, onConnected)
if (methods.length === 1) return openMethod(integration, methods[0], location, 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, dialog, onConnected),
onSelect: () => openMethod(integration, method, location, dialog, onConnected),
}))}
/>
))
@@ -275,23 +283,27 @@ function selectMethod(
function openMethod(
integration: IntegrationInfo,
method: ConnectMethod,
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
if (method.type === "key") {
void beginKey(integration, method, dialog, onConnected)
void beginKey(integration, method, location, dialog, onConnected)
return
}
if (method.type === "command") {
dialog.replace(() => <CommandStarting integration={integration} method={method} onConnected={onConnected} />)
dialog.replace(() => (
<CommandStarting integration={integration} method={method} location={location} onConnected={onConnected} />
))
return
}
void beginOAuth(integration, method, dialog, onConnected)
void beginOAuth(integration, method, location, dialog, onConnected)
}
async function beginKey(
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "key" }>,
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
@@ -300,16 +312,16 @@ async function beginKey(
: undefined
if (answer === null) return
dialog.replace(() => (
<KeyMethod integration={integration} method={method} answer={answer} onConnected={onConnected} />
<KeyMethod integration={integration} method={method} location={location} 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()
@@ -321,14 +333,14 @@ function CommandStarting(props: {
.connect({
integrationID: props.integration.id,
methodID: props.method.id,
location: location(data),
location: locationQuery(props.location),
})
.then((result) => {
if (closed) {
void client.api.integration.command.cancel({
integrationID: props.integration.id,
attemptID: result.data.attemptID,
location: location(data),
location: locationQuery(props.location),
})
return
}
@@ -338,6 +350,7 @@ function CommandStarting(props: {
integration={props.integration}
title={props.method.label}
attempt={result.data}
location={props.location}
onConnected={props.onConnected}
/>
))
@@ -359,6 +372,7 @@ function CommandPending(props: {
integration: IntegrationInfo
title: string
attempt: CommandAttempt
location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -374,7 +388,7 @@ function CommandPending(props: {
.status({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: location(data),
location: locationQuery(props.location),
})
.then((result) => {
const status = result.data
@@ -385,7 +399,7 @@ function CommandPending(props: {
}
settled = true
if (status.status === "complete") {
void connected(props.integration, data, dialog, toast, props.onConnected)
void connected(props.integration, props.location, data, dialog, toast, props.onConnected)
return
}
toast.show({
@@ -408,7 +422,7 @@ function CommandPending(props: {
void client.api.integration.command.cancel({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: location(data),
location: locationQuery(props.location),
})
})
@@ -449,6 +463,7 @@ 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
}) {
@@ -468,11 +483,11 @@ function KeyMethod(props: {
void client.api.integration.connect
.key({
integrationID: props.integration.id,
location: location(data),
location: locationQuery(props.location),
key,
...(props.answer ? { answer: props.answer } : {}),
})
.then(() => connected(props.integration, data, dialog, toast, props.onConnected))
.then(() => connected(props.integration, props.location, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause)))
}}
description={() => (
@@ -485,23 +500,30 @@ 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} answer={answer} onConnected={onConnected} />
<OAuthStarting
integration={integration}
method={method}
location={location}
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()
@@ -510,7 +532,7 @@ function OAuthStarting(props: {
void client.api.integration.oauth
.connect({
integrationID: props.integration.id,
location: location(data),
location: locationQuery(props.location),
methodID: props.method.id,
...(props.answer ? { answer: props.answer } : {}),
})
@@ -521,6 +543,7 @@ function OAuthStarting(props: {
integration={props.integration}
title={props.method.label}
attempt={result.data}
location={props.location}
onConnected={props.onConnected}
/>
))
@@ -531,6 +554,7 @@ function OAuthStarting(props: {
integration={props.integration}
title={props.method.label}
attempt={result.data}
location={props.location}
onConnected={props.onConnected}
/>
))
@@ -548,6 +572,7 @@ function OAuthAuto(props: {
integration: IntegrationInfo
title: string
attempt: IntegrationAttempt
location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -591,7 +616,11 @@ function OAuthAuto(props: {
const poll = () => {
void client.api.integration.oauth
.status({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data) })
.status({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: locationQuery(props.location),
})
.then((result) => {
const status = result.data
if (status.status === "pending") {
@@ -600,7 +629,7 @@ function OAuthAuto(props: {
}
settled = true
if (status.status === "complete") {
void connected(props.integration, data, dialog, toast, props.onConnected)
void connected(props.integration, props.location, data, dialog, toast, props.onConnected)
return
}
toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" })
@@ -620,7 +649,7 @@ function OAuthAuto(props: {
void client.api.integration.oauth.cancel({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: location(data),
location: locationQuery(props.location),
})
})
@@ -640,6 +669,7 @@ function OAuthCode(props: {
integration: IntegrationInfo
title: string
attempt: IntegrationAttempt
location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -655,7 +685,7 @@ function OAuthCode(props: {
void client.api.integration.oauth.cancel({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: location(data),
location: locationQuery(props.location),
})
})
@@ -669,12 +699,12 @@ function OAuthCode(props: {
.complete({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: location(data),
location: locationQuery(props.location),
code,
})
.then(() => {
settled = true
return connected(props.integration, data, dialog, toast, props.onConnected)
return connected(props.integration, props.location, data, dialog, toast, props.onConnected)
})
.catch((cause) => setError(message(cause)))
}}
@@ -968,26 +998,31 @@ 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()
data.location.model.invalidate()
data.location.provider.invalidate()
await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()])
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),
])
toast.show({ variant: "success", message: `Connected ${integration.name}` })
if (onConnected) {
onConnected(providerID(data, integration.id))
onConnected(providerID(data, location, integration.id))
return
}
dialog.clear()
}
function providerID(data: ReturnType<typeof useData>, integrationID: string) {
const models = data.location.model.list() ?? []
const matches = (data.location.provider.list() ?? []).filter(
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(
(provider) => provider.integrationID === integrationID || provider.id === integrationID,
)
return (
@@ -997,9 +1032,8 @@ function providerID(data: ReturnType<typeof useData>, integrationID: string) {
)
}
function location(data: ReturnType<typeof useData>) {
const current = data.location.default()
return { directory: current.directory, workspace: current.workspaceID }
function locationQuery(location: LocationRef) {
return { directory: location.directory, workspace: location.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: status(entry) === "active" ? undefined : status(entry),
footer: footer(entry),
footerColor:
status(entry) === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
: outdated(entry)
? props.context.theme.text.feedback.info.default
: props.context.theme.text.subdued,
gutter:
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,
status(entry) === "failed"
? () => <text fg={props.context.theme.text.feedback.error.default}>x</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.package
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "local") return context.ui.format.path(plugin.source.path)
return plugin.source.type
}
@@ -221,6 +221,25 @@ 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.package : path.dirname(plugin.source.path),
entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path),
install: false,
server: true,
discovered: false,
@@ -1,5 +1,6 @@
/** @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"
@@ -8,7 +9,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 } from "../../../src/context/location"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
@@ -203,9 +204,27 @@ test("hides account rename and delete actions while the add account row is selec
}
})
async function renderIntegration() {
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) {
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" },
@@ -213,12 +232,19 @@ async function renderIntegration() {
]
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 = {
directory: process.cwd(),
project: { id: "proj_test", directory: process.cwd(), canonical: process.cwd() },
...requestedLocation,
project: { id: "proj_test", directory, canonical: directory },
}
if (url.pathname === "/api/integration") {
locations.push(requestedLocation)
reads.integration++
return json({
location,
@@ -244,6 +270,7 @@ async function renderIntegration() {
}
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}`)
@@ -289,9 +316,11 @@ async function renderIntegration() {
function Probe() {
const data = useData()
const dialog = useDialog()
const location = useLocation()
onMount(() => {
location.set(activeLocation)
void data.location.integration
.sync()
.sync(activeLocation)
.then(() => dialog.replace(() => <DialogIntegration integrationID="openai" autoConnect />))
})
return null
@@ -332,6 +361,7 @@ async function renderIntegration() {
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", package: "test-plugin@1.0.0" },
source: { type: "package", target: "test-plugin@1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
},
+2
View File
@@ -54,6 +54,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:"
},
"devDependencies": {
@@ -63,6 +64,7 @@
"@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:"
}
}
+297 -104
View File
@@ -1,8 +1,8 @@
export * as Npm from "./npm.js"
import path from "path"
import { createHash } from "node:crypto"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { createHash, randomUUID } from "node:crypto"
import { Clock, 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,14 +22,21 @@ 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[]; readonly refresh?: boolean },
options?: { readonly subpaths?: readonly string[] },
) => 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>
}
@@ -43,35 +50,70 @@ export function sanitize(pkg: string) {
}
export async function isRegistryPackage(pkg: string) {
const { default: npa } = await import("npm-package-arg")
try {
const result = npa(pkg)
return result.name !== undefined && ["version", "range", "tag"].includes(result.type)
} catch {
return false
}
return (await parse(pkg))?.type === "registry"
}
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
}
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 {
if (npa(pkg).type === "git") return `git-${createHash("sha256").update(pkg).digest("hex")}`
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",
}
} catch {
// Preserve the existing fallback for invalid and non-registry package strings.
return
}
}
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)
}
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"
)
}
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
const entrypoint = subpaths
.map((subpath) => {
@@ -99,8 +141,16 @@ 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* () {
@@ -108,13 +158,33 @@ const layer = Layer.effect(
const global = yield* Global.Service
const fs = yield* FileSystem.FileSystem
const flock = yield* EffectFlock.Service
const directory = (pkg: string) =>
Effect.map(
Effect.promise(() => cacheKey(pkg)),
(key) => path.join(global.cache, "packages", key),
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 installedName = Effect.fnUntraced(function* (pkg: string, dir: string, parsedName?: string) {
if (parsedName) return parsedName
})
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 manifest = yield* afs
.readJson(path.join(dir, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
@@ -124,13 +194,48 @@ const layer = Layer.effect(
}
return pkg
})
const refreshed = new Set<string>()
const reify = (input: { dir: string; add?: string[]; update?: boolean }) =>
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 }) =>
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.dir)
const npmOptions = yield* NpmConfig.load(input.config ?? input.dir)
const options = input.update ? { ...npmOptions, preferOnline: true, noGitRevCache: true } : npmOptions
const arborist = new Arborist({
...options,
@@ -162,111 +267,188 @@ 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[]; readonly refresh?: boolean },
options?: { readonly subpaths?: readonly string[] },
) {
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)
const target = yield* Effect.promise(() => parse(pkg))
const dir = directory(pkg, target)
return yield* install(pkg, target, dir, options?.subpaths, false)
}, Effect.scoped)
const resolve = Effect.fn("Npm.resolve")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const 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)
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)
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
return resolveEntryPoint(name, dir, options?.subpaths)
return yield* entry(generation ?? root, name, dir, target, options?.subpaths)
})
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
const dir = yield* directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
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 pick = Effect.fnUntraced(function* () {
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 pick = Effect.fnUntraced(function* (dir: string) {
const binDir = path.join(dir, "node_modules", ".bin")
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(bin) : Option.none<string>()
if (files.length === 1) return Option.some(files[0])
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]))
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
const packageName = target?.name ?? pkg
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", packageName, "package.json")).pipe(Effect.option)
if (Option.isSome(pkgJson)) {
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
if (parsed?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const unscoped = packageName.startsWith("@") ? packageName.split("/")[1] : packageName
const parsedBin = parsed.bin
if (typeof parsedBin === "string") return Option.some(unscoped)
if (typeof parsedBin === "string") return Option.some(path.join(binDir, unscoped))
const keys = Object.keys(parsedBin)
if (keys.length === 1) return Option.some(keys[0])
return parsedBin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
const selected = parsedBin[unscoped] ? unscoped : keys[0]
return selected ? Option.some(path.join(binDir, selected)) : Option.none<string>()
}
}
return Option.some(files[0])
return Option.some(path.join(binDir, files[0]))
})
return Option.getOrUndefined(
yield* Effect.gen(function* () {
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(() => {}))
const generation = yield* current(root)
const selected = generation ? yield* pick(generation) : Option.none<string>()
if (Option.isSome(selected)) return selected
yield* add(pkg)
const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value))
const installed = yield* current(root)
if (!installed) return Option.none<string>()
return yield* pick(installed)
}).pipe(
Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()),
@@ -277,6 +459,8 @@ const layer = Layer.effect(
return Service.of({
add,
resolve,
check,
update,
which,
})
}),
@@ -298,13 +482,22 @@ 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 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 ?? "")
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 ?? "")
}
+119 -2
View File
@@ -486,6 +486,116 @@
"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"],
@@ -16593,11 +16703,18 @@
"type": "string",
"enum": ["package"]
},
"package": {
"target": {
"type": "string"
},
"version": {
"type": "string"
},
"outdated": {
"type": "boolean",
"enum": [true]
}
},
"required": ["type", "package"],
"required": ["type", "target"],
"additionalProperties": false
},
{
+119 -2
View File
@@ -486,6 +486,116 @@
"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"],
@@ -16593,11 +16703,18 @@
"type": "string",
"enum": ["package"]
},
"package": {
"target": {
"type": "string"
},
"version": {
"type": "string"
},
"outdated": {
"type": "boolean",
"enum": [true]
}
},
"required": ["type", "package"],
"required": ["type", "target"],
"additionalProperties": false
},
{
+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,
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.
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.
```sh
touch .opencode/plugins/concise/index.ts