Compare commits

..

2 Commits

Author SHA1 Message Date
rekram1-node 6fab33d59b fix(tui): preserve resumable activity 2026-08-31 22:13:18 +00:00
rekram1-node 8b1091c35a fix(tui): settle parked session tabs 2026-08-31 21:58:32 +00:00
147 changed files with 1852 additions and 3658 deletions
-2
View File
@@ -969,7 +969,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -979,7 +978,6 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
+1 -1
View File
@@ -5,7 +5,7 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.0",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
+161 -163
View File
@@ -72,11 +72,6 @@ const OpenResponsesReasoningSummaryText = Schema.Struct({
text: Schema.String,
})
const OpenResponsesReasoningText = Schema.Struct({
type: Schema.tag("reasoning_text"),
text: Schema.String,
})
const OpenResponsesReasoningItem = Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.optionalKey(Schema.String),
@@ -84,18 +79,6 @@ const OpenResponsesReasoningItem = Schema.Struct({
encrypted_content: optionalNull(Schema.String),
})
export const CompletedReasoningItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.String,
summary: Schema.Array(OpenResponsesReasoningSummaryText),
content: Schema.optional(Schema.Array(OpenResponsesReasoningText)),
encrypted_content: optionalNull(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type CompletedReasoningItem = Schema.Schema.Type<typeof CompletedReasoningItem>
const OpenResponsesWebSearchCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("web_search_call"),
@@ -201,7 +184,6 @@ export type HostedToolReplayItem = {
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| CompletedReasoningItem
| {
readonly type: "message"
readonly id?: string
@@ -363,7 +345,6 @@ export const Event = Schema.StructWithRest(
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
content_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
// OutputItemAdded/Done permit a null item in the Open Responses OpenAPI schema.
item: optionalNull(StreamItem),
@@ -401,7 +382,6 @@ export interface ProviderAdapter {
readonly request: LLMRequest
}) => MediaInput | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
readonly restoreReasoningItem?: (item: unknown) => CompletedReasoningItem | undefined
}
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
@@ -417,18 +397,22 @@ export interface ParserState {
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly open: boolean
readonly encryptedContent: string | null | undefined
readonly streamedText: string
readonly emittedRawIndexes: ReadonlySet<number>
readonly emittedSummaryIndexes: ReadonlySet<number>
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
// and matches the wire field.
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
// Summary indexes that received at least one streamed delta. The `:0` block
// is started eagerly when the item opens, so block existence cannot tell
// whether a `.done` final would duplicate streamed text.
readonly deltaIndexes: ReadonlySet<number>
}
// =============================================================================
@@ -479,22 +463,9 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
}
}
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
adapter: ProviderAdapter,
): OpenResponsesReasoningInput | CompletedReasoningItem | undefined => {
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata)) return undefined
const restored = adapter.restoreReasoningItem?.(metadata.reasoningItem)
if (restored) return restored
if (!adapter.restoreReasoningItem && Schema.is(CompletedReasoningItem)(metadata.reasoningItem))
return {
type: "reasoning",
id: metadata.reasoningItem.id,
summary: [...metadata.reasoningItem.summary],
encrypted_content: metadata.reasoningItem.encrypted_content,
}
const id = itemID(part.providerMetadata, providerMetadataKey)
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
@@ -649,18 +620,17 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey, adapter)
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
if (reasoning.encrypted_content === null || typeof reasoning.encrypted_content === "string")
if (typeof reasoning.encrypted_content === "string")
existing.encrypted_content = reasoning.encrypted_content
continue
}
const converted = { ...reasoning, summary: [...reasoning.summary] }
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = converted
input.push(converted)
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning
input.push(reasoning)
continue
}
if (part.type === "tool-call") {
@@ -892,22 +862,39 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const appendReasoningText = (
state: ParserState,
itemID: string,
item: ReasoningStreamItem,
text: string,
): StepResult => {
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const lifecycle = Object.entries(item.summaryParts)
.filter((entry) => entry[1] !== "concluded")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, itemID, text),
lifecycle: Lifecycle.reasoningStart(
lifecycle,
events,
`${itemID}:${index}`,
providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
reasoningItems: {
...state.reasoningItems,
[itemID]: {
...item,
streamedText: item.streamedText + text,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "concluded" ? entry : [entry[0], "concluded" as const],
),
),
[index]: "active",
},
},
},
},
@@ -915,59 +902,42 @@ const appendReasoningText = (
]
}
type ReasoningRawDeltaEvent = Pick<Event, "content_index" | "delta">
type ReasoningRawDoneEvent = Pick<Event, "content_index" | "text">
type ReasoningSummaryDeltaEvent = Pick<Event, "delta" | "summary_index">
type ReasoningSummaryDoneEvent = Pick<Event, "summary_index" | "text">
const onReasoningRawDelta = (state: ParserState, event: ReasoningRawDeltaEvent, itemID: string): StepResult => {
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item?.open) return [state, NO_EVENTS]
return appendReasoningText(
state,
itemID,
{ ...item, emittedRawIndexes: new Set([...item.emittedRawIndexes, event.content_index ?? 0]) },
event.delta,
)
}
const onReasoningRawDone = (state: ParserState, event: ReasoningRawDoneEvent, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.text || !item?.open) return [state, NO_EVENTS]
const index = event.content_index ?? 0
if (item.emittedRawIndexes.has(index)) return [state, NO_EVENTS]
return appendReasoningText(
state,
itemID,
{ ...item, emittedRawIndexes: new Set([...item.emittedRawIndexes, index]) },
event.text,
)
}
const onReasoningSummaryDelta = (state: ParserState, event: ReasoningSummaryDeltaEvent, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item?.open) return [state, NO_EVENTS]
return appendReasoningText(
state,
itemID,
{ ...item, emittedSummaryIndexes: new Set([...item.emittedSummaryIndexes, event.summary_index ?? 0]) },
event.delta,
)
}
const onReasoningSummaryDone = (state: ParserState, event: ReasoningSummaryDoneEvent, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.text || !item?.open) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.emittedSummaryIndexes.has(index)) return [state, NO_EVENTS]
return appendReasoningText(
state,
itemID,
{ ...item, emittedSummaryIndexes: new Set([...item.emittedSummaryIndexes, index]) },
event.text,
)
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
const current = started.reasoningItems[itemID]
if (!current) return [started, emitted]
const events: LLMEvent[] = [...emitted]
return [
{
...started,
lifecycle: Lifecycle.reasoningDelta(started.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...started.reasoningItems,
[itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) },
},
},
events,
]
}
// Some compatible gateways emit a reasoning final without streaming any
// deltas, mirroring `response.output_text.done`. Reconcile the complete text
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
// `output_item.added` (reasoning) →
// `reasoning_summary_part.added` (index=0) →
@@ -975,21 +945,19 @@ const onReasoningSummaryDone = (state: ParserState, event: ReasoningSummaryDoneE
// `reasoning_summary_part.done` (index=0) →
// (repeat for index>0) →
// `output_item.done` (reasoning).
// `onOutputItemAdded` seeds one lifecycle for the complete provider item.
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -1002,7 +970,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
{
...state,
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
@@ -1017,15 +984,14 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, item.id),
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: true,
encryptedContent: item.encrypted_content,
streamedText: "",
emittedRawIndexes: new Set(),
emittedSummaryIndexes: new Set(),
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
},
},
},
@@ -1054,6 +1020,34 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
]
}
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
return startReasoningSummaryPart(state, event.item_id, event.summary_index)
}
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item?.open) return [state, NO_EVENTS]
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
return [
{
...state,
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...item.summaryParts,
[event.summary_index]: "can-conclude",
},
},
},
},
NO_EVENTS,
]
}
const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* (
state: ParserState,
event: Event,
@@ -1092,12 +1086,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const message = state.message?.id === item.id ? state.message : undefined
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
@@ -1110,13 +1099,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
const lifecycle =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
completedMessages,
message: undefined,
message: message ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1172,15 +1161,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (isReasoningItem(item)) {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const tracked = state.reasoningItems[item.id]
if (!tracked && state.lifecycle.reasoning.size > 0)
return yield* ProviderShared.eventError(state.id, "reasoning completed before the previous item ended")
const completed = Schema.is(CompletedReasoningItem)(item) ? item : undefined
const metadata = providerMetadata(state, {
itemId: item.id,
reasoningEncryptedContent: item.encrypted_content ?? tracked?.encryptedContent ?? null,
...(completed ? { reasoningItem: completed } : {}),
})
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
const summary: Array<string | undefined> = []
for (const part of summaryParts) {
@@ -1194,41 +1175,63 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const decoded = Option.getOrUndefined(decodeReasoningPart(part))
if (decoded) content.push(decoded.text)
}
const text = joinReasoningText(summary) ?? joinReasoningText(content) ?? tracked?.streamedText
const itemText = joinReasoningText(summary) ?? joinReasoningText(content)
const events: LLMEvent[] = []
if (tracked) {
const reasoningItem = state.reasoningItems[item.id]
if (reasoningItem) {
const fragments = Object.entries(reasoningItem.summaryParts)
let lifecycle = state.lifecycle
for (const [index, status] of fragments) {
if (status === "concluded") continue
// Do not repeat earlier summaries that were already emitted as separate fragments.
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
}
return [
{
...state,
lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, text),
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
...tracked,
...reasoningItem,
open: false,
encryptedContent: item.encrypted_content ?? tracked.encryptedContent,
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
},
},
},
events,
] satisfies StepResult
}
const started = Lifecycle.reasoningStart(state.lifecycle, events, item.id)
return [
{
...state,
lifecycle: Lifecycle.reasoningEnd(started, events, item.id, metadata, text),
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: false,
encryptedContent: item.encrypted_content,
streamedText: text ?? "",
emittedRawIndexes: new Set(),
emittedSummaryIndexes: new Set(),
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(
LLMEvent.reasoningEnd({
id: item.id,
providerMetadata: metadata,
text: itemText,
}),
)
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: false,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "concluded" },
deltaIndexes: new Set(),
},
},
},
},
events,
] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
events,
] satisfies StepResult
}
@@ -1333,29 +1336,25 @@ export const step = (state: ParserState, input: Event) => {
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_text.delta") {
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningRawDelta(state, event, event.item_id))
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_text.done") {
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_text.done"
) {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningRawDone(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_text.delta") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningSummaryDelta(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_text.done") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningSummaryDone(state, event, event.item_id))
return Effect.succeed(onReasoningDone(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id !== undefined
? Effect.succeed<StepResult>([state, NO_EVENTS])
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_summary_part.done")
return event.item_id !== undefined
? Effect.succeed<StepResult>([state, NO_EVENTS])
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
@@ -1420,7 +1419,6 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -75,9 +75,7 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(
Schema.Union([OpenResponses.CompletedReasoningItem, OpenResponses.InputItem, OpenAIResponsesHostedToolItem]),
),
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -88,16 +86,10 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const restoreReasoningItem = (item: unknown) => {
if (!Schema.is(OpenResponses.CompletedReasoningItem)(item)) return undefined
return item.content?.length === 0 ? { ...item, content: undefined } : item
}
const adapter = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
restoreReasoningItem,
} satisfies OpenResponses.ProviderAdapter
const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -193,6 +185,12 @@ const HOSTED_TOOLS = {
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
@@ -82,32 +82,6 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -164,23 +138,13 @@ describe("Open Responses completed item reasoning", () => {
expect(response.reasoning).toBe(fixture.text)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
"openai-compatible": {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted",
reasoningItem: {
type: "reasoning",
id: "rs_1",
summary: fixture.summary,
content: fixture.content,
encrypted_content: "encrypted",
},
},
"openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted" },
})
}),
)
})
it.effect("replaces streamed reasoning with the completed summary", () =>
it.effect("replaces only the still-open summary without repeating earlier text", () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
@@ -200,32 +164,8 @@ describe("Open Responses completed item reasoning", () => {
},
completed,
)
expect(response.reasoning).toBe("First \n\nfinal")
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual(["First \n\nfinal"])
}),
)
it.effect("does not store malformed completed reasoning items", () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Draft" },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
content: [{ type: "reasoning_text", text: "Raw" }],
encrypted_content: "encrypted",
},
},
completed,
)
expect(response.reasoning).toBe("Raw")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
"openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted" },
})
expect(response.reasoning).toBe("First final")
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined, "final"])
}),
)
})
@@ -264,7 +204,6 @@ describe("Open Responses completed item reasoning", () => {
expect(response.reasoning).toBe("Draft")
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.text)).toEqual([undefined])
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined])
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toBeUndefined()
}),
)
})
@@ -71,61 +71,59 @@ function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
}
describe("Open Responses basic-item lifecycles", () => {
it.effect("streams mixed reasoning in one lifecycle and ignores late events", () =>
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
Effect.gen(function* () {
const item = {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Completed" }],
encrypted_content: "encrypted-state",
}
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
const events = yield* collect(
{ type: "response.output_item.added", output_index: 0, item: { ...item, encrypted_content: null } },
{ type: "response.output_item.added", item: { ...item, encrypted_content: null } },
{ type: "response.reasoning.delta", item_id: "rs_1", content_index: 0, delta: "Raw " },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "summary " },
{ type: "response.reasoning.done", item_id: "rs_1", content_index: 0, text: "ignored raw final" },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "Second" },
// The third part omits both explicit summary boundaries.
{
type: "response.reasoning_summary_text.done",
item_id: "rs_1",
summary_index: 0,
text: "ignored summary final",
},
{
type: "response.reasoning_text.done",
type: "response.reasoning_summary_text.delta",
output_index: 0,
item_id: "wrong",
content_index: 1,
text: "raw final ",
summary_index: 2,
delta: "Third",
},
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "summary final" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning.delta", item_id: "rs_1", content_index: 2, delta: "late raw" },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "late summary" },
{ type: "response.reasoning.done", item_id: "rs_1", content_index: 3, text: "late raw final" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 3, text: "late final" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
completed,
)
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "Raw " },
{ type: "reasoning-delta", id: "rs_1", text: "summary " },
{ type: "reasoning-delta", id: "rs_1", text: "raw final " },
{ type: "reasoning-delta", id: "rs_1", text: "summary final" },
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:2",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:2", text: "Third" },
{
type: "reasoning-end",
id: "rs_1",
text: "Completed",
providerMetadata: {
"openai-compatible": {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: item,
},
},
id: "rs_1:2",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
@@ -154,18 +152,13 @@ describe("Open Responses basic-item lifecycles", () => {
{
type: "reasoning-start",
id: "rs_1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{
type: "reasoning-end",
id: "rs_1",
text: "Not streamed",
providerMetadata: {
"openai-compatible": {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: item,
},
},
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
@@ -223,63 +216,7 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
Effect.gen(function* () {
const text = {
type: "message",
id: "msg_text",
content: [{ type: "output_text", text: "Done-only text." }],
}
const refusal = {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_text",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-end",
id: "msg_text",
text: "Done-only text.",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-start",
id: "msg_refusal",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
{
type: "text-end",
id: "msg_refusal",
text: "Done-only refusal.",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
])
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
@@ -296,44 +233,9 @@ describe("Open Responses basic-item lifecycles", () => {
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
}),
)
;[undefined, "fc_1"].forEach((id) => {
@@ -438,7 +340,7 @@ describe("Open Responses basic-item lifecycles", () => {
providerExecuted: undefined,
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
},
{ type: "reasoning-end", id: "rs_1" },
{ type: "reasoning-end", id: "rs_1:0" },
])
}),
)
@@ -475,8 +377,7 @@ describe("Open Responses basic-item lifecycles", () => {
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([
{
type: "reasoning-end",
id: "",
text: "Thinking",
id: ":0",
providerMetadata: { "openai-compatible": { itemId: "", reasoningEncryptedContent: "state" } },
},
])
@@ -551,7 +452,7 @@ describe("Open Responses basic-item lifecycles", () => {
)
expect(events.filter(LLMEvent.is.toolInputEnd)).toEqual([])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: "rs_1" }])
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: "rs_1:0" }])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
@@ -303,49 +303,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("projects completed reasoning items through the shared protocol", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant({
type: "reasoning",
text: "Portable summary",
providerMetadata: {
example: {
reasoningItem: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Summary" }],
content: [{ type: "reasoning_text", text: "Raw" }],
encrypted_content: "state",
status: "completed",
future_field: { retained: true },
},
},
},
}),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Summary" }],
encrypted_content: "state",
},
])
}),
)
it.effect("routes response deltas by output index", () =>
Effect.gen(function* () {
const model = configure({
@@ -470,7 +427,7 @@ describe("Open Responses-compatible route", () => {
})
routings.forEach((routing) => {
it.effect(`keeps one reasoning lifecycle without item completion with ${routing.name}`, () =>
it.effect(`preserves reasoning summary boundaries without terminal reconciliation with ${routing.name}`, () =>
Effect.gen(function* () {
const address = { item_id: routing.item_id, output_index: routing.output_index }
const response = yield* LLMClient.generate(request).pipe(
@@ -501,10 +458,26 @@ describe("Open Responses-compatible route", () => {
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "First.Second.",
text: "First.",
providerMetadata: { "openai-compatible": { itemId: routing.id } },
},
{
type: "reasoning",
text: "Second.",
providerMetadata: {
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: null },
},
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: routing.id }])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
{
type: "reasoning-end",
id: `${routing.id}:0`,
text: undefined,
providerMetadata: { "openai-compatible": { itemId: routing.id } },
},
{ type: "reasoning-end", id: `${routing.id}:1` },
])
}),
)
})
@@ -744,7 +717,7 @@ describe("Open Responses-compatible route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
id: "rs_raw",
id: "rs_raw:0",
})
}),
)
@@ -2500,11 +2500,11 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{ type: "reasoning-start", id: "rs_1:0" },
{ type: "reasoning-delta", id: "rs_1:0", text: "thinking" },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "reasoning-end", id: "rs_1:0" },
{ type: "text-end", id: "msg_1" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
@@ -2514,6 +2514,7 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
])
@@ -2546,19 +2547,8 @@ describe("OpenAI Responses route", () => {
expect(response.events).toContainEqual(
expect.objectContaining({
type: "reasoning-end",
id: "rs_1",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: {
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "thinking" }],
},
},
},
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
)
}),
@@ -2605,11 +2595,12 @@ describe("OpenAI Responses route", () => {
expect(response.reasoning).toBe("Checked the diff.")
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1" },
{ type: "reasoning-end", id: "rs_1:0" },
])
expect(response.message.content).toContainEqual({
type: "reasoning",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
})
}),
)
@@ -2677,7 +2668,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
id: "rs_1",
id: "rs_1:0",
})
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
@@ -2689,7 +2680,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("streams reasoning summary parts in wire order in one lifecycle", () =>
it.effect("streams each reasoning summary part as a separate block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
@@ -2720,13 +2711,22 @@ describe("OpenAI Responses route", () => {
expect(response.reasoning).toBe("FirstSecond")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "First" },
{ type: "reasoning-delta", id: "rs_1", text: "Second" },
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{
type: "reasoning-end",
id: "rs_1",
text: "FirstSecond",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
@@ -2735,6 +2735,73 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("concludes reasoning at implicit summary boundaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
// The next part is enough to conclude the previous one even when
// its done event is delayed.
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
// Some compatible providers begin the next part with its first delta.
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "Third" },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("FirstSecondThird")
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First", providerMetadata: undefined },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{
type: "reasoning-start",
id: "rs_1:2",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:2", text: "Third", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:2",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("rejects a reasoning item that starts before the previous item ends", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -2930,26 +2997,15 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1",
providerMetadata: undefined,
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1", text: "Checked the diff.", providerMetadata: undefined },
{ type: "reasoning-delta", id: "rs_1:0", text: "Checked the diff.", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1",
id: "rs_1:0",
text: "Checked the diff.",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
},
},
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
@@ -2995,7 +3051,7 @@ describe("OpenAI Responses route", () => {
expect(response.reasoning).toBe("Streamed")
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([
{ type: "reasoning-delta", id: "rs_1", text: "Streamed", providerMetadata: undefined },
{ type: "reasoning-delta", id: "rs_1:0", text: "Streamed", providerMetadata: undefined },
])
}),
)
@@ -3018,15 +3074,7 @@ describe("OpenAI Responses route", () => {
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [
{ type: "summary_text", text: "First" },
{ type: "summary_text", text: "Second" },
],
encrypted_content: "encrypted-state",
},
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -3035,25 +3083,11 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-end",
id: "rs_1",
text: "First\n\nSecond",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: {
type: "reasoning",
id: "rs_1",
summary: [
{ type: "summary_text", text: "First" },
{ type: "summary_text", text: "Second" },
],
encrypted_content: "encrypted-state",
},
},
},
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
@@ -3161,15 +3195,6 @@ describe("OpenAI Responses route", () => {
it.effect("replays complete reasoning items when storage is enabled", () =>
Effect.gen(function* () {
const item = {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
content: [{ type: "reasoning_text", text: "Provider reasoning" }],
encrypted_content: "encrypted-state",
status: "completed",
provider_field: { retained: true },
}
const prepared = yield* compileRequest(
LLM.request({
model,
@@ -3178,13 +3203,7 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning",
text: "Checked the previous diff.",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: item,
},
},
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
]),
],
@@ -3192,7 +3211,14 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body.input[0]).toEqual(item)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: "encrypted-state",
},
])
}),
)
@@ -1,16 +1,5 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
})
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
const component = await mount(`opencode-composer-flow--${draft}`)
@@ -10,7 +10,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
@@ -21,7 +21,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
if (dismissal === "drag") {
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
const bounds = await handle.boundingBox()
expect(bounds).not.toBeNull()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
@@ -64,7 +64,7 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", target: id },
source: { type: "package", package: id },
state: { status: "active" },
features: { server: true },
})),
@@ -85,7 +85,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
data: [
{
id: "demo-plugin",
source: { type: "package", target: "demo-plugin" },
source: { type: "package", package: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
@@ -1,7 +1,3 @@
[data-component="composer-editor"]:empty::before {
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
background: var(--v2-background-bg-layer-01);
}
+3 -1
View File
@@ -114,8 +114,10 @@ export function ComposerEditor(props: ComposerEditorProps) {
<form
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
classList={{
"bg-v2-background-bg-layer-01": props.borderUnderlay,
"bg-v2-background-bg-base": !props.borderUnderlay,
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
@@ -8,7 +8,7 @@ describe("pluginLabels", () => {
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", target: "example" },
source: { type: "package", package: "example" },
state: { status: "active" },
features: { server: true },
},
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
-90
View File
@@ -1,90 +0,0 @@
[data-slot="mobile-drawer-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-drawer-backdrop-in 240ms ease-out;
}
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-drawer-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-drawer-content"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-drawer-content"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-drawer-content"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-drawer-content"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-drawer-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-drawer-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
@keyframes mobile-drawer-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-drawer-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-drawer-content"][data-transitioning],
[data-slot="mobile-drawer-content"][data-closing] {
transition: none;
}
[data-slot="mobile-drawer-overlay"],
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
-47
View File
@@ -1,47 +0,0 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import "./mobile-drawer.css"
export function MobileDrawer(
props: ParentProps<{
open: boolean
onOpenChange: (open: boolean) => void
onContentPresentChange?: (present: boolean) => void
returnFocus?: () => HTMLElement | undefined
closeOnOutsideFocus?: boolean
}>,
) {
return (
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
onContentPresentChange={props.onContentPresentChange}
side="bottom"
finalFocusEl={props.returnFocus?.()}
closeOnOutsideFocus={props.closeOnOutsideFocus}
>
{props.children}
</Drawer>
)
}
export const MobileDrawerTrigger = Drawer.Trigger
export function MobileDrawerContent(props: ParentProps) {
const language = useLanguage()
return (
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-drawer-overlay" />
<Drawer.Content forceMount data-slot="mobile-drawer-content" dir={language.direction()}>
<div data-slot="mobile-drawer-handle" aria-hidden="true">
<span />
</div>
{props.children}
</Drawer.Content>
</Drawer.Portal>
)
}
export const MobileDrawerLabel = Drawer.Label
export const MobileDrawerClose = Drawer.Close
@@ -1,34 +0,0 @@
[data-slot="mobile-panel"] {
display: flex;
min-height: 0;
flex-direction: column;
}
[data-slot="mobile-panel-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-panel-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-panel-close"][data-component="button-v2"] {
height: 44px;
flex-shrink: 0;
}
[data-slot="mobile-panel-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
+23 -21
View File
@@ -1,8 +1,7 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
import "./mobile-panel-drawer.css"
import "./status/status-drawer.css"
export function MobilePanelDrawer(
props: ParentProps<{
@@ -14,29 +13,32 @@ export function MobilePanelDrawer(
) {
const language = useLanguage()
return (
<MobileDrawer
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
returnFocus={props.returnFocus}
side="bottom"
finalFocusEl={props.returnFocus?.()}
// Menu focus handoff must not dismiss the drawer during its opening transition.
closeOnOutsideFocus={false}
>
<MobileDrawerContent>
<div data-slot="mobile-panel" data-corvu-no-drag>
<div data-slot="mobile-panel-header">
<MobileDrawerLabel>{props.title}</MobileDrawerLabel>
<MobileDrawerClose
as={Button}
variant="ghost"
data-slot="mobile-panel-close"
aria-label={language.t("common.close")}
>
{language.t("common.close")}
</MobileDrawerClose>
{/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-status-overlay" />
<Drawer.Content forceMount data-slot="mobile-status-drawer" dir={language.direction()}>
<div data-slot="mobile-status-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-panel-content">{props.children}</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
<div data-slot="mobile-status-header" data-corvu-no-drag>
<Drawer.Label>{props.title}</Drawer.Label>
<Drawer.Close data-slot="mobile-status-close" aria-label={language.t("common.close")}>
{language.t("common.close")}
</Drawer.Close>
</div>
<div data-slot="mobile-status-content" data-corvu-no-drag>
{props.children}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
)
}
@@ -1,3 +1,109 @@
[data-slot="mobile-status-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-status-backdrop-in 240ms ease-out;
}
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-status-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-status-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-status-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-status-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-status-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-status-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-status-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-status-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-status-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-status-close"] {
min-height: 44px;
flex-shrink: 0;
padding-inline: 12px;
border-radius: 6px;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
@media (hover: hover) {
[data-slot="mobile-status-close"]:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
}
[data-slot="mobile-status-close"]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: -2px;
}
[data-slot="mobile-status-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
[data-slot="mobile-status-loading"] {
display: flex;
min-height: 56px;
@@ -7,3 +113,33 @@
font-size: 13px;
line-height: var(--line-height-base);
}
@keyframes mobile-status-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-status-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-status-drawer"][data-transitioning],
[data-slot="mobile-status-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-status-overlay"],
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
@@ -1,7 +1,6 @@
import { lazy, Suspense } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { MobilePanelDrawer } from "../mobile-panel-drawer"
import "./status-drawer.css"
const Body = lazy(async () => {
const { StatusPopoverBody } = await import("./body")
+82 -2
View File
@@ -14,13 +14,63 @@
var(--v2-background-bg-layer-02);
}
[data-slot="mobile-tabs-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-tabs-backdrop-in 240ms ease-out;
}
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-tabs-backdrop-out 200ms ease-in forwards;
}
/* Keep the strip mounted for tab shortcuts and session metadata while collapsed. */
[data-slot="mobile-tabs-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
margin-block-start: 8px;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
box-shadow: var(--v2-elevation-overlay);
outline: none;
}
[data-slot="mobile-tabs-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-tabs-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-tabs-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-tabs-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-tabs-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-tabs-drawer-list"] {
@@ -29,6 +79,36 @@
flex-direction: column;
}
@keyframes mobile-tabs-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-tabs-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-tabs-drawer"][data-transitioning],
[data-slot="mobile-tabs-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-tabs-overlay"],
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
[data-slot="mobile-tabs-drawer"] [data-slot="vertical-tabs"] {
display: flex;
flex-direction: column;
+22 -12
View File
@@ -25,7 +25,7 @@ import type { ComposerState } from "@/composer/persistence"
import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import Drawer from "@corvu/drawer"
import { sessionLabel } from "@/session/title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { projectForSession } from "@/shell/layout/helpers"
@@ -415,7 +415,7 @@ export function Titlebar(props: {
<Show
when={!mobile()}
fallback={
<MobileDrawer
<Drawer
open={mobileTabs.open}
onOpenChange={(open) => setMobileTabs("open", open)}
onContentPresentChange={(present) => {
@@ -423,9 +423,11 @@ export function Titlebar(props: {
setMobileTabs("settings", false)
openSettings()
}}
side="bottom"
>
<MobileDrawerTrigger
<Drawer.Trigger
data-slot="mobile-tabs-trigger"
aria-expanded={mobileTabs.open}
class="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base focus-visible:outline-none [app-region:no-drag]"
aria-label={language.t("titlebar.tabs")}
>
@@ -465,11 +467,15 @@ export function Titlebar(props: {
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
</MobileDrawerTrigger>
<MobileDrawerContent>
<MobileDrawerLabel class="sr-only">{language.t("titlebar.tabs")}</MobileDrawerLabel>
<div data-slot="mobile-tabs-drawer" data-corvu-no-drag>
<div data-slot="mobile-tabs-drawer-list">
</Drawer.Trigger>
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-tabs-overlay" />
<Drawer.Content forceMount data-slot="mobile-tabs-drawer" dir={language.direction()}>
<Drawer.Label class="sr-only">{language.t("titlebar.tabs")}</Drawer.Label>
<div data-slot="mobile-tabs-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-tabs-drawer-list" data-corvu-no-drag>
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
@@ -487,6 +493,7 @@ export function Titlebar(props: {
</div>
<button
type="button"
data-corvu-no-drag
data-action="mobile-tabs-new-session"
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
onClick={() => {
@@ -497,7 +504,10 @@ export function Titlebar(props: {
<Icon name="plus" />
{language.t("command.session.new")}
</button>
<div class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2">
<div
class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2"
data-corvu-no-drag
>
<button
type="button"
data-action="mobile-tabs-home"
@@ -536,9 +546,9 @@ export function Titlebar(props: {
</button>
</div>
</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
}
>
<Show
@@ -54,7 +54,7 @@ export function format(
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.target, source: "advertised" as const }]
? [{ target: plugin.source.package, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
@@ -73,7 +73,7 @@ export function format(
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+2 -2
View File
@@ -9,12 +9,12 @@ test("formats server and TUI plugins in sections without builtins", () => {
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "acme.dual",
source: { type: "package", target: "acme-plugin@1.0.0" },
source: { type: "package", package: "acme-plugin@1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
},
{
source: { type: "package", target: "broken-plugin" },
source: { type: "package", package: "broken-plugin" },
state: { status: "failed", error: "broken" },
features: { server: true },
},
-8
View File
@@ -88,16 +88,8 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginUpdateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginUpdateOutput = void
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly update: PluginUpdateOperation<E>
}
export type SessionListInput = {
+1 -13
View File
@@ -15,8 +15,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -324,17 +322,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
update: EndpointPluginUpdate(raw),
})
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
@@ -9,8 +9,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -468,19 +466,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
request<PluginUpdateOutput>(
{
method: "POST",
path: `/api/plugin/update`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 204,
declaredStatuses: [400, 503, 401],
empty: true,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+21 -20
View File
@@ -12,7 +12,7 @@ export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginSource =
| { type: "builtin" }
| { type: "package"; target: string; version?: string; outdated?: true }
| { type: "package"; package: string }
| { type: "local"; path: string }
| { type: "sdk" }
@@ -153,7 +153,12 @@ export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
export type SessionInboxSyntheticPayload = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionInboxSyntheticPayload = {
text: string
description?: string
metadata?: { [x: string]: JsonValue }
resume?: boolean
}
export type SessionInboxCompactionPayload = {}
@@ -161,7 +166,12 @@ export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type SessionInboxSyntheticPayload1 = {
text: string
description?: string
metadata?: { [x: string]: any }
resume?: boolean
}
export type ShellInfo = {
id: string
@@ -2354,14 +2364,6 @@ export type AgentNotFoundError = {
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2423,6 +2425,14 @@ export type SkillNotFoundError = {
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
@@ -2582,15 +2592,6 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginUpdateOutput = void
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
@@ -1,6 +1,5 @@
import {
type AstNode,
AsyncIteratorSymbol,
CodeModeFunction,
CodeModeGenerator,
CoercionFunction,
@@ -10,7 +9,6 @@ import {
GeneratorMethodReference,
InterpreterRuntimeError,
IntrinsicReference,
IteratorSymbol,
JsonMethodReference,
PromiseCapabilityFunction,
PromiseInstanceMethodReference,
@@ -44,12 +42,13 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof SymbolNamespace ||
isCodeModeValue(value)
function* childValues(value: object): Generator {
for (const key of Reflect.ownKeys(value)) {
if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue
if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
yield Reflect.get(value, key)
function* childValues(value: object): Generator<unknown> {
if (Array.isArray(value)) {
const length = value.length
for (let index = 0; index < length; index++) yield value[index]
return
}
yield* Object.values(value)
}
export const containsRuntimeReference = (value: unknown): boolean => {
@@ -91,14 +90,9 @@ export const containsOpaqueReference = (value: unknown): boolean => {
}
// Reject cycles before mutation so later boundary walks remain safe.
export const rejectCircularInsertion = (
container: object,
value: unknown,
label: string,
node: AstNode,
seen = new Set<object>(),
): void => {
export const rejectCircularInsertion = (container: object, value: unknown, label: string, node: AstNode): void => {
const pending: Array<Iterator<unknown>> = [[value].values()]
const seen = new Set<object>()
while (pending.length > 0) {
const next = pending.at(-1)!.next()
if (next.done) {
@@ -110,7 +104,7 @@ export const rejectCircularInsertion = (
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
if (current === null || typeof current !== "object" || isRuntimeReference(current) || seen.has(current)) continue
seen.add(current)
pending.push(childValues(current))
pending.push(Array.isArray(current) ? current[Symbol.iterator]() : childValues(current))
}
}
+1 -1
View File
@@ -53,6 +53,7 @@ export const fromSpec = (options: Options): Result => {
if (!isRecord(pathValue)) continue
for (const [method, operationValue] of Object.entries(pathValue)) {
if (!methods.has(method) || !isRecord(operationValue)) continue
const segments = operationPath(method, path, operationValue, used, namespaces)
const operation: Operation = {
operationId: nonEmptyString(operationValue.operationId),
method: method.toUpperCase(),
@@ -98,7 +99,6 @@ export const fromSpec = (options: Options): Result => {
auth: options.auth,
headers: options.headers ?? {},
}
const segments = operationPath(method, path, operationValue, used, namespaces)
used.add(segments.join("."))
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
setTool(
+3 -1
View File
@@ -461,7 +461,9 @@ export const operationInput = (
const fields = [...parameters.value, ...requestBody.value.fields]
const conflicts = new Set(
[...Map.groupBy(fields, (field) => field.name)].filter(([, matches]) => matches.length > 1).map(([name]) => name),
[...Map.groupBy(fields, (field) => field.name)]
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
.map(([name]) => name),
)
const used = new Set<string>()
return {
+15 -20
View File
@@ -1,6 +1,12 @@
import { Effect } from "effect"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
import { containsOpaqueReference, rejectCircularInsertion } from "../interpreter/references.js"
import {
type AstNode,
AsyncIteratorSymbol,
InterpreterRuntimeError,
IteratorSymbol,
IteratorSymbols,
} from "../interpreter/model.js"
import { containsOpaqueReference } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModePromise } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
@@ -31,6 +37,10 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
}
return input as Record<string, unknown>
}
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
out[key] = item
}
switch (name) {
case "keys":
return Object.keys(requireObject())
@@ -54,29 +64,14 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
}
const out = target as Record<string, unknown>
const seen = new Set<object>()
const guardedSet = (key: PropertyKey, item: unknown): void => {
if (typeof key === "string" && isBlockedMember(key))
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
rejectCircularInsertion(out, item, "Object.assign result", node, seen)
if (!Reflect.set(out, key, item))
throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as(
"TypeError",
)
}
for (const source of args.slice(1)) {
if (source === null || source === undefined || isCodeModeValue(source)) continue
if (typeof source !== "object" || Array.isArray(source)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
for (const key of Reflect.ownKeys(source)) {
if (typeof key === "string") {
if (Object.prototype.propertyIsEnumerable.call(source, key)) guardedSet(key, Reflect.get(source, key))
continue
}
if (key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue
guardedSet(key, Reflect.get(source, key))
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
}
}
return out
+1 -11
View File
@@ -63,18 +63,8 @@ const docTags = (schema: JsonSchema): Array<string> => {
} catch {}
}
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
if (schema.type === "integer") tags.push("@integer")
if (typeof schema.minimum === "number") tags.push(`@minimum ${schema.minimum}`)
if (typeof schema.maximum === "number") tags.push(`@maximum ${schema.maximum}`)
if (typeof schema.exclusiveMinimum === "number") tags.push(`@exclusiveMinimum ${schema.exclusiveMinimum}`)
if (typeof schema.exclusiveMaximum === "number") tags.push(`@exclusiveMaximum ${schema.exclusiveMaximum}`)
if (typeof schema.multipleOf === "number") tags.push(`@multipleOf ${schema.multipleOf}`)
if (typeof schema.minLength === "number") tags.push(`@minLength ${schema.minLength}`)
if (typeof schema.maxLength === "number") tags.push(`@maxLength ${schema.maxLength}`)
if (typeof schema.pattern === "string") tags.push(`@pattern ${schema.pattern}`)
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
if (schema.uniqueItems === true) tags.push("@uniqueItems true")
return tags
}
@@ -137,8 +127,8 @@ const renderSchema = (
])
}
if (schema.allOf) {
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
}
if (Array.isArray(schema.type)) {
-9
View File
@@ -21,17 +21,8 @@ export type JsonSchema = {
readonly default?: unknown
readonly format?: string
readonly deprecated?: boolean
readonly minimum?: number
readonly maximum?: number
readonly exclusiveMinimum?: number
readonly exclusiveMaximum?: number
readonly multipleOf?: number
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
readonly minItems?: number
readonly maxItems?: number
readonly uniqueItems?: boolean
readonly $ref?: string
readonly $defs?: Readonly<Record<string, JsonSchema>>
readonly definitions?: Readonly<Record<string, JsonSchema>>
+1 -28
View File
@@ -282,30 +282,6 @@ describe("OpenAPI.fromSpec", () => {
expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true)
})
test("does not reserve names for unsupported operations between duplicate operation IDs", () => {
const operation = { operationId: "group.item", responses: { 200: { description: "Success" } } }
for (const unsupported of [false, true]) {
const result = OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/first": { get: operation },
...(unsupported ? { "/unsupported": { get: { ...operation, "x-websocket": true } } } : {}),
"/last": { get: operation },
},
},
})
expect(Object.keys(result.tools)).toEqual(["group", "group_item_2"])
expect(toolAt(result.tools, "group.item")).toMatchObject({ _tag: "CodeModeTool", description: "GET /first" })
expect(toolAt(result.tools, "group_item_2")).toMatchObject({ _tag: "CodeModeTool", description: "GET /last" })
expect(result.skipped).toEqual(
unsupported ? [{ method: "GET", path: "/unsupported", reason: "WebSocket operations are not supported" }] : [],
)
}
})
test("synthesizes flat operation IDs from methods and paths", () => {
const response = { responses: { 200: { description: "Success" } } }
const tools = OpenAPI.fromSpec({
@@ -343,10 +319,7 @@ describe("OpenAPI.fromSpec", () => {
parameters: [{ name: "limit", in: "query", schema: { type: "string" } }],
get: {
operationId: "test",
parameters: [
{ name: "limit", in: "query", schema: { type: "boolean" } },
{ name: "limit", in: "query", required: true, schema: { type: "number" } },
],
parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }],
responses: { 200: { description: "Success" } },
},
},
+16 -158
View File
@@ -139,81 +139,6 @@ describe("pretty signature rendering", () => {
expect(pretty).toBe(["{", " size?: number,", "}"].join("\n"))
})
test.each([
[{ type: "number", minimum: 0 }, "@minimum 0", "number"],
[{ type: "number", maximum: 0 }, "@maximum 0", "number"],
[{ type: "number", exclusiveMinimum: 0 }, "@exclusiveMinimum 0", "number"],
[{ type: "number", exclusiveMaximum: 0 }, "@exclusiveMaximum 0", "number"],
[{ type: "number", multipleOf: 0.25 }, "@multipleOf 0.25", "number"],
[{ type: "string", minLength: 0 }, "@minLength 0", "string"],
[{ type: "string", maxLength: 0 }, "@maxLength 0", "string"],
[{ type: "string", pattern: "^[a-z]+$" }, "@pattern ^[a-z]+$", "string"],
[{ type: "array", minItems: 0 }, "@minItems 0", "Array<unknown>"],
[{ type: "array", maxItems: 0 }, "@maxItems 0", "Array<unknown>"],
[{ type: "array", uniqueItems: true }, "@uniqueItems true", "Array<unknown>"],
] as const)("renders constraint %j without changing the compact type", (value, tag, type) => {
const schema = { type: "object", properties: { value } }
expect(jsonSchemaToTypeScript(schema, true)).toBe(["{", ` /** ${tag} */`, ` value?: ${type},`, "}"].join("\n"))
expect(jsonSchemaToTypeScript(schema)).toBe(`{ value?: ${type} }`)
})
test("documents integer numbers without adding redundant types or requiring uniqueness when false", () => {
expect(
jsonSchemaToTypeScript(
{
type: "object",
properties: {
count: { type: "integer" },
amount: { type: "number" },
name: { type: "string" },
enabled: { type: "boolean" },
values: { type: "array", uniqueItems: false },
choice: { type: ["integer", "string"] },
},
},
true,
),
).toBe(
[
"{",
" /** @integer */",
" count?: number,",
" amount?: number,",
" name?: string,",
" enabled?: boolean,",
" values?: Array<unknown>,",
" choice?: number | string,",
"}",
].join("\n"),
)
})
test.each([false, null, ""])("preserves default %j alongside constraint tags", (value) => {
expect(jsonSchemaToTypeScript({ properties: { value: { default: value, minLength: 0 } } }, true)).toContain(
` * @default ${JSON.stringify(value)}\n * @minLength 0\n`,
)
})
test("escapes comment terminators in tag values", () => {
expect(
jsonSchemaToTypeScript(
{ properties: { value: { type: "string", default: "*/", format: "*/", pattern: "^a*/b$" } } },
true,
),
).toBe(
[
"{",
" /**",
' * @default "* /"',
" * @format * /",
" * @pattern ^a* /b$",
" */",
" value?: string,",
"}",
].join("\n"),
)
})
test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
const pretty = jsonSchemaToTypeScript(
{ type: "object", properties: { note: { type: "string", description: "Ends */ early" } } },
@@ -419,100 +344,33 @@ describe("union schemas render every alternative", () => {
expect(outputTypeScript(tool)).toBe("number | boolean")
})
test("allOf keeps siblings and parenthesized union members in order", () => {
test("allOf renders intersections with parenthesized union members", () => {
const schema = {
properties: { common: { type: "boolean" } },
allOf: [{ type: "object", properties: { id: { type: "string" } } }, { type: ["string", "null"] }],
} as const
expect(jsonSchemaToTypeScript(schema)).toBe("{ common?: boolean } & { id?: string } & (string | null)")
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", " common?: boolean,", " } & {", " id?: string,", " } & (string | null)"].join("\n"),
)
expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)")
})
test.each([false, true])("allOf does not discard an unresolved constraint (pretty=%s)", (pretty) => {
for (const $ref of ["#/$defs/Missing", "#/definitions/Missing", "https://example.com/external.json"]) {
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref }] }, pretty)).toBe("unknown")
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { allOf: [{ $ref }] }] }, pretty)).toBe("unknown")
expect(
jsonSchemaToTypeScript({ allOf: [{ properties: { nested: { $ref } } }, { type: "string" }] }, pretty),
).toBe("unknown")
}
test("allOf does not discard an unresolved constraint", () => {
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe(
"unknown",
)
expect(
jsonSchemaToTypeScript(
{
type: "string",
allOf: [{ $ref: "#/$defs/Constraint" }],
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
},
pretty,
),
jsonSchemaToTypeScript({
allOf: [{ type: "string" }, { allOf: [{ $ref: "https://example.com/external.json" }] }],
}),
).toBe("unknown")
expect(
jsonSchemaToTypeScript({
type: "string",
allOf: [{ $ref: "#/$defs/Constraint" }],
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
}),
).toBe("string")
})
})
describe("JSDoc signatures in catalogs and search results", () => {
test.each([
{
source: "JSON Schema",
schema: {
type: "object",
properties: {
count: { type: "integer", minimum: 0, maximum: 10 },
name: { type: "string", minLength: 1, maxLength: 20, pattern: "^[a-z]+$" },
labels: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 },
},
required: ["count", "name", "labels"],
},
},
{
source: "Effect",
schema: Schema.Struct({
count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(10)),
name: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(20), Schema.isPattern(/^[a-z]+$/)),
labels: Schema.Array(Schema.String).check(Schema.isMinLength(1), Schema.isMaxLength(5)),
}),
},
])("$source constraints survive input/output catalog and search signatures", async ({ schema }) => {
const runtime = CodeMode.make({
tools: {
constrained: Tool.make({
description: "Constrained tool",
input: schema,
output: schema,
execute: () => Effect.succeed({ count: 1, name: "test", labels: ["test"] }),
}),
},
})
const type = [
"{",
" /**",
" * @integer",
" * @minimum 0",
" * @maximum 10",
" */",
" count: number,",
" /**",
" * @minLength 1",
" * @maxLength 20",
" * @pattern ^[a-z]+$",
" */",
" name: string,",
" /**",
" * @minItems 1",
" * @maxItems 5",
" */",
" labels: Array<string>,",
"}",
].join("\n")
const signature = `tools.constrained(input: ${type}): Promise<${type}>`
expect(runtime.catalog()[0]?.signature).toBe(signature)
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.constrained" })'))
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
expect(result.value).toMatchObject({ items: [{ signature }] })
})
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
const search = async (query: string) => {
-170
View File
@@ -17,8 +17,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import { AsyncIteratorSymbol, IteratorSymbol } from "../src/interpreter/model.js"
import { invokeObjectMethod } from "../src/stdlib/object.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
@@ -826,174 +824,6 @@ describe("stdlib integration", () => {
expect(await value(`try { Object.assign(null, { a: 1 }); return false } catch { return true }`)).toBe(true)
})
test("Object.assign ignores non-enumerable supported symbols without reading them", () => {
const target = {}
const reads: Array<boolean> = []
const source = Object.defineProperty({}, IteratorSymbol, {
get() {
reads.push(true)
return target
},
})
expect(invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toBe(target)
expect(reads).toEqual([])
expect(Object.hasOwn(target, IteratorSymbol)).toBe(false)
})
test("Object.assign ignores nested non-enumerable supported symbols during cycle checks", () => {
const target = {}
const reads: Array<boolean> = []
const nested = Object.defineProperty({}, IteratorSymbol, {
get() {
reads.push(true)
return target
},
})
expect(invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toBe(target)
expect(reads).toEqual([])
expect(target).toEqual({ nested })
})
test("Object.assign rejects cycles through supported symbols on nested arrays", () => {
const target = {}
const nested = Object.defineProperty([], IteratorSymbol, { enumerable: true, value: target })
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
"Object.assign result contains a circular value.",
)
expect(Object.hasOwn(target, "nested")).toBe(false)
})
test("Object.assign cycle checks traverse sparse keys lazily", () => {
const target = {}
const reads: Array<boolean> = []
const nested = Object.defineProperties([], {
4294967294: { enumerable: true, value: target },
later: {
enumerable: true,
get() {
reads.push(true)
return null
},
},
})
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
"Object.assign result contains a circular value.",
)
expect(reads).toEqual([])
})
test("Object.assign stops after a supported symbol write fails", () => {
const previous = () => ({ done: true })
const target = Object.defineProperty({}, IteratorSymbol, { value: previous })
const reads: Array<boolean> = []
const source = Object.defineProperties(
{},
{
[IteratorSymbol]: { enumerable: true, value: () => ({ done: false }) },
[AsyncIteratorSymbol]: {
enumerable: true,
get() {
reads.push(true)
return () => ({ done: true })
},
},
},
)
expect(() => invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toThrow(
"Object.assign could not assign property",
)
expect(Reflect.get(target, IteratorSymbol)).toBe(previous)
expect(reads).toEqual([])
})
test("Object.assign rejects direct and nested cycles", async () => {
expect(
await value(`
const target = { kept: true }
try { Object.assign(target, { self: target }) } catch { return target }
return null
`),
).toEqual({ kept: true })
expect(
await value(`
const target = { kept: true }
const nested = { target }
try { Object.assign(target, { nested }) } catch { return target }
return null
`),
).toEqual({ kept: true })
expect(
await value(`
const target = {}
const source = {}
source[Symbol.iterator] = target
try { Object.assign(target, source) } catch { return Object.hasOwn(target, Symbol.iterator) }
return true
`),
).toBe(false)
expect(
await value(`
const target = {}
const nested = {}
nested[Symbol.iterator] = target
try { Object.assign(target, { nested }) } catch { return Object.hasOwn(target, "nested") }
return true
`),
).toBe(false)
})
test("Object.assign preserves mutations before a circular field", async () => {
expect(
await value(`
const target = {}
try { Object.assign(target, { before: 1, cycle: { target }, after: 2 }) } catch { return target }
return null
`),
).toEqual({ before: 1 })
expect(
await value(`
const target = {}
const marker = {}
const source = {}
source[Symbol.iterator] = marker
source[Symbol.asyncIterator] = target
try { Object.assign(target, source) } catch {
return [target[Symbol.iterator] === marker, Object.hasOwn(target, Symbol.asyncIterator)]
}
return null
`),
).toEqual([true, false])
})
test("Object.assign preserves target identity and acyclic shared aliases", async () => {
expect(
await value(`
const shared = { count: 1 }
const target = {}
const result = Object.assign(target, { left: shared, right: shared })
result.left.count = 2
return [result === target, result.left === shared, result.left === result.right, shared.count]
`),
).toEqual([true, true, true, 2])
})
test("Object.assign traverses shared aliases once", () => {
const reads: Array<boolean> = []
const shared = Object.defineProperty({}, "value", {
enumerable: true,
get() {
reads.push(true)
return 1
},
})
const target = {}
expect(invokeObjectMethod("assign", [target, { left: shared, right: shared }], { type: "CallExpression" })).toBe(
target,
)
expect(target).toEqual({ left: shared, right: shared })
expect(reads).toEqual([true])
})
test("assignment resolves and reads its left side before evaluating the right side", async () => {
expect(await value(`let x = 1; x += (x = 5); return x`)).toBe(6)
expect(await value(`let i = 0; const values = [9]; values[i++] = i; return [values, i]`)).toEqual([[1], 1])
+10 -54
View File
@@ -1,29 +1,22 @@
export * as CodeModeCatalog from "./catalog.js"
import type { Namespace } from "@opencode-ai/schema/tool"
import { Schema } from "effect"
export const Tool = Schema.Struct({
export const Entry = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
pinned: Schema.optionalKey(Schema.Boolean),
})
export type Tool = typeof Tool.Type
export type Inventory = {
readonly tools: ReadonlyArray<Tool>
readonly namespaces?: ReadonlyMap<string, Namespace>
}
export type Entry = typeof Entry.Type
const Listing = Schema.Struct({
path: Schema.String,
line: Schema.String,
})
const NamespaceSummary = Schema.Struct({
const Namespace = Schema.Struct({
name: Schema.String,
description: Schema.optionalKey(Schema.String),
count: Schema.Number,
entries: Schema.Array(Listing),
})
@@ -31,30 +24,24 @@ const NamespaceSummary = Schema.Struct({
export const Summary = Schema.Struct({
total: Schema.Number,
shown: Schema.Number,
namespaces: Schema.Array(NamespaceSummary),
namespaces: Schema.Array(Namespace),
})
export type Summary = typeof Summary.Type
export type Options = {
readonly budget?: number
}
const DESCRIPTION_LIMIT = 120
const CHARACTERS_PER_TOKEN = 4
const INLINE_BUDGET = 2_000
// Keep every namespace visible, then select full listings one per namespace per round,
// Keep every namespace searchable, then select full listings one per namespace per round,
// considering shorter listings first until the inline budget is exhausted.
export function summarize(inventory: Inventory, options: Options = {}): Summary {
const budget = options.budget ?? INLINE_BUDGET
const namespaces = [...Map.groupBy(inventory.tools, (tool) => tool.path.split(".", 1)[0] ?? tool.path)]
export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary {
const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)]
.sort(([left], [right]) => {
if (left < right) return -1
if (left > right) return 1
return 0
})
.map(([name, namespaceEntries]) => {
const description = inventory.namespaces?.get(name)?.description
const listings = namespaceEntries
.map((entry) => {
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
@@ -77,7 +64,6 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary
)
return {
name,
...(description === undefined ? {} : { description }),
listings,
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
selectedListings: pinned,
@@ -86,25 +72,11 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary
})
const active = new Set(namespaces)
// TODO: Bound namespace discovery once large namespace inventories and descriptions can no longer stay inline.
let remaining =
budget -
namespaces.reduce(
(total, namespace) =>
total +
cost(
namespaceLine({
name: namespace.name,
...(namespace.description === undefined ? {} : { description: namespace.description }),
count: namespace.listings.length,
entries: [],
}),
),
0,
) -
namespaces
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
.reduce((total, listing) => total + cost(listing.line), 0)
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
while (active.size > 0) {
for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectionIndex]
@@ -121,31 +93,19 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary
const namespaceSummaries = namespaces.map((namespace) => ({
name: namespace.name,
...(namespace.description === undefined ? {} : { description: namespace.description }),
count: namespace.listings.length,
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
}))
return {
total: inventory.tools.length,
total: entries.length,
shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0),
namespaces: namespaceSummaries,
}
}
export function namespaceLine(namespace: typeof NamespaceSummary.Type) {
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
const label =
namespace.entries.length === namespace.count
? count
: namespace.entries.length === 0
? `${count}, none shown`
: `${count}, ${namespace.entries.length} shown`
return `- ${namespace.name} (${label})${namespace.description === undefined ? "" : ` // ${namespace.description}`}`
}
function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
return listings
.map((listing) => ({ listing, cost: cost(listing.line) }))
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) }))
.toSorted((left, right) => {
if (left.cost !== right.cost) return left.cost - right.cost
if (left.listing.path < right.listing.path) return -1
@@ -153,7 +113,3 @@ function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
return 0
})
}
function cost(text: string) {
return Math.round(text.length / CHARACTERS_PER_TOKEN)
}
+10 -12
View File
@@ -23,7 +23,14 @@ export function render(catalog: CodeModeCatalog.Summary) {
return "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool."
const tools = catalog.namespaces.flatMap((namespace) => {
return [CodeModeCatalog.namespaceLine(namespace), ...namespace.entries.map((entry) => entry.line)]
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
const label =
namespace.entries.length === namespace.count
? count
: namespace.entries.length === 0
? `${count}, none shown`
: `${count}, ${namespace.entries.length} shown`
return [`- ${namespace.name} (${label})`, ...namespace.entries.map((entry) => entry.line)]
})
return `${prompt(catalog.shown < catalog.total)}
@@ -40,15 +47,6 @@ ${render(current)}`
const currentComplete = current.shown === current.total
if (previousComplete !== currentComplete) return replacement
const descriptions = Instructions.diffByKey(
previous.namespaces.filter((namespace) => namespace.description !== undefined),
current.namespaces.filter((namespace) => namespace.description !== undefined),
(namespace) => namespace.name,
(before, after) => before.description !== after.description,
)
if (descriptions.added.length > 0 || descriptions.removed.length > 0 || descriptions.changed.length > 0)
return replacement
const diff = Instructions.diffByKey(
previous.namespaces.flatMap((namespace) => namespace.entries),
current.namespaces.flatMap((namespace) => namespace.entries),
@@ -128,8 +126,8 @@ ${render(current)}`
const key = Instructions.Key.make("core/codemode")
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
export const make = (inventory?: CodeModeCatalog.Inventory): Instructions.List => {
const catalog = inventory === undefined ? Instructions.removed : CodeModeCatalog.summarize(inventory)
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.List => {
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
return Instructions.make({
key,
codec,
+14 -97
View File
@@ -1,18 +1,9 @@
export * as CodeModeTool from "./tool.js"
import { CodeMode, Namespace, Tool, toolError } from "@opencode-ai/codemode"
import type {
Content,
Context,
Error,
Info,
Metadata,
Namespace as ToolNamespace,
Result,
} from "@opencode-ai/schema/tool"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
import { CodeModeCatalog } from "./catalog.js"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -40,21 +31,6 @@ type CollectedFiles = {
readonly files: Array<typeof ExecuteFile.Type>
}
type ToolNode = {
tool?: Tool.Tool<never>
namespace?: ToolNamespace
readonly children: Map<string, ToolNode>
}
type Tools = {
[name: string]: Tool.Tool<never> | Namespace.Namespace<never> | Tools
}
export type Inventory = {
readonly tools: ReadonlyMap<string, Info>
readonly namespaces?: ReadonlyMap<string, ToolNamespace>
}
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
@@ -66,7 +42,7 @@ const description = [
].join("\n")
export const create = (
inventory: Inventory,
registrations: ReadonlyMap<string, Info>,
executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error>,
) => {
return {
@@ -85,7 +61,7 @@ export const create = (
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
)
const result = yield* runtime(
inventory,
registrations,
(name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
@@ -156,95 +132,36 @@ export const create = (
} satisfies Info
}
export const catalog = (inventory: Inventory) => {
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
const pinned = new Set(
Array.from(inventory.tools.values())
Array.from(registrations.values())
.filter((registration) => registration.options?.pinned === true)
.map(qualifiedName),
)
return {
tools: runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((tool) => ({ ...tool, pinned: pinned.has(tool.path) })),
...(inventory.namespaces === undefined ? {} : { namespaces: inventory.namespaces }),
} satisfies CodeModeCatalog.Inventory
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
}
function runtime(
inventory: Inventory,
registrations: ReadonlyMap<string, Info>,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
// A path may carry namespace metadata, a callable tool, child tools, or all three.
const root: ToolNode = { children: new Map() }
for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace
for (const [name, registration] of inventory.tools) {
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(registration)
getNode(root, qualifiedName(registration)).tool = Tool.make({
const path = qualifiedName(registration)
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema ?? Schema.NullOr(Schema.String),
execute: (input) => executeTool(name, registration, input),
})
}
const tools = renderTools(root)
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function getNode(root: ToolNode, path: string) {
return path.split(".").reduce((parent, name) => {
const child: ToolNode = parent.children.get(name) ?? { children: new Map() }
parent.children.set(name, child)
return child
}, root)
}
function renderTools(root: ToolNode) {
const callables = new Map<string, Tool.Tool<never>>()
const tools = renderChildren(root, [], callables)
for (const [path, tool] of callables) tools[path] = tool
return tools
}
function renderChildren(node: ToolNode, path: ReadonlyArray<string>, callables: Map<string, Tool.Tool<never>>): Tools {
return Object.fromEntries(
Array.from(node.children).flatMap(([name, child]) => {
const next = [...path, name]
// A record cannot hold both a top-level tool and namespace under the same key.
if (path.length === 0 && child.tool !== undefined && (child.namespace !== undefined || child.children.size > 0)) {
const tools: Tools = {}
flattenTools(child, next, tools)
return Object.entries(tools)
}
return [[name, renderEntry(child, next, callables)]]
}),
)
}
function renderEntry(
node: ToolNode,
path: ReadonlyArray<string>,
callables: Map<string, Tool.Tool<never>>,
): Tools[string] {
const tools = renderChildren(node, path, callables)
// CodeMode merges this dotted tool path with the nested namespace entry.
if (node.tool !== undefined && (node.namespace !== undefined || node.children.size > 0))
callables.set(path.join("."), node.tool)
if (node.namespace !== undefined)
return Namespace.make({
description: node.namespace.description,
tools,
})
if (node.tool === undefined) return tools
if (node.children.size === 0) return node.tool
return tools
}
function flattenTools(node: ToolNode, path: ReadonlyArray<string>, tools: Tools) {
if (node.tool !== undefined) tools[path.join(".")] = node.tool
for (const [name, child] of node.children) flattenTools(child, [...path, name], tools)
}
function qualifiedName(registration: Info) {
const normalized = normalizedName(registration)
if (registration.options?.namespace === undefined) return normalized
+4 -2
View File
@@ -7,6 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { ProjectMarkers } from "./project/markers.js"
import { AbsolutePath } from "./schema.js"
export const Kind = Schema.Literals(["file", "directory"])
@@ -80,6 +81,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const markers = yield* ProjectMarkers.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = resolvePath(location.directory, input.path)
@@ -111,7 +113,7 @@ const layer = Layer.effect(
resource: externalResource,
save: slash(
path.join(
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
(yield* Project.root(fs, AbsolutePath.make(externalDirectory), markers.targets())) ?? externalDirectory,
"*",
),
),
@@ -126,5 +128,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node],
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
})
+2
View File
@@ -10,6 +10,7 @@ export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
readonly vcsBackend?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
@@ -27,6 +28,7 @@ const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
vcs: resolved.vcs,
vcsBackend: resolved.vcsBackend,
})
}),
)
+3 -3
View File
@@ -38,10 +38,10 @@ export function compatibility(input: unknown): Compatibility | undefined {
}
export function parse(input: string): { providerID: Provider.ID; modelID: ID } {
const index = input.indexOf("/")
const [providerID, ...modelID] = input.split("/")
return {
providerID: Provider.ID.make(index === -1 ? input : input.slice(0, index)),
modelID: ID.make(index === -1 ? "" : input.slice(index + 1)),
providerID: Provider.ID.make(providerID),
modelID: ID.make(modelID.join("/")),
}
}
+9 -13
View File
@@ -30,7 +30,7 @@ import { Permission } from "./permission.js"
export interface Interface {
readonly activate: (
plugins: readonly Generation[],
plugins: readonly Versioned[],
failures?: readonly Failure[],
) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
@@ -38,8 +38,8 @@ export interface Interface {
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
export type Generation = PluginDefinition & {
readonly revision: string
export type Versioned = PluginDefinition & {
readonly version: string
readonly source?: Plugin.Source
readonly features?: Plugin.Features
}
@@ -52,11 +52,11 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const kv = yield* KV.Service
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<PluginDefinition["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Generation) {
const load = Effect.fnUntraced(function* (plugin: Versioned) {
const child = yield* Scope.fork(scope)
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() =>
@@ -83,7 +83,7 @@ const layer = Layer.effect(
})
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly Generation[],
plugins: readonly Versioned[],
failures: readonly Failure[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
@@ -99,14 +99,10 @@ const layer = Layer.effect(
active.size === definitions.length &&
Array.from(active.values()).every((entry, index) => {
const definition = definitions[index]
return entry.plugin.id === definition?.id && entry.plugin.revision === definition.revision
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
})
) {
for (const definition of definitions) {
const entry = active.get(definition.id)
if (entry) active.set(definition.id, { ...entry, plugin: definition })
}
const nextInventory = [...definitions.map(activeInfo), ...failures]
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
@@ -178,7 +174,7 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Generation): Plugin.Info {
function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
+10 -7
View File
@@ -3,7 +3,7 @@ export * as InstancePlugins from "./instance.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Generation } from "../plugin.js"
import type { Versioned } from "../plugin.js"
/**
* Holds the plugins one instance is born with. Unlike the host-global
@@ -13,13 +13,16 @@ import type { Generation } from "../plugin.js"
* for the instance's lifetime; runtime dynamism lives inside plugins through
* the container transform/reload APIs.
*
* Config plugin operations may disable instance plugins by id, matching
* `SdkPlugins` behavior.
* Limitations: `vcs` marker declarations in an instance list are not seen by
* `ProjectMarkers` (it is global and runs during project resolution, before
* the instance exists — unlike `SdkPlugins`, whose declarations it consumes
* directly), and config plugin operations may disable instance plugins by id,
* matching `SdkPlugins` behavior.
*/
export type List = readonly Plugin[]
export interface Interface {
readonly all: () => readonly Generation[]
readonly all: () => readonly Versioned[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstancePlugins") {}
@@ -30,8 +33,8 @@ export const node = makeLocationNode({
deps: [],
})
// The constant revision is load-bearing: the plugin registry treats an
// unchanged (id, revision) pair as the same plugin across activations, which
// The constant version is load-bearing: the plugin registry treats an
// unchanged (id, version) pair as the same plugin across activations, which
// is only correct because a bound list never changes after creation.
// `source: "sdk"` means host-contributed; an instance list is the
// per-instance form of the same channel.
@@ -41,7 +44,7 @@ export function bound(plugins: List) {
throw new Error(`duplicate instance plugin ids: ${duplicates.map((plugin) => plugin.id).join(", ")}`)
}
const stamped = plugins.map(
(plugin): Generation => ({ ...plugin, revision: "instance", source: { type: "sdk" } }),
(plugin): Versioned => ({ ...plugin, version: "instance", source: { type: "sdk" } }),
)
return Layer.succeed(Service, Service.of({ all: () => stamped }))
}
+5
View File
@@ -26,6 +26,7 @@ import { ConfigShellPlugin } from "../config/plugin/shell.js"
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
@@ -96,6 +97,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const command = yield* Command.Service
const config = yield* Config.Service
const credential = yield* Credential.Service
const pluginSources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
@@ -139,6 +141,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Command.Service, command),
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(ConfigPluginSource.Service, pluginSources),
Context.make(Bus.Service, bus),
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
@@ -189,6 +192,7 @@ export const requirements = LayerNode.group([
Command.node,
Config.node,
Credential.node,
ConfigPluginSource.node,
Bus.node,
Environment.node,
FileMutation.node,
@@ -283,6 +287,7 @@ export const list = Effect.fn("PluginInternal.list")(function* () {
plugins.map(
(plugin): Plugin => ({
id: plugin.id,
vcs: plugin.vcs,
effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
}),
)
+14 -15
View File
@@ -8,17 +8,23 @@ import { readdir } from "node:fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Generation } from "../plugin.js"
import type { Versioned } from "../plugin.js"
import { PluginPromise } from "./promise.js"
const Discovery = Schema.Struct({
id: Schema.optional(Schema.String),
markers: Schema.Array(Schema.String),
})
const Definition = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
vcs: Schema.optional(Discovery),
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
}),
Schema.Struct({
id: Schema.String,
vcs: Schema.optional(Discovery),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
@@ -28,17 +34,13 @@ const Definition = Schema.Struct({
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
options?: { readonly install?: boolean },
) {
const npm = yield* Npm.Service
const local = path.isAbsolute(operation.target)
const installed: Npm.EntryPoint = local
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
: options?.install === false
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const installed = local
? { entrypoint: pathToFileURL(operation.target).href }
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
@@ -61,16 +63,13 @@ export const load = Effect.fn("PluginModule.load")(function* (
return {
id: plugin.id,
features,
revision: JSON.stringify([operation, installed.revision]),
vcs: plugin.vcs,
version: JSON.stringify(operation),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: {
type: "package" as const,
target: operation.target,
...(installed.version ? { version: installed.version } : {}),
},
: { type: "package" as const, package: operation.target },
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Generation
} satisfies Versioned
})
function localFeatures(entrypoint: string) {
+4 -4
View File
@@ -4,7 +4,7 @@ import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import type { Generation } from "../plugin.js"
import type { Versioned } from "../plugin.js"
export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
@@ -21,7 +21,7 @@ export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
*/
export interface Interface {
readonly register: (plugin: Plugin) => Effect.Effect<void>
readonly all: () => readonly Generation[]
readonly all: () => readonly Versioned[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
@@ -30,12 +30,12 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const plugins = new Map<string, Generation>()
const plugins = new Map<string, Versioned>()
let revision = 0
return Service.of({
register: (plugin) =>
Effect.sync(() => {
plugins.set(plugin.id, { ...plugin, revision: String(++revision), source: { type: "sdk" } })
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
all: () => [...plugins.values()],
})
+4 -7
View File
@@ -3,11 +3,10 @@
export * as SkillPlugin from "./skill.js"
import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { Effect } from "effect"
import { AbsolutePath } from "../schema.js"
import { Skill } from "../skill.js"
import { Config } from "../config.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import os from "os"
import opencodeContent from "./skill/opencode.md" with { type: "text" }
import reportContent from "./skill/report.md" with { type: "text" }
@@ -69,11 +68,9 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
})
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
const config = yield* Config.Service
return (yield* config.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => entry.info.plugins ?? [])
.map((entry) => (typeof entry === "string" ? entry : entry.package))
const sources = yield* ConfigPluginSource.Service
return (yield* sources.operations())
.map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target))
.toSorted()
})
@@ -7,10 +7,7 @@ import { Context, Effect } from "effect"
* imports: the supervisor reaches PluginRuntime, which depends on Session.
*/
export interface Interface {
/**
* Wait for the plugin generation to settle. Use this rarely: blocking reads,
* UI startup, or other unrelated work on plugin boot should be avoided.
*/
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
}
+29 -65
View File
@@ -14,20 +14,17 @@ import { PluginInternal } from "./internal.js"
import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
import { Service } from "./supervisor-service.js"
import { PluginUpdate } from "./update.js"
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Generation[],
post: readonly Plugin.Generation[],
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
operations: readonly ConfigPluginSource.Operation[],
install: boolean,
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Generation>()
const pending = new Set<string>()
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<
string,
Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
@@ -54,17 +51,13 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const plugin = yield* PluginModule.load(operation, { install }).pipe(
const plugin = yield* PluginModule.load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
if ("pending" in plugin) {
pending.add(operation.target)
continue
}
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
@@ -87,7 +80,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
pending: [...pending],
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
),
}
})
@@ -99,74 +94,45 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const updates = yield* PluginUpdate.Service
const npm = yield* Npm.Service
const ready = yield* Latch.make()
let packages = new Set<string>()
let outdated = new Set<string>()
let generation = 0
let observed = 0
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
const current = ++generation
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed plugins in boot order.
// Instance-bound plugins come last: later activation can override earlier
// container writes, so the instance's explicit choices win over globals.
const pre = [
...internal.pre.map((plugin) => ({ ...plugin, revision: "internal", source: { type: "builtin" as const } })),
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
...sdk.all(),
...instance.all(),
]
const post = internal.post.map((plugin) => ({
...plugin,
revision: "internal",
version: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// Activate everything available locally before waiting on missing package installs.
const immediate = yield* resolve(pre, post, operations, false)
const source = (source: Plugin.Source) =>
source.type === "package" && outdated.has(source.target)
? { ...source, outdated: true as const }
: source
const apply = (resolved: typeof immediate) =>
registry.activate(
resolved.plugins.map((plugin) => (plugin.source ? { ...plugin, source: source(plugin.source) } : plugin)),
resolved.failures.map((failure) => ({ ...failure, source: source(failure.source) })),
)
yield* apply(immediate)
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true) : immediate
if (resolved !== immediate) yield* apply(resolved)
const loaded = new Set(
[...resolved.plugins, ...resolved.failures].flatMap((plugin) =>
plugin.source?.type === "package" ? [plugin.source.target] : [],
),
)
packages = loaded
yield* Effect.forEach(
loaded,
(target) => updates.check(target).pipe(Effect.map((available) => [target, available] as const)),
{ concurrency: "unbounded" },
).pipe(
Effect.flatMap((checked) => {
if (current !== generation) return Effect.void
const next = new Set(checked.flatMap(([target, available]) => (available ? [target] : [])))
if (next.size === outdated.size && [...next].every((target) => outdated.has(target))) return Effect.void
outdated = next
return apply(resolved)
}),
Effect.forkScoped({ startImmediately: true }),
)
// Apply config operations and load enabled package plugins into one ordered generation.
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
if (resolved.refreshes.length) {
yield* Effect.forEach(
resolved.refreshes,
(target) =>
npm
.add(target, { subpaths: ["server", ""], refresh: true })
.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.forkDetach)
}
})
const reloads = Stream.merge(
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
updates.changes().pipe(
Stream.filter((target) => packages.has(target)),
Stream.tap((target) => Effect.sync(() => outdated.delete(target))),
Stream.map(() => undefined),
),
).pipe(
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
@@ -176,7 +142,7 @@ export const layer = Layer.effect(
}),
),
)
yield* Stream.concat(Stream.succeed(0), reloads).pipe(
yield* Stream.concat(Stream.succeed(0), updates).pipe(
// Keep observing updates while activation runs, retaining only the latest generation request.
Stream.buffer({ capacity: 1, strategy: "sliding" }),
Stream.debounce("100 millis"),
@@ -188,7 +154,6 @@ export const layer = Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("24 hours").pipe(Effect.andThen(activate()), Effect.forever, Effect.forkScoped)
return Service.of({ flush: ready.await })
}),
)
@@ -198,7 +163,6 @@ const nodeDeps = [
SdkPlugins.node,
InstancePlugins.node,
ConfigPluginSource.node,
PluginUpdate.node,
Bus.node,
Npm.node,
PluginInternal.requirements,
@@ -206,7 +170,7 @@ const nodeDeps = [
function pluginSource(target: string): Plugin.Source {
if (path.isAbsolute(target)) return { type: "local", path: target }
return { type: "package", target }
return { type: "package", package: target }
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
-54
View File
@@ -1,54 +0,0 @@
export * as PluginUpdate from "./update.js"
import { Clock, Context, Effect, Layer, Option, PubSub, Stream } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { KeyedMutex } from "../effect/keyed-mutex.js"
const interval = 24 * 60 * 60 * 1_000
export interface Interface {
readonly check: (target: string) => Effect.Effect<boolean>
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
readonly changes: () => Stream.Stream<string>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginUpdate") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const npm = yield* Npm.Service
const locks = KeyedMutex.makeUnsafe<string>()
const status = new Map<string, { readonly outdated: boolean; readonly checkedAt: number }>()
const changes = yield* PubSub.unbounded<string>()
return Service.of({
check: (target) =>
locks.withLock(target)(
Effect.gen(function* () {
const checkedAt = yield* Clock.currentTimeMillis
const current = status.get(target)
if (current && checkedAt - current.checkedAt < interval) return current.outdated
const outdated = yield* npm.check(target).pipe(
Effect.tapCause((cause) => Effect.logWarning("failed to check plugin update", { target, cause })),
Effect.option,
)
const value = Option.getOrElse(outdated, () => current?.outdated ?? false)
status.set(target, { outdated: value, checkedAt })
return value
}),
),
update: (target) =>
npm.update(target).pipe(
Effect.tap(() => Effect.sync(() => status.delete(target))),
Effect.tap(() => PubSub.publish(changes, target)),
Effect.asVoid,
),
changes: () => Stream.fromPubSub(changes),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Npm.node] })
+1
View File
@@ -20,6 +20,7 @@ import type { Patch } from "../../vcs/patch.js"
export const Plugin = define({
id: "opencode.vcs.git",
vcs: { id: "git", markers: [".git"] },
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "git") return
+1
View File
@@ -24,6 +24,7 @@ import {
export const Plugin = define({
id: "opencode.vcs.hg",
vcs: { id: "hg", markers: [".hg"] },
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "hg") return
+22 -5
View File
@@ -13,6 +13,7 @@ import { Git } from "./git.js"
import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Hash } from "@opencode-ai/util/hash"
import { ProjectMarkers } from "./project/markers.js"
import { ProjectSchema } from "./project/schema.js"
import { ProjectTable, upsertProject } from "./project/sql.js"
import { WorktreeTable } from "./worktree/sql.js"
@@ -43,6 +44,7 @@ export interface Resolved {
// This checkout's main directory; the stored project canonical may be another clone.
readonly canonical: AbsolutePath
readonly vcs?: Vcs
readonly vcsBackend?: string
}
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
@@ -96,6 +98,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const markers = yield* ProjectMarkers.Service
const proc = yield* AppProcess.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
@@ -169,7 +172,7 @@ const layer = Layer.effect(
if (candidate.id === item.projectID) return false
if (!FSUtil.contains(directory, candidate.directory)) return false
const found = yield* fs
.up({ targets: [".git", ".hg"], start: candidate.directory, stop: directory, mode: "first" })
.up({ targets: [...markers.targets()], start: candidate.directory, stop: directory, mode: "first" })
.pipe(Effect.orElseSucceed(() => []))
if (!found[0]) return false
return (yield* fs.resolve(path.dirname(found[0]))) === directory
@@ -315,9 +318,10 @@ const layer = Layer.effect(
const resolve = Effect.fn("Project.resolve")(function* (
input: AbsolutePath,
_options?: { readonly discovery?: boolean },
options?: { readonly discovery?: boolean },
) {
const directory = AbsolutePath.make(yield* fs.resolve(input))
const marker = yield* markers.discover(directory, options)
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.orElseSucceed(() => undefined),
@@ -326,7 +330,7 @@ const layer = Layer.effect(
native && path.basename(native) === ".git"
? yield* git.repo.discover(AbsolutePath.make(path.dirname(native)))
: undefined
if (repo) {
if (repo && (!marker || FSUtil.contains(marker.directory, repo.worktree))) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
const canonical =
@@ -342,14 +346,27 @@ const layer = Layer.effect(
directory: repo.worktree,
canonical,
vcs: { type: "git" as const, store: repo.commonDirectory },
...(marker?.directory === repo.worktree && marker.type !== "git" ? { vcsBackend: marker.type } : {}),
})
}
const hg = native && path.basename(native) === ".hg" ? yield* hgDiscover(AbsolutePath.make(native)) : undefined
if (hg) {
if (hg && (!marker || FSUtil.contains(marker.directory, hg.directory))) {
return yield* persist({
...hg,
canonical: hg.directory,
...(marker?.directory === hg.directory && marker.type !== "hg" ? { vcsBackend: marker.type } : {}),
})
}
if (marker) {
const previous = yield* cached(marker.marker)
return yield* persist({
previous,
id: previous ?? ID.make(Hash.fast(`vcs-repository:${marker.type}:${marker.marker}`)),
directory: marker.directory,
canonical: marker.directory,
vcs: { type: marker.type, store: marker.marker },
})
}
@@ -368,5 +385,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
deps: [Bus.node, Database.node, FSUtil.node, Git.node, ProjectMarkers.node, AppProcess.node],
})
+191
View File
@@ -0,0 +1,191 @@
export * as ProjectMarkers from "./markers.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
import { fileURLToPath } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Versioned } from "../plugin.js"
import { PluginModule } from "../plugin/module.js"
import { PluginSourceDirectory } from "../plugin/source-directory.js"
import { SdkPlugins } from "../plugin/sdk.js"
import { AbsolutePath } from "../schema.js"
export interface Match {
readonly type: string
readonly directory: AbsolutePath
readonly marker: AbsolutePath
}
export interface Interface {
readonly discover: (
directory: AbsolutePath,
options?: { readonly discovery?: boolean },
) => Effect.Effect<Match | undefined>
readonly targets: () => readonly string[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectMarkers") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const npm = yield* Npm.Service
const sdk = yield* SdkPlugins.Service
const known = new Set([".git", ".hg"])
const loaded = new Map<string, Versioned | undefined>()
// The filesystem half of discovery: walk up for config, scan plugin
// directories, and read configured plugin operations. This is the part a
// no-discovery caller must skip — it imports plugin modules.
const scanOperations = Effect.fnUntraced(function* (directory: AbsolutePath) {
const found = yield* fs
.up({ targets: [".opencode", "opencode.json", "opencode.jsonc"], start: directory })
.pipe(Effect.orElseSucceed(() => []))
const roots = [global.config, ...found.filter((value) => path.basename(value) === ".opencode").toReversed()]
const files = [
...["opencode.json", "opencode.jsonc"].map((name) => path.join(global.config, name)),
...found.filter((value) => path.basename(value) !== ".opencode").toReversed(),
...roots.slice(1).flatMap((root) => ["opencode.json", "opencode.jsonc"].map((name) => path.join(root, name))),
]
const automatic = yield* Effect.forEach(roots, (root) => PluginSourceDirectory.discover(fs, root)).pipe(
Effect.map((entries) => entries.flat()),
)
const configured = yield* Effect.forEach([...new Set(files)], (file) => read(fs, file)).pipe(
Effect.map((entries) => entries.flat()),
)
return yield* Effect.forEach(
[
...automatic.map((target): ConfigPluginSource.Operation => ({ type: "add", target, options: {} })),
...configured,
],
(operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.orElseSucceed(() => operation),
)
},
)
})
const discover = Effect.fn("ProjectMarkers.discover")(function* (
directory: AbsolutePath,
options?: { readonly discovery?: boolean },
) {
// discovery: false skips the config scan and its plugin module loading;
// sdk-declared vcs markers are host-explicit, not ambient, so they stay.
const operations = options?.discovery === false ? [] : yield* scanOperations(directory)
const declarations = new Map<string, { readonly id: string; readonly markers: readonly string[] }>()
for (const plugin of sdk.all()) {
if (!plugin.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
for (const operation of operations) {
if (operation.type === "remove") {
for (const id of declarations.keys()) {
if (
operation.target === "*" ||
(operation.target.endsWith(".*") ? id.startsWith(operation.target.slice(0, -1)) : operation.target === id)
) {
declarations.delete(id)
}
}
continue
}
if (operation.target === "*" || operation.target.endsWith(".*") || operation.target.startsWith("opencode."))
continue
const key = JSON.stringify(operation)
const plugin = loaded.has(key)
? loaded.get(key)
: yield* PluginModule.load(operation).pipe(
Effect.provideService(Npm.Service, npm),
Effect.catchCause((cause) =>
Effect.logDebug("failed to discover plugin repository markers", {
target: operation.target,
cause,
}).pipe(Effect.as(undefined)),
),
Effect.tap((value) => Effect.sync(() => loaded.set(key, value))),
)
if (!plugin?.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
const markers = new Map<string, string>()
for (const declaration of declarations.values()) {
if (!/^[a-z][a-z0-9._-]*$/.test(declaration.id)) continue
for (const marker of declaration.markers) {
if (!marker || marker === "." || marker === ".." || /[\\/]/.test(marker)) continue
known.add(marker)
markers.set(marker, declaration.id)
}
}
if (!markers.size) return undefined
const marker = yield* fs.up({ targets: [...markers.keys()], start: directory, mode: "first" }).pipe(
Effect.map((entries) => entries[0]),
Effect.orElseSucceed(() => undefined),
)
if (!marker) return undefined
const type = markers.get(path.basename(marker))
if (!type) return undefined
return {
type,
directory: AbsolutePath.make(path.dirname(marker)),
marker: AbsolutePath.make(marker),
} satisfies Match
})
return Service.of({ discover, targets: () => [...known] })
}),
)
function read(fs: FSUtil.Interface, file: string): Effect.Effect<ConfigPluginSource.Operation[]> {
return Effect.gen(function* () {
const source = yield* fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))
if (!source) return []
const errors: ParseError[] = []
const document: unknown = parse(source, errors, { allowTrailingComma: true })
if (errors.length || typeof document !== "object" || document === null || !("plugins" in document)) return []
if (!Array.isArray(document.plugins)) return []
return document.plugins.flatMap<ConfigPluginSource.Operation>((entry) => {
if (typeof entry === "string" && entry.startsWith("-")) {
return [{ type: "remove", target: entry.slice(1) }]
}
if (
typeof entry !== "string" &&
(typeof entry !== "object" || entry === null || !("package" in entry) || typeof entry.package !== "string")
) {
return []
}
const target = typeof entry === "string" ? entry : entry.package
const options =
typeof entry !== "string" && "options" in entry && typeof entry.options === "object" && entry.options !== null
? Object.fromEntries(Object.entries(entry.options))
: {}
if (target.startsWith("file://")) return [{ type: "add", target: fileURLToPath(target), options }]
if (target.startsWith("./") || target.startsWith("../")) {
return [{ type: "add", target: path.resolve(path.dirname(file), target), options }]
}
return [{ type: "add", target, options }]
})
})
}
export const node = makeGlobalNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Npm.node, SdkPlugins.node],
})
+1 -1
View File
@@ -96,7 +96,7 @@ const layer = Layer.effect(
step = 1
}
if (pending?.type === "move")
return DrainResult.Moved({ continuation: continuing ? { step } : undefined })
return DrainResult.Moved({ continuation: !entering && continuing ? { step } : undefined })
if (pending?.type === "compaction") {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
+1
View File
@@ -316,6 +316,7 @@ export const make = Effect.fn("Session.make")(function* () {
text: input.text,
description: input.description,
metadata: input.metadata,
resume: input.resume === false ? false : undefined,
}),
delivery: SessionInbox.Delivery.make(input.delivery ?? "steer"),
} satisfies SessionInbox.Item
+20 -40
View File
@@ -1,6 +1,6 @@
export * as Tool from "./tool.js"
export { CallID, Content, Error, FileContent, TextContent } from "@opencode-ai/schema/tool"
export type { Context, Metadata, Namespace, Options, Result } from "@opencode-ai/schema/tool"
export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/tool"
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
@@ -26,7 +26,6 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
export interface Draft {
readonly list: () => readonly (Tool.Info & { readonly id: string })[]
readonly get: (id: string) => (Tool.Info & { readonly id: string }) | undefined
readonly namespace: (namespace: Tool.Namespace) => void
readonly add: (tool: Tool.Info) => void
readonly update: (id: string, update: (tool: Types.Mutable<Tool.Info>) => void) => void
readonly remove: (id: string) => void
@@ -34,8 +33,7 @@ export interface Draft {
type Data = {
tools: Map<string, Tool.Info & { readonly id: string }>
namespaces: Map<string, Tool.Namespace>
errors: { kind: "tool" | "namespace"; name: string; namespace?: string; error: RegistrationError }[]
errors: { tool: Tool.Info; error: RegistrationError }[]
}
export interface Interface extends State.Transformable<Draft> {
@@ -44,7 +42,7 @@ export interface Interface extends State.Transformable<Draft> {
export interface Snapshot {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly codeModeCatalog?: CodeModeCatalog.Inventory
readonly codeModeCatalog?: ReadonlyArray<CodeModeCatalog.Entry>
readonly execute: (input: {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
@@ -153,24 +151,15 @@ const layer = Layer.effect(
name: "tool",
initial: () => ({
tools: new Map(),
namespaces: new Map(),
errors: [],
}),
draft: (draft) => ({
list: () => Array.from(draft.tools.values()),
get: (id) => draft.tools.get(id),
namespace: (namespace) => {
const error = namespaceError(namespace.name)
if (error) {
draft.errors.push({ kind: "namespace", name: namespace.name, namespace: namespace.name, error })
return
}
draft.namespaces.set(namespace.name, { ...namespace })
},
add: (tool) => {
const error = registrationError(tool)
if (error) {
draft.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error })
draft.errors.push({ tool, error })
return
}
const id = effectiveName(tool)
@@ -187,7 +176,7 @@ const layer = Layer.effect(
tool.options = { ...tool.options, namespace: current.options?.namespace }
const error = registrationError(tool)
if (error) {
draft.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error })
draft.errors.push({ tool, error })
return
}
draft.tools.set(id, tool)
@@ -199,10 +188,10 @@ const layer = Layer.effect(
finalize: () =>
Effect.forEach(
state.get().errors,
({ kind, name, namespace, error }) =>
Effect.logError(`Skipping invalid ${kind} registration`, {
name,
namespace,
({ tool, error }) =>
Effect.logError("Skipping invalid tool registration", {
name: tool.name,
namespace: tool.options?.namespace,
error: error.message,
}),
{ discard: true },
@@ -221,25 +210,23 @@ const layer = Layer.effect(
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const namespaces = state.get().namespaces
const codeModeInventory = { tools: codeModeTools, namespaces }
const codeModeEnabled = !whollyDisabled("execute", rules)
const codeModeTool = codeModeEnabled
? CodeModeTool.create(codeModeInventory, (name, tool, input, context) =>
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const codemodeEnabled = !whollyDisabled("execute", rules)
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) =>
beforeExecute(name, input, context).pipe(
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
),
)
: undefined
const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, tool]) => definition(tool)),
...(codeModeTool ? [definition(codeModeTool)] : []),
...(codemodeTool ? [definition(codemodeTool)] : []),
],
execute: Effect.fnUntraced(function* (input: Parameters<Snapshot["execute"]>[0]) {
const context: Tool.Context = {
@@ -252,11 +239,11 @@ const layer = Layer.effect(
const event = yield* beforeExecute(input.call.name, input.call.input, context)
const requested = input.definitions?.get(event.tool)
// Preserve session context removal and alias resolution, now after the repair hook.
if (!requested && input.definitions && (direct.has(event.tool) || codeModeTool?.name === event.tool))
if (!requested && input.definitions && (direct.has(event.tool) || codemodeTool?.name === event.tool))
return yield* new Tool.Error({ message: `Tool is not available for this request: ${event.tool}` })
const name = requested?.name ?? event.tool
if (name === "execute" && codeModeTool)
return yield* executeTool(codeModeTool, name, event.input, context)
if (name === "execute" && codemodeTool)
return yield* executeTool(codemodeTool, name, event.input, context)
const tool = direct.get(name)
if (tool) return yield* executeTool(tool, name, event.input, context)
return yield* new Tool.Error({ message: `Unknown tool: ${name}` })
@@ -282,10 +269,8 @@ function schemaMakeError(error: unknown) {
function registrationError(tool: Tool.Info) {
const namespace = tool.options?.namespace
if (namespace !== undefined) {
const error = namespaceError(namespace)
if (error) return error
}
if (namespace !== undefined && !namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment)))
return new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` })
const name = normalizedName(tool)
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` })
const id = effectiveName(tool)
@@ -299,11 +284,6 @@ function registrationError(tool: Tool.Info) {
return Result.isFailure(result) ? result.failure : undefined
}
function namespaceError(name: string) {
if (name.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))) return
return new RegistrationError({ name, message: `Invalid tool namespace: ${JSON.stringify(name)}` })
}
export const node = makeLocationNode({
service: Service,
layer,
-2
View File
@@ -32,8 +32,6 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Namespace descriptions are registered once through `draft.namespace(...)`. Tool options continue to reference the namespace by string name; an unregistered namespace remains valid and simply has no namespace description.
The service uses shared `State` to replay synchronous transforms in registration order against a fresh draft. `Tool.Service.reload()` rebuilds from captured source data without changing registration precedence. Registrations are scoped and return a real, idempotent `dispose` Effect:
- The latest valid active registration for the same effective name wins.
+1 -1
View File
@@ -82,7 +82,7 @@ const layer = Layer.effect(
})
const selected = () => {
const value = state.get()
const id = value.selection ?? vcs?.type
const id = value.selection ?? location.vcsBackend ?? vcs?.type
return id ? value.providers.get(id) : undefined
}
const protect = <A>(provider: VcsDefinition, operation: string, effect: Effect.Effect<A, unknown>, fallback: A) =>
+8 -11
View File
@@ -23,17 +23,14 @@ describe("CodeMode", () => {
const snapshot = yield* tools.snapshot()
expect(snapshot.definitions.some((tool) => tool.name === "execute")).toBe(true)
expect(snapshot.codeModeCatalog).toStrictEqual({
tools: [
{
path: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
pinned: true,
},
],
namespaces: new Map(),
})
expect(snapshot.codeModeCatalog).toStrictEqual([
{
path: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
pinned: true,
},
])
}).pipe(
Effect.scoped,
Effect.provide(
+17 -69
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Tool => ({
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({
path,
description,
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
@@ -15,24 +15,21 @@ const lookup = entry(
"tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
)
const render = (tools: ReadonlyArray<CodeModeCatalog.Tool>, budget?: number) =>
CodeModeInstructions.render(CodeModeCatalog.summarize({ tools }, budget === undefined ? {} : { budget }))
const render = (entries: ReadonlyArray<CodeModeCatalog.Entry>, budget?: number) =>
CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget))
const update = (
previous: ReadonlyArray<CodeModeCatalog.Tool>,
current: ReadonlyArray<CodeModeCatalog.Tool>,
previous: ReadonlyArray<CodeModeCatalog.Entry>,
current: ReadonlyArray<CodeModeCatalog.Entry>,
budget?: number,
) =>
CodeModeInstructions.update(
CodeModeCatalog.summarize({ tools: previous }, budget === undefined ? {} : { budget }),
CodeModeCatalog.summarize({ tools: current }, budget === undefined ? {} : { budget }),
)
CodeModeInstructions.update(CodeModeCatalog.summarize(previous, budget), CodeModeCatalog.summarize(current, budget))
describe("CodeModeCatalog.summarize", () => {
test("retains namespace inventory without retaining tools outside the inline budget", () => {
const catalog = CodeModeCatalog.summarize(
{ tools: Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)) },
{ budget: 0 },
Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)),
0,
)
expect(catalog).toEqual({
total: 10_000,
@@ -43,8 +40,8 @@ describe("CodeModeCatalog.summarize", () => {
test("retains every namespace when no full tool listing fits", () => {
const catalog = CodeModeCatalog.summarize(
{ tools: [entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")] },
{ budget: 0 },
[entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")],
0,
)
expect(catalog.namespaces.map((namespace) => namespace.name)).toEqual(["alpha", "beta", "gamma"])
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
@@ -52,10 +49,7 @@ describe("CodeModeCatalog.summarize", () => {
test("always retains pinned tools beyond the inline budget", () => {
const pinned = [entry("alpha.first", "First", undefined, true), entry("beta.second", "Second", undefined, true)]
const catalog = CodeModeCatalog.summarize(
{ tools: [...pinned, entry("alpha.unpinned", "Unpinned")] },
{ budget: 0 },
)
const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0)
expect(catalog.shown).toBe(2)
expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([
@@ -69,48 +63,22 @@ describe("CodeModeCatalog.summarize", () => {
const unpinned = entry("beta.unpinned", "Unpinned")
const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4)
const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4)
const namespaceCost = [
{ name: "alpha", count: 1, entries: [] },
{ name: "beta", count: 1, entries: [] },
].reduce((total, namespace) => total + Math.round(CodeModeCatalog.namespaceLine(namespace).length / 4), 0)
expect(
CodeModeCatalog.summarize({ tools: [pinned, unpinned] }, { budget: namespaceCost + pinCost + unpinnedCost })
.shown,
).toBe(2)
expect(
CodeModeCatalog.summarize({ tools: [pinned, unpinned] }, { budget: namespaceCost + pinCost + unpinnedCost - 1 })
.shown,
).toBe(1)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1)
})
test("retains only the rendered portion of inline descriptions", () => {
const catalog = CodeModeCatalog.summarize({
tools: [entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)],
})
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
})
test("limits inline descriptions to 120 characters", () => {
const catalog = CodeModeCatalog.summarize({ tools: [entry("alpha.one", "x".repeat(121))] })
const catalog = CodeModeCatalog.summarize([entry("alpha.one", "x".repeat(121))])
const description = catalog.namespaces[0]?.entries[0]?.line.split(" // ")[1]
expect(description).toHaveLength(120)
expect(description).toEndWith("...")
})
test("always retains namespace descriptions and charges them before tool listings", () => {
const tool = entry("alpha.one", "One")
const listingCost = Math.round(` - ${tool.signature} // One`.length / 4)
const namespaceCost = Math.round(CodeModeCatalog.namespaceLine({ name: "alpha", count: 1, entries: [] }).length / 4)
const description = "A namespace description that stays visible beyond the available tool budget"
const namespaces = new Map([["alpha", { name: "alpha", description }]])
expect(CodeModeCatalog.summarize({ tools: [tool] }, { budget: namespaceCost + listingCost }).shown).toBe(1)
const catalog = CodeModeCatalog.summarize({ tools: [tool], namespaces }, { budget: namespaceCost + listingCost })
expect(catalog.shown).toBe(0)
expect(catalog.namespaces[0]?.description).toBe(description)
expect(CodeModeInstructions.render(catalog)).toContain(`- alpha (1 tool, none shown) // ${description}`)
})
})
describe("CodeModeInstructions.render", () => {
@@ -136,8 +104,7 @@ describe("CodeModeInstructions.render", () => {
)
expect(partial).not.toContain("surrounding top-level agent tools")
expect(partial).toContain("- search(input: {")
expect(partial).toContain(" /**\n * @integer\n * @exclusiveMinimum 0\n */\n limit?: number,")
expect(partial).toContain(" /**\n * @integer\n * @minimum 0\n */\n offset?: number,")
expect(partial).toContain(" limit?: number,\n offset?: number,")
expect(partial).not.toContain("tools.orders.lookup(input:")
})
@@ -151,11 +118,7 @@ describe("CodeModeInstructions.render", () => {
)
// Round 1 places alpha.cheap and beta.cheap; in round 2 alpha.expensive does not fit,
// which marks only alpha done - it must NOT prevent other namespaces from inlining.
const namespaceCost = [
{ name: "alpha", count: 2, entries: [] },
{ name: "beta", count: 1, entries: [] },
].reduce((total, namespace) => total + Math.round(CodeModeCatalog.namespaceLine(namespace).length / 4), 0)
const instructions = render([cheapAlpha, expensive, cheapBeta], 40 + namespaceCost)
const instructions = render([cheapAlpha, expensive, cheapBeta], 40)
expect(instructions).toContain("## Search")
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
expect(instructions).toContain(` - ${cheapAlpha.signature} // Cheap`)
@@ -207,21 +170,6 @@ describe("CodeModeInstructions.update", () => {
)
})
test("restates namespace descriptions when they change", () => {
const previous = CodeModeCatalog.summarize({
tools: [echo],
namespaces: new Map([["notes", { name: "notes", description: "Old description" }]]),
})
const current = CodeModeCatalog.summarize({
tools: [echo],
namespaces: new Map([["notes", { name: "notes", description: "New description" }]]),
})
const text = CodeModeInstructions.update(previous, current)
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
expect(text).toContain("- notes (1 tool) // New description")
expect(text).not.toContain("Old description")
})
test("restates the full catalog when the rendering mode crosses full and compact", () => {
const wide = Array.from({ length: 40 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
const text = update([echo], [echo, ...wide], 30)
@@ -9,13 +9,13 @@ import { Effect, Schema } from "effect"
import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
const echo: CodeModeCatalog.Tool = {
const echo: CodeModeCatalog.Entry = {
path: "notes.echo",
description: "Echo text",
signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>",
}
const lookup: CodeModeCatalog.Tool = {
const lookup: CodeModeCatalog.Entry = {
path: "orders.lookup",
description: "Look up an order",
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<unknown>",
@@ -24,16 +24,16 @@ const lookup: CodeModeCatalog.Tool = {
describe("CodeModeInstructions", () => {
it.effect("instructs the model not to call execute while the catalog is empty", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [] }))
const initialized = yield* readInitial(CodeModeInstructions.make([]))
expect(initialized.text).toBe(
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
)
const added = yield* readUpdate(CodeModeInstructions.make({ tools: [echo] }), initialized)
const added = yield* readUpdate(CodeModeInstructions.make([echo]), initialized)
expect(added.text).toContain("New tools are available in addition to those previously listed:")
expect(added.text).toContain(echo.signature)
expect(yield* readUpdate(CodeModeInstructions.make({ tools: [] }), { values: added.values })).toMatchObject({
expect(yield* readUpdate(CodeModeInstructions.make([]), { values: added.values })).toMatchObject({
text:
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\n" +
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
@@ -43,7 +43,7 @@ describe("CodeModeInstructions", () => {
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [echo] }))
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
expect(initialized.text).toContain(
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
)
@@ -51,13 +51,13 @@ describe("CodeModeInstructions", () => {
expect(initialized.text).not.toContain("## Search")
expect(initialized.text).toContain(` - ${echo.signature} // Echo text`)
const added = yield* readUpdate(CodeModeInstructions.make({ tools: [echo, lookup] }), initialized)
const added = yield* readUpdate(CodeModeInstructions.make([echo, lookup]), initialized)
expect(added.text).toContain("The Code Mode tool catalog has changed.")
expect(added.text).toContain("New tools are available in addition to those previously listed:")
expect(added.text).toContain(` - ${lookup.signature} // Look up an order`)
expect(added.text).not.toContain("## Available tools")
const removed = yield* readUpdate(CodeModeInstructions.make({ tools: [echo] }), { values: added.values })
const removed = yield* readUpdate(CodeModeInstructions.make([echo]), { values: added.values })
expect(removed.text).toBe(
"The Code Mode tool catalog has changed.\n\n" +
"The following tools are no longer available and must not be called: tools.orders.lookup.",
@@ -93,27 +93,22 @@ describe("CodeModeInstructions", () => {
const initialized = yield* Effect.scoped(
Effect.gen(function* () {
yield* tools.transform((draft) => {
draft.namespace({ name: "tools", description: "Project utilities" })
draft.add({ ...zeta, options: { namespace: "tools" } })
draft.add({ ...alpha, options: { namespace: "tools" } })
})
const snapshot = yield* tools.snapshot()
return yield* readInitial(CodeModeInstructions.make(snapshot.codeModeCatalog))
return yield* readInitial(CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog))
}),
)
const reordered = yield* Effect.scoped(
Effect.gen(function* () {
yield* tools.transform((draft) => {
draft.namespace({ name: "tools", description: "Project utilities" })
draft.add({ ...alpha, options: { namespace: "tools" } })
draft.add({ ...zeta, options: { namespace: "tools" } })
})
const snapshot = yield* tools.snapshot()
return yield* readUpdate(CodeModeInstructions.make(snapshot.codeModeCatalog), initialized)
return yield* readUpdate(CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog), initialized)
}),
)
expect(initialized.text).toContain("- tools (2 tools) // Project utilities")
expect(reordered.changed).toBe(false)
expect(reordered.text).toBe("")
}).pipe(Effect.provide(layer))
+54 -89
View File
@@ -20,7 +20,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
@@ -37,79 +37,38 @@ const staticIt = testEffect(
Global.node.replace(tempGlobalLayer),
]),
)
const outdatedNpm = makeGlobalNode({
const refreshNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "outdated-plugin")
let version = "1.0.0"
const installed = () => ({
directory,
entrypoint: pathToFileURL(path.join(directory, "index.js")).href,
version,
revision: version,
})
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await Bun.write(path.join(directory, "index.js"), 'export default { id: "outdated-plugin", setup() {} }')
})
const directory = path.join(global.tmp, "background-refresh-plugin")
const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href }
return Npm.Service.of({
add: () => Effect.sync(installed),
resolve: () => Effect.sync(installed),
check: () => Effect.sync(() => version === "1.0.0"),
update: () => Effect.sync(() => (version = "1.1.0")).pipe(Effect.map(installed)),
add: (_pkg, options) =>
options?.refresh
? Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-requested"), ""))
yield* waitForFile(path.join(directory, "refresh-release")).pipe(Effect.orDie)
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-finished"), ""))
return installed
})
: Effect.succeed(installed),
resolve: () => Effect.succeed(installed),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const updateIt = testEffect(
const refreshIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[Global.node.replace(tempGlobalLayer), Npm.node.replace(outdatedNpm)],
),
)
const coldNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "cold-plugin")
const started = path.join(directory, "started")
const release = path.join(directory, "release")
const entry = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href, revision: "1" }
let installed = false
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await Bun.write(path.join(directory, "index.js"), 'export default { id: "cold-plugin", setup() {} }')
})
return Npm.Service.of({
add: () =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(started, ""))
yield* waitForFile(release).pipe(Effect.orDie)
installed = true
return entry
}),
resolve: () => Effect.sync(() => (installed ? entry : { directory })),
check: () => Effect.succeed(false),
update: () => Effect.succeed(entry),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const coldIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[Global.node.replace(tempGlobalLayer), Npm.node.replace(coldNpm)],
[Global.node.replace(tempGlobalLayer), Npm.node.replace(refreshNpm)],
),
)
describe("PluginSupervisor config", () => {
it.live("applies selectors in order", () =>
withLocation(
@@ -504,40 +463,46 @@ describe("PluginSupervisor config", () => {
}),
)
updateIt.live("marks active package plugins as outdated after a background check", () =>
withLocation(
{ plugins: ["outdated-plugin"] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const source = yield* Effect.suspend(() => plugins.list()).pipe(
Effect.map((items) => items.find((item) => item.id === "outdated-plugin")?.source),
Effect.filterOrFail((source) => source?.type === "package" && source.outdated === true),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("2 seconds"),
refreshIt.live("refreshes active package plugins after setup without blocking flush", () =>
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const activated = path.join(directory, "activated")
const release = path.join(directory, "release")
const refreshed = path.join(directory, "refresh-requested")
const refreshRelease = path.join(directory, "refresh-release")
const refreshFinished = path.join(directory, "refresh-finished")
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(
path.join(directory, "index.js"),
`export default {
id: "background-refresh-plugin",
async setup() {
await Bun.write(${JSON.stringify(activated)}, "")
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
},
}`,
)
expect(source).toEqual({ type: "package", target: "outdated-plugin", version: "1.0.0", outdated: true })
}),
),
)
})
coldIt.live("activates available plugins before a missing package finishes installing", () =>
withLocation(
{ plugins: ["cold-plugin"] },
Effect.gen(function* () {
const global = yield* Global.Service
yield* waitForFile(path.join(global.tmp, "cold-plugin", "started"))
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("opencode.provider.openai")
const supervisor = yield* PluginSupervisor.Service
expect(Option.isNone(yield* supervisor.flush.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
yield* Effect.promise(() => Bun.write(path.join(global.tmp, "cold-plugin", "release"), ""))
yield* supervisor.flush.pipe(Effect.timeout("2 seconds"))
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("cold-plugin")
}),
),
yield* withLocation(
{ plugins: ["background-refresh-plugin"] },
Effect.gen(function* () {
yield* waitForFile(activated)
yield* Effect.sleep("100 millis")
expect(yield* Effect.promise(() => Bun.file(refreshed).exists())).toBeFalse()
yield* Effect.promise(() => Bun.write(release, ""))
yield* waitForFile(refreshed)
yield* ready().pipe(Effect.timeout("2 seconds"))
yield* Effect.promise(() => Bun.write(refreshRelease, ""))
yield* waitForFile(refreshFinished)
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("background-refresh-plugin")
}),
)
}),
)
})
const ready = Effect.fnUntraced(function* () {
@@ -147,20 +147,6 @@ describe("cross-spawn spawner", () => {
})
describe("stderr", () => {
fx.live(
"captures both streams across backpressure",
Effect.gen(function* () {
const handle = yield* js(
'process.stdout.write("o".repeat(256 * 1024)); process.stderr.write("e".repeat(256 * 1024))',
)
const output = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
concurrency: "unbounded",
})
expect(output).toEqual(["o".repeat(256 * 1024), "e".repeat(256 * 1024)])
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
fx.effect(
"captures stderr output",
Effect.gen(function* () {
@@ -213,30 +199,6 @@ describe("cross-spawn spawner", () => {
)
})
describe("delayed output consumption", () => {
for (const combined of [false, true]) {
fx.live(
`retains ${combined ? "combined" : "separate"} output after process completion`,
Effect.gen(function* () {
const handle = yield* js(
'require("node:fs").writeSync(1, "stdout\\n"); require("node:fs").writeSync(2, "stderr\\n")',
)
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
if (combined) {
const output = yield* decodeByteStream(handle.all)
expect(output).toContain("stdout")
expect(output).toContain("stderr")
return
}
const output = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
concurrency: "unbounded",
})
expect(output).toEqual(["stdout", "stderr"])
}),
)
}
})
describe("stdin", () => {
fx.effect(
"allows providing standard input to a command",
@@ -30,9 +30,7 @@ export const promptLocationNode = makeGlobalNode({
replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))],
}),
Layer.succeed(FSUtil.Service, fs),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
),
),
+1 -1
View File
@@ -35,7 +35,7 @@ export function waitForCodeModeTool(
): Effect.Effect<Tool.Snapshot, Error> {
return Effect.gen(function* () {
const toolSet = yield* registry.snapshot()
if (toolSet.codeModeCatalog?.tools.some((tool) => tool.path === path)) return toolSet
if (toolSet.codeModeCatalog?.some((tool) => tool.path === path)) return toolSet
if (remaining === 0) {
return yield* Effect.fail(new Error(`Timed out waiting for Code Mode tool: ${path}`))
}
+1 -1
View File
@@ -940,7 +940,7 @@ describe("LocationServiceMap", () => {
})
.pipe(Effect.asVoid),
})
yield* plugins.activate([{ ...reviewer, revision: "1" }])
yield* plugins.activate([{ ...reviewer, version: "1" }])
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
+5 -9
View File
@@ -1764,9 +1764,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
"direct_media",
"execute",
])
expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).toContain(
"ok: boolean",
)
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).toContain("ok: boolean")
expect(execute?.description).not.toContain("tools.demo.search")
}),
)
@@ -1784,9 +1782,7 @@ it.effect("forwards the invoking session through direct and Code Mode MCP tools"
expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty(
"properties.sessionID",
)
expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).not.toContain(
"sessionID",
)
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).not.toContain("sessionID")
const directSessionID = Session.ID.make("ses_mcp_direct")
yield* toolSet.execute({
@@ -1830,7 +1826,7 @@ it.effect("returns content-only MCP results through Code Mode", () =>
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.status")).toBe(true)
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.status")).toBe(true)
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_content_only"),
@@ -1916,7 +1912,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true)
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
const fiber = yield* toolSet
.execute({
@@ -1960,7 +1956,7 @@ it.effect("does not call MCP when permission is blocked", () =>
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true)
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_blocked"),
-17
View File
@@ -5,23 +5,6 @@ import { Provider } from "@opencode-ai/core/provider"
const decode = Schema.decodeUnknownSync(Model.Ref)
describe("Model.parse", () => {
test.each([
["vendor/model", "vendor", "model"],
["vendor/team/model", "vendor", "team/model"],
["vendor", "vendor", ""],
["", "", ""],
["/model", "", "model"],
["vendor/", "vendor", ""],
["vendor//model/", "vendor", "/model/"],
])("parses %j at the first slash", (input, providerID, modelID) => {
expect(Model.parse(input)).toEqual({
providerID: Provider.ID.make(providerID),
modelID: Model.ID.make(modelID),
})
})
})
describe("Model.Ref", () => {
test("accepts a model selection without a variant", () => {
expect(decode({ id: "claude-sonnet", providerID: "anthropic" })).toEqual({
+31 -140
View File
@@ -55,58 +55,6 @@ async function createGitFixture(directory: string) {
return { repository, commit }
}
async function createRegistryFixture(directory: string) {
const tarballs = new Map<string, Uint8Array>()
for (const version of ["1.0.0", "1.1.0"]) {
const root = path.join(directory, version)
await fs.mkdir(path.join(root, "package"), { recursive: true })
await writePackage(path.join(root, "package"), {
name: "@fixture/registry-plugin",
version,
exports: "./index.js",
})
await Bun.write(path.join(root, "package", "index.js"), `export const version = "${version}"\n`)
await Bun.$`tar -czf ${path.join(root, "package.tgz")} -C ${root} package`
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
}
const state = { latest: "1.0.0" }
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
return Response.json({
name: "@fixture/registry-plugin",
"dist-tags": { latest: state.latest },
versions: Object.fromEntries(
[...tarballs.keys()].map((version) => [
version,
{ name: "@fixture/registry-plugin", version, dist: { tarball: `${url.origin}/${version}.tgz` } },
]),
),
})
const tarball = tarballs.get(url.pathname.slice(1).replace(".tgz", ""))
return tarball ? new Response(tarball) : new Response("missing", { status: 404 })
},
})
return {
state,
async configure(cache: string, spec: string) {
const root = path.join(cache, "npm", await Npm.cacheKey(spec))
await fs.mkdir(root, { recursive: true })
await Bun.write(
path.join(root, ".npmrc"),
`@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
)
return root
},
async [Symbol.asyncDispose]() {
await server.stop(true)
},
}
}
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -150,12 +98,10 @@ describe("Npm.isInstallablePackage", () => {
})
describe("Npm.cacheKey", () => {
test("canonicalizes registry keys and hashes Git specs", async () => {
test("preserves registry keys and hashes Git specs", async () => {
expect(await Npm.cacheKey("@opencode/acme@1.0.0")).toBe(Npm.sanitize("@opencode/acme@1.0.0"))
expect(await Npm.cacheKey("plugin")).toBe(Npm.sanitize("plugin@latest"))
expect(await Npm.cacheKey("@opencode/acme")).toBe(Npm.sanitize("@opencode/acme@latest"))
const spec = "git+ssh://git@github.com/acme/plugin.git#main"
expect(await Npm.cacheKey(spec)).toMatch(/^git-plugin-[a-f0-9]{12}$/)
expect(await Npm.cacheKey(spec)).toMatch(/^git-[a-f0-9]{64}$/)
expect(await Npm.cacheKey(spec)).toBe(await Npm.cacheKey(spec))
expect(await Npm.cacheKey(`${spec}-other`)).not.toBe(await Npm.cacheKey(spec))
})
@@ -168,9 +114,8 @@ describe("Npm.add", () => {
const directory = path.join(
tmp.path,
"cache",
"npm",
await Npm.cacheKey(spec),
"1000",
"packages",
Npm.sanitize(spec),
"node_modules",
"@fixture",
"provider",
@@ -191,7 +136,7 @@ describe("Npm.add", () => {
test("falls back to the original spec when parsing fails", async () => {
await using tmp = await tmpdir()
const spec = "fixture provider"
const directory = path.join(tmp.path, "cache", "npm", Npm.sanitize(spec), "1000", "node_modules", spec)
const directory = path.join(tmp.path, "cache", "packages", Npm.sanitize(spec), "node_modules", spec)
await fs.mkdir(directory, { recursive: true })
await writePackage(directory, { name: spec, exports: "./index.js" })
await Bun.write(path.join(directory, "index.js"), "export const fixture = true\n")
@@ -219,7 +164,7 @@ describe("Npm.add", () => {
await Bun.write(path.join(tmp.path, "fixture-provider", "tui.js"), "export const tui = true\n")
const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}`
await fs.mkdir(path.join(tmp.path, "cache", "npm", Npm.sanitize(spec)), { recursive: true })
await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true })
const entries = await Effect.gen(function* () {
const npm = yield* Npm.Service
@@ -253,14 +198,12 @@ describe("Npm.add", () => {
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(entries.added.entrypoint).toEndWith("/index.js")
expect(entries.added.version).toBe(fixture.commit)
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
expect(
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
).toBeTruthy()
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
expect(entries.added.directory).toContain("node_modules")
expect(entries.added.directory).toContain(path.join("packages", await Npm.cacheKey(spec), "node_modules"))
}
})
@@ -280,8 +223,8 @@ describe("Npm.add", () => {
).toBeTruthy()
})
// Several real Git installs and updates exceed Bun's default timeout on Windows.
test("checks and updates mutable Git packages without changing pinned installs", async () => {
// Several real Git installs and refreshes exceed Bun's default timeout on Windows.
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
@@ -289,38 +232,37 @@ describe("Npm.add", () => {
const mutable = `git+${repository}#fixture-branch`
const pinned = `git+${repository}#${fixture.commit}`
const result = await Effect.gen(function* () {
const first = await Effect.gen(function* () {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable)
const pinnedEntry = yield* npm.add(pinned)
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
await Bun.$`git -C ${fixture.repository} add .`
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
})
const before = yield* Effect.promise(() => Bun.file(path.join(mutableEntry.directory, "index.js")).text())
const outdated = yield* npm.check(mutable)
const pinnedOutdated = yield* npm.check(pinned)
const unchanged = yield* Effect.promise(() => Bun.file(path.join(mutableEntry.directory, "index.js")).text())
const updated = yield* npm.update(mutable)
const pinnedUpdated = yield* npm.update(pinned)
yield* npm.add(mutable, { refresh: true })
return { mutable: mutableEntry, pinned: pinnedEntry }
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(first.mutable.directory, "index.js")).text()).toContain("root: true")
expect(await Bun.file(path.join(first.pinned.directory, "index.js")).text()).toContain("root: true")
const second = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
before,
outdated,
pinnedOutdated,
unchanged,
updated: yield* Effect.promise(() => Bun.file(path.join(updated.directory, "index.js")).text()),
pinned: yield* Effect.promise(() => Bun.file(path.join(pinnedUpdated.directory, "index.js")).text()),
current: yield* npm.check(mutable),
mutable: yield* npm.add(mutable, { refresh: true }),
pinned: yield* npm.add(pinned, { refresh: true }),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(result.before).toContain("root: true")
expect(result.outdated).toBeTrue()
expect(result.pinnedOutdated).toBeFalse()
expect(result.unchanged).toContain("root: true")
expect(result.updated).toContain('root: "second"')
expect(result.pinned).toContain("root: true")
expect(result.current).toBeFalse()
expect(await Bun.file(path.join(second.mutable.directory, "index.js")).text()).toContain('root: "second"')
expect(await Bun.file(path.join(second.pinned.directory, "index.js")).text()).toContain("root: true")
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
const offline = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(mutable, { refresh: true })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
}, 30_000)
})
@@ -329,7 +271,7 @@ describe("Npm.resolve", () => {
await using tmp = await tmpdir()
const cache = path.join(tmp.path, "cache")
const spec = "fixture-plugin@1.0.0"
const directory = path.join(cache, "npm", Npm.sanitize(spec), "1000", "node_modules", "fixture-plugin")
const directory = path.join(cache, "packages", Npm.sanitize(spec), "node_modules", "fixture-plugin")
const missing = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
@@ -349,56 +291,5 @@ describe("Npm.resolve", () => {
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(resolved.entrypoint).toEndWith("/tui.js")
expect(resolved.version).toBe("1.0.0")
})
})
describe("Npm.check and Npm.update", () => {
test("checks registry targets without mutation and explicitly updates mutable targets", async () => {
await using tmp = await tmpdir()
await using registry = await createRegistryFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const mutable = "@fixture/registry-plugin@latest"
const pinned = "@fixture/registry-plugin@1.0.0"
const root = await registry.configure(cache, mutable)
await registry.configure(cache, pinned)
const result = await Effect.gen(function* () {
const npm = yield* Npm.Service
const installed = yield* npm.add(mutable)
yield* npm.add(pinned)
const current = yield* npm.check(mutable)
registry.state.latest = "1.1.0"
const outdated = yield* npm.check(mutable)
const pinnedOutdated = yield* npm.check(pinned)
const before = yield* Effect.promise(() => Bun.file(path.join(installed.directory, "index.js")).text())
yield* Effect.promise(() => Promise.all([fs.mkdir(path.join(root, "1")), fs.mkdir(path.join(root, "2"))]))
const updated = yield* npm.update(mutable)
const unchanged = yield* npm.update(mutable)
return {
current,
outdated,
pinnedOutdated,
before,
after: yield* Effect.promise(() => Bun.file(path.join(updated.directory, "index.js")).text()),
version: updated.version,
changedDirectory: installed.directory !== updated.directory,
unchangedDirectory: unchanged.directory === updated.directory,
generations: yield* Effect.promise(() => fs.readdir(root)),
updated: yield* npm.check(mutable),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(result.current).toBeFalse()
expect(result.outdated).toBeTrue()
expect(result.pinnedOutdated).toBeFalse()
expect(result.before).toContain('version = "1.0.0"')
expect(result.after).toContain('version = "1.1.0"')
expect(result.version).toBe("1.1.0")
expect(result.changedDirectory).toBeTrue()
expect(result.unchangedDirectory).toBeTrue()
expect(result.generations).not.toContain("1")
expect(result.generations).not.toContain("2")
expect(result.updated).toBeFalse()
})
})
+28 -47
View File
@@ -24,7 +24,7 @@ const it = testEffect(PluginTestLayer)
class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
const generation = <R>(plugin: EffectPlugin.Plugin<R>, revision = "1") => ({ ...plugin, revision })
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
describe("Plugin", () => {
it.effect("routes experimental terminal reads through the runtime cell without wrapping results", () =>
@@ -81,7 +81,7 @@ describe("Plugin", () => {
const location = yield* Location.Service
const seen: Location.Info[] = []
yield* plugins.activate([
generation(
versioned(
EffectPlugin.define({
id: "location-context",
effect: (ctx) =>
@@ -215,7 +215,7 @@ describe("Plugin", () => {
.pipe(Effect.asVoid),
})
yield* plugins.activate([generation(provider)])
yield* plugins.activate([versioned(provider)])
expect(yield* vcs.info()).toEqual({ branch: { current: "feature" } })
expect(yield* vcs.branches()).toEqual(["feature"])
@@ -224,7 +224,7 @@ describe("Plugin", () => {
}),
)
it.effect("replaces plugins by ID and revision", () =>
it.effect("replaces plugins by ID and version", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
@@ -250,24 +250,24 @@ describe("Plugin", () => {
.pipe(Effect.asVoid),
})
yield* plugins.activate([generation(managed(), "1")])
yield* plugins.activate([versioned(managed(), "1")])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("first")
description = "second"
yield* plugins.activate([generation(managed(), "2")])
yield* plugins.activate([versioned(managed(), "2")])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
description = "third"
yield* plugins.activate([generation(managed(), "2")])
yield* plugins.activate([versioned(managed(), "2")])
expect(updates).toBe(2)
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate(
[generation(managed(), "2")],
[versioned(managed(), "2")],
[
{
source: { type: "package", target: "broken" },
source: { type: "package", package: "broken" },
state: { status: "failed", error: "failed to resolve" },
features: { server: true },
},
@@ -288,7 +288,7 @@ describe("Plugin", () => {
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const definitions = ["first", "second"].map((id) =>
generation(
versioned(
EffectPlugin.define({
id,
effect: (ctx) => ctx.agent.transform((draft) => draft.update(id, () => {})),
@@ -315,36 +315,17 @@ describe("Plugin", () => {
}),
)
it.effect("updates inventory metadata without restarting an unchanged generation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
let loads = 0
const plugin = {
id: "metadata",
revision: "1",
source: { type: "package" as const, target: "fixture" },
effect: () => Effect.sync(() => loads++),
}
yield* plugins.activate([plugin])
yield* plugins.activate([{ ...plugin, source: { ...plugin.source, outdated: true } }])
expect(loads).toBe(1)
expect((yield* plugins.list())[0]?.source).toEqual({ type: "package", target: "fixture", outdated: true })
}),
)
it.effect("rejects duplicate IDs before replacing active plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const active = Plugin.ID.make("active")
const duplicate = "duplicate"
yield* plugins.activate([{ id: active, revision: "1", effect: () => Effect.void }])
yield* plugins.activate([{ id: active, version: "1", effect: () => Effect.void }])
const result = yield* plugins
.activate([
{ id: duplicate, revision: "1", effect: () => Effect.void },
{ id: duplicate, revision: "1", effect: () => Effect.void },
{ id: duplicate, version: "1", effect: () => Effect.void },
{ id: duplicate, version: "1", effect: () => Effect.void },
])
.pipe(Effect.exit)
@@ -359,7 +340,7 @@ describe("Plugin", () => {
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.activate([
{ id: "rpc-plugin", revision: "1", features: { rpc: true }, effect: () => Effect.void },
{ id: "rpc-plugin", version: "1", features: { rpc: true }, effect: () => Effect.void },
])
expect(yield* plugins.list()).toEqual([
@@ -397,7 +378,7 @@ describe("Plugin", () => {
},
})
yield* plugins.activate([generation(good), generation(bad)])
yield* plugins.activate([versioned(good), versioned(bad)])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("good"),
@@ -415,7 +396,7 @@ describe("Plugin", () => {
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
fail = false
yield* plugins.activate([generation(good), generation(bad, "2")])
yield* plugins.activate([versioned(good), versioned(bad, "2")])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("good"),
@@ -441,7 +422,7 @@ describe("Plugin", () => {
yield* plugins.activate([
{
id: "partial-tools",
revision: "1",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool.transform((draft) => {
@@ -507,8 +488,8 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([generation(previous)])
yield* plugins.activate([generation(replacement, "2")])
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
@@ -546,8 +527,8 @@ describe("Plugin", () => {
effect: () => Effect.die(new Error("replacement failed")),
})
yield* plugins.activate([generation(previous)])
yield* plugins.activate([generation(replacement, "2")])
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
@@ -568,7 +549,7 @@ describe("Plugin", () => {
yield* plugins.activate(
["first", "second"].map((id) => ({
id,
revision: "1",
version: "1",
effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
})),
)
@@ -592,7 +573,7 @@ describe("Plugin", () => {
),
})
yield* plugins.activate([generation(plugin)]).pipe(Effect.provideService(Secret, "secret"))
yield* plugins.activate([versioned(plugin)]).pipe(Effect.provideService(Secret, "secret"))
expect(visible).toBe(false)
}),
@@ -605,7 +586,7 @@ describe("Plugin", () => {
yield* plugins.activate(
["a", "a:b", "雪"].map((id) => ({
id,
revision: "1",
version: "1",
effect: (context: EffectPlugin.Context) => Effect.sync(() => storage.set(id, context.storage)),
})),
)
@@ -667,7 +648,7 @@ describe("Plugin", () => {
.pipe(Effect.orDie),
})
yield* plugins.activate([generation(plugin)])
yield* plugins.activate([versioned(plugin)])
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
yield* plugins.activate([])
@@ -699,7 +680,7 @@ describe("Plugin", () => {
.pipe(Effect.orDie),
})
yield* plugins.activate([generation(plugin)])
yield* plugins.activate([versioned(plugin)])
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([
"context7_look_up",
@@ -778,7 +759,7 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([generation(plugin)])
yield* plugins.activate([versioned(plugin)])
const toolSet = yield* registry.snapshot()
const execution = yield* toolSet.execute({
@@ -836,7 +817,7 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([generation(plugin)])
yield* plugins.activate([versioned(plugin)])
const toolSet = yield* registry.snapshot()
const failure = yield* toolSet
-2
View File
@@ -38,8 +38,6 @@ const npmLayer = Layer.succeed(
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
}),
)
+1 -4
View File
@@ -15,15 +15,13 @@ test("loads cached plugin packages without requesting a refresh", async () => {
add: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href, version: "1.2.3" }
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
resolve: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
check: () => Effect.die(new Error("Unexpected check")),
update: () => Effect.die(new Error("Unexpected update")),
which: () => Effect.die(new Error("Unexpected which")),
}),
),
@@ -32,6 +30,5 @@ test("loads cached plugin packages without requesting a refresh", async () => {
expect(plugin.id).toBe("config-effect-plugin")
expect(plugin.features).toEqual({ tui: true, rpc: true })
expect(plugin.source).toEqual({ type: "package", target: "fixture-plugin", version: "1.2.3" })
expect(calls).toEqual([{ subpaths: ["server", ""] }, { subpaths: ["tui"] }, { subpaths: ["rpc"] }])
})
+3 -3
View File
@@ -167,7 +167,7 @@ describe("fromPromise", () => {
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
yield* plugins.activate([{ ...adapted, version: "1" }])
}),
)
@@ -963,7 +963,7 @@ describe("fromPromise", () => {
})
const original = yield* registry.snapshot()
expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
expect(original.codeModeCatalog?.tools).toEqual([])
expect(original.codeModeCatalog).toEqual([])
yield* PluginPromise.fromPromise(
define({
@@ -981,7 +981,7 @@ describe("fromPromise", () => {
const snapshot = yield* registry.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["acme.hello"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["acme.hello"])
expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
expect(
yield* snapshot.execute({
@@ -24,8 +24,6 @@ function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.undefined,
})
}
@@ -16,8 +16,6 @@ const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
})
+4 -4
View File
@@ -39,7 +39,7 @@ it.effect("Effect plugins register, call, and publish RPCs independently of plug
yield* plugins.activate([
{
id: "implementer",
revision: "1",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Echo, {
@@ -51,7 +51,7 @@ it.effect("Effect plugins register, call, and publish RPCs independently of plug
},
{
id: "consumer",
revision: "1",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
expect(yield* ctx.rpc(Echo).echo("hello")).toBe("hello!")
@@ -78,7 +78,7 @@ it.effect("failed plugin setup removes RPC overrides and restores the previous i
yield* plugins.activate([
{
id: "implementer",
revision: "1",
version: "1",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
@@ -91,7 +91,7 @@ it.effect("failed plugin setup removes RPC overrides and restores the previous i
yield* plugins.activate([
{
id: "implementer",
revision: "2",
version: "2",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
@@ -84,7 +84,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
@@ -141,7 +141,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
@@ -197,7 +197,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins
.activate([{ ...adapted, revision: "1" }])
.activate([{ ...adapted, version: "1" }])
.pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])))
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
yield* plugins.activate([])
@@ -279,7 +279,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
const active = yield* Effect.promise(() => subscriptions.promise)
yield* plugins.activate([])
+10 -14
View File
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { Effect, Layer, Stream } from "effect"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
import { Skill } from "@opencode-ai/core/skill"
@@ -9,13 +8,10 @@ import { testEffect } from "../lib/effect"
import { host } from "./host"
const it = testEffect(AppNodeBuilder.build(Skill.node))
const config = (plugins: Info["plugins"] = []) =>
const sources = (operations: readonly ConfigPluginSource.Operation[] = []) =>
Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.succeed([new Document({ type: "document", info: new Info({ plugins }) })]),
changes: () => Stream.never,
}),
ConfigPluginSource.Service,
ConfigPluginSource.Service.of({ operations: () => Effect.succeed(operations), changes: () => Stream.never }),
)
describe("SkillPlugin.Plugin", () => {
@@ -31,7 +27,7 @@ describe("SkillPlugin.Plugin", () => {
reload: skill.reload,
},
}),
).pipe(Effect.provide(config()))
).pipe(Effect.provide(sources()))
const skills = yield* skill.list()
const report = skills.find((item) => item.id === "report")
@@ -71,11 +67,11 @@ describe("SkillPlugin.Plugin", () => {
expect(report?.content).toContain("- Active plugins: -disabled, local.ts, package-plugin, package-plugin")
}).pipe(
Effect.provide(
config([
"package-plugin",
"-disabled",
"local.ts",
{ package: "package-plugin", options: { enabled: true } },
sources([
{ type: "add", target: "package-plugin", options: {} },
{ type: "remove", target: "disabled" },
{ type: "add", target: "local.ts", options: {}, mtime: 1 },
{ type: "add", target: "package-plugin", options: { enabled: true } },
]),
),
),
-51
View File
@@ -1,51 +0,0 @@
import { expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { PluginUpdate } from "@opencode-ai/core/plugin/update"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Npm } from "@opencode-ai/util/npm"
import { Effect, Fiber, Layer, Option, Stream } from "effect"
import { testEffect } from "../lib/effect"
const checks: string[] = []
const npm = makeGlobalNode({
service: Npm.Service,
layer: Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.die("unused add"),
resolve: () => Effect.die("unused resolve"),
check: (target) => Effect.sync(() => checks.push(target)).pipe(Effect.as(true)),
update: () => Effect.succeed({ directory: "" }),
which: () => Effect.die("unused which"),
}),
),
deps: [],
})
const it = testEffect(AppNodeBuilder.build(PluginUpdate.node, [Npm.node.replace(npm)]))
it.effect("caches checks by target", () =>
Effect.gen(function* () {
checks.length = 0
const updates = yield* PluginUpdate.Service
const first = yield* updates.check("fixture")
const second = yield* updates.check("fixture")
expect(checks).toEqual(["fixture"])
expect(first).toBeTrue()
expect(second).toBeTrue()
}),
)
it.effect("publishes successful package updates", () =>
Effect.gen(function* () {
const updates = yield* PluginUpdate.Service
const changed = yield* updates
.changes()
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* updates.update("fixture")
expect(Option.getOrUndefined(yield* Fiber.join(changed))).toBe("fixture")
}),
)
+100
View File
@@ -170,6 +170,106 @@ describe("Project.resolve", () => {
}),
)
it.live("discovers repository markers from automatically loaded plugins", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(tmp.path, ".svn"))
await fs.mkdir(path.join(tmp.path, "nested", "directory"), { recursive: true })
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "svn.ts"),
'export default { id: "svn", vcs: { markers: [".svn"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(path.join(tmp.path, "nested", "directory")))
expect(result.directory).toBe(abs(tmp.path))
expect(result.canonical).toBe(abs(tmp.path))
expect(result.vcs).toEqual({ type: "svn", store: abs(path.join(tmp.path, ".svn")) })
expect(result.id).not.toBe(Project.ID.global)
expect((yield* project.list()).find((item) => item.id === result.id)?.vcs).toBe("svn")
}),
)
it.live("discovers repository markers from configured plugin files", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, ".pijul"))
await Bun.write(path.join(tmp.path, "opencode.jsonc"), '{ "plugins": ["./pijul.ts"] }')
await Bun.write(
path.join(tmp.path, "pijul.ts"),
'export default { id: "custom.pijul", vcs: { id: "pijul", markers: [".pijul"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.directory).toBe(abs(tmp.path))
expect(result.vcs?.type).toBe("pijul")
}),
)
it.live("prefers a nested plugin repository over its parent git repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const nested = path.join(tmp.path, "nested")
yield* Effect.promise(async () => {
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(nested, ".svn"), { recursive: true })
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "svn.ts"),
'export default { id: "svn", vcs: { markers: [".svn"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(nested))
expect(result.directory).toBe(abs(nested))
expect(result.vcs?.type).toBe("svn")
}),
)
it.live("preserves git identity when a plugin marker shares its repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(tmp.path, ".jj"))
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "jj.ts"),
'export default { id: "jj", vcs: { markers: [".jj"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.vcs?.type).toBe("git")
expect(result.vcsBackend).toBe("jj")
}),
)
it.live("repository markers override markerless directory projects", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
+7 -9
View File
@@ -115,15 +115,13 @@ const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const tools = Layer.mock(Tool.Service, {
snapshot: () =>
Effect.succeed({
codeModeCatalog: {
tools: [
{
path: "captured.lookup",
description: "Captured Code Mode catalog",
signature: "tools.captured.lookup(input: {}): Promise<string>",
},
],
},
codeModeCatalog: [
{
path: "captured.lookup",
description: "Captured Code Mode catalog",
signature: "tools.captured.lookup(input: {}): Promise<string>",
},
],
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
execute: () => Effect.die(new Error("unused")),
}),
+2 -3
View File
@@ -114,9 +114,7 @@ const locations = (references: Layer.Layer<Reference.Service>) =>
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
flush: Effect.sync(() => (ready = true)),
}),
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
),
),
),
@@ -1114,6 +1112,7 @@ describe("Session.prompt", () => {
text: "Background work completed",
description: "shell completion",
metadata: { job: "shell" },
resume: false,
},
})
+25 -42
View File
@@ -1454,49 +1454,32 @@ describe("SessionRunnerLLM", () => {
).toEqual([Bus.versionedType(SessionEvent.Moved.type, 1), Bus.versionedType(SessionEvent.InboxDelivered.type, 1)])
})
for (const delivery of ["steer", "queue"] as const) {
scenario(`preserves a tool continuation and step allowance across chained moves (${delivery})`, function* (s) {
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("build"), (agent) => {
agent.steps = 2
}),
)
yield* s.admit("Echo before moving")
yield* s.llm.push(TestLLM.tool("call-move", "echo", { text: "moving" }), TestLLM.text("Done", "text-after-move"))
const tools = yield* s.blockTools()
const run = yield* s.resume.pipe(Effect.forkChild)
yield* tools.started
yield* Effect.forEach(["steer", delivery] as const, (delivery) =>
s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery,
},
}),
)
yield* tools.release
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(messageRoles(s.requests[1])?.slice(0, 3)).toEqual(["user", "assistant", "tool"])
expect(s.requests[0]?.toolChoice).toBeUndefined()
expect(s.requests[1]?.toolChoice).toMatchObject({ type: "none" })
expect(
(yield* recordedEventTypes(sessionID)).filter(
(type) => type === "session.step.started.1" || type === "session.moved.1",
),
).toEqual(["session.step.started.1", "session.moved.1", "session.moved.1", "session.step.started.1"])
expect(yield* s.inbox).toEqual([])
scenario("preserves a tool continuation across a steered move", function* (s) {
yield* s.admit("Echo before moving")
yield* s.llm.push(TestLLM.tool("call-move", "echo", { text: "moving" }), TestLLM.text("Done", "text-after-move"))
const tools = yield* s.blockTools()
const run = yield* s.resume.pipe(Effect.forkChild)
yield* tools.started
yield* s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery: "steer",
},
})
}
yield* tools.release
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(s.requests.map(messageRoles).at(1)?.slice(0, 3)).toEqual(["user", "assistant", "tool"])
expect(yield* s.inbox).toEqual([])
})
scenario("keeps queued input parked across a mid-turn move", function* (s) {
yield* s.admit("Echo before moving")
+1 -3
View File
@@ -52,9 +52,7 @@ const locations = makeGlobalNode({
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
),
),
+1 -1
View File
@@ -17,7 +17,7 @@ const context = {
}
const createCodeMode = (tools: ReadonlyMap<string, Info>) =>
CodeModeTool.create({ tools }, (_, tool, input, context) => execute(tool, input, context))
CodeModeTool.create(tools, (_, tool, input, context) => execute(tool, input, context))
test("execute describes invariant Code Mode behavior", () => {
expect(createCodeMode(new Map()).description).toBe(
+11 -100
View File
@@ -230,7 +230,7 @@ describe("Tool", () => {
})
const scope = yield* Scope.make()
yield* service.transform((draft) => draft.remove("hidden")).pipe(Scope.provide(scope))
expect((yield* service.snapshot()).codeModeCatalog?.tools).toEqual([])
expect((yield* service.snapshot()).codeModeCatalog).toEqual([])
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "original updated" })
text = "refreshed"
@@ -239,7 +239,7 @@ describe("Tool", () => {
yield* Fiber.join(reload)
const refreshed = yield* service.snapshot()
expect(refreshed.definitions[0]?.description).toBe("Updated")
expect(refreshed.codeModeCatalog?.tools).toEqual([])
expect(refreshed.codeModeCatalog).toEqual([])
expect((yield* refreshed.execute(call("acme_echo"))).output).toEqual({ text: "refreshed updated" })
expect((yield* original.execute(call("acme_echo"))).output).toEqual({ text: "original" })
@@ -247,7 +247,7 @@ describe("Tool", () => {
yield* update.dispose
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "refreshed" })
yield* Scope.close(scope, Exit.void)
expect((yield* service.snapshot()).codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["hidden"])
expect((yield* service.snapshot()).codeModeCatalog?.map((tool) => tool.path)).toEqual(["hidden"])
yield* service.transform((draft) =>
draft.update("acme_echo", (tool) => {
@@ -440,7 +440,7 @@ describe("Tool", () => {
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools).toEqual([])
expect(snapshot.codeModeCatalog).toEqual([])
}).pipe(Effect.provide(Logger.layer([logger])))
})
@@ -465,7 +465,7 @@ describe("Tool", () => {
expect((yield* snapshot.execute(call("before"))).output).toEqual({ text: "before" })
expect((yield* snapshot.execute(call("after"))).output).toEqual({ text: "after" })
expect((yield* snapshot.execute(call("echo_tool"))).output).toEqual({ text: "last" })
expect(snapshot.codeModeCatalog?.tools).toEqual([])
expect(snapshot.codeModeCatalog).toEqual([])
}),
)
@@ -502,7 +502,7 @@ describe("Tool", () => {
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual([
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual([
"-lookup",
"123",
"123._private.-tools.2d_get_scene",
@@ -534,7 +534,6 @@ describe("Tool", () => {
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.namespace({ name: "invalid..namespace", description: "Invalid" })
draft.add({ ...make(), name: "first", options: { codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid__namespace" } })
@@ -542,95 +541,7 @@ describe("Tool", () => {
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["first", "execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["invalid__namespace.second"])
}),
)
it.effect("keeps namespace descriptions beside catalog tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.namespace({ name: "registry", description: "Package publishing and discovery" })
draft.namespace({ name: "registry.search", description: "Pricing operations" })
draft.add({ ...make(), name: "plain", options: { namespace: "legacy" } })
draft.add({ ...make(), name: "direct", options: { namespace: "registry", codemode: false } })
draft.add({ ...make(), name: "search", description: "Search packages", options: { namespace: "registry" } })
draft.add({ ...make(), name: "sales", description: "Read sales", options: { namespace: "registry.search" } })
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["registry_direct", "execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual([
"legacy.plain",
"registry.search",
"registry.search.sales",
])
expect(snapshot.codeModeCatalog?.namespaces).toEqual(
new Map([
["registry", { name: "registry", description: "Package publishing and discovery" }],
["registry.search", { name: "registry.search", description: "Pricing operations" }],
]),
)
const result = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "namespace-search",
name: "execute",
input: { code: 'return search({ query: "pricing operations" })' },
},
})
expect(result.output).toMatchObject({ output: expect.stringContaining("tools.registry.search") })
const callable = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "callable-namespace",
name: "execute",
input: {
code: `return await Promise.all([
tools.registry.search({ text: "search" }),
tools.registry.search.sales({ text: "sales" }),
])`,
},
},
})
expect(callable.output).toMatchObject({
output: expect.stringContaining('"text": "sales"'),
toolCalls: [
{ tool: "registry.search", status: "completed" },
{ tool: "registry.search.sales", status: "completed" },
],
})
}),
)
it.effect("preserves a top-level tool that also has child tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.namespace({ name: "pricing", description: "Pricing operations" })
draft.add({ ...make(), name: "pricing" })
draft.add({ ...make(), name: "sales", options: { namespace: "pricing" } })
})
const snapshot = yield* service.snapshot()
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["pricing", "pricing.sales"])
const result = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "top-level-callable",
name: "execute",
input: {
code: `return await Promise.all([
tools.pricing({ text: "pricing" }),
tools.pricing.sales({ text: "sales" }),
])`,
},
},
})
expect(result.output).toMatchObject({ output: expect.stringContaining('"text": "sales"') })
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["invalid__namespace.second"])
}),
)
@@ -664,7 +575,7 @@ describe("Tool", () => {
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["codemode"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
}).pipe(Effect.provide(Logger.layer([logger])))
})
@@ -729,7 +640,7 @@ describe("Tool", () => {
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools[0]?.signature).toContain("tools.echo")
expect(snapshot.codeModeCatalog?.[0]?.signature).toContain("tools.echo")
}),
)
@@ -739,7 +650,7 @@ describe("Tool", () => {
const available = yield* service.snapshot()
expect(available.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(available.codeModeCatalog?.tools).toEqual([])
expect(available.codeModeCatalog).toEqual([])
const denied = yield* service.snapshot([{ action: "execute", resource: "*", effect: "deny" }])
expect(denied.definitions).toEqual([])
@@ -1192,7 +1103,7 @@ describe("Tool", () => {
}).pipe(Scope.provide(scope))
const toolSet = yield* service.snapshot()
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
expect(toolSet.codeModeCatalog?.tools[0]?.signature).toContain("tools.echo")
expect(toolSet.codeModeCatalog?.[0]?.signature).toContain("tools.echo")
expect(execute?.description).toContain("confined Code Mode runtime")
expect(execute?.description).not.toContain("Echo text")
yield* Scope.close(scope, Exit.void)
+3 -1
View File
@@ -2,6 +2,7 @@ import type { ExperimentalApi, GenerateApi, PluginApi } from "@opencode-ai/clien
import type { Location } from "@opencode-ai/schema/location"
import type { Effect, Scope } from "effect"
import type { PluginOptions } from "../options.js"
import type { VcsDiscovery } from "../vcs.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js"
import type { AISDKDomain } from "./aisdk.js"
@@ -37,7 +38,7 @@ export interface Context {
readonly mcp: MCPDomain
readonly generate: GenerateApi<unknown>
readonly permission: PermissionDomain
readonly plugin: Pick<PluginApi<unknown>, "list">
readonly plugin: PluginApi<unknown>
readonly reference: ReferenceDomain
readonly rpc: RpcDomain
readonly session: SessionDomain
@@ -51,6 +52,7 @@ export interface Context {
export interface Plugin<R = Scope.Scope> {
readonly id: string
readonly vcs?: VcsDiscovery
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
-1
View File
@@ -8,7 +8,6 @@ import type { Hooks, Transform } from "./registration.js"
export interface ToolDraft {
list(): readonly (Tool.Info & { readonly id: string })[]
get(id: string): (Tool.Info & { readonly id: string }) | undefined
namespace(namespace: Tool.Namespace): void
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void
+1 -1
View File
@@ -214,6 +214,7 @@ function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
export function fromPromise(plugin: Plugin) {
return define({
id: plugin.id,
vcs: plugin.vcs,
effect: (host) =>
Effect.gen(function* () {
const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
@@ -464,7 +465,6 @@ export function fromPromise(plugin: Plugin) {
const tool = draft.get(id)
return tool ? { ...tool, execute: promiseExecutor(tool.execute) } : undefined
},
namespace: draft.namespace,
add: (tool: Info) =>
draft.add({
...tool,
+3 -1
View File
@@ -2,6 +2,7 @@ import type { OpenCodeClient } from "@opencode-ai/client"
import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api"
import type { Location } from "@opencode-ai/schema/location"
import type { PluginOptions } from "../options.js"
import type { VcsDiscovery } from "../vcs.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js"
import type { AISDKDomain } from "./aisdk.js"
@@ -37,7 +38,7 @@ export interface Context {
readonly mcp: MCPDomain
readonly generate: GenerateApi
readonly permission: PermissionDomain
readonly plugin: Pick<PluginApi, "list">
readonly plugin: PluginApi
readonly reference: ReferenceDomain
readonly rpc: RpcDomain
readonly session: SessionDomain
@@ -53,6 +54,7 @@ export type Cleanup = () => Promise<void> | void
export interface Plugin {
readonly id: string
readonly vcs?: VcsDiscovery
readonly setup: (context: Context) => Promise<Cleanup | void> | Cleanup | void
}
-1
View File
@@ -25,7 +25,6 @@ export type Info<
interface ToolDraft {
list(): readonly (Info & { readonly id: string })[]
get(id: string): (Info & { readonly id: string }) | undefined
namespace(namespace: Tool.Namespace): void
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Info<Input, Output>,
): void
+4
View File
@@ -0,0 +1,4 @@
export interface VcsDiscovery {
readonly id?: string
readonly markers: readonly string[]
}
@@ -61,6 +61,13 @@ test.each([
])
})
test.each([
["effect", Plugin.Plugin.define({ id: "svn", vcs: { markers: [".svn"] }, effect: () => Effect.void })],
["promise", PromisePlugin.Plugin.define({ id: "svn", vcs: { markers: [".svn"] }, setup() {} })],
])("%s plugin definitions retain repository markers", (_name, plugin) => {
expect(plugin.vcs).toEqual({ markers: [".svn"] })
})
test("tui entrypoint exposes the plugin definition", () => {
const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} })
expect(plugin.id).toBe("demo")
+2 -119
View File
@@ -486,116 +486,6 @@
"summary": "List plugins"
}
},
"/api/plugin/update": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.update",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Update one package plugin and notify active locations to reload it.",
"summary": "Update plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16703,18 +16593,11 @@
"type": "string",
"enum": ["package"]
},
"target": {
"package": {
"type": "string"
},
"version": {
"type": "string"
},
"outdated": {
"type": "boolean",
"enum": [true]
}
},
"required": ["type", "target"],
"required": ["type", "package"],
"additionalProperties": false
},
{
+1 -18
View File
@@ -1,8 +1,7 @@
import { Location } from "@opencode-ai/schema/location"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { InvalidRequestError, ServiceUnavailableError } from "../errors.js"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const PluginGroup = HttpApiGroup.make("server.plugin")
@@ -20,22 +19,6 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
}),
),
)
.add(
HttpApiEndpoint.post("plugin.update", "/api/plugin/update", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String }),
success: HttpApiSchema.NoContent,
error: [InvalidRequestError, ServiceUnavailableError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.update",
summary: "Update plugin",
description: "Update one package plugin and notify active locations to reload it.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "plugin",

Some files were not shown because too many files have changed in this diff Show More