mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 22:46:20 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9db8fa35c | ||
|
|
815d4ab9b4 | ||
|
|
5d73a5789f |
@@ -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:",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,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()],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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] })
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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"] }])
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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 } },
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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")
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user