Compare commits

..

9 Commits

Author SHA1 Message Date
Aiden Cline 94ec27d03f fix(ai): preserve Responses reasoning state 2026-08-19 01:23:32 -05:00
opencode-agent[bot] 33567c5792 fix(desktop): connect wildcard service through loopback (#43171)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-19 14:39:54 +10:00
Aiden Cline daf3f9ed08 feat(ai): support Responses tool controls (#43329) 2026-08-18 23:33:04 -05:00
opencode-agent[bot] d5bf8799c0 fix(cli): keep run event stream alive (#43348)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-18 23:31:26 -05:00
Dax f6f64d7ece feat(cli): manage plugin packages (#43283) 2026-08-19 00:06:15 -04:00
Aiden Cline 6baad7fc3e feat(ai): support Responses truncation policy (#43339) 2026-08-18 22:54:37 -05:00
Dax 0762d63b6a fix(core): identify user-initiated web fetches (#43330) 2026-08-18 23:51:52 -04:00
Aiden Cline 4df0591025 feat(core): retain provider finish details (#43332) 2026-08-18 22:40:13 -05:00
Aiden Cline 30cb420900 feat(ai): lower system updates as developer (#43326) 2026-08-18 22:20:42 -05:00
48 changed files with 1497 additions and 346 deletions
+1 -1
View File
@@ -193,7 +193,7 @@ If you find yourself copying a 3-to-5-line snippet between two protocols, lift i
`LLMRequest.system` is the initial privileged prompt that applies ahead of the conversation. `Message.system(...)` is a separate, provider-neutral chronological operator update inside `LLMRequest.messages`; it applies only from its position in history onward and accepts text content only.
Native chronological system messages are route/model-specific. Anthropic Messages lowers them natively for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
Native chronological system messages are route/model-specific. Open Responses lowers them to standard `developer` messages, while Anthropic Messages lowers them to native system messages for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
```text
<system-update>
+281 -177
View File
@@ -57,17 +57,42 @@ const OpenResponsesOutputText = Schema.Struct({
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
const OpenResponsesReasoningSummaryText = Schema.Struct({
type: Schema.tag("summary_text"),
text: Schema.String,
})
const OpenResponsesReasoningSummaryText = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("summary_text"),
text: Schema.String,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenResponsesReasoningItem = Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.optionalKey(Schema.String),
summary: Schema.Array(OpenResponsesReasoningSummaryText),
encrypted_content: optionalNull(Schema.String),
})
const OpenResponsesReasoningContentText = Schema.StructWithRest(
Schema.Struct({
type: Schema.Literals(["reasoning_text", "output_text"]),
text: Schema.String,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenResponsesReasoningItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.optionalKey(Schema.String),
summary: Schema.Array(OpenResponsesReasoningSummaryText),
content: optionalNull(Schema.Array(OpenResponsesReasoningContentText)),
encrypted_content: optionalNull(Schema.String),
status: Schema.optional(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type OpenResponsesReasoningItem = Schema.Schema.Type<typeof OpenResponsesReasoningItem>
type MutableReasoningItem = {
type: "reasoning"
id?: string
summary: Array<{ type: "summary_text"; text: string }>
content?: ReadonlyArray<{ type: "reasoning_text" | "output_text"; text: string }> | null
encrypted_content?: string | null
status?: string
}
const OpenResponsesItemReference = Schema.Struct({
type: Schema.tag("item_reference"),
@@ -90,6 +115,7 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
role: Schema.tag("assistant"),
@@ -119,16 +145,6 @@ type LoweredInputItem =
readonly phase?: MessagePhase | null
}
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
// multiple streamed summary parts into the same item before flushing.
type OpenResponsesReasoningInput = {
type: "reasoning"
id: string
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
export const Tool = Schema.Struct({
type: Schema.tag("function"),
name: Schema.String,
@@ -140,6 +156,11 @@ export const Tool = Schema.Struct({
export const ToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]),
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
Schema.Struct({
type: Schema.tag("allowed_tools"),
mode: Schema.Literals(["auto", "none", "required"]),
tools: Schema.Array(Schema.Struct({ type: Schema.tag("function"), name: Schema.String })),
}),
])
// Fields shared between the HTTP body and the WebSocket `response.create`
@@ -153,6 +174,7 @@ export const coreFields = {
tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean),
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
prompt_cache_key: Schema.optional(Schema.String),
include: optionalArray(OpenResponsesOptions.ResponseIncludableSchema),
@@ -168,6 +190,8 @@ export const coreFields = {
}),
),
max_output_tokens: Schema.optional(Schema.Number),
max_tool_calls: Schema.optional(Schema.Int),
parallel_tool_calls: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
}
@@ -254,6 +278,7 @@ export const Event = Schema.StructWithRest(
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
summary_index: Schema.optional(Schema.Number),
content_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
response: Schema.optional(
Schema.StructWithRest(
@@ -263,6 +288,7 @@ export const Event = Schema.StructWithRest(
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
usage: optionalNull(OpenResponsesUsage),
error: optionalNull(OpenResponsesErrorPayload),
output: optionalArray(StreamItem),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
@@ -303,17 +329,14 @@ export interface ParserState {
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
readonly reasoningOutputItems: Readonly<Record<string, OpenResponsesReasoningItem>>
readonly completedReasoningItems: ReadonlySet<string>
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly encryptedContent: string | null | undefined
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
// and matches the wire field.
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
readonly summary: Readonly<Record<number, string>>
readonly content: Readonly<Record<number, string>>
}
// =============================================================================
@@ -351,19 +374,28 @@ const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
arguments: ProviderShared.encodeJson(part.input),
})
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
): { readonly id: string; readonly item: OpenResponsesReasoningItem; readonly native: boolean } | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
return undefined
if (!ProviderShared.isRecord(metadata)) return undefined
if (typeof metadata.itemId !== "string" || metadata.itemId.length === 0) return undefined
if (Schema.is(OpenResponsesReasoningItem)(metadata.reasoningItem))
return { id: metadata.itemId, item: metadata.reasoningItem, native: true }
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: metadata.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
native: false,
item: {
type: "reasoning",
id: metadata.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
},
}
}
@@ -434,19 +466,16 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: LoweredInputItem[] = [...system]
const nativeReasoningItems = new Set<OpenResponsesReasoningItem>()
const store = OpenResponsesOptions.resolve(request).store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) {
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user")
input[input.length - 1] = {
role: "user",
content: [...previous.content, { type: "input_text", text: part.text }],
}
else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] })
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
})
continue
}
@@ -460,7 +489,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
const reasoningItems: Record<string, MutableReasoningItem> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
@@ -494,25 +523,30 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
const id = reasoning.id
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
if (id && !reasoningReferences.has(id)) input.push({ type: "item_reference", id })
if (id) reasoningReferences.add(id)
continue
}
const existing = reasoningItems[reasoning.id]
if (reasoning.native) {
if (!id || !reasoningItems[id]) input.push(reasoning.item)
if (id) reasoningItems[id] = { ...reasoning.item, summary: [...reasoning.item.summary] }
nativeReasoningItems.add(reasoning.item)
continue
}
if (!id) continue
const existing = reasoningItems[id]
if (existing) {
existing.summary.push(...reasoning.summary)
if (typeof reasoning.encrypted_content === "string")
existing.encrypted_content = reasoning.encrypted_content
existing.summary.push(...reasoning.item.summary)
if (typeof reasoning.item.encrypted_content === "string")
existing.encrypted_content = reasoning.item.encrypted_content
continue
}
const replay = {
type: reasoning.type,
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
reasoningItems[reasoning.id] = replay
input.push(replay)
const { id: _id, ...replay } = reasoning.item
const replayItem = { ...replay, summary: [...replay.summary] }
reasoningItems[id] = replayItem
input.push(replayItem)
continue
}
if (part.type === "tool-call") {
@@ -558,12 +592,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
}
// With store:false, Responses APIs only accept previous reasoning items when the
// complete item has encrypted state. Summary blocks for one item may carry
// that state only on the last block, so filter after they have been joined.
return store === false
? input.filter(
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
(item) =>
!("type" in item) ||
item.type !== "reasoning" ||
nativeReasoningItems.has(item) ||
typeof item.encrypted_content === "string",
)
: input
})
@@ -580,6 +615,19 @@ const lowerOptions = (request: LLMRequest) => {
: {}),
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
...(options.truncation ? { truncation: options.truncation } : {}),
}
}
const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
return {
type: "allowed_tools" as const,
mode: allowed.mode,
tools: allowed.toolNames.map((name) => ({ type: "function" as const, name })),
}
}
@@ -602,7 +650,9 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined,
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -661,6 +711,13 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string" && item.id.length > 0
type ReasoningOutputItem = {
readonly [key: string]: unknown
readonly type: "reasoning"
readonly id: string
readonly encrypted_content?: string | null
}
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
@@ -689,15 +746,42 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const emptyReasoningItem = (): ReasoningStreamItem => ({
encryptedContent: undefined,
summary: {},
content: {},
})
const joinedReasoning = (parts: Readonly<Record<number, string>>) =>
Object.entries(parts)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.map((entry) => entry[1])
.filter((text) => text.length > 0)
.join("\n\n")
export const onReasoningDelta = (
state: ParserState,
event: Event,
itemID: string,
source: "summary" | "content",
): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const item = state.reasoningItems[itemID] ?? emptyReasoningItem()
const index = source === "summary" ? (event.summary_index ?? 0) : (event.content_index ?? 0)
const parts = source === "summary" ? item.summary : item.content
const previous = parts[index] ?? ""
const events: LLMEvent[] = []
const id =
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, itemID),
reasoningItems: {
...state.reasoningItems,
[itemID]: {
...item,
[source]: { ...parts, [index]: `${previous}${event.delta}` },
},
},
},
events,
]
@@ -705,8 +789,85 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS]
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
const completedReasoningItem = (
item: ReasoningOutputItem,
streamed: ReasoningStreamItem,
): OpenResponsesReasoningItem => {
if (Schema.is(OpenResponsesReasoningItem)(item)) return item
const summary = Object.entries(streamed.summary)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.map((entry) => ({ type: "summary_text" as const, text: entry[1] }))
const content = Object.entries(streamed.content)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.map((entry) => ({ type: "reasoning_text" as const, text: entry[1] }))
return {
type: "reasoning",
id: item.id,
summary,
...(content.length > 0 ? { content } : {}),
...(item.encrypted_content !== undefined ? { encrypted_content: item.encrypted_content } : {}),
...(typeof item.status === "string" ? { status: item.status } : {}),
}
}
const completeReasoning = (state: ParserState, item: ReasoningOutputItem): StepResult => {
if (state.completedReasoningItems.has(item.id)) return [state, NO_EVENTS]
const streamed = state.reasoningItems[item.id] ?? emptyReasoningItem()
const reasoningItem = completedReasoningItem(item, streamed)
const finalSummary = reasoningItem.summary.map((part) => part.text).join("\n\n")
const summary = finalSummary || joinedReasoning(streamed.summary)
const content = reasoningItem.content
? reasoningItem.content.map((part) => part.text).join("\n\n")
: joinedReasoning(streamed.content)
const text = summary || content
const { id: _id, ...replayItem } = reasoningItem
const reasoningReplay =
replayItem.content && replayItem.content.length > 0
? replayItem
: Object.fromEntries(Object.entries(replayItem).filter((entry) => entry[0] !== "content"))
const metadata = providerMetadata(state, {
itemId: item.id,
reasoningEncryptedContent: reasoningItem.encrypted_content ?? null,
reasoningItem: reasoningReplay,
})
const events: LLMEvent[] = []
const started = Lifecycle.reasoningStart(state.lifecycle, events, item.id)
const lifecycle = Lifecycle.reasoningEnd(
text.length > 0 ? Lifecycle.reasoningDelta(started, events, item.id, text) : started,
events,
item.id,
metadata,
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
return [
{
...state,
lifecycle,
reasoningItems,
completedReasoningItems: new Set([...state.completedReasoningItems, item.id]),
},
events,
]
}
const stageReasoning = (state: ParserState, item: ReasoningOutputItem): StepResult => {
const streamed = state.reasoningItems[item.id] ?? emptyReasoningItem()
const reasoningItem = completedReasoningItem(item, streamed)
const summary = reasoningItem.summary.map((part) => part.text).join("\n\n") || joinedReasoning(streamed.summary)
const content = reasoningItem.content
? reasoningItem.content.map((part) => part.text).join("\n\n")
: joinedReasoning(streamed.content)
if (summary || content || typeof reasoningItem.encrypted_content === "string") return completeReasoning(state, item)
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, item.id),
reasoningOutputItems: { ...state.reasoningOutputItems, [item.id]: reasoningItem },
},
events,
]
}
// Responses APIs stream reasoning items in a stable order:
// `output_item.added` (reasoning) →
@@ -714,12 +875,10 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// `reasoning_summary_text.delta` →
// `reasoning_summary_part.done` (index=0) →
// (repeat for index>0) →
// `output_item.done` (reasoning).
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
// short-circuits when the entry already exists, and higher-index handlers
// fold against the same entry. Behaviour for out-of-order events is
// best-effort, not guaranteed.
// `output_item.done` (reasoning)
// `response.completed`.
// Buffer deltas until `output_item.done` can choose summary over raw content.
// Sparse item completions remain open for recovery from terminal output.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id)
@@ -739,10 +898,15 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
item.id,
providerMetadata(state, { itemId: item.id }),
),
reasoningItems: {
...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
[item.id]: { ...emptyReasoningItem(), encryptedContent: item.encrypted_content },
},
},
events,
@@ -772,63 +936,20 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
if (event.summary_index === 0) {
if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${event.item_id}:0`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: { ...item, summaryParts: { 0: "active" } },
},
},
events,
]
}
const item = state.reasoningItems[event.item_id] ?? emptyReasoningItem()
const events: LLMEvent[] = []
const closed = Object.entries(item.summaryParts)
.filter((entry) => entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(
lifecycle,
events,
`${event.item_id}:${entry[0]}`,
providerMetadata(state, { itemId: event.item_id }),
),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
closed,
state.lifecycle,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
event.item_id,
providerMetadata(state, { itemId: event.item_id }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
),
),
[event.summary_index]: "active",
},
},
[event.item_id]: item,
},
},
events,
@@ -837,34 +958,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle:
state.store !== false
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id }),
)
: state.lifecycle,
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...item.summaryParts,
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
},
},
},
},
events,
]
return [state, NO_EVENTS]
}
const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* (
@@ -940,29 +1034,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (isReasoningItem(item)) {
const events: LLMEvent[] = []
const metadata = reasoningMetadata(state, item)
const reasoningItem = state.reasoningItems[item.id]
if (reasoningItem) {
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
state.lifecycle,
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
events,
] satisfies StepResult
return stageReasoning(state, item)
}
return [state, NO_EVENTS] satisfies StepResult
@@ -970,7 +1042,31 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const onResponseFinish = (state: ParserState, event: Event): StepResult => {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
const terminal = (event.response?.output ?? [])
.filter(isReasoningItem)
.filter((item) => !state.completedReasoningItems.has(item.id))
const terminalIDs = new Set(terminal.map((item) => item.id))
const output = Object.values(state.reasoningOutputItems).filter(
(item): item is OpenResponsesReasoningItem & { readonly id: string } =>
typeof item.id === "string" &&
item.id.length > 0 &&
!terminalIDs.has(item.id) &&
!state.completedReasoningItems.has(item.id),
)
const completedIDs = new Set([...terminalIDs, ...output.map((item) => item.id)])
const buffered = Object.entries(state.reasoningItems)
.filter((entry) => !completedIDs.has(entry[0]))
.map(([id, item]) => ({ type: "reasoning" as const, id, encrypted_content: item.encryptedContent }))
const reasoning = [...terminal, ...output, ...buffered]
const completed = reasoning.reduce(
(result, item) => {
const next = completeReasoning(result[0], item)
result[1].push(...next[1])
return [next[0], result[1]] satisfies [ParserState, LLMEvent[]]
},
[state, events] satisfies [ParserState, LLMEvent[]],
)
const lifecycle = Lifecycle.finish(completed[0].lifecycle, events, {
reason: {
normalized: mapFinishReason(event, state.hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
@@ -984,7 +1080,7 @@ const onResponseFinish = (state: ParserState, event: Event): StepResult => {
})
: undefined,
})
return [{ ...state, lifecycle }, events]
return [{ ...completed[0], lifecycle }, events]
}
// Build a single human-readable message from whatever the provider supplied.
@@ -1029,7 +1125,14 @@ export const step = (state: ParserState, event: Event) => {
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
return Effect.succeed(
onReasoningDelta(
state,
event,
event.item_id,
event.type === "response.reasoning_summary_text.delta" ? "summary" : "content",
),
)
}
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
@@ -1083,7 +1186,8 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
messagePhase: (value) => messagePhase(value, extension),
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
reasoningOutputItems: {},
completedReasoningItems: new Set<string>(),
})
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
@@ -67,12 +67,10 @@ const comparable = (value: unknown) => {
name: value.name,
arguments: json(value.arguments),
}
if (value.type === "reasoning")
return {
type: value.type,
summary: value.summary,
encrypted_content: value.encrypted_content,
}
if (value.type === "reasoning") {
const { id: _id, ...reasoning } = value
return reasoning
}
return value
}
@@ -141,6 +139,8 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
)
const observation = yield* input.base.observe(create, frame)
if (event.type === "response.output_item.done" && event.item) output.push(event.item)
if ((event.type === "response.completed" || event.type === "response.incomplete") && event.response?.output)
output = [...event.response.output]
if (observation.type === "provider-failure") {
const rejection = code(event)
if (rejection === "previous_response_not_found") return rejected(input, observation, "retry-full")
+10 -2
View File
@@ -121,7 +121,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined,
tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
} satisfies OpenAIResponsesBody
})
@@ -215,7 +216,14 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
? Effect.succeed(
OpenResponses.onReasoningDelta(
state,
event,
event.item_id,
event.type === "response.reasoning_summary.delta" ? "summary" : "content",
),
)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
@@ -1,4 +1,4 @@
import { Schema } from "effect"
import { Option, Schema } from "effect"
import { TextVerbosity, type LLMRequest } from "../../schema/index.js"
export const ResponseIncludables = [
@@ -11,52 +11,62 @@ export const ResponseIncludables = [
"reasoning.encrypted_content",
"message.output_text.logprobs",
] as const
export type ResponseIncludable = (typeof ResponseIncludables)[number]
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number]
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(ResponseIncludables)
const SERVICE_TIERS = new Set<string>(ServiceTiers)
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value)
export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number]
export const ReasoningEffort = Schema.String
export const TextVerbositySchema = TextVerbosity
export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables)
export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
(value): value is ResponseIncludable => typeof value === "string",
{ title: "ResponseIncludable" },
)
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export const TruncationSchema = Schema.Literals(Truncations)
export interface Resolved {
readonly instructions?: string
readonly store?: boolean
readonly reasoningEffort?: string
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity>
readonly serviceTier?: ServiceTier
export const AllowedTools = Schema.Struct({
toolNames: Schema.Array(Schema.String),
mode: Schema.optional(Schema.Literals(["auto", "none", "required"])),
})
export type AllowedTools = typeof AllowedTools.Type
export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
textVerbosity: Schema.optional(TextVerbositySchema),
serviceTier: Schema.optional(ServiceTierSchema),
truncation: Schema.optional(TruncationSchema),
allowedTools: Schema.optional(AllowedTools),
maxToolCalls: Schema.optional(Schema.Int),
parallelToolCalls: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
export type Resolved = Omit<Options, "allowedTools"> & {
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
}
const decodeOptions = Schema.decodeUnknownOption(Options)
export const resolve = (request: LLMRequest): Resolved => {
const input = request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]
const include = Array.isArray(input?.include)
? input.include.filter((entry): entry is ResponseIncludable => INCLUDABLES.has(entry))
: []
const reasoningSummary = input?.reasoningSummary
const input = Option.getOrUndefined(
decodeOptions(request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]),
)
if (!input) return {}
return {
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
store: typeof input?.store === "boolean" ? input.store : undefined,
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
reasoningSummary:
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
? reasoningSummary
...input,
include: input.include?.length ? input.include : undefined,
allowedTools:
input.allowedTools && input.allowedTools.toolNames.length > 0
? { ...input.allowedTools, mode: input.allowedTools.mode ?? "auto" }
: undefined,
include: include.length > 0 ? include : undefined,
textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined,
serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined,
}
}
@@ -1,16 +1,7 @@
import type { ResponseIncludable, ServiceTier } from "../protocols/utils/open-responses-options.js"
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema/index.js"
import type { Options } from "../protocols/utils/open-responses-options.js"
import type { ProviderOptions } from "../schema/index.js"
export interface OpenResponsesOptionsInput {
readonly [key: string]: unknown
readonly instructions?: string
readonly store?: boolean
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: TextVerbosity
readonly serviceTier?: ServiceTier
}
export type OpenResponsesOptionsInput = Options & { readonly [key: string]: unknown }
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
readonly openresponses?: OpenResponsesOptionsInput
+13 -3
View File
@@ -7,6 +7,7 @@ import {
type FinishReasonDetails,
type AIError,
type LLMRequest,
type ProviderMetadata,
type UsageInput,
} from "./schema/index.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
@@ -33,13 +34,22 @@ export interface LayerOptions {
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
export const complete = (
options: { readonly reason: FinishReasonDetails; readonly usage?: UsageInput },
options: {
readonly reason: FinishReasonDetails
readonly usage?: UsageInput
readonly providerMetadata?: ProviderMetadata
},
...events: readonly LLMEvent[]
) => [
LLMEvent.stepStart({ index: 0 }),
...events,
LLMEvent.stepFinish({ index: 0, reason: options.reason, usage: options.usage }),
LLMEvent.finish({ reason: options.reason }),
LLMEvent.stepFinish({
index: 0,
reason: options.reason,
usage: options.usage,
providerMetadata: options.providerMetadata,
}),
LLMEvent.finish({ reason: options.reason, providerMetadata: options.providerMetadata }),
]
export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events)
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { OpenAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
@@ -56,6 +56,28 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("lowers chronological system updates as standard developer messages", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
it.effect("rejects OpenAI-native tools", () =>
Effect.gen(function* () {
const model = configure({
@@ -101,13 +123,68 @@ describe("Open Responses-compatible route", () => {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
providerOptions: {
openresponses: {
reasoningEffort: "low",
store: true,
truncation: "auto",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
parallelToolCalls: false,
},
},
}).model("example-model")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." }))
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Think.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
)
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
store: true,
truncation: "auto",
tool_choice: {
type: "allowed_tools",
mode: "auto",
tools: [{ type: "function", name: "lookup" }],
},
max_tool_calls: 2,
parallel_tool_calls: false,
})
}),
)
it.effect("preserves native reasoning in the Open Responses namespace", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const reasoningItem = {
type: "reasoning" as const,
id: "rs_1",
summary: [{ type: "summary_text" as const, text: "Short summary." }],
content: [{ type: "reasoning_text" as const, text: "Long raw reasoning." }],
encrypted_content: "encrypted-state",
}
const { id: _id, ...replayItem } = reasoningItem
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item: reasoningItem },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Short summary.")
expect(response.message.content[0]).toMatchObject({
providerMetadata: { openresponses: { reasoningItem: replayItem } },
})
}),
)
@@ -241,27 +241,18 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
it.effect("lowers chronological system updates to developer messages in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Before."),
Message.system("Treat </system-update> literally."),
Message.assistant("After."),
],
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [
{ type: "input_text", text: "Before." },
{ type: "input_text", text: "<system-update>\nTreat &lt;/system-update&gt; literally.\n</system-update>" },
],
},
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
@@ -498,14 +489,31 @@ describe("OpenAI Responses route", () => {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
content: [{ type: "reasoning_text", text: "Raw thought" }],
encrypted_content: "encrypted",
status: "completed",
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
ProviderShared.encodeJson({
type: "response.completed",
response: {
id: "resp_1",
output: [
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
content: [{ type: "reasoning_text", text: "Raw thought" }],
encrypted_content: "encrypted",
status: "completed",
},
],
},
}),
),
)
const next = continuationDriver({
@@ -515,7 +523,9 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning",
summary: [{ type: "summary_text", text: "Thought" }],
content: [{ type: "reasoning_text", text: "Raw thought" }],
encrypted_content: "encrypted",
status: "completed",
},
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
],
@@ -1283,11 +1293,20 @@ describe("OpenAI Responses route", () => {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think",
promptCacheKey: "session_123",
tools: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
],
toolChoice: "none",
providerOptions: {
openai: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
truncation: "disabled",
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
maxToolCalls: 4,
parallelToolCalls: false,
},
},
}),
@@ -1298,6 +1317,17 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.truncation).toBe("disabled")
expect(prepared.body.tool_choice).toEqual({
type: "allowed_tools",
mode: "required",
tools: [
{ type: "function", name: "read" },
{ type: "function", name: "grep" },
],
})
expect(prepared.body.max_tool_calls).toBe(4)
expect(prepared.body.parallel_tool_calls).toBe(false)
}),
)
@@ -1323,20 +1353,17 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("filters unknown includable values out of the include array", () =>
it.effect("passes forward-compatible includable values through", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "hi",
// The user passed one invalid entry alongside a valid one. Keep the
// valid one so the request still succeeds rather than failing on a
// typo from upstream config.
providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
}),
)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.include).toEqual(["reasoning.encrypted_content", "bogus.thing"])
}),
)
@@ -1350,13 +1377,13 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("treats an all-invalid include as no include at all", () =>
it.effect("passes an unknown includable value through", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
)
expect(prepared.body.include).toBeUndefined()
expect(prepared.body.include).toEqual(["bogus.thing"])
}),
)
@@ -1668,16 +1695,29 @@ describe("OpenAI Responses route", () => {
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{
type: "reasoning-end",
id: "rs_1",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: null,
reasoningItem: {
type: "reasoning",
summary: [{ type: "summary_text", text: "thinking" }],
},
},
},
},
{ type: "text-end", id: "msg_1" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
expect(response.message.content).toMatchObject([
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" },
])
@@ -1710,13 +1750,197 @@ describe("OpenAI Responses route", () => {
expect.objectContaining({
type: "reasoning-end",
id: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: {
type: "reasoning",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "thinking" }],
},
},
},
}),
)
}),
)
it.effect("streams each reasoning summary part as a separate block", () =>
it.effect("displays reasoning summaries and replays the native item", () =>
Effect.gen(function* () {
const reasoningItem = {
type: "reasoning" as const,
id: "rs_1",
summary: [
{ type: "summary_text" as const, text: "Checked Codex." },
{ type: "summary_text" as const, text: "Checked Pi." },
],
content: [
{ type: "reasoning_text" as const, text: "Raw Codex analysis." },
{ type: "reasoning_text" as const, text: "Raw Pi analysis." },
],
encrypted_content: "encrypted-state",
status: "completed",
provider_extension: { trace: "native-value" },
}
const { id: _id, ...replayItem } = reasoningItem
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { openai: { store: false } } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{
type: "response.reasoning_text.delta",
item_id: "rs_1",
content_index: 0,
delta: "Raw Codex analysis.",
},
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_1",
summary_index: 0,
delta: "Checked Codex.",
},
{ type: "response.output_item.done", item: reasoningItem },
{ type: "response.completed", response: { id: "resp_1", output: [reasoningItem] } },
),
),
),
)
expect(response.reasoning).toBe("Checked Codex.\n\nChecked Pi.")
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Checked Codex.\n\nChecked Pi.",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: replayItem,
},
},
},
])
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [response.message, Message.user("Continue.")],
providerOptions: { openai: { store: false } },
}),
)
expect(prepared.body.input).toEqual([
replayItem,
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
)
it.effect("uses raw reasoning when no summary is available", () =>
Effect.gen(function* () {
const reasoningItem = {
type: "reasoning" as const,
id: "rs_raw",
summary: [],
content: [
{ type: "reasoning_text" as const, text: "First raw part." },
{ type: "reasoning_text" as const, text: "Second raw part." },
],
encrypted_content: "encrypted-state",
}
const { id: _id, ...replayItem } = reasoningItem
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_raw" } },
{ type: "response.output_item.done", item: reasoningItem },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("First raw part.\n\nSecond raw part.")
expect(response.message.content[0]).toMatchObject({
type: "reasoning",
text: "First raw part.\n\nSecond raw part.",
providerMetadata: { openai: { reasoningItem: replayItem } },
})
}),
)
it.effect("preserves native reasoning for xAI Responses", () =>
Effect.gen(function* () {
const reasoningItem = {
type: "reasoning" as const,
id: "rs_xai",
summary: [{ type: "summary_text" as const, text: "xAI summary." }],
content: [{ type: "reasoning_text" as const, text: "xAI raw reasoning." }],
encrypted_content: "xai-state",
}
const { id: _id, ...replayItem } = reasoningItem
const response = yield* LLMClient.generate(LLM.request({ model: xaiModel, prompt: "Think." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item: reasoningItem },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("xAI summary.")
expect(response.message.content[0]).toMatchObject({
providerMetadata: { xai: { reasoningItem: replayItem } },
})
const prepared = yield* compileRequest(
LLM.request({ model: xaiModel, messages: [response.message, Message.user("Continue.")] }),
)
expect(prepared.body.input).toEqual([
replayItem,
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
)
it.effect("uses terminal reasoning output over a sparse item completion", () =>
Effect.gen(function* () {
const reasoningItem = {
type: "reasoning" as const,
id: "rs_terminal",
summary: [{ type: "summary_text" as const, text: "Terminal summary." }],
content: [{ type: "reasoning_text" as const, text: "Terminal raw content." }],
encrypted_content: "encrypted-state",
}
const { id: _id, ...replayItem } = reasoningItem
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_terminal" } },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_terminal", encrypted_content: null },
},
{ type: "response.completed", response: { id: "resp_1", output: [reasoningItem] } },
),
),
),
)
expect(response.reasoning).toBe("Terminal summary.")
expect(response.message.content[0]).toMatchObject({
providerMetadata: { openai: { reasoningItem: replayItem } },
})
}),
)
it.effect("projects reasoning summary parts as one display block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { openai: { store: false } } }),
@@ -1744,26 +1968,32 @@ describe("OpenAI Responses route", () => {
),
)
expect(response.reasoning).toBe("FirstSecond")
expect(response.reasoning).toBe("First\n\nSecond")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
id: "rs_1",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ 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-delta", id: "rs_1", text: "First\n\nSecond" },
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
id: "rs_1",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
reasoningItem: {
type: "reasoning",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
{ type: "summary_text", text: "Second" },
],
},
},
},
},
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
@@ -1771,7 +2001,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("closes reasoning summary parts when storage is not disabled", () =>
it.effect("closes the reasoning item when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { openai: { store: true } } }),
@@ -1800,8 +2030,24 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-end",
id: "rs_1",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: null,
reasoningItem: {
type: "reasoning",
encrypted_content: null,
summary: [
{ type: "summary_text", text: "First" },
{ type: "summary_text", text: "Second" },
],
},
},
},
},
])
}),
)
@@ -7,7 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server"
import { pluginLabels } from "@/utils/plugin"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "./external-link"
type SkillItem = {
@@ -102,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => pluginLabels(globalPluginList.latest ?? []))
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return pluginLabels(projectPluginList.latest ?? []).filter((name) => !shared.has(name))
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
})
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
@@ -6,7 +6,7 @@ import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useMcpToggle } from "@/context/mcp"
import { pluginLabels } from "@/utils/plugin"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css"
@@ -45,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
)
const plugins = createMemo<PluginRowItem[]>(() => pluginLabels(pluginList.latest ?? []).map((name) => ({ name })))
const plugins = createMemo<PluginRowItem[]>(() =>
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
)
createEffect(() => {
if (serverSdk.connection.status() !== "connected") return
@@ -6,7 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { pluginLabels } from "@/utils/plugin"
import { pluginLabel } from "@/utils/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -39,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => pluginLabels(pluginList.latest ?? []))
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
-16
View File
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import { pluginLabels } from "./plugin"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
]
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
})
})
-4
View File
@@ -6,7 +6,3 @@ export function pluginLabel(plugin: PluginInfo) {
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
export function pluginLabels(plugins: readonly PluginInfo[]) {
return plugins.filter((plugin) => plugin.source.type !== "builtin").map(pluginLabel)
}
+23 -1
View File
@@ -160,7 +160,29 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
}),
Spec.make("plugin", {
description: "Manage plugins",
commands: [Spec.make("list", { description: "List active plugins" })],
commands: [
Spec.make("list", {
description: "List plugins",
params: {
builtin: Flag.boolean("builtin").pipe(
Flag.withDescription("Include built-in server plugins"),
Flag.withDefault(false),
),
},
}),
Spec.make("add", {
description: "Install a plugin and add it to the global configuration",
params: {
package: Argument.string("package").pipe(Argument.withDescription("npm registry package specifier")),
},
}),
Spec.make("remove", {
description: "Remove a plugin from global configuration",
params: {
package: Argument.string("package").pipe(Argument.withDescription("configured package specifier")),
},
}),
],
}),
Spec.make("models", {
description: "List all available models",
@@ -84,8 +84,12 @@ export default Runtime.handler(Commands, (input) =>
update: (update) => runPromise(config.update(update)),
},
packages: {
resolve: (spec) =>
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
resolve: (spec, install = true) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
@@ -0,0 +1,82 @@
import { EOL } from "node:os"
import path from "node:path"
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { resolveConfigPath } from "../mcp/add"
import { Config } from "../../../config"
export default Runtime.handler(
Commands.commands.plugin.commands.add,
Effect.fn("cli.plugin.add")(function* (input) {
if (!(yield* Effect.promise(() => Npm.isRegistryPackage(input.package))))
return yield* Effect.fail(
new Error("Plugin target must be an npm registry package name, version, tag, or semver range"),
)
const npm = yield* Npm.Service
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
if (!target)
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
if (target === "server") {
const global = yield* Global.Service
const configPath = yield* Effect.promise(() => resolveConfigPath(global.config))
const changed = yield* Effect.promise(() => writePluginConfig(configPath, input.package))
process.stdout.write(
changed
? `Plugin "${input.package}" installed and added to ${configPath}${EOL}`
: `Plugin "${input.package}" is already configured in ${configPath}${EOL}`,
)
return
}
const config = yield* Config.Service
yield* config.update((draft) => {
if (configured(draft.plugins, input.package)) return
draft.plugins = [...(draft.plugins ?? []), input.package]
})
process.stdout.write(`TUI plugin "${input.package}" installed and added to ${config.path}${EOL}`)
}),
)
export function configurationTarget(server?: string, tui?: string) {
if (server) return "server" as const
if (tui) return "tui" as const
}
export async function writePluginConfig(configPath: string, spec: string) {
const text = await readFile(configPath, "utf8").catch((error) => {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return "{}"
throw error
})
const errors: ParseError[] = []
const config: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length || typeof config !== "object" || config === null || Array.isArray(config))
throw new Error(`Invalid global configuration: ${configPath}`)
const plugins = "plugins" in config ? config.plugins : undefined
if (plugins !== undefined && !Array.isArray(plugins)) throw new Error(`Invalid plugins configuration: ${configPath}`)
if (configured(plugins, spec)) return false
const updated = applyEdits(
text,
modify(text, ["plugins"], [...(plugins ?? []), spec], { formattingOptions: { tabSize: 2, insertSpaces: true } }),
)
await mkdir(path.dirname(configPath), { recursive: true })
const temporary = configPath + ".tmp"
await writeFile(temporary, updated.endsWith("\n") ? updated : updated + "\n", { mode: 0o600 })
await rename(temporary, configPath)
return true
}
function configured(plugins: readonly unknown[] | undefined, spec: string) {
return plugins?.some(
(entry) =>
entry === spec || (typeof entry === "object" && entry !== null && "package" in entry && entry.package === spec),
)
}
@@ -5,24 +5,69 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
Effect.fn("cli.plugin.list")(function* () {
Effect.fn("cli.plugin.list")(function* (input) {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b)))
if (plugins.length === 0) {
process.stdout.write("No plugins loaded" + EOL)
const config = yield* Config.Service
const global = yield* Global.Service
const info = yield* config.get()
const discovered = yield* Effect.promise(() =>
tuiPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
)
const output = format(
response.data,
[
...(info.plugins ?? []).flatMap((entry) => {
const target = typeof entry === "string" ? entry : entry.package
return target.startsWith("-") ? [] : [{ target, source: "configured" as const }]
}),
...discovered.map((target) => ({ target, source: "discovered" as const })),
],
input.builtin,
)
if (!output) {
process.stdout.write("No plugins found" + EOL)
return
}
process.stdout.write(plugins.map(name).join(EOL) + EOL)
process.stdout.write(output + EOL)
}),
)
export function format(
plugins: readonly PluginInfo[],
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
builtin = false,
) {
const server = plugins
.filter((plugin) => builtin || plugin.source.type !== "builtin")
.toSorted((a, b) => name(a).localeCompare(name(b)))
.map((plugin) => `${name(plugin)} (${plugin.status})`)
const advertised = plugins.flatMap((plugin) =>
plugin.status === "active" && plugin.tui && plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
: [],
)
const targets = [...tui, ...advertised]
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
.toSorted((a, b) => a.target.localeCompare(b.target))
.map((plugin) => `${plugin.target} (${plugin.source})`)
return [
targets.length ? ["TUI", ...targets].join(EOL) : undefined,
server.length ? ["Server", ...server].join(EOL) : undefined,
]
.filter((section) => section !== undefined)
.join(EOL + EOL)
}
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
@@ -0,0 +1,74 @@
import { EOL } from "node:os"
import path from "node:path"
import { readFile, rename, writeFile } from "node:fs/promises"
import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import { Global } from "@opencode-ai/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Config } from "../../../config"
import { resolveConfigPath } from "../mcp/add"
export default Runtime.handler(
Commands.commands.plugin.commands.remove,
Effect.fn("cli.plugin.remove")(function* (input) {
const global = yield* Global.Service
const configPath = yield* Effect.promise(() => resolveConfigPath(global.config))
const server = yield* Effect.promise(() => removePluginConfig(configPath, input.package))
const config = yield* Config.Service
const info = yield* config.get()
const tui = configured(info.plugins, input.package)
if (tui)
yield* config.update((draft) => {
draft.plugins = draft.plugins?.filter((entry) => !matches(entry, input.package))
})
const removed = [server ? configPath : undefined, tui ? config.path : undefined].filter(
(file) => file !== undefined,
)
process.stdout.write(
removed.length
? `Plugin "${input.package}" removed from ${removed.join(", ")}${EOL}`
: `Plugin "${input.package}" is not configured${EOL}`,
)
}),
)
export async function removePluginConfig(configPath: string, spec: string) {
const text = await readFile(configPath, "utf8").catch((error) => {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return undefined
throw error
})
if (text === undefined) return false
const errors: ParseError[] = []
const config: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length || typeof config !== "object" || config === null || Array.isArray(config))
throw new Error(`Invalid global configuration: ${configPath}`)
const plugins = "plugins" in config ? config.plugins : undefined
if (plugins !== undefined && !Array.isArray(plugins)) throw new Error(`Invalid plugins configuration: ${configPath}`)
if (!configured(plugins, spec)) return false
const updated = applyEdits(
text,
modify(
text,
["plugins"],
plugins?.filter((entry) => !matches(entry, spec)),
{
formattingOptions: { tabSize: 2, insertSpaces: true },
},
),
)
const temporary = configPath + ".tmp"
await writeFile(temporary, updated.endsWith("\n") ? updated : updated + "\n", { mode: 0o600 })
await rename(temporary, configPath)
return true
}
function configured(plugins: readonly unknown[] | undefined, spec: string) {
return plugins?.some((entry) => matches(entry, spec)) ?? false
}
function matches(entry: unknown, spec: string) {
return entry === spec || (typeof entry === "object" && entry !== null && "package" in entry && entry.package === spec)
}
+2
View File
@@ -38,6 +38,8 @@ const Handlers = Runtime.handlers(Commands, {
},
plugin: {
list: () => import("./commands/handlers/plugin/list"),
add: () => import("./commands/handlers/plugin/add"),
remove: () => import("./commands/handlers/plugin/remove"),
},
models: () => import("./commands/handlers/models"),
export: () => import("./commands/handlers/export"),
+7 -1
View File
@@ -80,7 +80,13 @@ async function run(input: RunCommandInput, options: ExecutionOptions) {
}
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const client = OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
// Bun's default five-minute deadline terminates the event stream used by long-running sessions.
fetch: ((request: RequestInfo | URL, init?: RequestInit) =>
fetch(request, { ...init, timeout: false } as BunFetchRequestInit)) as typeof fetch,
})
const explicit = parseRunModel(input.model)
const target = await resolveSessionTarget({
client,
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { parse } from "jsonc-parser"
import { configurationTarget, writePluginConfig } from "../src/commands/handlers/plugin/add"
test("routes packages according to their exported runtimes", () => {
expect(configurationTarget("server.js", "tui.js")).toBe("server")
expect(configurationTarget("server.js", undefined)).toBe("server")
expect(configurationTarget(undefined, "tui.js")).toBe("tui")
expect(configurationTarget(undefined, undefined)).toBeUndefined()
})
test("adds a package to global plugin config without replacing unrelated settings", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "opencode.jsonc")
await Bun.write(file, '{\n // retained\n "model": "provider/model",\n "plugins": ["first"]\n}\n')
try {
expect(await writePluginConfig(file, "second@1.0.0")).toBe(true)
expect(await writePluginConfig(file, "second@1.0.0")).toBe(false)
const text = await Bun.file(file).text()
expect(text).toContain("// retained")
expect(parse(text)).toEqual({
model: "provider/model",
plugins: ["first", "second@1.0.0"],
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from "bun:test"
import { EOL } from "node:os"
import { format } from "../src/commands/handlers/plugin/list"
test("formats server and TUI plugins in sections without builtins", () => {
expect(
format(
[
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
{
id: "acme.dual",
source: { type: "package", package: "acme-plugin@1.0.0" },
status: "active",
tui: true,
},
{
source: { type: "package", package: "broken-plugin" },
status: "failed",
error: "broken",
tui: false,
},
],
[
{ target: "tui-only", source: "configured" },
{ target: "/tmp/local.ts", source: "discovered" },
],
),
).toBe(
[
"TUI",
"/tmp/local.ts (discovered)",
"acme-plugin@1.0.0 (advertised)",
"tui-only (configured)",
"",
"Server",
"acme.dual (active)",
"broken-plugin (failed)",
].join(EOL),
)
})
test("includes builtins when requested", () => {
expect(
format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true),
).toBe(["Server", "opencode.agent (active)"].join(EOL))
})
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { parse } from "jsonc-parser"
import { removePluginConfig } from "../src/commands/handlers/plugin/remove"
test("removes string and object package entries without replacing unrelated settings", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "opencode.jsonc")
await Bun.write(
file,
'{\n // retained\n "model": "provider/model",\n "plugins": ["remove-me", { "package": "remove-me", "options": {} }, "keep-me"]\n}\n',
)
try {
expect(await removePluginConfig(file, "remove-me")).toBe(true)
expect(await removePluginConfig(file, "remove-me")).toBe(false)
const text = await Bun.file(file).text()
expect(text).toContain("// retained")
expect(parse(text)).toEqual({ model: "provider/model", plugins: ["keep-me"] })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
+5
View File
@@ -579,6 +579,8 @@ export type Endpoint5_31Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly cost: number & Brand.Brand<"Money.USD">
readonly tokens: {
readonly input: number
@@ -601,6 +603,9 @@ export type Endpoint5_31Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly finish?: "content-filter" | undefined
readonly rawFinish?: string | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
@@ -1108,6 +1108,8 @@ export type SessionStepEnded = {
sessionID: string
assistantMessageID: string
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState1
cost: MoneyUSD
tokens: TokenUsageInfo
snapshot?: string
@@ -1145,6 +1147,9 @@ export type SessionStepFailed = {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
finish?: "content-filter"
rawFinish?: string
providerState?: SessionMessageProviderState1
cost?: MoneyUSD
tokens?: TokenUsageInfo
snapshot?: string
@@ -1921,6 +1926,8 @@ export type SessionMessageAssistant = {
content: Array<SessionMessageAssistantText | SessionMessageAssistantReasoning | SessionMessageAssistantTool>
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
@@ -2691,6 +2698,8 @@ export type SessionImportInput = {
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -2958,6 +2967,8 @@ export type SessionImportInput = {
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3225,6 +3236,8 @@ export type SessionImportInput = {
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
+7 -1
View File
@@ -601,6 +601,8 @@ export function createData(config: CreateDataInput) {
existing.retry = undefined
existing.error = undefined
existing.finish = undefined
existing.rawFinish = undefined
existing.providerState = undefined
existing.time.completed = undefined
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
return
@@ -628,6 +630,8 @@ export function createData(config: CreateDataInput) {
if (!currentAssistant) return
currentAssistant.time.completed = event.created
currentAssistant.finish = event.data.finish
currentAssistant.rawFinish = event.data.rawFinish
currentAssistant.providerState = event.data.providerState
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
if (event.data.snapshot)
@@ -640,7 +644,9 @@ export function createData(config: CreateDataInput) {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.created
currentAssistant.finish = "error"
currentAssistant.finish = event.data.finish ?? "error"
currentAssistant.rawFinish = event.data.rawFinish
currentAssistant.providerState = event.data.providerState
currentAssistant.error = event.data.error
currentAssistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
+7 -1
View File
@@ -195,6 +195,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.retry = undefined
draft.error = undefined
draft.finish = undefined
draft.rawFinish = undefined
draft.providerState = undefined
draft.time.completed = undefined
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
}),
@@ -228,6 +230,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = created
draft.finish = event.data.finish
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.cost = event.data.cost
draft.tokens = event.data.tokens
if (event.data.snapshot || event.data.files)
@@ -241,7 +245,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.step.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = created
draft.finish = "error"
draft.finish = event.data.finish ?? "error"
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.error = castDraft(event.data.error)
draft.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
+2
View File
@@ -326,6 +326,8 @@ const layer = Layer.effect(
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: finish.finish,
rawFinish: finish.rawFinish,
providerState: finish.providerState,
...stepUsage(finish),
...end,
})
@@ -35,6 +35,8 @@ export interface StepRecord {
/** Present once the provider finished the step normally. */
readonly finish?: {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly rawFinish?: string
readonly providerState?: SessionMessage.ProviderState
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
readonly calls: ReadonlyArray<{
@@ -364,6 +366,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
sessionID: input.sessionID,
assistantMessageID,
error: stepFailure,
finish: stepSettlement?.finish === "content-filter" ? stepSettlement.finish : undefined,
rawFinish: stepSettlement?.rawFinish,
providerState: stepSettlement?.providerState,
...details,
})
})
@@ -517,7 +522,12 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
case "step-finish":
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
stepSettlement = {
finish: event.reason.normalized,
rawFinish: event.reason.raw,
providerState: providerState(event.providerMetadata),
tokens: SessionUsage.tokens(event.usage),
}
if (event.reason.normalized === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
+4 -4
View File
@@ -56,8 +56,8 @@ const headers = (format: Format, userAgent: string) => ({
"Accept-Language": "en-US,en;q=0.9",
})
const browserUserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
const openCodeUserAgent =
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OpenCode-User/1.0; +https://opencode.ai"
const isCloudflareChallenge = (error: unknown) => {
if (!error || typeof error !== "object" || !("reason" in error)) return false
@@ -74,14 +74,14 @@ const isCloudflareChallenge = (error: unknown) => {
return response.status === 403 && response.headers["cf-mitigated"] === "challenge"
}
const request = (url: string, format: Format, userAgent = browserUserAgent) =>
const request = (url: string, format: Format, userAgent = openCodeUserAgent) =>
HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers(format, userAgent)))
const assertHttpUrl = (url: URL) => {
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("URL must use http:// or https://")
}
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = browserUserAgent) =>
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = openCodeUserAgent) =>
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
+39
View File
@@ -35,6 +35,17 @@ describe("Npm.sanitize", () => {
})
})
describe("Npm.isRegistryPackage", () => {
test("accepts registry packages and rejects unsupported install targets", async () => {
expect(await Npm.isRegistryPackage("plugin")).toBe(true)
expect(await Npm.isRegistryPackage("@acme/plugin@beta")).toBe(true)
expect(await Npm.isRegistryPackage("plugin@^1.2.0")).toBe(true)
expect(await Npm.isRegistryPackage("./plugin")).toBe(false)
expect(await Npm.isRegistryPackage("github:acme/plugin")).toBe(false)
expect(await Npm.isRegistryPackage("alias@npm:plugin@1.0.0")).toBe(false)
})
})
describe("Npm.add", () => {
test("resolves cached scoped package specs without reifying", async () => {
await using tmp = await tmpdir()
@@ -106,3 +117,31 @@ describe("Npm.add", () => {
expect(entries.fallback.entrypoint).toEndWith("/index.js")
})
})
describe("Npm.resolve", () => {
test("resolves a TUI entrypoint only when the package is already cached", async () => {
await using tmp = await tmpdir()
const cache = path.join(tmp.path, "cache")
const spec = "fixture-plugin@1.0.0"
const directory = path.join(cache, "packages", Npm.sanitize(spec), "node_modules", "fixture-plugin")
const missing = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(missing.entrypoint).toBeUndefined()
await fs.mkdir(directory, { recursive: true })
await writePackage(directory, {
name: "fixture-plugin",
exports: { ".": "./index.js", "./tui": "./tui.js" },
})
await Bun.write(path.join(directory, "index.js"), "export default {}\n")
await Bun.write(path.join(directory, "tui.js"), "export default {}\n")
const resolved = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(resolved.entrypoint).toEndWith("/tui.js")
})
})
+1
View File
@@ -31,6 +31,7 @@ const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.succeed(undefined),
}),
)
@@ -23,6 +23,7 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.succeed(undefined),
})
}
@@ -14,6 +14,7 @@ const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.ur
const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.succeed(undefined),
})
@@ -353,7 +353,12 @@ test("content-filter finish retains failure evidence until step closeout", async
publisher.publish(
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "content-filter" },
reason: { normalized: "content-filter", raw: "refusal" },
providerMetadata: {
anthropic: {
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
},
},
usage: {
nonCachedInputTokens: 8,
outputTokens: 3,
@@ -367,6 +372,10 @@ test("content-filter finish retains failure evidence until step closeout", async
const settlement = publisher.record().finish
expect(settlement).toMatchObject({
finish: "content-filter",
rawFinish: "refusal",
providerState: {
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
},
tokens: { input: 8, output: 2, reasoning: 1 },
})
if (!settlement) throw new Error("Expected content-filter settlement")
@@ -381,6 +390,11 @@ test("content-filter finish retains failure evidence until step closeout", async
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
expect(published.at(-1)?.data).toMatchObject({
error: { type: "provider.content-filter", message: "Provider blocked the response" },
finish: "content-filter",
rawFinish: "refusal",
providerState: {
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
},
cost: 1.25,
tokens: { input: 8, output: 2, reasoning: 1 },
snapshot: "tree-end",
+43 -2
View File
@@ -4161,13 +4161,49 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("persists raw finish reasons and provider state", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(
TestLLM.complete(
{
reason: { normalized: "stop", raw: "end_turn" },
providerMetadata: { openai: { responseId: "response-1", serviceTier: "priority" } },
},
LLMEvent.textStart({ id: "answer" }),
LLMEvent.textDelta({ id: "answer", text: "Complete" }),
LLMEvent.textEnd({ id: "answer" }),
),
)
yield* runPrompt(session, "Keep provider finish details")
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{
type: "assistant",
finish: "stop",
rawFinish: "end_turn",
providerState: { responseId: "response-1", serviceTier: "priority" },
content: [{ type: "text", text: "Complete" }],
},
])
}),
)
it.effect("projects content-filter finishes as visible terminal failures", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(
TestLLM.complete(
{
reason: { normalized: "content-filter" },
reason: { normalized: "content-filter", raw: "SAFETY" },
providerMetadata: {
openai: {
responseId: "response-blocked",
refusal: { category: "safety", explanation: "Prompt blocked" },
},
},
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
},
LLMEvent.textStart({ id: "partial" }),
@@ -4182,7 +4218,12 @@ describe("SessionRunnerLLM", () => {
{ type: "user" },
{
type: "assistant",
finish: "error",
finish: "content-filter",
rawFinish: "SAFETY",
providerState: {
responseId: "response-blocked",
refusal: { category: "safety", explanation: "Prompt blocked" },
},
error: { type: "provider.content-filter" },
cost: 0,
tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } },
+38 -10
View File
@@ -23,6 +23,8 @@ const webFetchToolNode = makeLocationNode({
})
const sessionID = Session.ID.make("ses_webfetch_test")
const webFetchUserAgent =
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OpenCode-User/1.0; +https://opencode.ai"
const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
const assertions: Permission.AssertInput[] = []
let respond = (_request: HttpClientRequest.HttpClientRequest) =>
@@ -376,7 +378,17 @@ describe("WebFetchTool registration", () => {
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
])
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
expect(requests).toMatchObject([
{
url,
headers: {
accept: "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1",
"accept-language": "en-US,en;q=0.9",
"user-agent": webFetchUserAgent,
},
},
])
expect(requests[0]?.headers).not.toHaveProperty("sec-fetch-mode")
}),
)
@@ -397,15 +409,23 @@ describe("WebFetchTool registration", () => {
}),
)
live.effect("follows redirects while approving only the requested URL", () =>
Effect.acquireUseRelease(
live.effect("follows redirects while approving only the requested URL", () => {
const received: Array<Record<string, string | null>> = []
return Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: (request) =>
new URL(request.url).pathname === "/redirect"
? new Response("", { status: 302, headers: { location: "/target" } })
: new Response("redirected", { headers: { "content-type": "text/plain" } }),
fetch: (request) => {
received.push({
accept: request.headers.get("accept"),
"accept-language": request.headers.get("accept-language"),
"sec-fetch-mode": request.headers.get("sec-fetch-mode"),
"user-agent": request.headers.get("user-agent"),
})
if (new URL(request.url).pathname === "/redirect")
return new Response("", { status: 302, headers: { location: "/target" } })
return new Response("redirected", { headers: { "content-type": "text/plain" } })
},
}),
),
(server) =>
@@ -421,10 +441,18 @@ describe("WebFetchTool registration", () => {
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
])
expect(received).toEqual(
Array.from({ length: 2 }, () => ({
accept: "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1",
"accept-language": "en-US,en;q=0.9",
"sec-fetch-mode": null,
"user-agent": webFetchUserAgent,
})),
)
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
)
})
it.effect("rejects non-HTTP schemes before permission or transport", () =>
Effect.gen(function* () {
@@ -549,7 +577,7 @@ describe("WebFetchTool registration", () => {
content: [{ type: "text", text: "ok" }],
})
expect(requests).toHaveLength(2)
expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0")
expect(requests[0]?.headers["user-agent"]).toBe(webFetchUserAgent)
expect(requests[1]?.headers["user-agent"]).toBe("opencode")
}),
)
@@ -43,14 +43,16 @@ export async function startBackgroundCli(logger: Logger) {
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
const url = new URL(service.url)
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
logger.log("v2 CLI background service ready", {
username: service.auth.username,
version: cli.version,
...endpoint(service.url),
...endpoint(url.origin),
})
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
return {
url: service.url,
url: url.origin,
username: service.auth.username,
password: service.auth.password,
version: cli.version,
+5
View File
@@ -298,6 +298,8 @@ export namespace Step {
...Base,
assistantMessageID: SessionMessage.ID,
finish: FinishReason,
rawFinish: Schema.String.pipe(optional),
providerState: SessionMessage.ProviderState.pipe(optional),
cost: Money.USD,
tokens: TokenUsage.Info,
snapshot: Snapshot.ID.pipe(optional),
@@ -313,6 +315,9 @@ export namespace Step {
...Base,
assistantMessageID: SessionMessage.ID,
error: SessionError.Error,
finish: Schema.Literals(["content-filter"]).pipe(optional),
rawFinish: Schema.String.pipe(optional),
providerState: SessionMessage.ProviderState.pipe(optional),
cost: Money.USD.pipe(optional),
tokens: TokenUsage.Info.pipe(optional),
snapshot: Snapshot.ID.pipe(optional),
+2
View File
@@ -215,6 +215,8 @@ export const Assistant = Schema.Struct({
files: Schema.Array(RelativePath).pipe(optional),
}).pipe(optional),
finish: FinishReason.pipe(optional),
rawFinish: Schema.String.pipe(optional),
providerState: ProviderState.pipe(optional),
cost: Money.USD.pipe(optional),
tokens: TokenUsage.Info.pipe(optional),
error: SessionError.Error.pipe(optional),
@@ -0,0 +1,50 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { SessionEvent } from "../src/session-event.js"
import { SessionMessage } from "../src/session-message.js"
const assistant = {
id: "msg_terminal",
type: "assistant" as const,
agent: "build",
model: { providerID: "openai", id: "gpt-test" },
content: [],
time: { created: 0 },
}
test("assistant terminal diagnostics remain optional and round trip", () => {
const decode = Schema.decodeUnknownSync(SessionMessage.Assistant)
const encode = Schema.encodeSync(SessionMessage.Assistant)
expect(encode(decode(assistant))).toEqual(assistant)
expect(
encode(
decode({
...assistant,
finish: "content-filter",
rawFinish: "SAFETY",
providerState: { promptFeedback: { blockReason: "SAFETY" } },
}),
),
).toMatchObject({
finish: "content-filter",
rawFinish: "SAFETY",
providerState: { promptFeedback: { blockReason: "SAFETY" } },
})
})
test("failed steps only override the assistant finish for content filters", () => {
const decode = Schema.decodeUnknownSync(SessionEvent.Step.Failed.data)
const input = {
sessionID: "ses_terminal",
assistantMessageID: "msg_terminal",
error: { type: "provider.content-filter", message: "Blocked" },
}
expect(decode(input)).toMatchObject(input)
expect(decode({ ...input, finish: "content-filter", rawFinish: "SAFETY" })).toMatchObject({
finish: "content-filter",
rawFinish: "SAFETY",
})
expect(() => decode({ ...input, finish: "stop" })).toThrow()
})
+1
View File
@@ -24,6 +24,7 @@
"./context/client": "./src/context/client.tsx",
"./context/theme": "./src/context/theme.tsx",
"./theme/discovery": "./src/theme/discovery.ts",
"./plugin/discovery": "./src/plugin/discovery.ts",
"./context/editor": "./src/context/editor.ts",
"./context/clipboard": "./src/context/clipboard.tsx",
"./attention": "./src/attention.ts",
+47 -6
View File
@@ -1,3 +1,4 @@
import type { PluginInfo } from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
import {
@@ -5,6 +6,7 @@ import {
createContext,
createEffect,
createMemo,
createSignal,
on,
onCleanup,
onMount,
@@ -21,6 +23,8 @@ import { isDeepEqual } from "remeda"
import "#runtime-plugin-support"
import { useConfig } from "../config"
import { useTuiLifecycle } from "../context/runtime"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { errorMessage } from "../util/error"
import { builtins } from "./builtins"
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
@@ -28,7 +32,7 @@ import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string) => Promise<string | undefined>
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
}
type State =
@@ -90,6 +94,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const host = usePluginHost()
const config = useConfig()
const lifecycle = useTuiLifecycle()
const client = useClient()
const data = useData()
const [serverPlugins, setServerPlugins] = createSignal<
ReadonlyArray<
Extract<PluginInfo, { readonly status: "active" }> & { readonly source: { readonly type: "package" } }
>
>([])
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@@ -230,7 +241,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const npmFailures = new Map<string, string>()
const reconcile = async () => {
await Promise.all(props.directories.map(watcher.wait))
const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])]
const entries = [
...(await discoverTuiPlugins(props.directories)).map((entry) => ({ entry, install: true, server: false })),
...serverPlugins().map((plugin) => ({ entry: plugin.source.package, install: false, server: true })),
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, server: false })),
]
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
@@ -238,7 +253,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
for (const plugin of builtins)
desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
const failures: State[] = []
for (const entry of entries) {
for (const source of entries) {
const entry = source.entry
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-")) {
for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
@@ -259,11 +275,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
: await resolvePlugin(target, local, options, previous, props.packages, source.install).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}))
if (resolved.status === "unsupported") {
if (source.server) continue
failures.push({ target, status: "unsupported" })
continue
}
@@ -439,7 +456,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
createEffect(
on(
() => JSON.stringify(config.data.plugins ?? []),
() => JSON.stringify([serverPlugins(), config.data.plugins ?? []]),
() => {
npmFailures.clear()
void enqueue(reconcile).then(
@@ -449,6 +466,29 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
},
),
)
const syncServerPlugins = () =>
client.api.plugin
.list({ location: data.location.default() })
.then((response) =>
setServerPlugins(
response.data.filter(
(
plugin,
): plugin is Extract<PluginInfo, { readonly status: "active" }> & {
readonly source: { readonly type: "package" }
} => plugin.status === "active" && plugin.tui && plugin.source.type === "package",
),
),
)
.catch(() => undefined)
createEffect(
on(
() => JSON.stringify(data.location.default()),
() => void syncServerPlugins(),
),
)
onCleanup(client.event.on("plugin.updated", syncServerPlugins))
onCleanup(client.event.on("server.connected", syncServerPlugins))
onMount(() => {
let disposing: Promise<void> | undefined
const dispose = () => {
@@ -523,12 +563,13 @@ async function resolvePlugin(
options: Readonly<Record<string, any>> | undefined,
previous: Registration | undefined,
packages: PackageResolver,
install: boolean,
) {
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
if (!local && previous && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
if (!entrypoint) return { status: "unsupported" as const }
// The cache-busted specifier doubles as the version: unique per entrypoint
// and mtime, so equal versions mean an identical module.
+5
View File
@@ -95,6 +95,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location")
return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } })
if (url.pathname === "/api/plugin")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
data: [],
})
if (url.pathname === "/api/vcs")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
+51 -2
View File
@@ -4,6 +4,7 @@ import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
@@ -30,12 +31,26 @@ async function until(read: () => Promise<string>, expected: (value: string | und
return value
}
async function bootApp(directory: string) {
async function bootApp(
directory: string,
options?: {
plugins?: unknown[]
resolve?: (spec: string, install?: boolean) => Promise<string | undefined>
},
) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname === "/api/plugin")
return json({
location: {
directory,
project: { id: "proj_test", directory, canonical: directory },
},
data: options?.plugins ?? [],
})
if (url.pathname !== "/api/fs/list") return
return json({
location: {
@@ -54,7 +69,7 @@ async function bootApp(directory: string) {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { resolve: options?.resolve ?? (async () => undefined) },
args: {},
log: () => {},
}).pipe(
@@ -73,6 +88,40 @@ async function bootApp(directory: string) {
}
}
test("loads an advertised package TUI entrypoint only from the local cache", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const entrypoint = path.join(tmp.path, "tui.ts")
await writeFile(entrypoint, lifecycleSource(marker, "test.package", "package"))
const resolutions: Array<{ spec: string; install?: boolean }> = []
await using app = await bootApp(tmp.path, {
plugins: [
{
id: "test.server",
source: { type: "package", package: "test-plugin@1.0.0" },
status: "active",
tui: true,
},
],
resolve: async (spec, install) => {
resolutions.push({ spec, install })
return pathToFileURL(entrypoint).href
},
})
expect(
await until(
() => readFile(marker, "utf8"),
(value) => value === "package:setup\n",
),
).toBe("package:setup\n")
expect(resolutions).toContainEqual({ spec: "test-plugin@1.0.0", install: false })
process.emit("SIGHUP")
await app.task
})
test("discovers an ancestor TUI plugin directory created after startup", async () => {
await using tmp = await tmpdir()
const cwd = path.join(tmp.path, "repo", "packages", "app")
+33
View File
@@ -29,6 +29,7 @@ export interface Interface {
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
}
@@ -41,6 +42,16 @@ export function sanitize(pkg: string) {
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
export async function isRegistryPackage(pkg: string) {
const { default: npa } = await import("npm-package-arg")
try {
const result = npa(pkg)
return result.name !== undefined && ["version", "range", "tag"].includes(result.type)
} catch {
return false
}
}
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
const entrypoint = subpaths
.map((subpath) => {
@@ -134,6 +145,23 @@ const layer = Layer.effect(
return resolveEntryPoint(first.name, first.path, options?.subpaths)
}, Effect.scoped)
const resolve = Effect.fn("Npm.resolve")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const name = (() => {
try {
return npa(pkg).name ?? pkg
} catch {
return pkg
}
})()
const dir = path.join(directory(pkg), "node_modules", name)
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
return resolveEntryPoint(name, dir, options?.subpaths)
})
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
@@ -187,6 +215,7 @@ const layer = Layer.effect(
return Service.of({
add,
resolve,
which,
})
}),
@@ -204,6 +233,10 @@ export async function add(...args: Parameters<Interface["add"]>) {
return runPromise((svc) => svc.add(...args))
}
export async function resolve(...args: Parameters<Interface["resolve"]>) {
return runPromise((svc) => svc.resolve(...args))
}
export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args))
}
+35 -1
View File
@@ -99,6 +99,32 @@ an isolated cache. Package installation does not run lifecycle scripts.
Published packages should expose their plugin entrypoint and include every
runtime import in `dependencies`.
Install a package plugin globally with the CLI:
```sh
opencode2 plugin add opencode-acme-plugin@1.2.0
```
This installs and inspects the package before changing configuration. Packages
with a server entrypoint are added to global `opencode.json(c)`. Packages that
only expose `./tui` are added to global `cli.json` instead.
The command accepts npm registry package names with an optional version,
dist-tag, or semver range. Configure local paths directly instead; Git, tarball,
and npm alias targets are not accepted by `plugin add`.
List configured and active plugins, or remove a package from both global server
and TUI configuration:
```sh
opencode2 plugin list
opencode2 plugin list --builtin
opencode2 plugin remove opencode-acme-plugin@1.2.0
```
Built-in server plugins are hidden from the default list. Removing a plugin
keeps its package cache available for later reuse.
Local files and local package directories are imported directly. OpenCode does
**not** install their dependencies. Install dependencies in a `package.json`
visible from the plugin file, for example:
@@ -397,13 +423,21 @@ manifest is:
"name": "opencode-acme-plugin",
"version": "1.0.0",
"type": "module",
"exports": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./tui": "./src/tui.tsx"
},
"dependencies": {
"@opencode-ai/plugin": "beta"
}
}
```
Packages with a TUI entrypoint should set `tui: true` on their server plugin
definition. A locally connected TUI loads the package's `./tui` export from the
existing OpenCode package cache. A TUI connected to a remote server skips it
when that package is not installed locally.
Use versions compatible with the OpenCode release you target and test the
installed package, not only a workspace-linked copy. Because the plugin API is
beta, publish compatible plugin updates when V2 entrypoints or contracts