Compare commits

..
Author SHA1 Message Date
Aiden Cline 1a1aa97235 refactor(ai): simplify response finalization flow 2026-08-28 01:01:30 -05:00
Aiden Cline b1f29c83f8 fix(ai): respect response text and reasoning finals 2026-08-28 00:51:54 -05:00
Aiden Cline 85d8b07f09 fix(ai): ignore late converse tool deltas (#45847) 2026-08-28 00:38:01 -05:00
Aiden Cline d2ee536c16 fix(core): classify AISDK network failures as transport errors (#45840) 2026-08-28 00:21:17 -05:00
Aiden Cline e12e04f482 fix(core): strengthen background shell guidance (#45843) 2026-08-28 00:19:15 -05:00
Kit Langton 5743537945 test(core): use effectful temp fixtures (#45637) 2026-08-28 01:01:04 -04:00
Aiden Cline 18e22cd82e refactor(ai): simplify response stream state (#45835) 2026-08-27 23:56:42 -05:00
Aiden Cline bdf019a9ac fix(ai): detect DashScope input length overflow errors (#45834) 2026-08-27 23:43:14 -05:00
Aiden Cline 5cbafc57c0 refactor(ai): default unrecognized provider failures to retry (#45825) 2026-08-27 23:40:52 -05:00
Aiden Cline 55674b858b fix(ai): make final snapshots authoritative for text and reasoning (#45831) 2026-08-27 23:38:19 -05:00
Luke Parker bb390f435c fix(session-ui): enable word diffs in unified view (#45833) 2026-08-28 04:31:18 +00:00
Kit Langton 92b9eebab2 feat(tui): streamline diff review workflow (#45817) 2026-08-28 00:11:29 -04:00
Aiden Cline 074413a96d fix(ai): normalize response item boundaries (#45789) 2026-08-27 23:08:50 -05:00
opencode-agent[bot] 4685ba8d3e chore: update nix node_modules hashes 2026-08-28 04:03:16 +00:00
122 changed files with 5302 additions and 7806 deletions
+3 -11
View File
@@ -966,7 +966,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -976,7 +975,6 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
@@ -5814,7 +5812,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -6644,6 +6642,8 @@
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
@@ -7450,12 +7450,6 @@
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -8162,8 +8156,6 @@
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Fmwqp/fjTMX7gQW01Zgta51reLHp1S53GSQgBcJePrY=",
"aarch64-linux": "sha256-KLI6OIbvycMePKxt66nNKeILYJSEkufOIaSawZ/gotc=",
"aarch64-darwin": "sha256-go1wmrsHfYfJ3ukUIR/fveobyVba4E9G/rdsB/J2cQQ=",
"x86_64-darwin": "sha256-UnVYMijlG12k4RLFkTwf4Or0yXqFSwe1Ji6QCGufjzg="
"x86_64-linux": "sha256-EtUp4pHl9TyPtRrLGvk/X7kd2LuIxNxCpUwF5aLtzN4=",
"aarch64-linux": "sha256-m0j/pMZCguclR3/T9JmzCfi11YzmIvBFyR2bVhIO37Y=",
"aarch64-darwin": "sha256-nqefk68ZTUfNU15q1WkXaGsFzPNwOjCtMHpp6WrpNqM=",
"x86_64-darwin": "sha256-syD7hX62E4yCDV/wux1QKw4q/zZr24f99Y2mmzMJo6o="
}
}
@@ -496,6 +496,7 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive.
@@ -574,6 +575,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
if (event.contentBlockDelta?.delta?.toolUse) {
const index = event.contentBlockDelta.contentBlockIndex
if (state.finishedTools.has(index)) return [state, []] as const
const result = ToolStream.appendExisting(
ADAPTER,
state.tools,
@@ -612,6 +614,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
state.hasToolCalls,
lifecycle,
tools: result.tools,
finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
reasoningSignatures: Object.fromEntries(
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
),
@@ -703,6 +706,7 @@ export const protocol = Protocol.make({
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
pendingFinish: undefined,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
+372 -153
View File
@@ -1,4 +1,4 @@
import { Effect, Schema } from "effect"
import { Effect, Option, Schema } from "effect"
import type { Content } from "@opencode-ai/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -346,6 +346,7 @@ export const Event = Schema.StructWithRest(
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
content_index: Schema.optional(Schema.Number),
// OutputItemAdded/Done permit a null item in the Open Responses OpenAPI schema.
item: optionalNull(StreamItem),
response: Schema.optional(
@@ -391,17 +392,25 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Call ids stay independent of item ids, which may be omitted or reused.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly messageItems: ReadonlySet<string>
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly message:
| {
readonly id: string
readonly phase: MessagePhase | null | undefined
readonly parts: Readonly<Record<number, { readonly text: string; readonly done: boolean }>>
}
| undefined
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly open: boolean
readonly encryptedContent: string | null | undefined
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
@@ -411,6 +420,7 @@ interface ReasoningStreamItem {
// is started eagerly when the item opens, so block existence cannot tell
// whether a `.done` final would duplicate streamed text.
readonly deltaIndexes: ReadonlySet<number>
readonly finalTexts: Readonly<Record<number, string>>
}
// =============================================================================
@@ -826,84 +836,219 @@ const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incompl
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
if (!event.delta || !state.messageItems.has(id)) return [state, NO_EVENTS]
if (!event.delta || state.message?.id !== id) return [state, NO_EVENTS]
const index = event.content_index ?? 0
const part = state.message.parts[index]
if (part?.done) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const phase = state.message.phase
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
if (state.messageItems.has(id)) {
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
}
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...state.reasoningItems,
[itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta),
message: {
...state.message,
parts: { ...state.message.parts, [index]: { text: (part?.text ?? "") + event.delta, done: false } },
},
},
events,
]
}
// Some compatible gateways emit a reasoning final without streaming any
// deltas, mirroring `response.output_text.done`. Reconcile the complete text
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
if (state.message?.id === id) {
const index = event.content_index ?? 0
if (event.text === undefined || (state.message.parts[index]?.done && event.type !== "response.content_part.done"))
return [state, NO_EVENTS]
const [current, emitted] = state.message.parts[index]
? [state, NO_EVENTS]
: onOutputTextDelta(state, { ...event, delta: event.text }, id)
return [
{
...current,
message: {
...state.message,
parts: { ...state.message.parts, [index]: { text: event.text, done: true } },
},
},
emitted,
]
}
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
const messageFinalText = (message: ParserState["message"]) => {
const parts = Object.values(message?.parts ?? {})
return parts.some((part) => part.done) ? parts.map((part) => part.text).join("") : undefined
}
const decodeMessagePart = Schema.decodeUnknownOption(
Schema.Union([
Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String }),
Schema.Struct({ type: Schema.tag("refusal"), refusal: Schema.String }),
]),
)
const decodeSummaryPart = Schema.decodeUnknownOption(
Schema.Struct({ type: Schema.tag("summary_text"), text: Schema.String }),
)
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const lifecycle = Object.entries(item.summaryParts)
.filter((entry) => entry[1] !== "concluded")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(
lifecycle,
events,
`${itemID}:${entry[0]}`,
providerMetadata(state, { itemId: itemID }),
item.finalTexts[Number(entry[0])],
),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
lifecycle,
events,
`${itemID}:${index}`,
providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
reasoningItems: {
...state.reasoningItems,
[itemID]: {
...item,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "concluded" ? entry : [entry[0], "concluded" as const],
),
),
[index]: "active",
},
},
},
},
events,
]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item?.open) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
const current = started.reasoningItems[itemID]
if (!current) return [started, emitted]
const events: LLMEvent[] = [...emitted]
return [
{
...started,
lifecycle: Lifecycle.reasoningDelta(started.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...started.reasoningItems,
[itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) },
},
},
events,
]
}
// Keep the final until the existing boundary closes the fragment, so encrypted
// item metadata can still be attached to its single reasoning-end event.
export const onReasoningDone = (
state: ParserState,
itemID: string,
index: number,
text: string | undefined,
): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || text === undefined) return [state, NO_EVENTS]
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
const current = started.reasoningItems[itemID]
if (!current) return [started, emitted]
const events = [...emitted]
const append = text.length > 0 && !current.deltaIndexes.has(index)
return [
{
...started,
lifecycle: append
? Lifecycle.reasoningDelta(started.lifecycle, events, `${itemID}:${index}`, text)
: started.lifecycle,
reasoningItems: {
...started.reasoningItems,
[itemID]: {
...current,
deltaIndexes: append ? new Set([...current.deltaIndexes, index]) : current.deltaIndexes,
finalTexts: { ...current.finalTexts, [index]: text },
},
},
},
events,
]
}
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs stream reasoning items in a stable order:
// Responses APIs normally stream reasoning items in this order:
// `output_item.added` (reasoning) →
// `reasoning_summary_part.added` (index=0) →
// `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.
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
const phase = messagePhase(item.phase)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
events,
id,
providerMetadata(state, { itemId: id, ...(openPhase === undefined ? {} : { phase: openPhase }) }),
messageFinalText(state.message),
)
}, state.lifecycle)
return [
{
...state,
messageItems: new Set([...state.messageItems, item.id]),
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
lifecycle,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
parts: state.message?.id === itemID ? state.message.parts : {},
},
},
NO_EVENTS,
events,
]
}
if (item && isReasoningItem(item)) {
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
@@ -912,9 +1057,11 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: true,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
finalTexts: {},
},
},
},
@@ -923,6 +1070,8 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
}
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
return [state, NO_EVENTS]
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -943,60 +1092,23 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
if (event.summary_index === 0) return [state, NO_EVENTS]
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,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
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",
},
},
},
},
events,
]
return startReasoningSummaryPart(state, event.item_id, event.summary_index)
}
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
const part = Option.getOrUndefined(decodeSummaryPart(event.part))
const [current, events] = part
? onReasoningDone(state, event.item_id, event.summary_index, part.text)
: [state, NO_EVENTS]
const item = current.reasoningItems[event.item_id]
if (!item?.open) return [state, NO_EVENTS]
if (item.summaryParts[event.summary_index] !== "active") return [current, events]
return [
{
...state,
...current,
reasoningItems: {
...state.reasoningItems,
...current.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
@@ -1006,7 +1118,7 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
},
},
},
NO_EVENTS,
events,
]
}
@@ -1041,28 +1153,32 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
})
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (state: ParserState, event: Event) {
const item = event.item
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: Event["item"],
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
const message = state.message?.id === item.id ? state.message : undefined
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const phase = itemPhase === undefined ? message?.phase : itemPhase
const content = Array.isArray(item.content)
? item.content.flatMap((part: unknown) => {
const decoded = Option.getOrUndefined(decodeMessagePart(part))
return decoded ? [decoded.type === "output_text" ? decoded.text : decoded.refusal] : []
})
: []
const text = content.length > 0 ? content.join("") : messageFinalText(message)
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const messageItems = new Set(state.messageItems)
messageItems.delete(item.id)
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
const lifecycle =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(
state.lifecycle,
events,
item.id,
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
),
messageItems,
messagePhases,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
message: message ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1070,20 +1186,36 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const id = item.id ?? item.call_id
const tools = state.tools[id]
? state.tools
: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name,
providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined,
})
const callID = item.call_id
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const fallback = item.id ?? callID
// Match the pending tool by call id so item events that disagree on
// whether `item.id` is present still resolve the same call.
const registered =
state.tools[fallback] !== undefined
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
providerMetadata: metadata,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const finished = result.events ?? []
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents =
registered !== undefined || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1094,30 +1226,86 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, callID]),
},
events,
] satisfies StepResult
}
if (isReasoningItem(item)) {
const events: LLMEvent[] = []
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
const reasoningItem = state.reasoningItems[item.id]
const parts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
const summary: string[] = []
const events: LLMEvent[] = []
let current = state
const lastIndex = Math.max(-1, ...Object.keys(state.reasoningItems[item.id]?.summaryParts ?? {}).map(Number))
for (const [index, part] of parts.entries()) {
const decoded = Option.getOrUndefined(decodeSummaryPart(part))
if (!decoded) continue
summary.push(decoded.text)
// Backfilling an earlier gap would close the current fragment before its final arrives.
if (index < lastIndex) continue
const [next, emitted] = onReasoningDone(current, item.id, index, decoded.text)
current = next
events.push(...emitted)
}
const reasoningItem = current.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,
let lifecycle = current.lifecycle
for (const [index, status] of Object.entries(reasoningItem.summaryParts)) {
if (status === "concluded") continue
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
`${item.id}:${index}`,
metadata,
reasoningItem.finalTexts[Number(index)],
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
}
return [
{
...current,
lifecycle,
reasoningItems: {
...current.reasoningItems,
[item.id]: {
...reasoningItem,
open: false,
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
},
},
},
events,
] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
return [{ ...state, lifecycle }, events] satisfies StepResult
events.push(
LLMEvent.reasoningEnd({
id: item.id,
providerMetadata: metadata,
text: summary.length > 0 ? summary.join("\n\n") : undefined,
}),
)
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: false,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "concluded" },
deltaIndexes: new Set(),
finalTexts: {},
},
},
},
events,
] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
@@ -1129,36 +1317,48 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
})
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
const reconciled =
event.type === "response.completed"
? yield* Effect.reduce(
event.response?.output ?? [],
() => [state, NO_EVENTS] satisfies StepResult,
([current, events], item) => {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (
id === undefined ||
((item.type !== "function_call" || !current.tools[id]) &&
(item.type !== "reasoning" || !current.reasoningItems[id]))
)
return Effect.succeed([current, events] satisfies StepResult)
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
Effect.map(([next, emitted]) => [next, [...events, ...emitted]] satisfies StepResult),
)
},
)
: ([state, NO_EVENTS] satisfies StepResult)
const current = reconciled[0]
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (id === undefined) continue
const tracked =
(item.type === "function_call" && current.tools[id]) ||
(item.type === "message" && current.message?.id === id) ||
(item.type === "reasoning" && current.reasoningItems[id]?.open)
if (!tracked) continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
}
}
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(current.id, current.tools)
: { tools: current.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...reconciled[1], ...pending.events]
events.push(...pending.events)
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
current.hasFunctionCall
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
// Generic terminal closure must also carry finals received before a missing item boundary.
let lifecycle = current.lifecycle
for (const [id, item] of Object.entries(current.reasoningItems)) {
for (const index of Object.keys(item.summaryParts)) {
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${id}:${index}`, undefined, item.finalTexts[Number(index)])
}
}
for (const id of lifecycle.text) {
lifecycle = Lifecycle.textEnd(
lifecycle,
events,
id,
undefined,
current.message?.id === id ? messageFinalText(current.message) : undefined,
)
}
lifecycle = Lifecycle.finish(lifecycle, events, {
reason: {
normalized: mapFinishReason(event, hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
@@ -1233,6 +1433,18 @@ export const step = (state: ParserState, input: Event) => {
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
)
}
if (event.type === "response.content_part.done" && event.item_id !== undefined) {
const part = Option.getOrUndefined(decodeMessagePart(event.part))
return Effect.succeed<StepResult>(
part
? onOutputTextDone(
state,
{ ...event, text: part.type === "output_text" ? part.text : part.refusal },
event.item_id,
)
: [state, NO_EVENTS],
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
@@ -1243,7 +1455,7 @@ export const step = (state: ParserState, input: Event) => {
event.type === "response.reasoning_text.done"
) {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event, event.item_id))
return Effect.succeed(onReasoningDone(state, event.item_id, event.summary_index ?? 0, event.text))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id !== undefined
@@ -1256,6 +1468,13 @@ export const step = (state: ParserState, input: Event) => {
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
if (
event.item &&
isReasoningItem(event.item) &&
state.reasoningItems[event.item.id] === undefined &&
state.lifecycle.reasoning.size > 0
)
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
@@ -1273,7 +1492,7 @@ export const step = (state: ParserState, input: Event) => {
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
return onOutputItemDone(state, event.item)
}
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
@@ -1305,10 +1524,10 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
completedTools: new Set<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
messageItems: new Set<string>(),
messagePhases: {},
message: undefined,
reasoningItems: {},
})
+15 -6
View File
@@ -62,22 +62,31 @@ export const reasoningEnd = (
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
/** Authoritative complete value; replaces accumulated deltas when present. */
text?: string,
): State => {
if (!state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
events.push(LLMEvent.reasoningEnd({ id, text, providerMetadata }))
const reasoning = new Set(stepped.reasoning)
reasoning.delete(id)
return { ...stepped, reasoning }
}
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
export const textEnd = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
/** Authoritative complete value; replaces accumulated deltas when present. */
text?: string,
): State => {
if (!state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textEnd({ id, providerMetadata }))
const text = new Set(stepped.text)
text.delete(id)
return { ...stepped, text }
events.push(LLMEvent.textEnd({ id, text, providerMetadata }))
const open = new Set(stepped.text)
open.delete(id)
return { ...stepped, text: open }
}
const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
+23 -31
View File
@@ -37,6 +37,7 @@ const patterns = [
/too large for model with \d+ maximum context length/i,
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
/model_context_window_exceeded/i,
/range of input length should be/i,
/too many tokens/i,
/token limit exceeded/i,
/request_too_large/i,
@@ -59,6 +60,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
"internal_error",
@@ -74,7 +76,6 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
export interface ProviderFailure {
readonly message: string
@@ -90,8 +91,12 @@ export interface ProviderFailure {
readonly rateLimit?: HttpRateLimitDetails | undefined
}
// Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching.
// Classification records affirmative evidence about a failure. Deterministic
// failures need positive identification (a 4xx status, quota/auth/policy
// signals); anything unrecognized stays UnknownProvider, which the session
// retry policy treats as retry-eligible because transient failures arrive in
// unpredictable shapes while deterministic rejections almost always carry a
// status or known code.
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
const details = { message: input.message, body: input.rawBody, http: input.http, cause: input.cause }
const body = input.rawBody ?? ""
@@ -116,46 +121,33 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyError(details)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededError(details)
if (input.status === 401) return new AuthenticationError({ ...details, kind: "invalid" })
if (input.status === 403) return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
if (codes.includes("authentication_error")) return new AuthenticationError({ ...details, kind: "invalid" })
if (codes.includes("permission_error"))
return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details)
if (
codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception")
input.status === 429 ||
codes.some(
(code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception",
) ||
RATE_LIMIT_TEXT.test(text)
)
return new RateLimitError({
...details,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (RATE_LIMIT_TEXT.test(text))
return new RateLimitError({
...details,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalError(details)
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
return new ProviderInternalError({
...details,
retryAfterMs: input.retryAfterMs,
})
if (input.status === 429) {
return new RateLimitError({
...details,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
}
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
if (
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
)
return new ProviderInternalError({
...details,
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestError(details)
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
return new InvalidRequestError(details)
// Any remaining 4xx is a deterministic rejection of this request.
if (input.status !== undefined && input.status >= 400 && input.status < 500) return new InvalidRequestError(details)
return new UnknownProviderError(details)
}
+1 -1
View File
@@ -139,7 +139,7 @@ const toAIError = (error: AuthError): AIError => {
return new AIError({
reason:
error instanceof MissingCredentialError
? new AuthenticationError({ message: error.message, cause: error, kind: "missing" })
? new AuthenticationError({ message: error.message, cause: error })
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
})
}
+1 -4
View File
@@ -44,10 +44,7 @@ export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoR
export class AuthenticationError extends Schema.TaggedError<AuthenticationError>("AI.Error.Authentication")(
"Authentication",
{
...ReasonFields,
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
},
ReasonFields,
) {}
export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error.RateLimit")("RateLimit", {
+35 -14
View File
@@ -112,6 +112,8 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
export const TextEnd = Schema.Struct({
type: Schema.tag("text-end"),
id: ContentBlockID,
/** Authoritative complete value; replaces accumulated deltas when present. */
text: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextEnd" })
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
@@ -134,6 +136,8 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
export const ReasoningEnd = Schema.Struct({
type: Schema.tag("reasoning-end"),
id: ContentBlockID,
/** Authoritative complete value; replaces accumulated deltas when present. */
text: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
@@ -328,17 +332,32 @@ export const LLMEvent = Object.assign(llmEventTagged, {
})
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
events: ReadonlyArray<LLMEvent>,
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
) => {
const order: string[] = []
const parts = new Map<string, string>()
for (const event of events) {
if (isDelta(event)) {
if (!parts.has(event.id)) order.push(event.id)
parts.set(event.id, (parts.get(event.id) ?? "") + event.text)
}
if (isEnd(event) && event.text !== undefined) {
if (!parts.has(event.id)) order.push(event.id)
parts.set(event.id, event.text)
}
}
return order.map((id) => parts.get(id)).join("")
}
const responseText = (events: ReadonlyArray<LLMEvent>) =>
events
.filter(LLMEvent.is.textDelta)
.map((event) => event.text)
.join("")
joinFragments(events, LLMEvent.is.textDelta, LLMEvent.is.textEnd)
const responseReasoning = (events: ReadonlyArray<LLMEvent>) =>
events
.filter(LLMEvent.is.reasoningDelta)
.map((event) => event.text)
.join("")
joinFragments(events, LLMEvent.is.reasoningDelta, LLMEvent.is.reasoningEnd)
const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
events.reduce<Usage | undefined>(
@@ -445,10 +464,11 @@ const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState
const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
const current = state.textParts[event.id]
if (!current) return state
const text = event.text ?? current.text
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
...replaceContent(state, current.contentIndex, textContent(text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, text, providerMetadata } },
}
}
@@ -478,10 +498,11 @@ const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): Resp
const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): ResponseState => {
const current = state.reasoningParts[event.id]
if (!current) return state
const text = event.text ?? current.text
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
...replaceContent(state, current.contentIndex, reasoningContent(text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
}
}
@@ -579,12 +600,12 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
usage: Schema.optional(Usage),
finishReason: FinishReasonDetails,
}) {
/** Concatenated assistant text assembled from streamed `text-delta` events. */
/** Concatenated assistant text; each fragment's `text-end` value replaces its accumulated deltas when present. */
get text() {
return responseText(this.events)
}
/** Concatenated reasoning text assembled from streamed `reasoning-delta` events. */
/** Concatenated reasoning text; each fragment's `reasoning-end` value replaces its accumulated deltas when present. */
get reasoning() {
return responseReasoning(this.events)
}
+9 -4
View File
@@ -11,6 +11,7 @@ describe("provider error classification", () => {
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
"Range of input length should be [1, 129024]",
"Too many tokens",
"Token limit exceeded",
]
@@ -87,10 +88,12 @@ describe("provider error classification", () => {
])
})
test("classifies network error text as provider internal", () => {
test("classifies any remaining 4xx status as an invalid request", () => {
expect(
["network error", "network-error", "network_error"].map((message) => classifyProviderFailure({ message })._tag),
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
[400, 402, 404, 418, 422, 451].map(
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
),
).toEqual(Array(6).fill("InvalidRequest"))
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -103,10 +106,12 @@ describe("provider error classification", () => {
).toEqual(["QuotaExceeded", "ProviderInternal", "InvalidRequest"])
})
test("keeps unknown and malformed provider payloads non-retryable", () => {
test("leaves unrecognized failures unclassified for the retry default", () => {
expect(classifyProviderFailure({ message: '{"error":{"message":"no_kv_space"}}' })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: '{"type":"error","error":{"code":123}}' })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: "network error" })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: "Provider returned error" })._tag).toBe("UnknownProvider")
})
})
@@ -491,6 +491,59 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("ignores late tool deltas after contentBlockStop", () =>
Effect.gen(function* () {
const body = eventStreamBody(
[
"contentBlockStart",
{
contentBlockIndex: 0,
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
},
],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"weather"}' } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"late":true}' } } }],
["messageStop", { stopReason: "tool_use" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.toolCalls).toEqual([
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } },
])
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
{
type: "tool-input-delta",
id: "tool_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
])
}),
)
it.effect("rejects tool deltas without contentBlockStart", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
["messageStop", { stopReason: "tool_use" }],
),
),
),
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
message: "Bedrock Converse tool delta is missing its tool call",
})
}),
)
it.effect("recovers incomplete tool input at finalization", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -52,7 +52,7 @@ describe("provider error retention", () => {
Effect.flip,
)
expect(error.message).toContain("Slow down")
expect(error.reason._tag).toBe(entry.name === "Gemini" ? "ProviderInternal" : "RateLimit")
expect(error.reason._tag).toBe("RateLimit")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
@@ -0,0 +1,723 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { OpenAI, XAI } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model")
const completed = { type: "response.completed", response: { id: "resp_1" } }
const collect = (input: ReadonlyArray<OpenResponses.Event>, selected = model) =>
Effect.gen(function* () {
const request = LLM.request({ model: selected, prompt: "Respond." })
const response = yield* LLMClient.generate(request)
const events = yield* LLMClient.stream(request).pipe(Stream.runCollect)
expect(events).toEqual(response.events)
const active = { text: new Set<string>(), reasoning: new Set<string>() }
const closed = { text: new Set<string>(), reasoning: new Set<string>() }
events.forEach((event) => {
if (event.type === "text-start" || event.type === "reasoning-start") {
const kind = event.type === "text-start" ? "text" : "reasoning"
expect(active[kind].size).toBe(0)
expect(closed[kind].has(event.id)).toBe(false)
active[kind].add(event.id)
}
if (event.type === "text-delta" || event.type === "reasoning-delta") {
expect(active[event.type === "text-delta" ? "text" : "reasoning"].has(event.id)).toBe(true)
}
if (event.type === "text-end" || event.type === "reasoning-end") {
const kind = event.type === "text-end" ? "text" : "reasoning"
expect(active[kind].delete(event.id)).toBe(true)
expect(closed[kind].has(event.id)).toBe(false)
closed[kind].add(event.id)
}
if (event.type === "step-finish" || event.type === "finish") {
expect(active.text.size).toBe(0)
expect(active.reasoning.size).toBe(0)
}
})
expect(events[0]?.type).toBe("step-start")
expect(events.filter(LLMEvent.is.stepStart)).toHaveLength(1)
expect(events.filter(LLMEvent.is.stepFinish)).toHaveLength(1)
expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
expect(events.slice(-2).map((event) => event.type)).toEqual(["step-finish", "finish"])
return response
}).pipe(Effect.provide(fixedResponse(sseEvents(...input))))
describe("Open Responses final text", () => {
;["output_text", "refusal"].forEach((kind) => {
;[
{ name: "longer", text: "Draft answer expanded" },
{ name: "shorter", text: "Draft" },
{ name: "non-prefix", text: "Replacement" },
{ name: "empty", text: "" },
].forEach((final) => {
it.effect(`replaces ${kind} deltas with a ${final.name} final at item close`, () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: `response.${kind}.delta`, item_id: "msg_1", content_index: 0, delta: "Draft answer" },
{
type: `response.${kind}.done`,
item_id: "msg_1",
content_index: 0,
...(kind === "refusal" ? { refusal: final.text } : { text: final.text }),
},
{ type: "response.output_item.done", item: { type: "message", id: "msg_1", phase: "final_answer" } },
completed,
])
expect(response.text).toBe(final.text)
expect(response.events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["Draft answer"])
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "text-end",
id: "msg_1",
text: final.text,
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "final_answer" } },
},
])
expect(response.message.content.find((part) => part.type === "text")).toMatchObject({
text: final.text,
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "final_answer" } },
})
}),
)
})
})
it.effect("aggregates content indexes independently and retains deltas for parts without finals", () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 0, delta: "first draft" },
{ type: "response.output_text.done", item_id: "msg_1", content_index: 0, text: "First " },
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 1, delta: "discard" },
{ type: "response.output_text.done", item_id: "msg_1", content_index: 1, text: "" },
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 2, delta: "fallback " },
{ type: "response.refusal.delta", item_id: "msg_1", content_index: 3, delta: "last draft" },
{ type: "response.refusal.done", item_id: "msg_1", content_index: 3, refusal: "last" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
completed,
])
expect(response.text).toBe("First fallback last")
expect(response.events.filter(LLMEvent.is.textStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.textEnd)).toMatchObject([{ id: "msg_1", text: "First fallback last" }])
}),
)
;[false, true].forEach((streamed) => {
it.effect(`uses item content over cached part finals ${streamed ? "with" : "without"} deltas`, () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
...(streamed ? [{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" }] : []),
{ type: "response.output_text.done", item_id: "msg_1", content_index: 0, text: "Cached" },
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
phase: "final_answer",
content: [
{ type: "output_text", text: "Final " },
{ type: "refusal", refusal: "refusal" },
],
},
},
completed,
])
expect(response.text).toBe("Final refusal")
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "text-end",
id: "msg_1",
text: "Final refusal",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "final_answer" } },
},
])
}),
)
})
it.effect("uses a tracked message item snapshot without text deltas or part finals", () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Snapshot only" }] },
},
completed,
])
expect(response.text).toBe("Snapshot only")
expect(response.events.filter(LLMEvent.is.textEnd)).toMatchObject([{ id: "msg_1", text: "Snapshot only" }])
}),
)
it.effect("uses a content-part snapshot over an earlier scalar final when item content is absent", () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{ type: "response.output_text.done", item_id: "msg_1", content_index: 0, text: "Cached" },
{
type: "response.content_part.done",
item_id: "msg_1",
content_index: 0,
part: { type: "output_text", text: "Corrected" },
},
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
completed,
])
expect(response.text).toBe("Corrected")
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "text-end",
id: "msg_1",
text: "Corrected",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
}),
)
;[false, true].forEach((streamed) => {
it.effect(`empty item content ${streamed ? "preserves streamed text" : "does not create a text fragment"}`, () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
...(streamed ? [{ type: "response.output_text.delta", item_id: "msg_1", delta: "Keep" }] : []),
{ type: "response.output_item.done", item: { type: "message", id: "msg_1", content: [] } },
completed,
])
expect(response.text).toBe(streamed ? "Keep" : "")
expect(response.message.content.filter((part) => part.type === "text")).toHaveLength(streamed ? 1 : 0)
if (!streamed) {
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([])
return
}
expect(response.events.filter(LLMEvent.is.textEnd)).toMatchObject([{ id: "msg_1" }])
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.text)).toEqual([undefined])
}),
)
it.effect(`empty scalar and item finals ${streamed ? "clear active text" : "do not create a text fragment"}`, () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
...(streamed ? [{ type: "response.output_text.delta", item_id: "msg_1", delta: "Discard" }] : []),
{ type: "response.output_text.done", item_id: "msg_1", content_index: 0, text: "" },
{
type: "response.content_part.done",
item_id: "msg_1",
content_index: 0,
part: { type: "output_text", text: "" },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "" }] },
},
completed,
])
expect(response.text).toBe("")
expect(response.message.content.filter((part) => part.type === "text")).toHaveLength(streamed ? 1 : 0)
if (!streamed) {
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([])
return
}
expect(response.events.filter(LLMEvent.is.textEnd)).toMatchObject([{ id: "msg_1", text: "" }])
}),
)
})
;[
{
name: "readable text beside an unfamiliar part",
content: [
{ type: "output_text", text: "Readable final" },
{ type: "other", text: { value: "unfamiliar" } },
],
text: "Readable final",
},
{
name: "unknown-only content",
content: [{ type: "other", text: { value: "unfamiliar" } }],
text: "Cached final",
},
{
name: "malformed known text content",
content: [
{ type: "output_text", text: { value: "unfamiliar" } },
{ type: "refusal", refusal: null },
],
text: "Cached final",
},
{ name: "empty content", content: [], text: "Cached final" },
].forEach((fixture) => {
it.effect(`decodes ${fixture.name} without treating absent text as an empty final`, () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Cached final" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1", content: fixture.content } },
completed,
])
expect(response.text).toBe(fixture.text)
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "text-end",
id: "msg_1",
text: fixture.text,
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
}),
)
})
it.effect("defers cached finals until the next message and ignores late edits to closed text", () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{ type: "response.output_text.done", item_id: "msg_1", text: "First " },
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" },
{ type: "response.output_item.added", item: { type: "message", id: "msg_2" } },
{ type: "response.output_text.delta", item_id: "msg_2", delta: "second" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Late final" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Late snapshot" }] },
},
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Late delta" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
completed,
])
expect(response.text).toBe("First second")
expect(response.reasoning).toBe("Thinking")
expect(response.events.filter(LLMEvent.is.textEnd)).toMatchObject([
{
id: "msg_1",
text: "First ",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ id: "msg_2" },
])
expect(
response.events
.filter((event) => event.type.endsWith("-start") || event.type.endsWith("-end"))
.map((event) => event.type),
).toEqual(["step-start", "text-start", "reasoning-start", "text-end", "text-start", "text-end", "reasoning-end"])
}),
)
})
describe("Open Responses final reasoning", () => {
;["reasoning_summary_text.done", "reasoning_summary_part.done"].forEach((kind) => {
;[
{ name: "longer", text: "Draft reasoning expanded" },
{ name: "shorter", text: "Draft" },
{ name: "non-prefix", text: "Replacement" },
{ name: "empty", text: "" },
].forEach((final) => {
it.effect(`replaces reasoning with a ${final.name} ${kind} snapshot at item close`, () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_1",
summary_index: 0,
delta: "Draft reasoning",
},
{
type: `response.${kind}`,
item_id: "rs_1",
summary_index: 0,
...(kind === "reasoning_summary_part.done"
? { part: { type: "summary_text", text: final.text } }
: { text: final.text }),
},
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "final-state" },
},
completed,
])
expect(response.reasoning).toBe(final.text)
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => event.text)).toEqual([
"Draft reasoning",
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
{
type: "reasoning-end",
id: "rs_1:0",
text: final.text,
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "final-state" } },
},
])
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
text: final.text,
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "final-state" } },
})
}),
)
})
})
it.effect("closes per-index finals at the next summary and only reconciles still-open item summaries", () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Draft zero" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Zero" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Draft one" },
{
type: "response.reasoning_summary_part.done",
item_id: "rs_1",
summary_index: 1,
part: { type: "summary_text", text: "Cached one" },
},
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Late final" },
{
type: "response.reasoning_summary_part.done",
item_id: "rs_1",
summary_index: 0,
part: { type: "summary_text", text: "Late part" },
},
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
encrypted_content: "final-state",
summary: [
{ type: "summary_text", text: "Late item edit" },
{ type: "summary_text", text: "One" },
{ type: "summary_text", text: "Two" },
],
},
},
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "Late closed item" },
completed,
])
expect(response.reasoning).toBe("ZeroOneTwo")
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toMatchObject([
{ id: "rs_1:0", text: "Zero", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
{ id: "rs_1:1", text: "One" },
{
id: "rs_1:2",
text: "Two",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "final-state" } },
},
])
}),
)
it.effect("uses done-only reasoning summary text and ignores duplicate item finals", () =>
Effect.gen(function* () {
const item = {
type: "reasoning",
id: "rs_1",
encrypted_content: "final-state",
summary: [{ type: "summary_text", text: "Only summary" }],
}
const response = yield* collect([
{ type: "response.output_item.done", item },
{
type: "response.output_item.done",
item: { ...item, summary: [{ type: "summary_text", text: "Late edit" }] },
},
completed,
])
expect(response.reasoning).toBe("Only summary")
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toMatchObject([
{
text: "Only summary",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "final-state" } },
},
])
}),
)
it.effect("ignores earlier summary gaps without closing or reopening the active later index", () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Draft zero" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Closed zero " },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "Draft two" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "Cached two" },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
encrypted_content: "final-state",
summary: [
{ type: "summary_text", text: "Late zero" },
{ type: "summary_text", text: "Earlier gap" },
{ type: "summary_text", text: "Final two" },
],
},
},
completed,
])
expect(response.reasoning).toBe("Closed zero Final two")
expect(response.events.filter(LLMEvent.is.reasoningStart).map((event) => event.id)).toEqual(["rs_1:0", "rs_1:2"])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
{
type: "reasoning-end",
id: "rs_1:0",
text: "Closed zero ",
providerMetadata: { "openai-compatible": { itemId: "rs_1" } },
},
{
type: "reasoning-end",
id: "rs_1:2",
text: "Final two",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "final-state" } },
},
])
}),
)
it.effect("retains summary indexes and neighboring finals around an unfamiliar summary part", () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Draft" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Cached zero" },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
encrypted_content: "final-state",
summary: [
{ type: "summary_text", text: "Final zero " },
{ type: "other", text: { value: "unfamiliar" } },
{ type: "summary_text", text: "Final two" },
],
},
},
completed,
])
expect(response.reasoning).toBe("Final zero Final two")
expect(response.events.filter(LLMEvent.is.reasoningStart).map((event) => event.id)).toEqual(["rs_1:0", "rs_1:2"])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toMatchObject([
{ id: "rs_1:0", text: "Final zero " },
{
id: "rs_1:2",
text: "Final two",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "final-state" } },
},
])
}),
)
it.effect("uses a part-only summary final without any streamed reasoning deltas", () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{
type: "response.reasoning_summary_part.done",
item_id: "rs_1",
summary_index: 0,
part: { type: "summary_text", text: "Only final" },
},
completed,
])
expect(response.reasoning).toBe("Only final")
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toMatchObject([{ id: "rs_1:0", text: "Only final" }])
}),
)
it.effect("closes cached summary finals at implicit boundaries while retaining unsnapshotted indexes", () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Draft" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "First " },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "second " },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "fallback" },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "final-state" },
},
completed,
])
expect(response.reasoning).toBe("First second fallback")
expect(
response.events.filter(LLMEvent.is.reasoningEnd).map((event) => ({ id: event.id, text: event.text })),
).toEqual([
{ id: "rs_1:0", text: "First " },
{ id: "rs_1:1", text: "second " },
{ id: "rs_1:2", text: undefined },
])
}),
)
})
describe("Open Responses terminal finals", () => {
it.effect("recovers tracked messages with no deltas from completed output", () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{
type: "response.completed",
response: {
output: [{ type: "message", id: "msg_1", content: [{ type: "output_text", text: "Terminal only" }] }],
},
},
])
expect(response.text).toBe("Terminal only")
expect(response.events.filter(LLMEvent.is.textEnd)).toMatchObject([{ id: "msg_1", text: "Terminal only" }])
}),
)
;[
{ name: "compatible", model, key: "openai-compatible" },
{ name: "OpenAI", model: OpenAI.configure({ apiKey: "test-key" }).responses("gpt-5"), key: "openai" },
{ name: "xAI", model: XAI.configure({ apiKey: "test-key" }).responses("grok-4.6"), key: "xai" },
].forEach((provider) => {
it.effect(`reconciles tracked text and reasoning from ${provider.name} completed output`, () =>
Effect.gen(function* () {
const response = yield* collect(
[
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Reasoning draft" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "Cached reasoning" },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Text draft" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Cached text" },
{
type: "response.completed",
response: {
id: "resp_1",
output: [
{ type: "reasoning", id: "rs_unknown", summary: [{ type: "summary_text", text: "Untracked" }] },
{ type: "message", id: "msg_unknown", content: [{ type: "output_text", text: "Untracked" }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "terminal-state",
summary: [{ type: "summary_text", text: "Final reasoning" }],
},
{
type: "message",
id: "msg_1",
phase: "final_answer",
content: [{ type: "output_text", text: "Final text" }],
},
],
},
},
],
provider.model,
)
expect(response.text).toBe("Final text")
expect(response.reasoning).toBe("Final reasoning")
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "text-end",
id: "msg_1",
text: "Final text",
providerMetadata: { [provider.key]: { itemId: "msg_1", phase: "final_answer" } },
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
{
type: "reasoning-end",
id: "rs_1:0",
text: "Final reasoning",
providerMetadata: { [provider.key]: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
},
])
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
[provider.key]: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" },
})
expect(response.message.content.find((part) => part.type === "text")?.providerMetadata).toEqual({
[provider.key]: { itemId: "msg_1", phase: "final_answer" },
})
}),
)
})
;["response.completed", "response.incomplete"].forEach((terminal) => {
;[false, true].forEach((finals) => {
it.effect(`flushes ${finals ? "cached finals" : "delta fallback"} on ${terminal}`, () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Reasoning draft" },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Text draft" },
...(finals
? [
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "Reasoning final" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Text final" },
]
: []),
{
type: terminal,
response: {
id: "resp_1",
...(terminal === "response.incomplete"
? {
incomplete_details: { reason: "max_output_tokens" },
output: [
{ type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "Not reconciled" }] },
{ type: "message", id: "msg_1", content: [{ type: "output_text", text: "Not reconciled" }] },
],
}
: {}),
},
},
])
expect(response.text).toBe(finals ? "Text final" : "Text draft")
expect(response.reasoning).toBe(finals ? "Reasoning final" : "Reasoning draft")
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.text)).toEqual([
finals ? "Text final" : undefined,
])
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([
finals ? "Reasoning final" : undefined,
])
expect(response.events.filter(LLMEvent.is.finish).map((event) => event.reason.normalized)).toEqual([
terminal === "response.incomplete" ? "length" : "stop",
])
}),
)
})
})
it.effect("does not reopen closed text or reasoning from completed output", () =>
Effect.gen(function* () {
const response = yield* collect([
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Original reasoning" },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Original text" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{
type: "response.completed",
response: {
output: [
{ type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "Late reasoning" }] },
{ type: "message", id: "msg_1", content: [{ type: "output_text", text: "Late text" }] },
],
},
},
])
expect(response.text).toBe("Original text")
expect(response.reasoning).toBe("Original reasoning")
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
}),
)
})
@@ -0,0 +1,480 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { LLMClient } from "../../src/route.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const request = LLM.request({
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
prompt: "Respond.",
})
const completed = { type: "response.completed", response: { id: "resp_1" } }
const collect = (...input: OpenResponses.Event[]) =>
Effect.gen(function* () {
const events = yield* LLMClient.stream(request).pipe(
Stream.runCollect,
Effect.provide(fixedResponse(sseEvents(...input))),
)
expectLifecycle(
events,
input.some((event) => event.type === "response.completed"),
)
return events
})
// Deliberately local to these basic-item fixtures, not a general stream validator.
function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
const active = { text: new Set<string>(), reasoning: new Set<string>() }
const tools = new Map<string, "started" | "ended" | "called">()
events.forEach((event) => {
if (event.type === "text-start" || event.type === "reasoning-start") {
const blocks = event.type === "text-start" ? active.text : active.reasoning
expect(blocks.size).toBe(0)
blocks.add(event.id)
}
if (event.type === "text-delta" || event.type === "reasoning-delta") {
expect((event.type === "text-delta" ? active.text : active.reasoning).has(event.id)).toBe(true)
}
if (event.type === "text-end" || event.type === "reasoning-end") {
expect((event.type === "text-end" ? active.text : active.reasoning).delete(event.id)).toBe(true)
}
if (event.type === "tool-input-start") {
expect(tools.has(event.id)).toBe(false)
tools.set(event.id, "started")
}
if (event.type === "tool-input-delta") expect(tools.get(event.id)).toBe("started")
if (event.type === "tool-input-end") {
expect(tools.get(event.id)).toBe("started")
tools.set(event.id, "ended")
}
if (event.type === "tool-call") {
expect(tools.get(event.id)).toBe("ended")
tools.set(event.id, "called")
}
// Incomplete responses may leave pending tool inputs without a call.
if (event.type === "finish" && completed) {
expect(active.text.size).toBe(0)
expect(active.reasoning.size).toBe(0)
expect([...tools.values()].every((status) => status === "called")).toBe(true)
}
})
expect(events.filter(LLMEvent.is.stepStart)).toHaveLength(1)
expect(events[0]?.type).toBe("step-start")
expect(events.filter(LLMEvent.is.stepFinish)).toHaveLength(1)
expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
expect(events.slice(-2).map((event) => event.type)).toEqual(["step-finish", "finish"])
}
describe("Open Responses basic-item lifecycles", () => {
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
Effect.gen(function* () {
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
const events = yield* collect(
{ type: "response.output_item.added", output_index: 0, item: { ...item, encrypted_content: null } },
{ type: "response.output_item.added", item: { ...item, encrypted_content: null } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "Second" },
// The third part omits both explicit summary boundaries.
{
type: "response.reasoning_summary_text.delta",
output_index: 0,
item_id: "wrong",
summary_index: 2,
delta: "Third",
},
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
completed,
)
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{
type: "reasoning-end",
id: "rs_1:1",
text: "Second",
providerMetadata: { "openai-compatible": { itemId: "rs_1" } },
},
{
type: "reasoning-start",
id: "rs_1:2",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:2", text: "Third" },
{
type: "reasoning-end",
id: "rs_1:2",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
Effect.gen(function* () {
const item = {
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Not streamed" }],
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "late" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "late final" },
completed,
// Route termination must also prevent events after response completion.
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_after" } },
)
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{
type: "reasoning-end",
id: "rs_1",
text: "Not streamed",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("forgets never-streamed messages at implicit boundaries and preserves refusal phases", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_empty" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.done", item_id: "msg_1", text: "Checking" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Duplicate" },
{ type: "response.output_item.added", item: { type: "message", id: "msg_2", phase: null } },
{ type: "response.output_text.delta", item_id: "msg_empty", delta: "stale" },
{ type: "response.output_text.done", item_id: "msg_empty", text: "stale final" },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "late" },
{ type: "response.refusal.delta", item_id: "msg_2", delta: "Cannot help." },
{ type: "response.refusal.done", item_id: "msg_2", refusal: "Cannot help." },
{ type: "response.output_item.done", item: { type: "message", id: "msg_2", phase: "final_answer" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_3", phase: null } },
{ type: "response.refusal.done", item_id: "msg_3", refusal: "Done-only refusal." },
{ type: "response.output_item.done", item: { type: "message", id: "msg_3" } },
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Checking" },
{
type: "text-end",
id: "msg_1",
text: "Checking",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{
type: "text-start",
id: "msg_2",
providerMetadata: { "openai-compatible": { itemId: "msg_2", phase: null } },
},
{ type: "text-delta", id: "msg_2", text: "Cannot help." },
{
type: "text-end",
id: "msg_2",
text: "Cannot help.",
providerMetadata: { "openai-compatible": { itemId: "msg_2", phase: "final_answer" } },
},
{
type: "text-start",
id: "msg_3",
providerMetadata: { "openai-compatible": { itemId: "msg_3", phase: null } },
},
{ type: "text-delta", id: "msg_3", text: "Done-only refusal." },
{
type: "text-end",
id: "msg_3",
text: "Done-only refusal.",
providerMetadata: { "openai-compatible": { itemId: "msg_3", phase: null } },
},
])
}),
)
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
completed,
)
expect(events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "text-end",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
}),
)
;[undefined, "fc_1"].forEach((id) => {
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
Effect.gen(function* () {
const item = {
type: "function_call",
...(id === undefined ? {} : { id }),
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
const first = { ...item, ...(id === undefined ? {} : { id }) }
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
{ type: "response.output_item.added", item: duplicate },
{ type: "response.output_item.done", item: duplicate },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: duplicate },
completed,
)
// Identity metadata comes from the first admission, not the duplicate.
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
}),
)
})
it.effect("recovers pending items in completed output order with terminal encrypted metadata", () =>
Effect.gen(function* () {
const events = yield* collect(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"draft"}' },
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.completed",
response: {
id: "resp_1",
output: [
{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" },
{ type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: '{"query":"final"}' },
{ type: "function_call", id: "fc_unseen", call_id: "call_unseen", name: "lookup", arguments: "{}" },
],
},
},
)
expect(events.slice(5, -2)).toEqual([
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
},
{
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
},
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: { query: "final" },
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
},
])
}),
)
it.effect("preserves call identity and pending order when an item id is reused", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
{ type: "response.output_item.done", item: first },
completed,
)
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
}),
)
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "reasoning", id: "" } },
{ type: "response.output_item.added", item: { type: "message", id: "" } },
{ type: "response.output_item.added", item: { type: "reasoning", id: "" } },
{ type: "response.reasoning_summary_text.delta", item_id: "", delta: "Thinking" },
{ type: "response.output_text.delta", item_id: "", delta: "Answer" },
{ type: "response.output_item.done", item: { type: "reasoning", id: "", encrypted_content: "state" } },
{ type: "response.output_item.done", item: { type: "message", id: "" } },
completed,
)
expect(events.filter(LLMEvent.is.reasoningDelta).map((event) => event.text)).toEqual(["Thinking"])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["Answer"])
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([
{
type: "reasoning-end",
id: ":0",
providerMetadata: { "openai-compatible": { itemId: "", reasoningEncryptedContent: "state" } },
},
])
}),
)
it.effect("does not recover a completed tool from a tracked message with the same id", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "item_1" } },
{ type: "response.output_text.delta", item_id: "item_1", delta: "Answer" },
{
type: "response.completed",
response: {
id: "resp_1",
output: [{ type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "{}" }],
},
},
)
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(events.filter(LLMEvent.is.finish).map((event) => event.reason.normalized)).toEqual(["stop"])
}),
)
it.effect("flushes pending calls and open text when completed output is absent", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "final_answer" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
},
completed,
)
// Generic terminal closure does not repeat the message's phase metadata.
expect(events.slice(4, -2)).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "text-end", id: "msg_1" },
])
}),
)
it.effect("does not reconcile pending calls or terminal reasoning metadata on incomplete responses", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Partial" },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":' },
{
type: "response.incomplete",
response: {
id: "resp_1",
incomplete_details: { reason: "max_output_tokens" },
output: [
{ type: "reasoning", id: "rs_1", encrypted_content: "not-reconciled" },
{
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"not-reconciled"}',
},
],
},
},
)
expect(events.filter(LLMEvent.is.toolInputEnd)).toEqual([])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: "rs_1:0" }])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "length", raw: "max_output_tokens" },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
})
@@ -2599,6 +2599,7 @@ describe("OpenAI Responses route", () => {
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
text: "Checked the diff.",
},
])
expect(response.message.content).toContainEqual({
@@ -2750,6 +2751,227 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("concludes reasoning at implicit summary boundaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
// The next part is enough to conclude the previous one even when
// its done event is delayed.
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
// Some compatible providers begin the next part with its first delta.
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "Third" },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("FirstSecondThird")
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First", providerMetadata: undefined },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{
type: "reasoning-start",
id: "rs_1:2",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:2", text: "Third", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:2",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("rejects a reasoning item that starts before the previous item ends", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_2" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("started reasoning before the previous item ended")
}),
)
it.effect("concludes text at implicit message boundaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
// An item that never streams text is untracked at the boundary too.
{ type: "response.output_item.added", item: { type: "message", id: "msg_0" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
// The previous message's done event is missing; the next message
// item is the boundary for its open text.
{ type: "response.output_item.added", item: { type: "message", id: "msg_2" } },
// Late deltas for concluded or untracked messages must stay no-ops.
{ type: "response.output_text.delta", item_id: "msg_1", delta: " late" },
{ type: "response.output_text.delta", item_id: "msg_0", delta: " stale" },
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("FirstSecond")
expect(response.events.filter((event) => event.type.startsWith("text-"))).toMatchObject([
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1" },
{ type: "text-start", id: "msg_2" },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2" },
])
}),
)
it.effect("opens the tool lifecycle for a done-only function call", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
// No output_item.added: the call arrives only as a completed item.
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type.startsWith("tool-"))).toMatchObject([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
])
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)
it.effect("ignores duplicate item boundary events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
// Duplicate added for a known item is not overlap and must no-op.
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Think" },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"weather"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
// Duplicates that drop the item id still resolve the same call.
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Think")
expect(response.events.filter((event) => event.type === "reasoning-start")).toHaveLength(1)
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
expect(response.events.filter((event) => event.type === "tool-input-start")).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
])
}),
)
it.effect("reconciles reasoning summaries that arrive only as finals", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -2798,6 +3020,7 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning-end",
id: "rs_1:0",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
+54
View File
@@ -104,6 +104,60 @@ describe("LLMResponse reducer", () => {
])
})
test("authoritative text-end value replaces accumulated deltas", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "t1" }),
LLMEvent.textDelta({ id: "t1", text: "Hel" }),
LLMEvent.textEnd({ id: "t1", text: "Hello!" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([{ type: "text", text: "Hello!" }])
expect(response?.text).toBe("Hello!")
})
test("text-end without value keeps joined deltas", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "t1" }),
LLMEvent.textDelta({ id: "t1", text: "Hel" }),
LLMEvent.textDelta({ id: "t1", text: "lo" }),
LLMEvent.textEnd({ id: "t1" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([{ type: "text", text: "Hello" }])
expect(response?.text).toBe("Hello")
})
test("authoritative reasoning-end value replaces only its own fragment", () => {
const response = LLMResponse.fromEvents([
LLMEvent.reasoningStart({ id: "r1:0" }),
LLMEvent.reasoningDelta({ id: "r1:0", text: "First summ" }),
LLMEvent.reasoningEnd({ id: "r1:0", text: "First summary." }),
LLMEvent.reasoningStart({ id: "r1:1" }),
LLMEvent.reasoningDelta({ id: "r1:1", text: "Second summary." }),
LLMEvent.reasoningEnd({ id: "r1:1" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([
{ type: "reasoning", text: "First summary." },
{ type: "reasoning", text: "Second summary." },
])
expect(response?.reasoning).toBe("First summary.Second summary.")
})
test("end value recovers a fragment that streamed no deltas", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "t1" }),
LLMEvent.textEnd({ id: "t1", text: "Hello!" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([{ type: "text", text: "Hello!" }])
expect(response?.text).toBe("Hello!")
})
test("clears malformed tool input without appending an executable call", () => {
const state = reduce([
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
+1 -1
View File
@@ -250,7 +250,7 @@ test("AI error reasons are tagged Errors with required messages", () => {
provider: model.provider,
model: model.id,
}),
new AuthenticationError({ message: "Missing credentials", kind: "missing" }),
new AuthenticationError({ message: "Missing credentials" }),
new RateLimitError({ message: "Rate limited" }),
new QuotaExceededError({ message: "Quota exceeded" }),
new ContentPolicyError({ message: "Content blocked" }),
@@ -84,15 +84,12 @@ export default Runtime.handler(Commands, (input) =>
update: (update) => runPromise(config.update(update)),
},
packages: {
resolve: (spec, install = true, revision) =>
resolve: (spec, install = true) =>
runPromise(
install
? npm.add(spec, { subpaths: ["tui"], revision })
: npm.resolve(spec, { subpaths: ["tui"], revision }),
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
check: (spec) => runPromise(npm.check(spec)),
update: (spec) => runPromise(npm.update(spec, { subpaths: ["tui"] })),
reload: (spec, options) => runPromise(npm.reload(spec, { subpaths: ["tui"], ...options })),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
-24
View File
@@ -88,32 +88,8 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginCheckInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: Plugin.PackageStatus }
export type PluginCheckOperation<E = never> = (input: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
export type PluginUpdateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginUpdateOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
export type PluginReloadInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginReloadOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginReloadOperation<E = never> = (input: PluginReloadInput) => Effect.Effect<PluginReloadOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly check: PluginCheckOperation<E>
readonly update: PluginUpdateOperation<E>
readonly reload: PluginReloadOperation<E>
}
export type SessionListInput = {
+1 -33
View File
@@ -15,12 +15,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
PluginReloadInput,
PluginReloadOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -322,33 +316,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input: PluginCheckInput) =>
preserveEffect<PluginCheckOutput>()(
raw["plugin.check"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginReload = (raw: RawClient["server.plugin"]) => (input: PluginReloadInput) =>
preserveEffect<PluginReloadOutput>()(
raw["plugin.reload"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
update: EndpointPluginUpdate(raw),
reload: EndpointPluginReload(raw),
})
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
@@ -9,12 +9,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
PluginReloadInput,
PluginReloadOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -466,45 +460,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
check: (input: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{
method: "POST",
path: `/api/plugin/check`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
request<PluginUpdateOutput>(
{
method: "POST",
path: `/api/plugin/update`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
reload: (input: PluginReloadInput, requestOptions?: RequestOptions) =>
request<PluginReloadOutput>(
{
method: "POST",
path: `/api/plugin/reload`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+2 -60
View File
@@ -16,8 +16,6 @@ export type PluginSource =
| { type: "local"; path: string }
| { type: "sdk" }
export type PluginPackageStatus = { installed?: string; available?: string; mutable: boolean }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
export type MoneyUSD = number
@@ -435,24 +433,8 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| {
id: string
source: PluginSource
status: "active"
tui: boolean
revision?: string
generation?: string
error?: string
}
| {
id?: string
source: PluginSource
status: "failed"
error: string
tui: boolean
revision?: string
generation?: string
}
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
export type SessionMessageLocationSwitched = {
id: string
@@ -2348,10 +2330,6 @@ export type AgentNotFoundError = {
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type PluginOperationError = { readonly _tag: "PluginOperationError"; readonly message: string }
export const isPluginOperationError = (value: unknown): value is PluginOperationError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PluginOperationError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2562,42 +2540,6 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginCheckOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: PluginPackageStatus
}
export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginUpdateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
export type PluginReloadInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginReloadOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
+59 -4
View File
@@ -612,12 +612,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
Stream.unwrap(
Effect.tryPromise({
try: () => language.doStream(options),
catch: llmError,
catch: (error) => llmError(error, "request"),
}).pipe(
Effect.map((result) =>
Stream.fromReadableStream({
evaluate: () => result.stream,
onError: llmError,
onError: (error) => llmError(error, "read"),
}).pipe(
Stream.mapEffect((event) => streamPartEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
@@ -744,7 +744,7 @@ function streamPartEvents(
}),
])
case "error":
return Effect.fail(llmError(event.error))
return Effect.fail(llmError(event.error, "read"))
}
}
@@ -794,9 +794,20 @@ function messageValue(input: unknown) {
}
}
function llmError(error: unknown) {
function llmError(error: unknown, operation: "request" | "read") {
if (error instanceof AIError) return error
if (APICallError.isInstance(error)) return apiCallError(error)
const network = networkFailure(error)
if (network)
return new AIError({
reason: new TransportError({
message: network.message.trim() === "" ? unknownErrorMessage(error) : network.message,
cause: error,
transport: "http",
operation,
code: network.code,
}),
})
return new AIError({
reason: new UnknownProviderError({
message: unknownErrorMessage(error),
@@ -806,6 +817,50 @@ function llmError(error: unknown) {
})
}
// Runtime-generated network failure shapes. The codes mirror the AI SDK's own
// Bun network error list in handleFetchError; the messages are undici's fetch
// TypeError and stream termination strings plus our SSE chunk timeout error.
// Unrecognized shapes still retry via the UnknownProvider default; this match
// only adds transport semantics (continuation eligibility, display).
const NETWORK_ERROR_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"ETIMEDOUT",
"EPIPE",
"ConnectionRefused",
"ConnectionClosed",
"FailedToOpenSocket",
])
const NETWORK_ERROR_MESSAGES = new Set([
"fetch failed",
"failed to fetch",
"terminated",
"other side closed",
"sse read timed out",
])
const NativeErrorShape = Schema.Struct({
message: Schema.String,
code: Schema.optionalKey(Schema.String),
cause: Schema.optionalKey(Schema.Unknown),
})
const decodeNativeErrorShape = Schema.decodeUnknownOption(NativeErrorShape)
function networkFailure(error: unknown, depth = 0): { message: string; code?: string } | undefined {
if (depth > 4) return undefined
const shape = Option.getOrUndefined(decodeNativeErrorShape(error))
if (!shape) return undefined
// Prefer the deepest match: wrappers like undici's "fetch failed" TypeError
// carry the specific network code on their cause.
const cause = networkFailure(shape.cause, depth + 1)
if (cause) return cause
if (shape.code !== undefined && (NETWORK_ERROR_CODES.has(shape.code) || shape.code.startsWith("UND_ERR")))
return { message: shape.message, code: shape.code }
if (NETWORK_ERROR_MESSAGES.has(shape.message.trim().toLowerCase()))
return { message: shape.message, code: shape.code }
return undefined
}
function apiCallError(error: APICallError) {
const failure = RequestExecutor.httpFailure({
message: providerErrorMessage(error),
-2
View File
@@ -23,7 +23,6 @@ import { Mcp } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
import { PluginExecution } from "./plugin/execution.js"
import { InstancePlugins } from "./plugin/instance.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { Worktree } from "./worktree.js"
@@ -68,7 +67,6 @@ const nodes = [
AISDK.node,
Plugin.node,
PluginHooks.node,
PluginExecution.node,
InstancePlugins.node,
PluginSupervisor.node,
Worktree.refreshNode,
+44 -107
View File
@@ -1,5 +1,5 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source, PackageStatus, OperationError } from "@opencode-ai/schema/plugin"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
@@ -26,7 +26,6 @@ import { Vcs } from "./vcs.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Generate } from "./generate.js"
import { Permission } from "./permission.js"
import { PluginExecution } from "./plugin/execution.js"
export interface Interface {
readonly activate: (
@@ -39,8 +38,6 @@ export interface Interface {
export type Versioned = PluginDefinition & {
readonly version: string
readonly source?: Plugin.Source
readonly revision?: string
readonly generation?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
@@ -50,12 +47,8 @@ const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const kv = yield* KV.Service
const execution = yield* PluginExecution.Service
const scope = yield* Scope.make()
const active = new Map<
Plugin.ID,
{ readonly plugin: Versioned; readonly scope: Scope.Closeable; readonly error?: string }
>()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<PluginDefinition["effect"]>[0]
@@ -82,7 +75,7 @@ const layer = Layer.effect(
"plugin.id": plugin.id,
cause: loaded.cause,
})
return { error: errorMessage(loaded.cause) } as const
return { error: Cause.pretty(loaded.cause) } as const
})
const activate = Effect.fn("Plugin.activate")(function* (
@@ -105,84 +98,55 @@ const layer = Layer.effect(
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
})
) {
const nextInventory = withFailures(
Array.from(active.values(), (entry) => activeInfo(entry.plugin, entry.error)),
failures,
)
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return
}
yield* execution.exclusive(
State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
// Registrations are append-ordered. Preserve the unchanged prefix, then rebuild the suffix.
const current = Array.from(active.values(), (entry) => entry.plugin)
const prefix = definitions.findIndex(
(definition, index) =>
definition.id !== current[index]?.id || definition.version !== current[index]?.version,
)
const keep = prefix === -1 ? definitions.length : prefix
const previousEntries = new Map(active)
const closing = Array.from(active.entries()).slice(keep).toReversed()
yield* Effect.forEach(
closing,
([id, entry]) =>
Effect.gen(function* () {
yield* Scope.close(entry.scope, Exit.void).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to clean up plugin", { "plugin.id": id, cause }),
),
)
active.delete(id)
}),
{ discard: true },
)
for (const [index, definition] of definitions.entries()) {
if (index < keep) {
nextInventory.push(activeInfo(definition, active.get(definition.id)?.error))
continue
}
const previous = previousEntries.get(definition.id)
yield* State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
const failed: Plugin.Info = {
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
...(definition.revision === undefined ? {} : { revision: definition.revision }),
...(definition.generation === undefined ? {} : { generation: definition.generation }),
}
if (!previous) {
nextInventory.push(failed)
continue
}
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope, error: loaded.error })
nextInventory.push({ ...activeInfo(previous.plugin), error: loaded.error })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
"plugin.id": definition.id,
})
nextInventory.push(failed)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
})
inventory = withFailures(nextInventory, failures)
}),
).pipe(Effect.uninterruptible),
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
"plugin.id": definition.id,
})
}
const removed = Array.from(active.entries())
.filter(([id]) => !ids.has(id))
.toReversed()
removed.forEach(([id]) => active.delete(id))
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
discard: true,
})
inventory = [...nextInventory, ...failures]
}),
)
yield* bus.publish(Plugin.Event.Updated, {})
}),
@@ -207,41 +171,15 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Versioned, error?: string): Plugin.Info {
function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
status: "active",
tui: plugin.tui ?? false,
...(plugin.revision === undefined ? {} : { revision: plugin.revision }),
...(plugin.generation === undefined ? {} : { generation: plugin.generation }),
...(error === undefined ? {} : { error }),
}
}
/** Inventory errors must not expose credentials or unbounded import/setup traces. */
export function errorMessage(cause: Cause.Cause<unknown>) {
return Cause.pretty(cause)
.replace(/https?:\/\/[^\s"'<>]+/g, (value) => {
if (!URL.canParse(value)) return "[URL]"
const url = new URL(value)
url.username = ""
url.password = ""
url.search = ""
url.hash = ""
return url.href
})
.slice(0, 2000)
}
function withFailures(active: Plugin.Info[], failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[]) {
return failures.reduce((inventory, failure) => {
const index = inventory.findIndex((entry) => JSON.stringify(entry.source) === JSON.stringify(failure.source))
if (index === -1) return [...inventory, failure]
return inventory.map((entry, current) => (current === index ? { ...entry, error: failure.error } : entry))
}, active)
}
export const node = makeLocationNode({
service: Service,
layer,
@@ -265,6 +203,5 @@ export const node = makeLocationNode({
WebSearch.node,
Generate.node,
Permission.node,
PluginExecution.node,
],
})
-74
View File
@@ -1,74 +0,0 @@
export * as PluginExecution from "./execution.js"
import { Context, Effect, Latch, Layer, Semaphore } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
export interface Interface {
readonly lease: <A, E, R>(
session: { readonly id: string; readonly parentID?: string; readonly admission?: boolean },
effect: Effect.Effect<A, E, R>,
) => Effect.Effect<A, E, R>
readonly exclusive: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginExecution") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const admission = yield* Latch.make(true)
const idle = yield* Latch.make(true)
const mutations = yield* Semaphore.make(1)
const readers = new Map<string, number>()
let leases = 0
const acquire = (session: {
readonly id: string
readonly parentID?: string
readonly admission?: boolean
}): Effect.Effect<void> =>
Effect.suspend(() => {
// Child execution must finish the parent's tool call even while activation waits.
if (
!admission.isOpen() &&
!readers.has(session.id) &&
!(session.parentID && readers.has(session.parentID)) &&
!(session.admission && leases > 0)
)
return Effect.interruptible(admission.await).pipe(Effect.andThen(acquire(session)))
// Admission protects callbacks but does not establish Session execution ownership.
if (!session.admission) readers.set(session.id, (readers.get(session.id) ?? 0) + 1)
leases++
return idle.close
})
return Service.of({
lease: (session, effect) =>
Effect.acquireUseRelease(
acquire(session),
() => effect,
() =>
Effect.suspend(() => {
leases--
if (!session.admission) {
const count = (readers.get(session.id) ?? 1) - 1
if (count === 0) readers.delete(session.id)
else readers.set(session.id, count)
}
return leases === 0 ? idle.open : Effect.void
}),
),
exclusive: (effect) =>
mutations.withPermit(
Effect.uninterruptibleMask((restore) =>
admission.close.pipe(
Effect.andThen(restore(idle.await).pipe(Effect.andThen(restore(effect)))),
Effect.ensuring(admission.open),
),
),
),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
-5
View File
@@ -4,7 +4,6 @@ import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { OperationError } from "@opencode-ai/schema/plugin"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -328,10 +327,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
},
plugin: {
list: () => response(plugin.list()),
// In-process management can wait on its own setup/tool lease. Use an external client instead.
check: () => Effect.fail(new OperationError({ message: "Check plugin updates through an external client." })),
update: () => Effect.fail(new OperationError({ message: "Update plugins through an external client." })),
reload: () => Effect.fail(new OperationError({ message: "Reload plugins through an external client." })),
},
reference: {
list: () => response(reference.list()),
+6 -15
View File
@@ -36,22 +36,15 @@ const Definition = Schema.Struct({
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
fresh?: Npm.EntryPoint,
) {
const npm = yield* Npm.Service
const resolved =
fresh ??
(path.isAbsolute(operation.target)
? { entrypoint: pathToFileURL(operation.target).href, revision: undefined, generation: undefined }
: yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true }))
const entrypoint = resolved.entrypoint
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target =
typeof Bun !== "undefined" && path.isAbsolute(operation.target)
? operation.target.replaceAll("\\", "/")
: entrypoint
const source = fresh || operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
@@ -60,9 +53,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
id: plugin.id,
tui: plugin.tui,
vcs: plugin.vcs,
version: JSON.stringify([operation, resolved.generation]),
revision: resolved.revision,
generation: resolved.generation,
version: JSON.stringify(operation),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: { type: "package" as const, package: operation.target },
@@ -1,7 +1,6 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect } from "effect"
import type { Plugin } from "@opencode-ai/schema/plugin"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
@@ -10,11 +9,6 @@ import type { Plugin } from "@opencode-ai/schema/plugin"
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
/** Initial reconciliation barrier; it never closes again after startup settles. */
readonly initialized: Effect.Effect<void>
readonly check: (target: string) => Effect.Effect<Plugin.PackageStatus, Plugin.OperationError>
readonly update: (target: string) => Effect.Effect<Plugin.Info[], Plugin.OperationError>
readonly reload: (target: string) => Effect.Effect<Plugin.Info[], Plugin.OperationError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+19 -203
View File
@@ -2,10 +2,8 @@ export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Option, Scope, Semaphore, Stream } from "effect"
import { Cause, Effect, Latch, Layer, Stream } from "effect"
import path from "path"
import { randomUUID } from "node:crypto"
import { pathToFileURL } from "node:url"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
@@ -17,13 +15,10 @@ import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
import { Service } from "./supervisor-service.js"
type Cached = { readonly operation: string; readonly plugin: Plugin.Versioned; readonly error?: string }
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
operations: readonly ConfigPluginSource.Operation[],
cache: Map<string, Cached>,
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
@@ -31,7 +26,6 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
const previousSources = new Map(cache)
const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) {
@@ -54,23 +48,13 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const cached = previousSources.get(operation.target)
const key = JSON.stringify(operation)
const plugin =
cached?.operation === key
? cached.plugin
: yield* PluginModule.load(operation).pipe(
Effect.flatMap((plugin) =>
cached && plugin.id !== cached.plugin.id
? Effect.fail(new Error(`Plugin ID changed from ${cached.plugin.id} to ${plugin.id}`))
: Effect.succeed(plugin),
),
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Plugin.errorMessage(cause) }),
),
),
)
const plugin = yield* PluginModule.load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
@@ -78,24 +62,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
error: plugin.error,
tui: false,
})
if (cached) {
cache.set(operation.target, { ...cached, operation: key, error: plugin.error })
packages.set(operation.target, cached.plugin)
enabled.add(cached.plugin.id)
}
continue
}
if (cached?.operation === key && cached.error) {
failures.set(operation.target, {
source: pluginSource(operation.target),
status: "failed",
error: cached.error,
tui: plugin.tui ?? false,
})
} else {
failures.delete(operation.target)
cache.set(operation.target, { operation: key, plugin })
}
failures.delete(operation.target)
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
packages.set(operation.target, plugin)
@@ -108,12 +77,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.entries()]
.filter(([target]) => {
const plugin = packages.get(target)
return !plugin || enabled.has(plugin.id)
})
.map(([, failure]) => failure),
failures: [...failures.values()],
}
})
@@ -125,20 +89,12 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const scope = yield* Scope.Scope
const ready = yield* Latch.make()
const initialized = yield* Latch.make()
const mutations = yield* Semaphore.make(1)
const cache = new Map<string, Cached>()
// Internal definitions capture stable Location services; SDK contributions remain dynamic.
const internal = yield* PluginInternal.list()
let observed = 0
const activate = Effect.fn("PluginSupervisor.activate")(function* (
operations: readonly ConfigPluginSource.Operation[],
previous: ReadonlyMap<string, Cached> = new Map(cache),
) {
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed plugins in boot order.
// Instance-bound plugins come last: later activation can override earlier
// container writes, so the instance's explicit choices win over globals.
@@ -152,117 +108,12 @@ export const layer = Layer.effect(
version: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const resolved = yield* resolve(pre, post, operations, cache).pipe(Effect.provideService(Npm.Service, npm))
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
const activated = yield* registry.activate(resolved.plugins, resolved.failures).pipe(Effect.exit)
if (activated._tag === "Failure") {
cache.clear()
previous.forEach((entry, target) => cache.set(target, entry))
return yield* Effect.failCause(activated.cause)
}
const inventory = yield* registry.list()
for (const [target, entry] of cache) {
if (!operations.some((operation) => operation.type === "add" && operation.target === target)) {
cache.delete(target)
continue
}
const active = inventory.find((item) =>
item.source.type === "local"
? item.source.path === target
: item.source.type === "package" && item.source.package === target,
)
if (active?.status !== "active" || !active.error) continue
const old = previous.get(target)
if (old) cache.set(target, { ...old, operation: entry.operation, error: active.error })
}
return inventory
yield* registry.activate(resolved.plugins, resolved.failures)
})
const operation = Effect.fnUntraced(function* (target: string) {
const operations = yield* sources.operations().pipe(Effect.provideService(Scope.Scope, scope))
const entry = operations.findLast((entry) => entry.type === "add" && entry.target === target)
const inventory = yield* registry.list()
if (
!entry ||
entry.type !== "add" ||
target === "*" ||
target.endsWith(".*") ||
target.startsWith("opencode.") ||
(!cache.has(target) &&
!inventory.some((item) =>
item.source.type === "local"
? item.source.path === target
: item.source.type === "package" && item.source.package === target,
))
)
return yield* new Plugin.OperationError({ message: `Not a configured plugin source: ${target}` })
return { entry, operations }
})
const mutate = (target: string, action: "update" | "reload") =>
initialized.await.pipe(
Effect.andThen(
mutations.withPermit(
Effect.gen(function* () {
const selected = yield* operation(target)
if (action === "update") {
if (path.isAbsolute(target) || !(yield* npm.inspect(target)).mutable)
return yield* new Plugin.OperationError({ message: `Plugin source cannot be updated: ${target}` })
}
const previous = new Map(cache)
const generation = randomUUID()
const staged = path.isAbsolute(target)
? {
directory: path.dirname(target),
generation,
entrypoint: `${typeof Bun !== "undefined" ? target.replaceAll("\\", "/") : pathToFileURL(target).href}?reload=${generation}`,
}
: yield* action === "update"
? npm.update(target, { subpaths: ["server", ""] })
: npm.reload(target, {
subpaths: ["server", ""],
revision: cache.get(target)?.plugin.revision,
generation: cache.get(target)?.plugin.generation,
})
const loaded = yield* PluginModule.load(selected.entry, staged).pipe(
Effect.provideService(Npm.Service, npm),
Effect.exit,
)
if (loaded._tag === "Failure") {
const old = cache.get(target)
if (!old) return yield* Effect.failCause(loaded.cause)
cache.set(target, {
...old,
operation: JSON.stringify(selected.entry),
error: Plugin.errorMessage(loaded.cause),
})
return yield* activate(selected.operations, previous)
}
const old = cache.get(target)
if (old && loaded.value.id !== old.plugin.id) {
cache.set(target, {
...old,
operation: JSON.stringify(selected.entry),
error: `Plugin ID changed from ${old.plugin.id} to ${loaded.value.id}`,
})
return yield* activate(selected.operations, previous)
}
cache.set(target, { operation: JSON.stringify(selected.entry), plugin: loaded.value })
return yield* activate(selected.operations, previous)
}),
),
),
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
return Effect.fail(
error instanceof Plugin.OperationError
? error
: new Plugin.OperationError({ message: `Unable to ${action} plugin source.` }),
)
}),
)
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
@@ -279,48 +130,13 @@ export const layer = Layer.effect(
Stream.debounce("100 millis"),
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* mutations
.withPermit(
Effect.gen(function* () {
return yield* activate(yield* sources.operations().pipe(Effect.provideService(Scope.Scope, scope)))
}),
)
.pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
if (observed === target) {
yield* initialized.open
yield* ready.open
}
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
if (observed === target) yield* ready.open
}),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({
flush: ready.await,
initialized: initialized.await,
check: (target) =>
initialized.await.pipe(
Effect.andThen(
mutations.withPermit(
Effect.gen(function* () {
yield* operation(target)
if (path.isAbsolute(target)) return { mutable: false }
return yield* npm.check(target)
}),
),
),
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
return Effect.fail(
error instanceof Plugin.OperationError
? error
: new Plugin.OperationError({ message: "Unable to check plugin source." }),
)
}),
),
update: (target) => mutate(target, "update"),
reload: (target) => mutate(target, "reload"),
})
return Service.of({ flush: ready.await })
}),
)
+86 -114
View File
@@ -41,7 +41,6 @@ import { Session } from "@opencode-ai/schema/session"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "./image.js"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import { PluginExecution } from "./plugin/execution.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Mime } from "./mime.js"
import type { EventLog } from "@opencode-ai/schema/event-log"
@@ -691,39 +690,30 @@ const layer = Layer.effect(
),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const generate = yield* SessionGenerate.Service
return yield* gate.lease({ id: session.id, admission: true }, generate.generate(input))
}).pipe(Effect.provide(locations.get(session.location)))
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* generate.generate(input)
}),
command: Effect.fn("Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
yield* Effect.gen(function* () {
const commands = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const commands = yield* Command.Service
// Commands may await child Sessions, so they retain ownership that lets those children drain.
return yield* gate.lease(
session,
commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: {
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
},
delivery: input.delivery ?? "steer",
},
}),
)
yield* plugins.flush
return yield* Command.Service
}).pipe(Effect.provide(locations.get(session.location)))
const delivery = input.delivery ?? "steer"
yield* commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: {
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
},
delivery,
},
})
}),
shell: Effect.fn("Session.shell")(function* (input) {
const session = yield* result.get(input.sessionID)
@@ -733,20 +723,16 @@ const layer = Layer.effect(
yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
yield* plugins.flush
const shell = yield* Shell.Service
return yield* gate.lease(
{ id: input.sessionID, admission: true },
shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie),
)
return yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie)
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(
SessionEvent.Shell.Started,
@@ -978,14 +964,10 @@ const layer = Layer.effect(
return yield* new BusyError({ sessionID: input.sessionID })
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: session.id, admission: true },
SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
),
yield* plugins.flush
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
)
}).pipe(Effect.provide(locations.get(session.location)))
}),
@@ -994,12 +976,8 @@ const layer = Layer.effect(
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
const revert = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: session.id, admission: true },
SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus)),
)
yield* plugins.flush
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
}).pipe(Effect.provide(locations.get(session.location)))
yield* execution.wake(sessionID)
return revert
@@ -1041,65 +1019,59 @@ const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
messageID: SessionMessage.ID,
) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: request.sessionID, admission: true },
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID,
prompt: structuredClone({
text: request.text,
files: request.files?.slice(),
agents: request.agents?.slice(),
skills: request.skills?.slice(),
}),
metadata: structuredClone(request.metadata),
delivery: request.delivery ?? "steer",
})
const input = event.prompt
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* Skill.Service
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return SessionInbox.Item.make({
type: "user",
payload: {
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
},
delivery: event.delivery,
})
yield* plugins.flush
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID,
prompt: structuredClone({
text: request.text,
files: request.files?.slice(),
agents: request.agents?.slice(),
skills: request.skills?.slice(),
}),
)
metadata: structuredClone(request.metadata),
delivery: request.delivery ?? "steer",
})
const input = event.prompt
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* Skill.Service
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return SessionInbox.Item.make({
type: "user",
payload: {
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
},
delivery: event.delivery,
})
})
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
+1 -1
View File
@@ -125,7 +125,7 @@ const layer = Layer.effect(
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
yield* plugins.initialized
yield* plugins.flush
yield* mcpTools.flush
const agent = yield* agents.select(session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
+2 -11
View File
@@ -26,7 +26,6 @@ import { ToolOutput } from "../../tool-output.js"
import { PluginSupervisor } from "../../plugin/supervisor.js"
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
import { MAX_STEPS_PROMPT } from "./max-steps.js"
import { PluginExecution } from "../../plugin/execution.js"
const CONTINUE_AFTER_INCOMPLETE_STREAM =
"The previous response was interrupted. Continue from where you left off without repeating completed content."
@@ -41,7 +40,6 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const plugins = yield* PluginSupervisor.Service
const pluginExecution = yield* PluginExecution.Service
const title = yield* SessionTitle.Service
const steps = yield* SessionStep.make
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
@@ -91,6 +89,7 @@ const layer = Layer.effect(
const control = pending.type === "compaction" || pending.type === "move"
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
}
yield* plugins.flush
yield* settleStaleToolCalls(sessionID)
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
@@ -314,14 +313,7 @@ const layer = Layer.effect(
}
})
return Service.of({
drain: Effect.fnUntraced(function* (input) {
yield* plugins.initialized
const session = yield* store.get(input.sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${input.sessionID}`))
return yield* pluginExecution.lease(session, drain(input))
}),
})
return Service.of({ drain })
}),
)
@@ -340,6 +332,5 @@ export const node = makeLocationNode({
Snapshot.node,
ToolOutput.node,
Database.node,
PluginExecution.node,
],
})
@@ -396,7 +396,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* text.append(event.id, event.text, providerState(event.providerMetadata))
return
case "text-end":
yield* text.end(event.id, providerState(event.providerMetadata))
yield* text.end(event.id, providerState(event.providerMetadata), event.text)
return
case "reasoning-start":
outputStarted = true
@@ -412,7 +412,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* reasoning.append(event.id, event.text, providerState(event.providerMetadata))
return
case "reasoning-end":
yield* reasoning.end(event.id, providerState(event.providerMetadata))
yield* reasoning.end(event.id, providerState(event.providerMetadata), event.text)
return
case "tool-input-start":
outputStarted = true
+5 -1
View File
@@ -26,12 +26,16 @@ export function isRetryable(error: AIError) {
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
// Unrecognized failures retry: classification records affirmative
// deterministic evidence, and transient failures are exactly the ones
// that arrive in shapes no classifier anticipates.
case "UnknownProvider":
return true
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidRequest":
case "NoRoute":
case "UnknownProvider":
return false
default: {
const exhaustive: never = error.reason
+2 -8
View File
@@ -17,7 +17,6 @@ import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionUsage } from "./usage.js"
import { SessionStore } from "./store.js"
import { PluginExecution } from "../plugin/execution.js"
const MAX_LENGTH = 100
const MAX_CONTEXT_LENGTH = 8_000
@@ -166,14 +165,9 @@ export const layer = Layer.effect(
const context = yield* SessionContext.Service
const store = yield* SessionStore.Service
const database = yield* Database.Service
const gate = yield* PluginExecution.Service
const title = make({ bus, llm, context, store })
return Service.of({
generate: Effect.fnUntraced(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return
return yield* gate.lease({ id: session.id, admission: true }, title.generate(database.db, sessionID))
}),
generate: (sessionID) => title.generate(database.db, sessionID),
})
}),
)
@@ -181,5 +175,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node, PluginExecution.node],
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node],
})
+1 -1
View File
@@ -19,7 +19,7 @@ export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
const BACKGROUND_INSTRUCTION =
"You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly."
"You will be notified automatically when the command finishes. The notification will include the command's output. DO NOT run sleep commands or poll the output file to check for completion. You can read from the file when its current output would be useful, such as when inspecting logs from a background server. Otherwise, continue with other work or end your response."
const OS =
process.platform === "darwin"
? "macOS"
+51 -2
View File
@@ -757,7 +757,7 @@ it.effect("classifies data-only AI SDK authentication errors", () =>
data: { error: { code: "authentication_error" } },
}),
)
expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
}),
)
@@ -776,7 +776,7 @@ Object.entries({
responseBody,
})
const error = yield* streamFailure(cause)
expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
expect(error.reason.body).toBe(responseBody)
expect(error.reason.cause).toBe(cause)
@@ -823,6 +823,55 @@ it.effect("retries status-less AI SDK transport failures", () =>
}),
)
it.effect("classifies native fetch failures as request transport errors", () =>
Effect.gen(function* () {
const cause = Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:443"), { code: "ECONNREFUSED" }),
})
const error = yield* streamFailure(cause)
expect(error.reason).toMatchObject({
_tag: "Transport",
transport: "http",
operation: "request",
code: "ECONNREFUSED",
})
expect(error.message).toBe("connect ECONNREFUSED 127.0.0.1:443")
expect(error.reason.cause).toBe(cause)
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
}),
)
it.effect("classifies mid-stream socket drops as read transport errors", () =>
Effect.gen(function* () {
const cause = Object.assign(new Error("terminated"), {
cause: Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" }),
})
const error = yield* streamFailure(cause, true)
expect(error.reason).toMatchObject({
_tag: "Transport",
transport: "http",
operation: "read",
code: "UND_ERR_SOCKET",
})
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
}),
)
it.effect("classifies the SSE chunk timeout as a read transport error", () =>
Effect.gen(function* () {
const error = yield* streamFailure(new Error("SSE read timed out"), true)
expect(error.reason).toMatchObject({ _tag: "Transport", transport: "http", operation: "read" })
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
}),
)
it.effect("keeps unrecognized error codes on the unknown provider path", () =>
Effect.gen(function* () {
const error = yield* streamFailure(Object.assign(new Error("kaput"), { code: "E_SOMETHING_ELSE" }), true)
expect(error.reason).toBeInstanceOf(UnknownProviderError)
}),
)
it.effect("prefers a structured provider message over the code fallback", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
+1 -106
View File
@@ -19,15 +19,13 @@ import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Logger, Stream } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { git } from "../fixture/git"
import { tempGlobalLayer } from "../fixture/global"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Npm.node]), [
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
@@ -39,85 +37,6 @@ const staticIt = testEffect(
)
describe("PluginSupervisor config", () => {
it.live(
"updates a real package graph and retains its active revision after failed import and setup",
() =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const location = yield* Location.Service
const registry = yield* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
const agents = yield* Agent.Service
const npm = yield* Npm.Service
const repository = path.join(location.directory, "package")
const target = `git+${pathToFileURL(repository).href}#fixture-branch`
const initial = (yield* registry.list())[0]
expect(initial?.revision).toMatch(/^[0-9a-f]{40}$/)
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("first")
yield* Effect.promise(() => fs.writeFile(path.join(repository, "value.js"), `export const value = "second"`))
yield* commitPackage(repository)
const check = yield* supervisor.check(target)
expect(check.mutable).toBe(true)
expect(check.installed).toBe(initial?.revision)
expect(check.available).not.toBe(initial?.revision)
const updated = (yield* supervisor.update(target))[0]
expect(updated?.revision).toBe(check.available)
expect(updated?.generation).not.toBe(initial?.generation)
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("second")
yield* Effect.promise(() => fs.writeFile(path.join(repository, "index.js"), packagePlugin(true)))
yield* commitPackage(repository)
const failedSetup = (yield* supervisor.update(target))[0]
expect(failedSetup).toEqual({ ...updated, error: expect.stringContaining("package setup failed") })
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("second")
expect((yield* npm.inspect(target)).installed).not.toBe(updated?.revision)
yield* Effect.promise(() =>
fs.writeFile(path.join(repository, "index.js"), `import "./missing.js"; ${packagePlugin()}`),
)
yield* commitPackage(repository)
const failedImport = (yield* supervisor.update(target))[0]
expect(failedImport).toEqual({ ...updated, error: expect.stringContaining("missing.js") })
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("second")
const reloaded = (yield* supervisor.reload(target))[0]
expect(reloaded?.status).toBe("active")
expect(reloaded?.revision).toBe(updated?.revision)
expect(reloaded?.error).toBeUndefined()
expect(reloaded?.generation).not.toBe(updated?.generation)
}),
false,
async (directory) => {
const repository = path.join(directory, "package")
await fs.mkdir(repository)
await fs.writeFile(
path.join(repository, "package.json"),
JSON.stringify({
name: "fixture-core-plugin",
version: "1.0.0",
type: "module",
exports: "./index.js",
}),
)
await fs.writeFile(path.join(repository, "index.js"), packagePlugin())
await fs.writeFile(path.join(repository, "value.js"), `export const value = "first"`)
await git(repository, "init", "-q", "-b", "fixture-branch")
await Effect.runPromise(commitPackage(repository))
await fs.writeFile(
path.join(directory, "opencode.json"),
JSON.stringify({
plugins: [
"-*",
{ package: `git+${pathToFileURL(repository).href}#fixture-branch`, options: { selection: "first" } },
{ package: `git+${pathToFileURL(repository).href}#fixture-branch`, options: { selection: "last" } },
],
}),
)
},
),
30_000,
)
it.live("applies selectors in order", () =>
withLocation(
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
@@ -586,30 +505,6 @@ export default Plugin.define({
`
}
function packagePlugin(fail = false) {
return `import { value } from "./value.js"
export default { id: "package-plugin", tui: true, async setup(ctx) {
await ctx.agent.transform((draft) => draft.update("package-agent", (agent) => { agent.description = value }))
${fail ? 'throw new Error("package setup failed")' : ""}
} }`
}
const commitPackage = (repository: string) =>
Effect.promise(async () => {
await git(repository, "add", ".")
await git(
repository,
"-c",
"user.name=fixture",
"-c",
"user.email=fixture@example.com",
"commit",
"-q",
"-m",
"fixture",
)
})
function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
@@ -22,7 +22,6 @@ import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
import { noopPluginSupervisor } from "../fixture/plugin-supervisor"
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
@@ -32,7 +31,7 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
const configLayer = Config.testLayer()
const pluginNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor()),
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
deps: [],
})
@@ -315,7 +314,7 @@ describe("LocationWatcher subscriptions", () => {
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* policy.transform((draft) => draft.add([".git"]))
return noopPluginSupervisor()
return PluginSupervisor.Service.of({ flush: Effect.void })
}),
),
deps: [LocationWatcherPolicy.node],
@@ -1,11 +0,0 @@
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Effect } from "effect"
export const noopPluginSupervisor = (flush: Effect.Effect<unknown> = Effect.void) =>
PluginSupervisor.Service.of({
flush: Effect.asVoid(flush),
initialized: Effect.asVoid(flush),
check: () => Effect.die("unused plugin.check"),
update: () => Effect.die("unused plugin.update"),
reload: () => Effect.die("unused plugin.reload"),
})
@@ -2,20 +2,17 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { noopPluginSupervisor } from "./plugin-supervisor"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Layer, LayerMap } from "effect"
import { Effect, Layer, LayerMap } from "effect"
// Plain-prompt unit fixtures use virtual directories and need only the admission hook services.
export const promptLocationLayer = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
Layer.mergeAll(
Layer.merge(
LayerNode.compile(PluginHooks.node),
PluginExecution.layer,
Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor()),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
) as Layer.Layer<LocationServices>,
),
)
+18 -7
View File
@@ -3,23 +3,34 @@ import os from "os"
import path from "path"
import { Effect } from "effect"
type TempDir = { readonly path: string }
export const tmpdir = async (prefix = "opencode-core-test-") => {
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix)))
const dir = await make(prefix)
return {
path: dir,
async [Symbol.asyncDispose]() {
await remove(dir)
[Symbol.asyncDispose]() {
return remove(dir)
},
}
}
export const withTempDir = <A, E, R>(body: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>) =>
export const tmpdirScoped = (prefix = "opencode-core-test-") =>
Effect.acquireRelease(
Effect.tryPromise(() => make(prefix)),
(dir) => Effect.tryPromise(() => remove(dir)).pipe(Effect.orDie),
).pipe(Effect.map((path) => ({ path })))
export const withTempDir = <A, E, R>(body: (tmp: TempDir) => Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
body,
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
Effect.tryPromise(() => make("opencode-core-test-")),
(path) => body({ path }),
(dir) => Effect.tryPromise(() => remove(dir)).pipe(Effect.orDie),
)
const make = async (prefix: string) => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix)))
// Bun's callback APIs expose short paths and can hang during recursive removal on Windows.
async function remove(dir: string, retries = 30): Promise<void> {
try {
await fs.rm(dir, { recursive: true, force: true })
+11 -12
View File
@@ -304,7 +304,7 @@ describe("LocationServiceMap", () => {
),
)
itWithSdk.live("reconciles Config updates during startup without restarting unchanged plugins", () =>
itWithSdk.live("reruns activation for Config updates during startup", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -316,14 +316,18 @@ describe("LocationServiceMap", () => {
yield* Effect.promise(() => fs.writeFile(file, "{}"))
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const releaseSecond = yield* Deferred.make<void>()
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "blocked-config-reload",
effect: () =>
Effect.sync(() => ++activations.count).pipe(
Effect.andThen(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
Effect.flatMap((activation) =>
activation === 1
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
),
),
}),
@@ -351,17 +355,12 @@ describe("LocationServiceMap", () => {
Effect.provide(context),
Effect.forkChild,
)
const initialFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.initialized).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
expect(initialFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(flushFiber)
yield* Fiber.join(initialFiber)
expect(activations.count).toBe(1)
const inventory = yield* Plugin.Service.use((plugins) => plugins.list()).pipe(Effect.provide(context))
expect(inventory.map((plugin) => plugin.id)).toContain(Plugin.ID.make("config-effect-plugin"))
expect(activations.count).toBe(2)
}),
),
),
+7 -576
View File
@@ -2,12 +2,11 @@ import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { tmpdir, withTempDir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
const win = process.platform === "win32"
@@ -28,27 +27,12 @@ async function createGitFixture(directory: string) {
await fs.mkdir(path.join(repository, "dependency"), { recursive: true })
await writePackage(repository, {
name: "fixture-git-plugin",
type: "module",
exports: { ".": "./index.js", "./cjs": "./index.cjs" },
exports: "./index.js",
dependencies: { "fixture-dependency": "file:./dependency" },
})
await writePackage(path.join(repository, "dependency"), {
name: "fixture-dependency",
type: "module",
exports: { import: "./index.js", require: "./index.cjs" },
})
await Bun.write(
path.join(repository, "index.js"),
'import { dependency } from "fixture-dependency"\nimport { relative } from "./relative.js"\nexport default { root: true }\nexport { dependency, relative }\n',
)
await Bun.write(path.join(repository, "relative.js"), 'export const relative = { value: "first" }\n')
await Bun.write(path.join(repository, "dependency", "index.js"), 'export const dependency = { value: "first" }\n')
await Bun.write(
path.join(repository, "index.cjs"),
'module.exports = { relative: require("./relative.cjs"), dependency: require("fixture-dependency") }\n',
)
await Bun.write(path.join(repository, "relative.cjs"), 'module.exports = { value: "first" }\n')
await Bun.write(path.join(repository, "dependency", "index.cjs"), 'module.exports = { value: "first" }\n')
await writePackage(path.join(repository, "dependency"), { name: "fixture-dependency", exports: "./index.js" })
await Bun.write(path.join(repository, "index.js"), "export default { root: true }\n")
await Bun.write(path.join(repository, "dependency", "index.js"), "export const dependency = true\n")
const subdirectory = path.join(repository, "packages", "subdirectory-plugin")
await fs.mkdir(path.join(subdirectory, "dependency"), { recursive: true })
@@ -71,123 +55,6 @@ async function createGitFixture(directory: string) {
return { repository, commit }
}
async function advanceGitFixture(repository: string, value: string) {
await Bun.write(path.join(repository, "relative.js"), `export const relative = { value: ${JSON.stringify(value)} }\n`)
await Bun.write(
path.join(repository, "dependency", "index.js"),
`export const dependency = { value: ${JSON.stringify(value)} }\n`,
)
await Bun.write(path.join(repository, "relative.cjs"), `module.exports = { value: ${JSON.stringify(value)} }\n`)
await Bun.write(
path.join(repository, "dependency", "index.cjs"),
`module.exports = { value: ${JSON.stringify(value)} }\n`,
)
await Bun.$`git -C ${repository} add .`
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm ${value}`
return Bun.$`git -C ${repository} rev-parse HEAD`.text().then((value) => value.trim())
}
async function createRegistryFixture(directory: string) {
const tarballs = new Map<string, Uint8Array>()
for (const version of ["1.0.0", "1.1.0", "1.2.0"]) {
const root = path.join(directory, version)
const pkg = path.join(root, "package")
await fs.mkdir(pkg, { recursive: true })
await writePackage(pkg, {
name: "@fixture/registry-plugin",
version,
type: "module",
exports: { ".": "./index.js", "./tui": "./index.js" },
dependencies: { "@fixture/registry-dependency": "^1.0.0" },
})
await Bun.write(
path.join(pkg, "index.js"),
'export { relative } from "./relative.js"\nexport { dependency } from "@fixture/registry-dependency"\n',
)
await Bun.write(path.join(pkg, "relative.js"), `export const relative = { value: ${JSON.stringify(version)} }\n`)
await Bun.$`tar -czf ${path.join(root, "package.tgz")} -C ${root} package`
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
}
for (const version of ["1.0.0", "1.1.0"]) {
const root = path.join(directory, `dependency-${version}`)
const pkg = path.join(root, "package")
await fs.mkdir(pkg, { recursive: true })
await writePackage(pkg, { name: "@fixture/registry-dependency", version, type: "module", exports: "./index.js" })
await Bun.write(path.join(pkg, "index.js"), `export const dependency = { value: ${JSON.stringify(version)} }\n`)
await Bun.$`tar -czf ${path.join(root, "package.tgz")} -C ${root} package`
tarballs.set(`dependency-${version}`, await Bun.file(path.join(root, "package.tgz")).bytes())
}
const state = {
latest: "1.0.0",
dependency: "1.0.0",
requests: 0,
offline: false,
beforeTarball: () => Promise.resolve(),
}
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
state.requests++
if (state.offline) return new Response("offline", { status: 503 })
if (request.headers.get("authorization") !== "Bearer fixture-only-token")
return new Response("unauthorized", { status: 401 })
const url = new URL(request.url)
if (decodeURIComponent(url.pathname) === "/@fixture/registry-dependency")
return Response.json({
name: "@fixture/registry-dependency",
"dist-tags": { latest: state.dependency },
versions: Object.fromEntries(
["1.0.0", ...(state.dependency === "1.1.0" ? ["1.1.0"] : [])].map((version) => [
version,
{
name: "@fixture/registry-dependency",
version,
dist: { tarball: `${url.origin}/tarball/dependency-${version}.tgz` },
},
]),
),
})
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
return Response.json({
name: "@fixture/registry-plugin",
"dist-tags": { latest: state.latest },
versions: Object.fromEntries(
["1.0.0", "1.1.0", "1.2.0", "1.3.0"].map((version) => [
version,
{
name: "@fixture/registry-plugin",
version,
dependencies: { "@fixture/registry-dependency": "^1.0.0" },
dist: { tarball: `${url.origin}/tarball/${version}.tgz` },
},
]),
),
})
const tarball = tarballs.get(url.pathname.replace("/tarball/", "").replace(".tgz", ""))
if (tarball) await state.beforeTarball()
return tarball ? new Response(tarball) : new Response("missing tarball", { status: 404 })
},
})
return {
state,
async configure(cache: string, spec: string) {
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
await fs.mkdir(root, { recursive: true })
await Bun.write(
path.join(root, ".npmrc"),
`registry=${server.url}\n@fixture:registry=${server.url}\n//${server.url.host}/:_authToken=fixture-only-token\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
)
return root
},
async [Symbol.asyncDispose]() {
await server.stop(true)
},
}
}
const it = testEffect(Layer.empty)
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -336,10 +203,7 @@ describe("Npm.add", () => {
expect(
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
).toBeTruthy()
expect(entries.added.directory).toContain(
path.join("packages", await Npm.cacheKey(spec), "generations", entries.added.generation, "node_modules"),
)
expect(entries.added.revision).toBe(fixture.commit)
expect(entries.added.directory).toContain(path.join("packages", await Npm.cacheKey(spec), "node_modules"))
}
})
@@ -357,15 +221,6 @@ describe("Npm.add", () => {
expect(
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
await advanceGitFixture(fixture.repository, "second")
const exact = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(`git+file://${fixture.repository}#fixture-branch::path:packages/subdirectory-plugin`, {
revision: fixture.commit,
})
}).pipe(Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(exact.revision).toBe(fixture.commit)
expect(exact.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
})
// Several real Git installs and refreshes exceed Bun's default timeout on Windows.
@@ -438,427 +293,3 @@ describe("Npm.resolve", () => {
expect(resolved.entrypoint).toEndWith("/tui.js")
})
})
describe("Npm generations", () => {
it.live(
"loads the cached immutable graph while an updater holds the install lock",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(
Effect.promise(() => createRegistryFixture(tmp.path)),
(fixture) => Effect.promise(() => fixture[Symbol.asyncDispose]()),
)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
yield* Effect.promise(() => fixture.configure(cache, spec))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const first = yield* npm.add(spec, { refresh: true })
fixture.state.latest = "1.1.0"
fixture.state.dependency = "1.1.0"
const started = Promise.withResolvers<void>()
const resume = Promise.withResolvers<void>()
fixture.state.beforeTarball = () => {
started.resolve()
return resume.promise
}
const updating = yield* npm.update(spec).pipe(Effect.forkScoped)
yield* Effect.gen(function* () {
yield* Effect.promise(() => started.promise).pipe(Effect.timeout("5 seconds"))
const adding = yield* Effect.all([
npm.add(spec),
npm.add(spec, { revision: first.revision, subpaths: ["tui"] }),
npm.add(spec, { refresh: true }),
]).pipe(Effect.forkScoped)
// Timeout the join, not uninterruptible lock acquisition; release the updater before scoped cleanup.
expect(yield* Fiber.join(adding).pipe(Effect.timeout("5 seconds"))).toEqual([first, first, first])
expect(updating.pollUnsafe()).toBeUndefined()
}).pipe(Effect.ensuring(Effect.sync(() => resume.resolve())))
const updated = yield* Fiber.join(updating)
expect(updated.revision).toBe("1.1.0")
expect(updated.generation).not.toBe(first.generation)
expect(yield* npm.add(spec)).toEqual(updated)
const retained = yield* Effect.promise(() => import(first.entrypoint ?? ""))
expect(retained.relative.value).toBe("1.0.0")
expect(retained.dependency.value).toBe("1.0.0")
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
it.live(
"reloads the retained local generation when the same npm revision acquired a different dependency graph",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(
Effect.promise(() => createRegistryFixture(tmp.path)),
(fixture) => Effect.promise(() => fixture[Symbol.asyncDispose]()),
)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
const pinned = "@fixture/registry-plugin@1.0.0"
const root = yield* Effect.promise(() => fixture.configure(cache, spec))
yield* Effect.promise(() => fixture.configure(cache, pinned))
const entries = yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const first = yield* npm.add(spec)
const loaded = yield* Effect.promise(() => import(first.entrypoint ?? ""))
expect(loaded.dependency.value).toBe("1.0.0")
fixture.state.dependency = "1.1.0"
const second = yield* npm.update(spec)
expect(second.revision).toBe(first.revision)
expect(second.generation).not.toBe(first.generation)
expect((yield* Effect.promise(() => import(second.entrypoint ?? ""))).dependency.value).toBe("1.1.0")
expect(yield* npm.resolve(spec, { revision: first.revision })).toEqual(second)
const foreign = yield* npm.add(pinned)
return { first, second, foreign, loaded }
}).pipe(Effect.provide(npmLayer(cache)))
fixture.state.offline = true
const requests = fixture.state.requests
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
expect(yield* npm.resolve(spec)).toEqual(entries.second)
expect((yield* npm.reload(spec, { generation: "../../outside" }).pipe(Effect.flip))._tag).toBe(
"NpmInstallFailedError",
)
expect((yield* npm.reload(spec, { generation: entries.foreign.generation }).pipe(Effect.flip))._tag).toBe(
"NpmInstallFailedError",
)
expect(
(yield* npm.reload(spec, { generation: entries.first.generation, revision: "1.1.0" }).pipe(Effect.flip))
._tag,
).toBe("NpmInstallFailedError")
expect(yield* npm.resolve(spec)).toEqual(entries.second)
const reloaded = yield* npm.reload(spec, {
generation: entries.first.generation,
revision: entries.first.revision,
})
expect(reloaded.revision).toBe(entries.first.revision)
expect(reloaded.generation).not.toBe(entries.first.generation)
const fresh = yield* Effect.promise(() => import(reloaded.entrypoint ?? ""))
expect(fresh.dependency.value).toBe("1.0.0")
expect(fresh.dependency).not.toBe(entries.loaded.dependency)
expect(yield* npm.resolve(spec)).toEqual(reloaded)
expect(fixture.state.requests).toBe(requests)
expect(
yield* Effect.promise(() =>
Bun.file(path.join(root, "generations", entries.first.generation, "generation.json")).json(),
),
).toEqual({ name: "@fixture/registry-plugin", generation: entries.first.generation, revision: "1.0.0" })
expect(
yield* Effect.promise(() =>
Bun.file(path.join(path.dirname(entries.second.directory), "registry-dependency", "index.js")).text(),
),
).toContain('"1.1.0"')
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
it.live(
"defers cancellation through publication without removing the committed generation",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(
Effect.promise(() => createRegistryFixture(tmp.path)),
(fixture) => Effect.promise(() => fixture[Symbol.asyncDispose]()),
)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
const root = yield* Effect.promise(() => fixture.configure(cache, spec))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const first = yield* npm.add(spec)
fixture.state.latest = "1.1.0"
const started = Promise.withResolvers<void>()
const resume = Promise.withResolvers<void>()
fixture.state.beforeTarball = () => {
started.resolve()
return resume.promise
}
const updating = yield* npm.update(spec).pipe(Effect.forkScoped)
yield* Effect.promise(() => started.promise)
updating.interruptUnsafe()
expect(updating.pollUnsafe()).toBeUndefined()
resume.resolve()
const exit = yield* Fiber.await(updating)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
// The pending interrupt is observed after publication, outside staging's cleanup boundary.
const published = yield* npm.resolve(spec)
expect(published.revision).toBe("1.1.0")
expect(published.generation).not.toBe(first.generation)
expect((yield* Effect.promise(() => import(published.entrypoint ?? ""))).relative.value).toBe("1.1.0")
expect(yield* Effect.promise(() => Bun.file(path.join(first.directory, "relative.js")).text())).toContain(
'"1.0.0"',
)
expect((yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))).length).toBe(2)
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
it.live(
"finds binaries in published generations and keeps their old files usable after reload",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = path.join(tmp.path, "binary")
yield* Effect.promise(async () => {
await fs.mkdir(fixture)
await writePackage(fixture, {
name: "fixture-bin",
bin: { "fixture-bin": "./cli.js", "fixture-other": "./other.js" },
})
await Bun.write(path.join(fixture, "cli.js"), '#!/usr/bin/env node\nconsole.log(require("./value.cjs"))\n')
await Bun.write(path.join(fixture, "value.cjs"), 'module.exports = "fixture"\n')
await Bun.write(path.join(fixture, "other.js"), '#!/usr/bin/env node\nconsole.log("other")\n')
})
const spec = `fixture-bin@file:${fixture}`
const cache = path.join(tmp.path, "cache")
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const binary = yield* npm.which(spec)
expect(binary).toEndWith(path.join(".bin", "fixture-bin"))
expect(yield* npm.which(spec, "fixture-other")).toEndWith(path.join(".bin", "fixture-other"))
expect(yield* npm.which(spec, "missing")).toBeUndefined()
const original = yield* npm.resolve(spec)
const reloaded = yield* npm.reload(spec)
expect(reloaded.generation).not.toBe(original.generation)
const fresh = yield* npm.which(spec)
expect(fresh).not.toBe(binary)
expect(fresh).toContain(reloaded.generation)
expect(yield* Effect.promise(() => Bun.file(binary ?? "").text())).toContain("value.cjs")
expect(yield* Effect.promise(() => Bun.file(fresh ?? "").text())).toContain("value.cjs")
if (!win)
yield* Effect.promise(async () => {
const child = Bun.spawn([process.execPath, fresh ?? ""], { stdout: "pipe", stderr: "pipe" })
expect(await child.exited, await new Response(child.stderr).text()).toBe(0)
expect(await new Response(child.stdout).text()).toBe("fixture\n")
})
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
)
it.live(
"checks without changing files, updates repeatedly, preserves pins, and reloads the whole Git graph offline",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.promise(() => createGitFixture(tmp.path))
const cache = path.join(tmp.path, "cache")
const spec = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
const pinned = `git+${pathToFileURL(fixture.repository).href}#${fixture.commit}`
const entries = yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const first = yield* npm.add(spec, { refresh: true })
const pin = yield* npm.add(pinned)
const firstModule = yield* Effect.promise(() => import(first.entrypoint ?? ""))
expect(firstModule.relative.value).toBe("first")
expect(firstModule.dependency.value).toBe("first")
const secondCommit = yield* Effect.promise(() => advanceGitFixture(fixture.repository, "second"))
expect(yield* npm.check(spec)).toEqual({ installed: fixture.commit, available: secondCommit, mutable: true })
expect(yield* npm.resolve(spec)).toEqual(first)
expect(yield* npm.add(spec, { refresh: true })).toEqual(first)
const second = yield* npm.update(spec)
expect(second.revision).toBe(secondCommit)
const secondModule = yield* Effect.promise(() => import(second.entrypoint ?? ""))
expect(secondModule.relative.value).toBe("second")
expect(secondModule.dependency.value).toBe("second")
expect(second.generation).not.toBe(first.generation)
const thirdCommit = yield* Effect.promise(() => advanceGitFixture(fixture.repository, "third"))
const third = yield* npm.update(spec)
expect(third.revision).toBe(thirdCommit)
const thirdModule = yield* Effect.promise(() => import(third.entrypoint ?? ""))
expect(thirdModule.relative.value).toBe("third")
expect(thirdModule.dependency.value).toBe("third")
const thirdCjs = yield* npm.resolve(spec, { subpaths: ["cjs"] })
const thirdCommonJs = yield* Effect.promise(() => import(thirdCjs.entrypoint ?? ""))
expect(thirdCommonJs.default.relative.value).toBe("third")
expect(thirdCommonJs.default.dependency.value).toBe("third")
expect(yield* npm.check(pinned)).toEqual({
installed: fixture.commit,
available: fixture.commit,
mutable: false,
})
expect(yield* npm.update(pinned)).toEqual(pin)
const companionCommit = yield* Effect.promise(() => advanceGitFixture(fixture.repository, "companion"))
const companion = yield* npm.add(spec, { revision: companionCommit })
expect(companion.revision).toBe(companionCommit)
expect((yield* Effect.promise(() => import(companion.entrypoint ?? ""))).dependency.value).toBe("companion")
expect(yield* npm.resolve(spec)).toEqual(third)
expect(yield* npm.resolve(spec, { revision: companionCommit })).toEqual(companion)
yield* Effect.promise(() => fs.rename(fixture.repository, `${fixture.repository}-offline`))
const root = path.join(cache, "packages", yield* Effect.promise(() => Npm.cacheKey(spec)))
const before = yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))
expect((yield* npm.update(spec).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
expect(yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))).toEqual(before)
expect(yield* npm.resolve(spec)).toEqual(third)
const reloaded = yield* npm.reload(spec)
expect(reloaded.revision).toBe(thirdCommit)
expect(reloaded.generation).not.toBe(third.generation)
const reloadedModule = yield* Effect.promise(() => import(reloaded.entrypoint ?? ""))
expect(reloadedModule.relative).toEqual(thirdModule.relative)
expect(reloadedModule.relative).not.toBe(thirdModule.relative)
expect(reloadedModule.dependency).not.toBe(thirdModule.dependency)
const freshCjs = yield* npm.resolve(spec, { subpaths: ["cjs"] })
const freshCommonJs = yield* Effect.promise(() => import(freshCjs.entrypoint ?? ""))
expect(freshCommonJs.default.relative).not.toBe(thirdCommonJs.default.relative)
expect(freshCommonJs.default.dependency).not.toBe(thirdCommonJs.default.dependency)
const exactReload = yield* npm.reload(spec, { revision: companionCommit })
expect(exactReload.revision).toBe(companionCommit)
expect(yield* npm.resolve(spec)).toEqual(reloaded)
expect(yield* npm.update(pinned)).toEqual(pin)
expect(yield* Effect.promise(() => Bun.file(path.join(first.directory, "relative.js")).text())).toContain(
'"first"',
)
expect(yield* Effect.promise(() => Bun.file(path.join(second.directory, "relative.js")).text())).toContain(
'"second"',
)
return { first, third, reloaded, exactReload, thirdCjs, freshCjs }
}).pipe(Effect.provide(npmLayer(cache)))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
expect(yield* npm.resolve(spec)).toEqual(entries.reloaded)
expect(yield* npm.inspect(spec)).toEqual({ installed: entries.third.revision, mutable: true })
expect(yield* npm.resolve(spec, { revision: entries.exactReload.revision })).toEqual(entries.exactReload)
}).pipe(Effect.provide(npmLayer(cache)))
// Exercise the same realpaths in Node's ESM loader as well as Bun's loader above.
const node = Bun.which("node")
if (node)
yield* Effect.promise(async () => {
const script = `
import assert from "node:assert/strict";
const first = await import(${JSON.stringify(entries.first.entrypoint)});
const third = await import(${JSON.stringify(entries.third.entrypoint)});
const fresh = await import(${JSON.stringify(entries.reloaded.entrypoint)});
assert.equal(first.relative.value, "first");
assert.equal(third.relative.value, "third");
assert.equal(third.dependency.value, "third");
assert.notEqual(fresh.relative, third.relative);
assert.notEqual(fresh.dependency, third.dependency);
const thirdCjs = await import(${JSON.stringify(entries.thirdCjs.entrypoint)});
const freshCjs = await import(${JSON.stringify(entries.freshCjs.entrypoint)});
assert.equal(thirdCjs.default.relative.value, "third");
assert.equal(thirdCjs.default.dependency.value, "third");
assert.notEqual(freshCjs.default.relative, thirdCjs.default.relative);
assert.notEqual(freshCjs.default.dependency, thirdCjs.default.dependency);
`
const child = Bun.spawn([node, "--input-type=module", "-e", script], { stdout: "pipe", stderr: "pipe" })
expect(await child.exited, await new Response(child.stderr).text()).toBe(0)
})
}),
),
30_000,
)
it.live(
"auto-refreshes a legacy Git install once without overwriting it",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.promise(() => createGitFixture(tmp.path))
const cache = path.join(tmp.path, "cache")
const spec = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
const root = path.join(cache, "packages", yield* Effect.promise(() => Npm.cacheKey(spec)))
const initial = yield* Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.provide(npmLayer(cache)))
yield* Effect.promise(async () => {
// Recreate the shipped pre-generation cache layout, including its real npm lockfile.
await fs.cp(path.join(root, "generations", initial.generation), root, { recursive: true, dereference: true })
await fs.rm(path.join(root, "current.json"))
await fs.rm(path.join(root, "revisions"), { recursive: true })
await fs.rm(path.join(root, "generations"), { recursive: true })
})
const secondCommit = yield* Effect.promise(() => advanceGitFixture(fixture.repository, "second"))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const legacy = yield* npm.resolve(spec)
expect(legacy.revision).toBe(fixture.commit)
expect(legacy.generation).toStartWith("legacy-")
const updated = yield* npm.add(spec, { refresh: true })
expect(updated.revision).toBe(secondCommit)
expect(updated.directory).not.toBe(legacy.directory)
yield* Effect.promise(() => advanceGitFixture(fixture.repository, "third"))
expect(yield* npm.add(spec, { refresh: true })).toEqual(updated)
expect(yield* Effect.promise(() => Bun.file(path.join(legacy.directory, "relative.js")).text())).toContain(
'"first"',
)
yield* Effect.promise(() => fs.rename(fixture.repository, `${fixture.repository}-offline`))
const reloaded = yield* npm.reload(spec, { generation: legacy.generation, revision: legacy.revision })
expect(reloaded.revision).toBe(fixture.commit)
expect((yield* Effect.promise(() => import(reloaded.entrypoint ?? ""))).dependency.value).toBe("first")
expect(yield* npm.resolve(spec)).toEqual(updated)
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
it.live(
"uses npm configuration, stages exact registry revisions, and retains the old graph after a failed tarball",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(
Effect.promise(() => createRegistryFixture(tmp.path)),
(fixture) => Effect.promise(() => fixture[Symbol.asyncDispose]()),
)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
const pinned = "@fixture/registry-plugin@1.0.0"
const root = yield* Effect.promise(() => fixture.configure(cache, spec))
yield* Effect.promise(() => fixture.configure(cache, pinned))
const updated = yield* Effect.gen(function* () {
const npm = yield* Npm.Service
expect(yield* npm.inspect(spec)).toEqual({ installed: undefined, mutable: true })
expect(yield* npm.check(spec)).toEqual({ installed: undefined, available: "1.0.0", mutable: true })
expect((yield* npm.resolve(spec)).entrypoint).toBeUndefined()
const first = yield* npm.add(spec, { refresh: true })
const pin = yield* npm.add(pinned)
const firstModule = yield* Effect.promise(() => import(first.entrypoint ?? ""))
fixture.state.latest = "1.1.0"
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.1.0", mutable: true })
expect(yield* npm.resolve(spec)).toEqual(first)
const second = yield* npm.update(spec, { subpaths: [""] })
expect(second.revision).toBe("1.1.0")
expect((yield* Effect.promise(() => import(second.entrypoint ?? ""))).relative.value).toBe("1.1.0")
expect((yield* npm.update(spec, { subpaths: ["missing"] }).pipe(Effect.flip))._tag).toBe(
"NpmInstallFailedError",
)
expect(yield* npm.resolve(spec)).toEqual(second)
const companion = yield* npm.add(spec, { revision: "1.2.0", subpaths: ["tui"] })
expect(companion.revision).toBe("1.2.0")
expect(yield* npm.resolve(spec)).toEqual(second)
fixture.state.latest = "1.3.0"
expect((yield* npm.check(spec)).available).toBe("1.3.0")
const before = yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))
expect((yield* npm.update(spec).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
expect(yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))).toEqual(before)
expect(yield* npm.resolve(spec)).toEqual(second)
expect((yield* npm.add(spec, { revision: "latest" }).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
const requests = fixture.state.requests
fixture.state.offline = true
expect(yield* npm.check(pinned)).toEqual({ installed: "1.0.0", available: "1.0.0", mutable: false })
expect(yield* npm.update(pinned)).toEqual(pin)
const reloaded = yield* npm.reload(spec)
expect(reloaded.revision).toBe("1.1.0")
expect(fixture.state.requests).toBe(requests)
expect((yield* Effect.promise(() => import(reloaded.entrypoint ?? ""))).relative.value).toBe("1.1.0")
expect(firstModule.relative.value).toBe("1.0.0")
expect(yield* Effect.promise(() => Bun.file(path.join(first.directory, "relative.js")).text())).toContain(
'"1.0.0"',
)
return reloaded
}).pipe(Effect.provide(npmLayer(cache)))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
expect(yield* npm.resolve(spec)).toEqual(updated)
expect((yield* npm.resolve(spec, { revision: "1.2.0" })).revision).toBe("1.2.0")
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
})
+5 -181
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Cause, Context, Effect, Exit, Fiber, Latch, Schema, Stream } from "effect"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
@@ -8,8 +8,6 @@ import { Bus } from "@opencode-ai/core/bus"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -27,178 +25,6 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
describe("Plugin", () => {
it.effect("closes the entire suffix after cleanup defects without leaving old hooks behind", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* PluginHooks.Service
const closed: string[] = []
const observed: string[] = []
const definition = (id: string, version: string, broken = false) => ({
id,
version,
effect: (ctx: EffectPlugin.Context) =>
Effect.gen(function* () {
yield* ctx.session.hook("prompt", () =>
Effect.sync(() => {
observed.push(`${id}${version}`)
}),
)
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed.push(`${id}${version}`)
if (broken) throw new Error("cleanup failed")
}),
)
}),
})
const first = [definition("a", "1"), definition("b", "1"), definition("c", "1", true)]
yield* plugins.activate(first)
yield* plugins.activate([definition("a", "2"), ...first.slice(1)])
expect(closed).toEqual(["c1", "b1", "a1"])
expect((yield* plugins.list()).map((plugin) => [plugin.id, plugin.status])).toEqual(
["a", "b", "c"].map((id) => [Plugin.ID.make(id), "active"]),
)
yield* plugins.activate([definition("a", "2"), definition("b", "2"), definition("c", "2")])
yield* registry.trigger("session", "prompt", {
sessionID: Session.ID.make("ses_cleanup"),
messageID: SessionMessage.ID.make("msg_cleanup"),
prompt: { text: "hello" },
delivery: "steer",
metadata: undefined,
})
expect(observed).toEqual(["a2", "b2", "c2"])
expect(closed).toEqual(["c1", "b1", "a1", "c1", "b1"])
yield* plugins.activate([])
expect(closed.slice(-3)).toEqual(["c2", "b2", "a2"])
expect(yield* plugins.list()).toEqual([])
}),
)
it.effect("does not gate an unchanged generation while a Session lease is held", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const gate = yield* PluginExecution.Service
const definitions = [{ id: "unchanged", version: "1", effect: () => Effect.void }]
yield* plugins.activate(definitions)
yield* gate.lease({ id: "running" }, plugins.activate(definitions))
expect((yield* plugins.list())[0]?.status).toBe("active")
}),
)
it.effect("bounds inventory errors and removes URL credentials and query strings", () =>
Effect.sync(() => {
const error = Plugin.errorMessage(
Cause.die(new Error(`https://user:password@example.com/plugin?token=secret ${"x".repeat(3000)}`)),
)
expect(error.length).toBeLessThanOrEqual(2000)
expect(error).toContain("https://example.com/plugin")
expect(error).not.toContain("password")
expect(error).not.toContain("token=secret")
}),
)
it.effect("keeps the unchanged registration prefix and preserves suffix ordering", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
const loads: string[] = []
const closed: string[] = []
const definition = (id: string, version: string) => ({
id,
version,
effect: (ctx: EffectPlugin.Context) =>
Effect.gen(function* () {
loads.push(`${id}:${version}`)
yield* Effect.addFinalizer(() => Effect.sync(() => closed.push(id)))
yield* ctx.agent.transform((draft) =>
draft.update("ordered", (agent) => {
agent.description = `${agent.description ?? ""}${id}:${version};`
}),
)
}),
})
const prefix = definition("prefix", "1")
const suffix = definition("suffix", "1")
yield* plugins.activate([prefix, definition("target", "1"), suffix])
yield* plugins.activate([prefix, definition("target", "2"), suffix])
expect(loads).toEqual(["prefix:1", "target:1", "suffix:1", "target:2", "suffix:1"])
expect(closed).toEqual(["suffix", "target"])
expect((yield* agents.get(Agent.ID.make("ordered")))?.description).toBe("prefix:1;target:2;suffix:1;")
}),
)
it.effect("does not close plugin resources while a leased snapshot executes", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const gate = yield* PluginExecution.Service
const started = yield* Latch.make()
const finish = yield* Latch.make()
let closed = false
yield* plugins.activate([
{
id: "leased",
version: "1",
revision: "1.0.0",
generation: "first",
tui: true,
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed = true
}),
)
yield* ctx.tool.transform((draft) =>
draft.add({
name: "leased",
description: "Leased tool",
input: Schema.Struct({}),
options: { codemode: false },
execute: () =>
Effect.gen(function* () {
yield* started.open
yield* finish.await
expect(closed).toBe(false)
return { content: "first" }
}),
}),
)
}),
},
])
const running = yield* gate
.lease(
{ id: "ses_running" },
Effect.gen(function* () {
const snapshot = yield* tools.snapshot()
return yield* snapshot.execute({
sessionID: Session.ID.make("ses_running"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_message"),
call: { type: "tool-call", id: "call", name: "leased", input: {} },
})
}),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* started.await
const replacement = yield* plugins
.activate([
{
id: "leased",
version: "2",
revision: "2.0.0",
generation: "second",
effect: () => Effect.void,
},
])
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(closed).toBe(false)
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
yield* finish.open
yield* Fiber.join(running)
yield* Fiber.join(replacement)
expect(closed).toBe(true)
expect((yield* plugins.list())[0]?.revision).toBe("2.0.0")
}),
)
it.effect("exposes the current location to activated plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -574,18 +400,16 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([{ ...versioned(previous), revision: "1.0.0", generation: "previous", tui: true }])
yield* plugins.activate([{ ...versioned(replacement, "2"), revision: "2.0.0", generation: "replacement" }])
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "active",
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: true,
revision: "1.0.0",
generation: "previous",
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
-152
View File
@@ -1,152 +0,0 @@
import { expect } from "bun:test"
import { Effect, Fiber, Latch } from "effect"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { testEffect } from "../lib/effect"
const it = testEffect(PluginExecution.layer)
it.effect("defers activation through awaited children and titles, blocking unrelated selections", () =>
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
const childAllowed = yield* Latch.make()
const titleFinished = yield* Latch.make()
const events: string[] = []
const parent = yield* gate
.lease(
{ id: "parent" },
Effect.gen(function* () {
yield* childAllowed.await
yield* gate.lease(
{ id: "child", parentID: "parent" },
Effect.gen(function* () {
yield* gate.lease(
{ id: "grandchild", parentID: "child" },
Effect.sync(() => events.push("child")),
)
}),
)
yield* gate.lease(
{ id: "new-prompt", admission: true },
Effect.sync(() => events.push("admitted")),
)
events.push("parent")
}),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const title = yield* gate
.lease(
{ id: "parent", admission: true },
titleFinished.await.pipe(Effect.andThen(Effect.sync(() => events.push("title")))),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const activation = yield* gate
.exclusive(Effect.sync(() => events.push("activation")))
.pipe(Effect.forkScoped({ startImmediately: true }))
const unrelated = yield* gate
.lease(
{ id: "unrelated" },
Effect.sync(() => events.push("selection")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(events).toEqual([])
yield* childAllowed.open
yield* Fiber.join(parent)
expect(events).toEqual(["child", "admitted", "parent"])
yield* titleFinished.open
yield* Fiber.join(title)
yield* Fiber.join(activation)
yield* Fiber.join(unrelated)
expect(events).toEqual(["child", "admitted", "parent", "title", "activation", "selection"])
}),
)
it.effect("releases reader leases and pending admission on interruption", () =>
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
const reader = yield* gate.lease({ id: "reader" }, Effect.never).pipe(Effect.forkScoped({ startImmediately: true }))
const writer = yield* gate.exclusive(Effect.never).pipe(Effect.forkScoped({ startImmediately: true }))
const waiting = yield* gate
.lease({ id: "waiting" }, Effect.never)
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Fiber.interrupt(waiting)
yield* Fiber.interrupt(writer)
expect(yield* gate.lease({ id: "new" }, Effect.succeed("open"))).toBe("open")
yield* Fiber.interrupt(reader)
expect(yield* gate.exclusive(Effect.succeed("idle"))).toBe("idle")
}),
)
it.effect("does not let admission-only work establish same-Session or child execution ownership", () =>
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
const finishParent = yield* Latch.make()
const finishAdmission = yield* Latch.make()
const events: string[] = []
const parent = yield* gate
.lease({ id: "parent" }, finishParent.await)
.pipe(Effect.forkScoped({ startImmediately: true }))
const writer = yield* gate
.exclusive(Effect.sync(() => events.push("activation")))
.pipe(Effect.forkScoped({ startImmediately: true }))
const admission = yield* gate
.lease({ id: "unrelated", admission: true }, finishAdmission.await)
.pipe(Effect.forkScoped({ startImmediately: true }))
const unrelated = yield* gate
.lease(
{ id: "unrelated" },
Effect.sync(() => events.push("unrelated")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const child = yield* gate
.lease(
{ id: "child", parentID: "unrelated" },
Effect.sync(() => events.push("child")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(events).toEqual([])
yield* finishParent.open
yield* Fiber.join(parent)
expect(events).toEqual([])
yield* finishAdmission.open
yield* Fiber.join(admission)
yield* Fiber.join(writer)
yield* Fiber.join(unrelated)
yield* Fiber.join(child)
expect(events[0]).toBe("activation")
expect(events.slice(1).toSorted()).toEqual(["child", "unrelated"])
}),
)
it.effect("a title-only helper protects its resources without allowing an idle Session or child to drain", () =>
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
const finishTitle = yield* Latch.make()
const events: string[] = []
const title = yield* gate
.lease({ id: "idle", admission: true }, finishTitle.await)
.pipe(Effect.forkScoped({ startImmediately: true }))
const writer = yield* gate
.exclusive(Effect.sync(() => events.push("activation")))
.pipe(Effect.forkScoped({ startImmediately: true }))
const session = yield* gate
.lease(
{ id: "idle" },
Effect.sync(() => events.push("session")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const child = yield* gate
.lease(
{ id: "child", parentID: "idle" },
Effect.sync(() => events.push("child")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(events).toEqual([])
yield* finishTitle.open
yield* Fiber.join(title)
yield* Fiber.join(writer)
yield* Fiber.join(session)
yield* Fiber.join(child)
expect(events[0]).toBe("activation")
expect(events.slice(1).toSorted()).toEqual(["child", "session"])
}),
)
+2 -8
View File
@@ -19,7 +19,6 @@ import { Mcp } from "@opencode-ai/core/mcp/index"
import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Permission } from "@opencode-ai/core/permission"
import { Reference } from "@opencode-ai/core/reference"
@@ -36,12 +35,8 @@ import { emptyMcpLayer } from "../fixture/mcp"
const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined, generation: "test" }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined, generation: "test" }),
inspect: () => Effect.succeed({ mutable: false }),
check: () => Effect.succeed({ mutable: false }),
update: () => Effect.die("unused npm.update"),
reload: () => Effect.die("unused npm.reload"),
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
}),
)
@@ -83,7 +78,6 @@ export const PluginTestLayer = LayerNode.compile(
PluginRuntime.node,
Permission.node,
PluginHooks.node,
PluginExecution.node,
Reference.node,
Skill.node,
SkillDiscovery.node,
-3
View File
@@ -98,9 +98,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
check: () => Effect.die("unused plugin.check"),
update: () => Effect.die("unused plugin.update"),
reload: () => Effect.die("unused plugin.reload"),
},
reference: overrides.reference ?? {
list: () => Effect.die("unused reference.list"),
@@ -22,12 +22,8 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint, generation: "test" }),
resolve: () => Effect.succeed({ directory: "", entrypoint, generation: "test" }),
inspect: () => Effect.succeed({ mutable: false }),
check: () => Effect.succeed({ mutable: false }),
update: () => Effect.die("unused npm.update"),
reload: () => Effect.die("unused npm.reload"),
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.undefined,
})
}
@@ -13,12 +13,8 @@ import { PluginTestLayer } from "./fixture"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined, generation: "test" }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined, generation: "test" }),
inspect: () => Effect.succeed({ mutable: false }),
check: () => Effect.succeed({ mutable: false }),
update: () => Effect.die("unused npm.update"),
reload: () => Effect.die("unused npm.reload"),
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
})
@@ -1,129 +0,0 @@
import { expect } from "bun:test"
import { Effect, Fiber, Latch, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { App } from "@opencode-ai/core/app"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { KV } from "@opencode-ai/core/kv"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionGenerate } from "@opencode-ai/core/session/generate"
import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Session.node,
SessionProjector.node,
LocationServiceMap.node,
PluginRuntime.providerNode,
PluginRuntime.node,
KV.node,
App.node,
]),
[
[Global.node, tempGlobalLayer],
[Bus.node, Bus.configured({ persist: true })],
[SessionExecution.node, SessionExecution.noopLayer],
[
SessionGenerateNode.node,
Layer.succeed(
SessionGenerate.Service,
SessionGenerate.Service.of({
generate: () => Effect.succeed("generated"),
}),
),
],
],
),
)
for (const mode of ["Effect", "Promise"]) {
it.live(`${mode} prompt hooks can await auxiliary Session generation without granting drain ownership`, () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-plugin-reentry-")))
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.Service
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
const session = yield* sessions.create({ location: ref })
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const registry = yield* Plugin.Service
const host = yield* PluginHost.make(registry)
const entered = yield* Latch.make()
const proceed = yield* Latch.make()
const events: string[] = []
if (mode === "Effect") {
yield* host.session.hook(
"prompt",
(event) =>
Effect.gen(function* () {
yield* entered.open
yield* proceed.await
events.push(
(yield* host.session
.generate({ sessionID: event.sessionID, prompt: "Generate inside admission" })
.pipe(Effect.orDie)).text,
)
}),
undefined,
)
}
if (mode === "Promise") {
yield* PluginPromise.fromPromise({
id: "nested-generation",
setup: async (ctx) => {
await ctx.session.hook("prompt", async (event) => {
await Effect.runPromise(entered.open)
await Effect.runPromise(proceed.await)
events.push(
(await ctx.session.generate({ sessionID: event.sessionID, prompt: "Generate inside admission" }))
.text,
)
})
},
}).effect(host)
}
const preparing = yield* sessions
.prompt({ sessionID: session.id, text: "hello", resume: false })
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.addFinalizer(() => proceed.open)
yield* entered.await
const writer = yield* gate
.exclusive(Effect.sync(() => events.push("activation")))
.pipe(Effect.forkScoped({ startImmediately: true }))
const drain = yield* gate
.lease(
{ id: session.id },
Effect.sync(() => events.push("drain")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(events).toEqual([])
yield* proceed.open
yield* Fiber.join(preparing)
yield* Fiber.join(writer)
yield* Fiber.join(drain)
expect(events).toEqual(["generated", "activation", "drain"])
}).pipe(Effect.provide(locations.get(ref)))
}),
)
}
+6 -8
View File
@@ -32,9 +32,7 @@ describe("toSessionError", () => {
type: "provider.rate-limit",
message: "rate",
})
expect(toSessionError(llm(new AuthenticationError({ message: "auth", kind: "invalid" }))).type).toBe(
"provider.auth",
)
expect(toSessionError(llm(new AuthenticationError({ message: "auth" }))).type).toBe("provider.auth")
expect(toSessionError(llm(new QuotaExceededError({ message: "quota" }))).type).toBe("provider.quota")
expect(toSessionError(llm(new ContentPolicyError({ message: "blocked" }))).type).toBe("provider.content-filter")
expect(
@@ -129,14 +127,15 @@ describe("toSessionError", () => {
})
})
test("retries only rate limits, provider-internal failures, and transport failures", () => {
test("retries rate limits, provider-internal, transport, and unrecognized failures", () => {
const eligible = [
llm(new RateLimitError({ message: "rate" })),
llm(new ProviderInternalError({ message: "internal" })),
llm(new TransportError({ message: "transport", transport: "http", operation: "request" })),
llm(new UnknownProviderError({ message: "unknown" })),
]
const ineligible = [
llm(new AuthenticationError({ message: "auth", kind: "invalid" })),
llm(new AuthenticationError({ message: "auth" })),
llm(new QuotaExceededError({ message: "quota" })),
llm(new ContentPolicyError({ message: "blocked" })),
llm(new InvalidProviderOutputError({ message: "output" })),
@@ -149,11 +148,10 @@ describe("toSessionError", () => {
model: ModelID.make("model"),
}),
),
llm(new UnknownProviderError({ message: "unknown" })),
]
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false])
})
test("retries transport failures only when delivery is absent or not sent", () => {
+2 -26
View File
@@ -46,10 +46,9 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { Tool } from "@opencode-ai/core/tool"
import { asc, eq } from "drizzle-orm"
import { Effect, Fiber, Latch, Layer, Schema, Stream } from "effect"
import { Effect, Layer, Schema, Stream } from "effect"
import { testEffect } from "./lib/effect"
const requests: LLMRequest[] = []
@@ -112,7 +111,7 @@ const discovery = Layer.mock(InstructionDiscovery.Service, {
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.never, initialized: Effect.void })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const tools = Layer.mock(Tool.Service, {
snapshot: () =>
Effect.succeed({
@@ -140,7 +139,6 @@ const it = testEffect(
Agent.node,
InstructionBuiltIns.node,
PluginHooks.node,
PluginExecution.node,
SessionGenerateNode.node,
]),
[
@@ -227,28 +225,6 @@ const setup = Effect.gen(function* () {
return { db, bus, instructions: yield* instructionBuiltIns.load(sessionID) }
})
it.effect("generates under a held Session lease while config reconciliation is pending", () =>
Effect.gen(function* () {
requests.length = 0
instruction = "Initial context"
yield* setup
const gate = yield* PluginExecution.Service
const generate = yield* SessionGenerate.Service
const proceed = yield* Latch.make()
const running = yield* gate
.lease(
{ id: sessionID },
proceed.await.pipe(Effect.andThen(generate.generate({ sessionID, prompt: "Generate with active plugins" }))),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const writer = yield* gate.exclusive(Effect.void).pipe(Effect.forkScoped({ startImmediately: true }))
yield* proceed.open
expect(yield* Fiber.join(running)).toBe("Transient answer")
yield* Fiber.join(writer)
expect(requests).toHaveLength(1)
}),
)
it.effect(
"generates from fresh settled Session context without durable mutation",
() =>
+4 -13
View File
@@ -17,7 +17,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
@@ -49,10 +49,7 @@ const itWithUnavailableDestination = testEffect(
describe("Session.move", () => {
itWithUnavailableDestination.effect("rejects an unavailable destination before admitting the move", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
tmpdirScoped().pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -72,10 +69,7 @@ describe("Session.move", () => {
)
it.effect("applies a move immediately when the source directory no longer exists", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
tmpdirScoped().pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -111,10 +105,7 @@ describe("Session.move", () => {
)
it.effect("keeps a moved session out of its former directory's new identity", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
tmpdirScoped().pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const session = yield* Session.Service
+6 -5
View File
@@ -1,6 +1,4 @@
import { describe, expect } from "bun:test"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { noopPluginSupervisor } from "./fixture/plugin-supervisor"
import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect"
import { mkdtemp, rm } from "fs/promises"
import { tmpdir } from "os"
@@ -70,7 +68,6 @@ const locations = Layer.effect(
Effect.sync(() => {
let ready = false
return Layer.mergeAll(
PluginExecution.layer,
LayerNode.compile(PluginHooks.node),
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
@@ -81,9 +78,13 @@ const locations = Layer.effect(
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
}),
Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor(Effect.sync(() => (ready = true)))),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
),
)
}),
) as unknown as Layer.Layer<LocationServices>,
+2 -5
View File
@@ -16,7 +16,7 @@ import { SessionEnvironment } from "@opencode-ai/core/session/environment"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdirScoped } from "./fixture/tmpdir"
const closed: Session.ID[] = []
const transport = Layer.succeed(
@@ -49,10 +49,7 @@ const it = testEffect(
describe("Session.remove", () => {
it.effect("removes a session and its children", () =>
Effect.gen(function* () {
const temporary = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
)
const temporary = yield* tmpdirScoped()
const location = Location.Ref.make({ directory: AbsolutePath.make(temporary.path) })
const session = yield* Session.Service
const parent = yield* session.create({ location })
+2 -5
View File
@@ -23,7 +23,7 @@ import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
@@ -42,10 +42,7 @@ describe("Session.revert files", () => {
"undoes and restores a file rename without losing either path",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const original = path.join(directory, "old name.txt")
const renamed = path.join(directory, "new name.txt")
@@ -33,7 +33,6 @@ import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { noopPluginSupervisor } from "./fixture/plugin-supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { describe, expect } from "bun:test"
@@ -86,7 +85,7 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Config.testLayer()
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor())
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
@@ -287,6 +287,29 @@ it.effect("batches reasoning deltas and flushes pending reasoning before the ter
}),
)
test("authoritative end values replace accumulated deltas in the durable ended events", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
Effect.forEach(
[
LLMEvent.textStart({ id: "text" }),
LLMEvent.textDelta({ id: "text", text: "Hel" }),
LLMEvent.textEnd({ id: "text", text: "Hello!" }),
LLMEvent.reasoningStart({ id: "reasoning" }),
LLMEvent.reasoningDelta({ id: "reasoning", text: "Thin" }),
LLMEvent.reasoningEnd({ id: "reasoning", text: "Thinking done." }),
],
publisher.publish,
{ discard: true },
),
)
expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({ text: "Hello!" })
expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({
text: "Thinking done.",
})
})
test("tool input deltas are accumulated without being published", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
+6 -45
View File
@@ -48,8 +48,6 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { PromptCacheDiagnostics } from "@opencode-ai/core/session/prompt-cache-diagnostics"
import { SessionUsage } from "@opencode-ai/core/session/usage"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { noopPluginSupervisor } from "./fixture/plugin-supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { QuestionTool } from "@opencode-ai/core/tool/plugin/question"
@@ -208,7 +206,6 @@ const makeRunnerState = () => {
systemLoadHook: Effect.void,
skillBaselines: new Map<Agent.ID, string>(),
pluginFlushHook: Effect.void,
pluginConfigFlushHook: Effect.void,
authorizations: new Array<Tool.Context>(),
executions: new Array<string>(),
closedTransports: new Array<Session.ID>(),
@@ -389,10 +386,12 @@ const layer = Layer.unwrap(
}),
}),
])
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, {
...noopPluginSupervisor(Effect.suspend(() => state.pluginFlushHook)),
flush: Effect.suspend(() => state.pluginConfigFlushHook),
})
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
flush: Effect.suspend(() => state.pluginFlushHook),
}),
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
@@ -465,7 +464,6 @@ const layer = Layer.unwrap(
Catalog.node,
Tool.node,
PluginHooks.node,
PluginExecution.node,
echoNode,
SessionRunnerModel.node,
InstructionBuiltIns.node,
@@ -4308,43 +4306,6 @@ describe("SessionRunnerLLM", () => {
expect((yield* s.context).filter((message) => message.type === "assistant")).toHaveLength(1)
})
scenario("defers plugin activation through retry backoff and blocks a new Session drain", function* (s) {
const gate = yield* PluginExecution.Service
yield* s.admit("Retry before updating plugins")
yield* insertSession(otherSessionID)
yield* s.session.prompt({ sessionID: otherSessionID, text: "Wait for replacement", resume: false })
yield* s.llm.push(
Stream.fail(providerUnavailable()),
TestLLM.text("Recovered", "text-retry"),
TestLLM.text("New generation", "text-new"),
)
const scheduled = yield* s.bus
.subscribe(SessionEvent.RetryScheduled)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
const running = yield* s.resume.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Fiber.join(scheduled)
const reconciled = yield* Deferred.make<void>()
s.pluginConfigFlushHook = Deferred.await(reconciled)
let activated = false
const activation = yield* gate
.exclusive(
Effect.sync(() => {
activated = true
}).pipe(Effect.andThen(Deferred.succeed(reconciled, undefined))),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const waiting = yield* s.session.resume(otherSessionID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
expect(activated).toBe(false)
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("3 seconds")
yield* Fiber.join(running)
yield* Fiber.join(activation)
yield* Fiber.join(waiting)
expect(activated).toBe(true)
expect(s.requests).toHaveLength(3)
})
scenario("does not start another physical attempt after interruption during retry backoff", function* (s) {
yield* s.admit("Interrupt retry backoff")
yield* s.llm.push(Stream.fail(providerUnavailable()), TestLLM.text("Must not run", "unused-retry"))
+1 -4
View File
@@ -11,8 +11,6 @@ import type { LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { noopPluginSupervisor } from "./fixture/plugin-supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -35,13 +33,12 @@ const info = Skill.Info.make({
content: "Use Effect",
})
const skills = Layer.mergeAll(
PluginExecution.layer,
LayerNode.compile(PluginHooks.node),
Layer.mock(Skill.Service, {
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor()),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
)
const locations = Layer.effect(
LocationServiceMap.Service,
+1 -1
View File
@@ -130,7 +130,7 @@ const it = testEffect(
[Catalog.node, catalog],
[SessionRunnerModel.node, models],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[PluginSupervisor.node, Layer.mock(PluginSupervisor.Service, { flush: Effect.void, initialized: Effect.void })],
[PluginSupervisor.node, Layer.mock(PluginSupervisor.Service, { flush: Effect.void })],
],
),
)
+2 -5
View File
@@ -17,7 +17,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Effect, Layer } from "effect"
import { asc, eq } from "drizzle-orm"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
@@ -182,10 +182,7 @@ describe("Session.view", () => {
type: event.type,
data: event.data,
}))
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const tmp = yield* tmpdirScoped()
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[
+3 -4
View File
@@ -32,7 +32,6 @@ import { Permission } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { noopPluginSupervisor } from "./fixture/plugin-supervisor"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
@@ -128,7 +127,7 @@ const shellPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(noopPluginSupervisor())),
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
),
deps: [
Config.node,
@@ -1296,7 +1295,7 @@ describe("ShellTool", () => {
},
{
type: "text",
text: "You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly.",
text: "You will be notified automatically when the command finishes. The notification will include the command's output. DO NOT run sleep commands or poll the output file to check for completion. You can read from the file when its current output would be useful, such as when inspecting logs from a background server. Otherwise, continue with other work or end your response.",
},
])
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
@@ -1536,7 +1535,7 @@ describe("ShellTool", () => {
})
expect(settled.content?.[1]).toEqual({
type: "text",
text: "You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly.",
text: "You will be notified automatically when the command finishes. The notification will include the command's output. DO NOT run sleep commands or poll the output file to check for completion. You can read from the file when its current output would be useful, such as when inspecting logs from a background server. Otherwise, continue with other work or end your response.",
})
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(id)).status).toBe("running")
+1 -2
View File
@@ -25,7 +25,6 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { noopPluginSupervisor } from "./fixture/plugin-supervisor"
import { Permission } from "@opencode-ai/core/permission"
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
import { Tool } from "@opencode-ai/core/tool"
@@ -101,7 +100,7 @@ const subagentPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(noopPluginSupervisor())),
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
-3
View File
@@ -283,9 +283,6 @@ export function fromPromise(plugin: Plugin) {
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
check: adaptApiMethod(PluginEndpoints["plugin.check"], host.plugin.check),
update: adaptApiMethod(PluginEndpoints["plugin.update"], host.plugin.update),
reload: adaptApiMethod(PluginEndpoints["plugin.reload"], host.plugin.reload),
},
reference: {
list: adaptApiMethod(ReferenceEndpoints["reference.list"], host.reference.list),
+3 -515
View File
@@ -486,363 +486,6 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginOperationError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginOperationErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Check the configured plugin source for a newer compatible revision without activating it.",
"summary": "Check plugin updates",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/update": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.update",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.Info"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginOperationError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginOperationErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Update a mutable configured plugin source and activate it after running work finishes.",
"summary": "Update plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/reload": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.reload",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.Info"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginOperationError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginOperationErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Reload a configured plugin source without network access after running work finishes.",
"summary": "Reload plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -1334,16 +977,6 @@
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
@@ -1355,7 +988,7 @@
}
}
},
"description": "Import a projected session transcript at the requested location. If parentID is supplied, the parent session must already exist; import parents before children.",
"description": "Import a projected session transcript at the requested location.",
"summary": "Import session",
"requestBody": {
"content": {
@@ -10441,70 +10074,6 @@
}
}
},
"/api/experimental/persistent-pty/handoff": {
"post": {
"tags": ["persistentPty"],
"operationId": "server.experimental.persistentPty.handoff",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"handoff": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.Handoff"
},
{
"type": "null"
}
]
}
},
"required": ["handoff"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
}
}
},
"/api/experimental/persistent-pty/{ptyID}": {
"get": {
"tags": ["persistentPty"],
@@ -15414,9 +14983,6 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15927,9 +15493,6 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
@@ -16307,34 +15870,7 @@
"additionalProperties": false
}
},
"required": ["args", "title", "env"],
"additionalProperties": false
},
"PersistentPty.Handoff": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"instanceID": {
"type": "string"
},
"ticket": {
"type": "string"
},
"expiresAt": {
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"enum": ["Infinity", "-Infinity", "NaN"]
}
]
}
},
"required": ["directory", "instanceID", "ticket", "expiresAt"],
"required": ["command", "args", "cwd", "title", "env"],
"additionalProperties": false
},
"PersistentPty.Info": {
@@ -16506,15 +16042,6 @@
},
"tui": {
"type": "boolean"
},
"revision": {
"type": "string"
},
"generation": {
"type": "string"
},
"error": {
"type": "string"
}
},
"required": ["id", "source", "status", "tui"],
@@ -16538,12 +16065,6 @@
},
"tui": {
"type": "boolean"
},
"revision": {
"type": "string"
},
"generation": {
"type": "string"
}
},
"required": ["source", "status", "error", "tui"],
@@ -16551,22 +16072,6 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16621,20 +16126,6 @@
}
]
},
"PluginOperationErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginOperationError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
@@ -16731,7 +16222,7 @@
},
"Project.Vcs": {
"type": "string",
"pattern": "^[a-z][a-z0-9._-]*$"
"enum": ["git", "hg"]
},
"ProjectNotFoundErrorEncoded": {
"type": "object",
@@ -17549,9 +17040,6 @@
"created": {
"type": "number"
},
"streamed": {
"type": "number"
},
"completed": {
"type": "number"
}
+1 -45
View File
@@ -1,7 +1,7 @@
import { Location } from "@opencode-ai/schema/location"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const PluginGroup = HttpApiGroup.make("server.plugin")
@@ -19,50 +19,6 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
}),
),
)
.add(
HttpApiEndpoint.post("plugin.check", "/api/plugin/check", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String }),
success: Location.response(Plugin.PackageStatus),
error: Plugin.OperationError.pipe(HttpApiSchema.status(400)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.check",
summary: "Check plugin updates",
description: "Check the configured plugin source for a newer compatible revision without activating it.",
}),
),
HttpApiEndpoint.post("plugin.update", "/api/plugin/update", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String }),
success: Location.response(Schema.Array(Plugin.Info)),
error: Plugin.OperationError.pipe(HttpApiSchema.status(400)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.update",
summary: "Update plugin",
description: "Update a mutable configured plugin source and activate it after running work finishes.",
}),
),
HttpApiEndpoint.post("plugin.reload", "/api/plugin/reload", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String }),
success: Location.response(Schema.Array(Plugin.Info)),
error: Plugin.OperationError.pipe(HttpApiSchema.status(400)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.reload",
summary: "Reload plugin",
description: "Reload a configured plugin source without network access after running work finishes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "plugin",
-22
View File
@@ -21,13 +21,6 @@ export const Info = Schema.Union([
source: Source,
status: Schema.Literal("active"),
tui: Schema.Boolean,
revision: optional(Schema.String).annotate({ description: "Loaded root package version or full Git commit." }),
generation: optional(Schema.String).annotate({
description: "Opaque loaded module graph identity, local to this runtime.",
}),
error: optional(Schema.String).annotate({
description: "The last replacement failed; the reported revision is still active.",
}),
}),
Schema.Struct({
id: ID.pipe(optional),
@@ -35,25 +28,10 @@ export const Info = Schema.Union([
status: Schema.Literal("failed"),
error: Schema.String,
tui: Schema.Boolean,
revision: optional(Schema.String).annotate({ description: "Registered root package version or full Git commit." }),
generation: optional(Schema.String).annotate({
description: "Opaque registered module graph identity, local to this runtime.",
}),
}),
]).annotate({ identifier: "Plugin.Info" })
export type Info = typeof Info.Type
export interface PackageStatus extends Schema.Schema.Type<typeof PackageStatus> {}
export const PackageStatus = Schema.Struct({
installed: optional(Schema.String),
available: optional(Schema.String),
mutable: Schema.Boolean,
}).annotate({ identifier: "Plugin.PackageStatus" })
export class OperationError extends Schema.TaggedError<OperationError>()("PluginOperationError", {
message: Schema.String,
}) {}
const Added = ephemeral({
type: "plugin.added",
schema: { id: ID },
-61
View File
@@ -1,61 +0,0 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Plugin } from "../src/plugin.js"
test("plugin inventory distinguishes active code from a failed replacement", () => {
const info = Schema.decodeUnknownSync(Plugin.Info)({
id: "example.plugin",
source: { type: "package", package: "example-plugin@latest" },
status: "active",
tui: true,
revision: "1.0.0",
generation: "generation-one",
error: "Replacement setup failed",
})
expect(info.status).toBe("active")
expect(info.revision).toBe("1.0.0")
expect(info.error).toBe("Replacement setup failed")
expect(Schema.encodeSync(Plugin.Info)(info)).toEqual(info)
})
test("plugin revision fields remain optional for existing inventories", () => {
expect(
Schema.encodeSync(Plugin.Info)({
id: Plugin.ID.make("example.plugin"),
source: { type: "local", path: "/plugins/example.ts" },
status: "active",
tui: false,
revision: undefined,
generation: undefined,
error: undefined,
}),
).toEqual({
id: "example.plugin",
source: { type: "local", path: "/plugins/example.ts" },
status: "active",
tui: false,
})
})
test("package checks omit unknown revisions rather than guessing", () => {
expect(
Schema.encodeSync(Plugin.PackageStatus)({
installed: undefined,
available: undefined,
mutable: true,
}),
).toEqual({ mutable: true })
expect(
Schema.decodeUnknownSync(Plugin.PackageStatus)({
installed: "1.0.0",
available: "1.0.0",
mutable: false,
}),
).toEqual({ installed: "1.0.0", available: "1.0.0", mutable: false })
})
test("plugin operation errors expose a readable wire message", () => {
expect(
Schema.encodeSync(Plugin.OperationError)(new Plugin.OperationError({ message: "Unknown plugin source" })),
).toEqual({ _tag: "PluginOperationError", message: "Unknown plugin source" })
})
+1 -5
View File
@@ -47,11 +47,7 @@ for (const selection of ["explicit", "default"] as const) {
PluginSupervisor.Service,
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
return {
...plugins,
flush: release.open.pipe(Effect.andThen(plugins.flush)),
initialized: release.open.pipe(Effect.andThen(plugins.initialized)),
}
return { flush: release.open.pipe(Effect.andThen(plugins.flush)) }
}),
).pipe(Layer.provide(PluginSupervisor.layer))
const opencode = yield* fixture.sdk.OpenCode.create(
+2 -4
View File
@@ -1,5 +1,4 @@
import { Generate } from "@opencode-ai/core/generate"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -28,9 +27,8 @@ export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (han
Effect.fn("server.generate.text")(function* (request) {
yield* flushPlugins
const generate = yield* Generate.Service
const gate = yield* PluginExecution.Service
const text = yield* gate
.lease({ id: "generate", admission: true }, generate.text(request.payload))
const text = yield* generate
.text(request.payload)
.pipe(
Effect.mapError((error) =>
error._tag === "Generate.ModelSelectionError"
@@ -4,8 +4,7 @@ import { Effect } from "effect"
export function pluginReadiness(error: () => ServiceUnavailableError) {
return PluginSupervisor.Service.pipe(
// Reads may keep observing the coherent active graph while replacement waits for running tools.
Effect.flatMap((plugins) => plugins.initialized),
Effect.flatMap((plugins) => plugins.flush),
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(error()),
+5 -16
View File
@@ -1,24 +1,13 @@
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handlers) =>
handlers
.handle("plugin.list", () =>
Effect.gen(function* () {
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
)
.handle("plugin.check", ({ payload }) =>
response(PluginSupervisor.Service.use((plugins) => plugins.check(payload.target))),
)
.handle("plugin.update", ({ payload }) =>
response(PluginSupervisor.Service.use((plugins) => plugins.update(payload.target))),
)
.handle("plugin.reload", ({ payload }) =>
response(PluginSupervisor.Service.use((plugins) => plugins.reload(payload.target))),
),
handlers.handle("plugin.list", () =>
Effect.gen(function* () {
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
),
)
+13 -20
View File
@@ -2,7 +2,6 @@ import { Pty } from "@opencode-ai/core/pty"
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { Location } from "@opencode-ai/core/location"
import { Effect, Queue } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -42,25 +41,19 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
"pty.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: "pty", admission: true },
Effect.gen(function* () {
const pty = yield* Pty.Service
const location = yield* Location.Service
const cwd = ctx.payload.cwd || location.directory
return yield* response(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
cwd,
env: {
...ctx.payload.env,
...(yield* environment.get({ directory: location.directory, cwd })),
},
}),
)
yield* plugins.flush
const pty = yield* Pty.Service
const location = yield* Location.Service
const cwd = ctx.payload.cwd || location.directory
return yield* response(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
cwd,
env: {
...ctx.payload.env,
...(yield* environment.get({ directory: location.directory, cwd })),
},
}),
)
}),
+2 -7
View File
@@ -1,7 +1,6 @@
import { Shell } from "@opencode-ai/core/shell"
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { ShellNotFoundError } from "@opencode-ai/protocol/errors"
@@ -22,15 +21,11 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
"shell.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
yield* plugins.flush
const shell = yield* Shell.Service
const location = yield* Location.Service
return yield* response(
gate.lease(
{ id: "shell", admission: true },
shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }).pipe(Effect.orDie),
),
shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }).pipe(Effect.orDie),
)
}),
)
+1 -3
View File
@@ -1,5 +1,4 @@
import { WebSearch } from "@opencode-ai/core/websearch"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -31,9 +30,8 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
Effect.fn("server.websearch.query")(function* (request) {
yield* awaitPlugins
const websearch = yield* WebSearch.Service
const gate = yield* PluginExecution.Service
return yield* response(
gate.lease({ id: "websearch", admission: true }, websearch.query(request.payload)).pipe(
websearch.query(request.payload).pipe(
Effect.catchTags({
"WebSearch.ProviderRequired": () =>
new InvalidRequestError({
+2 -10
View File
@@ -49,8 +49,8 @@ test("plugin readiness stays lazy and resolves the supervisor for every executio
const readiness = pluginReadiness(
() => new ServiceUnavailableError({ message: "initialization timed out", service: "test" }),
)
const layer = Layer.mock(PluginSupervisor.Service, {
initialized: Effect.sync(() => {
const layer = Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
flushes++
}),
})
@@ -59,11 +59,3 @@ test("plugin readiness stays lazy and resolves the supervisor for every executio
await Effect.runPromise(Effect.all([readiness, readiness], { concurrency: 1 }).pipe(Effect.provide(layer)))
expect(flushes).toBe(2)
})
test("plugin state reads do not wait for a pending replacement", async () => {
const readiness = pluginReadiness(
() => new ServiceUnavailableError({ message: "initialization timed out", service: "test" }),
)
const layer = Layer.mock(PluginSupervisor.Service, { initialized: Effect.void, flush: Effect.never })
expect(await Effect.runPromise(readiness.pipe(Effect.as("active"), Effect.provide(layer)))).toBe("active")
})
@@ -1,154 +0,0 @@
import { expect } from "bun:test"
import { Context, Deferred, Effect, Fiber, Latch, Layer } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Bus } from "@opencode-ai/core/bus"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { Generate } from "@opencode-ai/core/generate"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { WebSearch } from "@opencode-ai/core/websearch"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
for (const endpoint of ["websearch", "generate"]) {
it.live(`${endpoint} HTTP execution retains plugin resources through completion`, () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-plugin-execution-")))
const sdkReady = yield* Deferred.make<SdkPlugins.Interface>()
const queryStarted = yield* Latch.make()
const finishQuery = yield* Latch.make()
const activationRequested = yield* Latch.make()
const admissionRequested = yield* Latch.make()
const activationStarted = yield* Latch.make()
const finishActivation = yield* Latch.make()
const events: string[] = []
const definition = (version: number) =>
define({
id: "executor-resource",
effect: (ctx) =>
Effect.gen(function* () {
const resource = { closed: false }
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
resource.closed = true
events.push(`closed:${version}`)
}),
)
if (version === 2) {
yield* activationStarted.open
yield* finishActivation.await
}
yield* ctx.websearch.transform((draft) =>
draft.add({
id: "leased-provider",
name: "Leased provider",
execute: () =>
Effect.gen(function* () {
if (version === 1) {
yield* queryStarted.open
yield* finishQuery.await
}
expect(resource.closed).toBe(false)
events.push(`query:${version}`)
return []
}),
}),
)
}),
})
const sdkNode = makeGlobalNode({
service: SdkPlugins.Service,
layer: SdkPlugins.layer.pipe(
Layer.tap((context) => {
const sdk = Context.get(context, SdkPlugins.Service)
return Deferred.succeed(sdkReady, sdk).pipe(Effect.andThen(sdk.register(definition(1))))
}),
),
deps: [Bus.node],
})
const executionNode = makeLocationNode({
service: PluginExecution.Service,
layer: Layer.effect(
PluginExecution.Service,
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
return PluginExecution.Service.of({
lease: (session, effect) => admissionRequested.open.pipe(Effect.andThen(gate.lease(session, effect))),
exclusive: (effect) => activationRequested.open.pipe(Effect.andThen(gate.exclusive(effect))),
})
}),
).pipe(Layer.provide(PluginExecution.layer)),
deps: [],
})
// The Generate handler executes a real plugin provider without contacting an LLM.
const generateNode = makeLocationNode({
service: Generate.Service,
layer: Layer.effect(
Generate.Service,
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
return Generate.Service.of({
text: () =>
websearch
.query({
providerID: WebSearch.ID.make("leased-provider"),
query: "generate",
})
.pipe(Effect.as("generated"), Effect.orDie),
})
}),
),
deps: [WebSearch.node],
})
const handler = yield* ServerFetch.make(
{
database: { path: ":memory:" },
config: { directory: tmp.path, project: false },
fs: { filewatcher: false },
},
{
overrides: [
[SdkPlugins.node, sdkNode],
[PluginExecution.node, executionNode],
[ConfigPluginSource.node, ConfigPluginSource.empty],
[Generate.node, generateNode],
],
},
)
const sdk = yield* Deferred.await(sdkReady)
const request = Effect.promise(() => {
const url = new URL(`http://opencode.local/api/${endpoint}`)
url.searchParams.set("location[directory]", tmp.path)
return handler(
new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
endpoint === "generate" ? { prompt: "hello" } : { providerID: "leased-provider", query: "hello" },
),
}),
)
})
const first = yield* request.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.addFinalizer(() => finishQuery.open.pipe(Effect.andThen(finishActivation.open)))
yield* queryStarted.await
yield* activationRequested.close
yield* sdk.register(definition(2))
yield* activationRequested.await
expect(events).toEqual([])
yield* finishQuery.open
expect((yield* Fiber.join(first)).status).toBe(200)
yield* activationStarted.await
expect(events).toEqual(["query:1", "closed:1"])
yield* admissionRequested.close
const second = yield* request.pipe(Effect.forkScoped({ startImmediately: true }))
yield* admissionRequested.await
expect(events).toEqual(["query:1", "closed:1"])
yield* finishActivation.open
expect((yield* Fiber.join(second)).status).toBe(200)
expect(events).toEqual(["query:1", "closed:1", "query:2"])
}),
)
}
-113
View File
@@ -1,113 +0,0 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Location } from "@opencode-ai/schema/location"
import { Plugin } from "@opencode-ai/schema/plugin"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
it.live("checks and reloads only configured sources, preserving working code after import and setup failures", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-plugin-endpoint-")))
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const target = path.join(project, "plugin.ts")
const disabled = path.join(project, "disabled.ts")
yield* Effect.promise(async () => {
await fs.mkdir(global)
await fs.mkdir(project)
await fs.writeFile(target, `export default { id: "managed", tui: true, setup() {} }`)
await fs.writeFile(
disabled,
`export default { id: "disabled", setup() { throw new Error("must remain disabled") } }`,
)
await fs.writeFile(
path.join(project, "opencode.json"),
JSON.stringify({ plugins: ["-*", target, disabled, "-disabled"] }),
)
})
const server = yield* startServer(global)
const invoke = (action: string, source: string) =>
Effect.promise(async () => {
const url = new URL(`/api/plugin/${action}`, server.base)
url.searchParams.set("location[directory]", project)
const response = await fetch(url, {
method: "POST",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ target: source }),
})
const body: unknown = await response.json()
return { status: response.status, body }
})
const check = yield* invoke("check", target)
expect(check.status).toBe(200)
expect(Schema.decodeUnknownSync(Location.response(Plugin.PackageStatus))(check.body).data).toEqual({
mutable: false,
})
const first = yield* invoke("reload", target)
expect(first.status).toBe(200)
const initial = Schema.decodeUnknownSync(Location.response(Schema.Array(Plugin.Info)))(first.body).data
expect(initial).toEqual([
expect.objectContaining({ id: "managed", status: "active", tui: true, generation: expect.any(String) }),
])
yield* Effect.promise(() =>
fs.writeFile(target, `export default { id: "managed", setup() { throw new Error("setup failed") } }`),
)
const setup = yield* invoke("reload", target)
expect(setup.status).toBe(200)
expect(Schema.decodeUnknownSync(Location.response(Schema.Array(Plugin.Info)))(setup.body).data).toEqual([
{ ...initial[0], error: expect.stringContaining("setup failed") },
])
yield* Effect.promise(() =>
fs.writeFile(target, `import "./missing.ts"; export default { id: "managed", setup() {} }`),
)
const imported = yield* invoke("reload", target)
expect(imported.status).toBe(200)
expect(Schema.decodeUnknownSync(Location.response(Schema.Array(Plugin.Info)))(imported.body).data).toEqual([
{ ...initial[0], error: expect.stringContaining("missing.ts") },
])
yield* Effect.promise(() =>
fs.writeFile(
target,
`export default { id: "changed-id", setup() { throw new Error("new setup must not run") } }`,
),
)
const renamed = yield* invoke("reload", target)
expect(renamed.status).toBe(200)
expect(Schema.decodeUnknownSync(Location.response(Schema.Array(Plugin.Info)))(renamed.body).data).toEqual([
{ ...initial[0], error: expect.stringContaining("Plugin ID changed from managed to changed-id") },
])
const off = yield* invoke("reload", disabled)
expect(off.status).toBe(200)
expect(
Schema.decodeUnknownSync(Location.response(Schema.Array(Plugin.Info)))(off.body).data.map((item) => item.id),
).toEqual([Plugin.ID.make("managed")])
yield* Effect.promise(() => fs.writeFile(target, `export default { id: "managed", tui: true, setup() {} }`))
const replacements = yield* Effect.all([invoke("reload", target), invoke("reload", target)], {
concurrency: "unbounded",
})
const generations = replacements.map((result) => {
expect(result.status).toBe(200)
const info = Schema.decodeUnknownSync(Location.response(Schema.Array(Plugin.Info)))(result.body).data[0]
expect(info?.status).toBe("active")
expect(info?.error).toBeUndefined()
return info?.generation
})
expect(new Set(generations).size).toBe(2)
expect(generations).not.toContain(initial[0]?.generation)
for (const operation of [
{ action: "update", source: target },
{ action: "reload", source: "opencode.agent" },
{ action: "update", source: "not-configured@latest" },
{ action: "check", source: "managed" },
]) {
const rejected = yield* invoke(operation.action, operation.source)
expect(rejected.status).toBe(400)
expect(Schema.decodeUnknownSync(Plugin.OperationError)(rejected.body)._tag).toBe("PluginOperationError")
}
}),
)
@@ -0,0 +1,35 @@
import { expect, story } from "../../storybook/playwright/story"
for (const split of [false, true]) {
for (const theme of ["light", "dark"]) {
story(`highlights changed words in ${split ? "split" : "unified"} ${theme} diffs`, async ({ mount }) => {
const root = await mount("components-session-review--inline-changes", { args: { split }, globals: { theme } })
const diffs = root.locator("diffs-container")
await expect(diffs).toHaveCount(2)
for (const diff of await diffs.all()) {
await expect(diff.locator('[data-line] [style*="--syntax-"]')).not.toHaveCount(0)
}
const additions = root.locator('[data-line-type="change-addition"] [data-diff-span]')
await expect(additions).toHaveText(["select-text"])
await expect(root.locator('[data-line-type="context"] [data-diff-span]')).toHaveCount(0)
await expect(root.locator('[data-line-type="change-deletion"] [data-diff-span]')).toHaveText([
'"http" in',
"? error.reason.",
"response?.",
": undefined",
])
await expect(additions).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
})
}
for (const source of ["files", "metadata"]) {
story(`skips word diffs for large ${split ? "split" : "unified"} ${source}`, async ({ mount }) => {
const root = await mount("components-session-review--large-file", { args: { split, source } })
const line = root.locator('[data-line][data-line-type="change-addition"]')
await expect(line).toHaveText("export const value = 'after'")
// Plain first paint is not proof that the worker kept inline diffs disabled.
await expect(line.locator('[style*="--syntax-"]')).not.toHaveCount(0)
await expect(root.locator("[data-diff-span]")).toHaveCount(0)
})
}
}
@@ -4,7 +4,7 @@ import { expect, story } from "../../storybook/playwright/story"
story("opens the comment editor when code is clicked", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
await review.locator('[data-line-type="change-addition"] [data-diff-span]').click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
})
+3 -2
View File
@@ -906,7 +906,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = getWorkerPool("unified")
const workerPool = getWorkerPool()
const virtualizer = virtuals.get()
renderViewer({
@@ -1111,7 +1111,8 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
// Worker render options override per-viewer options, including the large-file fallback.
const workerPool = getWorkerPool(large() ? "none" : "word-alt")
const virtualizer = virtuals.get()
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
@@ -1,6 +1,8 @@
import { createStore } from "solid-js/store"
import { parseDiffFromFile } from "@pierre/diffs"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures"
import { File } from "./file"
import { SessionReview, type SessionReviewComment } from "./session-review"
function ReviewStory(props: { split?: boolean }) {
@@ -77,3 +79,79 @@ function InteractiveCommentsStory() {
}
export const InteractiveComments = { render: () => <InteractiveCommentsStory /> }
const gitDiffs = [
{
// OpenCode 93e1f383dd79683af4fc5ad139cea0516603c838, unchanged git-show output.
file: "packages/session-ui/src/components/file.tsx",
additions: 1,
deletions: 1,
patch: `diff --git a/packages/session-ui/src/components/file.tsx b/packages/session-ui/src/components/file.tsx
index 704971b014..4876731cbd 100644
--- a/packages/session-ui/src/components/file.tsx
+++ b/packages/session-ui/src/components/file.tsx
@@ -702,7 +702,7 @@ function ViewerShell(props: {
data-mode={props.mode}
dir="ltr"
style={styleVariables}
- class="relative outline-none"
+ class="relative select-text outline-none"
classList={{
...props.classList,
[props.class ?? ""]: !!props.class,
`,
},
{
// OpenCode 497a24c17d, unchanged git-show output.
file: "packages/core/src/session/runner/retry.ts",
additions: 1,
deletions: 1,
patch: `diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts
index 10f6680097..ef26792ffe 100644
--- a/packages/core/src/session/runner/retry.ts
+++ b/packages/core/src/session/runner/retry.ts
@@ -15,7 +15,7 @@ export interface Input {
}
\x20
export function isRetryable(error: AIError) {
- const override = "http" in error.reason ? error.reason.http?.response?.headers["x-should-retry"] : undefined
+ const override = error.reason.http?.headers["x-should-retry"]
if (override === "true") return true
if (override === "false") return false
switch (error.reason._tag) {
`,
},
]
export const InlineChanges = {
args: { split: false },
render: (args: { split: boolean }) => (
<CurrentSessionProviders document={editThenTestDocument}>
<div class="mx-auto h-screen min-h-[620px] w-full max-w-[1100px] overflow-hidden bg-background-base">
<SessionReview
title="OpenCode Git history"
diffs={gitDiffs}
open={gitDiffs.map((diff) => diff.file)}
split={args.split}
/>
</div>
</CurrentSessionProviders>
),
}
export const LargeFile = {
args: { split: false, source: "files" },
argTypes: { source: { control: "select", options: ["files", "metadata"] } },
render: (args: { split: boolean; source: string }) => {
// Cross the viewer's 500,000-character limit without long changed lines or 1,000 total lines.
const padding = `// ${"unchanged ".repeat(70)}\n`.repeat(800)
const before = { name: "large.ts", contents: `export const value = 'before'\n${padding}` }
const after = { name: "large.ts", contents: `export const value = 'after'\n${padding}` }
const input = args.source === "metadata" ? { fileDiff: parseDiffFromFile(before, after) } : { before, after }
return (
<div class="h-screen overflow-auto bg-background-base">
<File mode="diff" {...input} diffStyle={args.split ? "split" : "unified"} />
</div>
)
},
}
+1 -1
View File
@@ -197,7 +197,7 @@ export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"])
disableBackground: false,
expansionLineCount: 20,
hunkSeparators: "line-info-basic",
lineDiffType: style === "split" ? "word-alt" : "none",
lineDiffType: "word-alt",
maxLineDiffLength: 1000,
maxLineLengthForHighlighting: 1000,
disableFileHeader: true,
+11 -12
View File
@@ -4,8 +4,6 @@ import { registerOpenCodeTheme } from "@opencode-ai/ui/context/marked-theme-regi
registerOpenCodeTheme()
export type WorkerPoolStyle = "unified" | "split"
export function workerFactory(): Worker {
return new Worker(ShikiWorkerUrl, { type: "module" })
}
@@ -32,24 +30,25 @@ function createPool(lineDiffType: "none" | "word-alt") {
return pool
}
let unified: WorkerPoolManager | undefined
let split: WorkerPoolManager | undefined
let plain: WorkerPoolManager | undefined
let diff: WorkerPoolManager | undefined
export function getWorkerPool(style: WorkerPoolStyle | undefined): WorkerPoolManager | undefined {
export function getWorkerPool(lineDiffType: "none" | "word-alt" = "word-alt"): WorkerPoolManager | undefined {
if (typeof window === "undefined") return
if (style === "split") {
if (!split) split = createPool("word-alt")
return split
if (lineDiffType === "none") {
if (!plain) plain = createPool("none")
return plain
}
if (!unified) unified = createPool("none")
return unified
if (!diff) diff = createPool("word-alt")
return diff
}
export function getWorkerPools() {
const pool = getWorkerPool()
return {
unified: getWorkerPool("unified"),
split: getWorkerPool("split"),
unified: pool,
split: pool,
}
}
@@ -152,7 +152,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
const locale = useLocale()
createEffect(() => {
getWorkerPool(props.diffStyle)
getWorkerPool()
})
const fileIndex = () => {
+11 -2
View File
@@ -1,4 +1,4 @@
import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
import { BoxRenderable, RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
import {
For,
Index,
@@ -398,7 +398,16 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
}
return (
<Portal>
<Portal
ref={(container) => {
if (!(container instanceof BoxRenderable)) return
// Portal's wrapper otherwise follows the full-height app in root layout.
container.position = "absolute"
container.left = 0
container.top = 0
container.zIndex = 2500
}}
>
<box
position="absolute"
left={0}
+13 -9
View File
@@ -66,21 +66,26 @@ export const Definitions = {
"diff.up": keybind("k,up", "Move diff viewer up"),
"diff.page.down": keybind("pagedown,ctrl+f", "Page diff viewer down"),
"diff.page.up": keybind("pageup,ctrl+b", "Page diff viewer up"),
"diff.toggle": keybind("enter,space", "Toggle diff viewer item"),
"diff.expand": keybind("right", "Expand diff viewer item"),
"diff.expand_all": keybind("E", "Expand all diff viewer folders"),
"diff.collapse": keybind("left", "Collapse diff viewer item"),
"diff.switch_focus": keybind("tab", "Switch diff viewer focus"),
"diff.half_page.down": keybind("ctrl+d", "Scroll diff viewer down half a page"),
"diff.half_page.up": keybind("ctrl+u", "Scroll diff viewer up half a page"),
"diff.first": keybind("gg,home", "Go to the start of the diff"),
"diff.last": keybind("shift+g,end", "Go to the end of the diff"),
// Retain shipped configuration names without registering the removed tree navigation commands.
"diff.toggle": keybind("none", "Deprecated: file tree is mouse-controlled"),
"diff.expand": keybind("none", "Deprecated: file tree is mouse-controlled"),
"diff.expand_all": keybind("none", "Deprecated: file tree is mouse-controlled"),
"diff.collapse": keybind("none", "Deprecated: file tree is mouse-controlled"),
"diff.switch_focus": keybind("none", "Deprecated: keyboard navigation always controls the diff"),
"diff.next_hunk": keybind("]", "Jump to next diff hunk"),
"diff.previous_hunk": keybind("[", "Jump to previous diff hunk"),
"diff.next_file": keybind("n", "Jump to next diff file"),
"diff.previous_file": keybind("p", "Jump to previous diff file"),
"diff.next_file": keybind("n,alt+down", "Jump to next diff file"),
"diff.previous_file": keybind("p,alt+up", "Jump to previous diff file"),
"diff.toggle_file_tree": keybind("b", "Toggle diff viewer file tree"),
"diff.single_patch": keybind("s", "Toggle single patch view"),
"diff.switch_source": keybind("d", "Switch diff viewer source"),
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
"diff.help": keybind("?", "Show more diff viewer shortcuts"),
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
"prompt.editor": keybind("<leader>e", "Open external editor"),
"theme.switch": keybind("<leader>t", "List available themes"),
@@ -264,7 +269,6 @@ export const Definitions = {
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"plugins.check": keybind("ctrl+r", "Check plugin updates"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
@@ -1,8 +1,3 @@
// Paths branch softly through the screen,
// A quiet tree of changed designs;
// Each leaf remembers what has been,
// And waits where careful light aligns.
export type FileTreeItem = {
readonly file: string
readonly status?: "added" | "deleted" | "modified"
@@ -119,44 +114,20 @@ export function compareFileTreeNodes(tree: FileTree, left: number, right: number
return left - right
}
export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: number | undefined, offset: number) {
if (rows.length === 0) return undefined
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
if (index === -1) return rows[0]!.id
return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id
}
export function moveFileTreeSelectionToFirstChild(rows: readonly FileTreeRow[], selected: number | undefined) {
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
const row = index === -1 ? undefined : rows[index]
if (row?.kind !== "directory") return selected
const child = rows[index + 1]
return child && child.depth > row.depth ? child.id : selected
}
export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], selected: number | undefined) {
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
const row = index === -1 ? undefined : rows[index]
if (!row || row.depth === 0) return selected
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
}
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return undefined
return {
highlightedNode: node.id,
expandedNodes: fileTreeParentDirectories(tree, node.id),
}
}
export function singlePatchFileIndex(
selected: number | undefined,
active: number | undefined,
current: number | undefined,
first: number | undefined,
) {
return selected ?? active ?? current ?? first
return selected ?? current ?? first
}
export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) {
@@ -186,19 +157,6 @@ export function toggleFileTreeDirectory(tree: FileTree, expanded: ReadonlySet<nu
return next
}
export function setFileTreeDirectoryExpanded(
tree: FileTree,
expanded: ReadonlySet<number>,
selected: number | undefined,
value: boolean,
) {
if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
const next = new Set(expanded)
if (value) next.add(selected)
else next.delete(selected)
return next
}
function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit<FileTreeNode, "id" | "children">) {
const id = nodes.length
nodes.push({ ...input, id, children: [] })
@@ -1,156 +1,259 @@
/** @jsxImportSource @opentui/solid */
import type { ScrollBoxRenderable } from "@opentui/core"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { Locale } from "../../util/locale"
import { MouseButton, TextAttributes, type MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
import { truncateFilePath } from "../../ui/file-path"
import { stringWidth } from "../../util/string-width"
import { useTheme } from "../../context/theme"
import { tint } from "../../theme/color"
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
import { Panel } from "./diff-viewer-ui"
const FILE_TREE_STATUS_WIDTH = 2
const FILE_TREE_STATUS_WIDTH = 1
export type DiffViewerFileTreeProps = {
readonly context: Plugin.Context
readonly width: number
readonly files: readonly FileTreeItem[]
readonly loading: boolean
readonly error: unknown
readonly focused?: boolean
readonly highlightedNode?: number
readonly layout?: "tree" | "list"
readonly selectedFileIndex?: number
readonly reviewedFileNames?: ReadonlySet<string>
readonly expandedNodes?: ReadonlySet<number>
readonly onRowClick?: (row: FileTreeRow) => void
readonly onFileContextMenu?: (fileIndex: number, event: MouseEvent) => void
readonly source?: string
readonly onSwitchSource?: () => void
readonly footer?: JSX.Element
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const theme = props.context.theme
const theme = useTheme("elevated")
const [sourceHovered, setSourceHovered] = createSignal(false)
const list = () => props.layout === "list"
const tree = createMemo(() => buildFileTree(props.files))
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
const rows = createMemo(() =>
list()
? flattenFileTree(tree()).filter((row) => row.fileIndex !== undefined)
: flattenFileTree(tree(), props.expandedNodes),
)
// Quieter than subdued text: markers are affordances, not content.
const faint = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.45))
// Rails are pure texture; keep them barely above the surface.
const rail = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.7))
const reviewedCount = createMemo(() => props.files.filter((file) => props.reviewedFileNames?.has(file.file)).length)
const contentWidth = () => Math.max(0, props.width - 4 - FILE_TREE_STATUS_WIDTH - 1)
let scroll: ScrollBoxRenderable | undefined
createEffect(() => {
const node = props.highlightedNode
if (node === undefined) return
const selectedIndex = rows().findIndex((row) => row.id === node)
if (selectedIndex === -1) return
const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, selectedIndex)
const index = rows().findIndex((row) => row.fileIndex !== undefined && row.fileIndex === props.selectedFileIndex)
if (index === -1) return
const top = index * (list() ? 3 : 1)
const height = list() ? 2 : 1
const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, top, height)
scrollSelectedIntoView()
requestAnimationFrame(scrollSelectedIntoView)
})
const fadedColor = () => tint(theme.text.default, theme.background.default, 0.75)
return (
<Panel border="both" width={props.width} context={props.context}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
<box width={props.width} height="100%" minWidth={0} minHeight={0} flexShrink={0} flexDirection="column">
<box id="diff-tree-top-edge" height={1} flexShrink={0} backgroundColor={theme.background.default} />
<box
flexGrow={1}
minWidth={0}
minHeight={0}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
backgroundColor={theme.background.default}
>
<Switch>
<Match when={props.loading || props.error}>
<text />
</Match>
<Match when={props.files.length === 0}>
<text fg={theme.text.default}>No files</text>
</Match>
<Match when={props.files.length > 0}>
<For each={rows()}>
{(row, index) => {
const highlighted = () => props.focused && props.highlightedNode === row.id
const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
const reviewed = () => {
const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
return file !== undefined && (props.reviewedFileNames?.has(file) ?? false)
}
const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
const status = () => fileTreeRowStatus(row, props.files, reviewed())
const name = () =>
Locale.truncate(row.name, Math.max(1, props.width - FILE_TREE_STATUS_WIDTH - prefix().length))
return (
<box
flexDirection="row"
width="100%"
backgroundColor={highlighted() ? theme.background.action.primary.focused : undefined}
onMouseUp={() => props.onRowClick?.(row)}
>
<text
fg={highlighted() ? theme.text.action.primary.focused : fadedColor()}
wrapMode="none"
flexShrink={0}
>
{prefix()}
</text>
<box flexGrow={1} minWidth={0}>
<text
fg={
highlighted()
? theme.text.action.primary.focused
: selected()
? theme.text.formfield.selected
: reviewed() || row.kind === "directory"
? theme.text.subdued
: theme.text.default
<box height={1} flexShrink={0} flexDirection="row" marginBottom={1} gap={1}>
<text
id="diff-source-switch"
fg={
props.onSwitchSource
? sourceHovered()
? theme.text.action.secondary.hovered
: theme.text.action.secondary.default
: theme.text.default
}
attributes={TextAttributes.BOLD}
flexGrow={1}
wrapMode="none"
truncate
selectable={false}
onMouseOver={() => setSourceHovered(true)}
onMouseOut={() => setSourceHovered(false)}
onMouseUp={(event) => {
if (event.button !== MouseButton.LEFT) return
event.stopPropagation()
props.onSwitchSource?.()
}}
>
{props.source ?? "Files"}
</text>
<text fg={theme.text.subdued} wrapMode="none" flexShrink={0}>
{reviewedCount()}/{props.files.length} reviewed
</text>
</box>
<scrollbox
id="diff-files"
ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
minHeight={0}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
<Switch>
<Match when={props.loading || props.error}>
<text />
</Match>
<Match when={props.files.length === 0}>
<text fg={theme.text.subdued}>No files</text>
</Match>
<Match when={props.files.length > 0}>
<box flexShrink={0} gap={list() ? 1 : 0}>
<For each={rows()}>
{(row) => {
const [hovered, setHovered] = createSignal(false)
const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
const reviewed = () => {
const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
return file !== undefined && (props.reviewedFileNames?.has(file) ?? false)
}
const foreground = () => {
if (row.kind === "directory") return theme.text.subdued
return reviewed() ? theme.text.subdued : theme.text.default
}
const background = () => {
// Elevated context maps this to a quiet neutral surface step, not the loud accent.
if (hovered()) return theme.background.action.primary.hovered
return theme.background.default
}
const marker = () => {
if (row.kind !== "directory") return "≡ "
return props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ "
}
// Rails run straight down from each ancestor folder; no end hooks.
const indent = createMemo(() => {
if (list()) return ""
return "│ ".repeat(Math.max(0, Math.min(row.depth, Math.floor((contentWidth() - 3) / 2))))
})
const status = () => fileTreeRowStatus(row, props.files, reviewed())
const statusColor = () => {
if (reviewed()) return theme.text.subdued
const status = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.status
if (status === "added") return theme.diff.text.added
if (status === "deleted") return theme.diff.text.removed
return theme.text.subdued
}
const name = () => {
const width = contentWidth() - stringWidth(indent()) - stringWidth(marker())
if (row.kind === "directory") return truncateDirectoryChain(row.name, width)
return truncateFilePath(row.name, width)
}
const parent = () => {
const file = row.fileIndex === undefined ? "" : (props.files[row.fileIndex]?.file ?? "")
const directory = file.slice(0, Math.max(0, file.lastIndexOf("/")))
return directory ? truncateDirectoryChain(directory, contentWidth() - stringWidth(marker())) : ""
}
return (
<box
id={
row.fileIndex === undefined ? `diff-folder-row-${row.id}` : `diff-file-row-${row.fileIndex}`
}
wrapMode="none"
flexDirection="column"
width="100%"
height={list() ? 2 : 1}
flexShrink={0}
backgroundColor={background()}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
if (row.fileIndex !== undefined) props.onFileContextMenu?.(row.fileIndex, event)
}}
onMouseUp={(event) => {
if (event.button !== MouseButton.LEFT) return
event.stopPropagation()
props.onRowClick?.(row)
}}
>
{name()}
</text>
</box>
<text
fg={highlighted() ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
flexShrink={0}
>
{status()}
</text>
</box>
)
}}
</For>
</Match>
</Switch>
</scrollbox>
</Panel>
<box flexDirection="row" height={1}>
<text wrapMode="none" flexShrink={0}>
<span style={{ fg: rail() }}>{indent()}</span>
<span style={{ fg: faint() }}>{marker()}</span>
</text>
<box flexGrow={1} minWidth={0} marginRight={1}>
<text
fg={foreground()}
attributes={selected() ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
>
{name()}
</text>
</box>
<text fg={statusColor()} wrapMode="none" width={FILE_TREE_STATUS_WIDTH} flexShrink={0}>
{status()}
</text>
</box>
<Show when={list()}>
<text
fg={foreground()}
attributes={selected() || hovered() ? TextAttributes.DIM : undefined}
marginLeft={stringWidth(marker())}
wrapMode="none"
truncate
>
{parent()}
</text>
</Show>
</box>
)
}}
</For>
</box>
</Match>
</Switch>
</scrollbox>
<Show when={props.footer}>
<box flexShrink={0} paddingTop={1} paddingBottom={1}>
{props.footer}
</box>
</Show>
</box>
</box>
)
}
function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, index: number) {
if (!scroll) return
if (index < scroll.scrollTop) {
scroll.scrollTo(index)
function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, top: number, height: number) {
if (!scroll || scroll.isDestroyed) return
if (top < scroll.scrollTop) {
scroll.scrollTo(top)
return
}
if (index >= scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(index - scroll.viewport.height + 1)
if (top + height > scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(top + height - scroll.viewport.height)
}
}
function fileTreeRowPrefix(
rows: readonly FileTreeRow[],
index: number,
row: FileTreeRow,
expandedNodes: ReadonlySet<number> | undefined,
) {
const indentation = Array.from({ length: row.depth }, (_, depth) => {
if (depth === 0 && !hasLaterSibling(rows, 0, 0)) return " "
return hasLaterSibling(rows, index, depth) ? "│ " : " "
}).join("")
const topRoot = index === 0 && row.depth === 0
const branch = topRoot ? " " : hasLaterSibling(rows, index, row.depth) ? "├─ " : "└─ "
const marker = row.kind === "directory" ? (expandedNodes && !expandedNodes.has(row.id) ? "▸ " : "▾ ") : ""
return `${indentation}${branch}${marker}`
}
function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: number) {
return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth
}
function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[], reviewed: boolean) {
if (row.fileIndex === undefined) return ""
if (reviewed) return "✓"
const status = files[row.fileIndex]?.status
const marker = status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
return `${reviewed ? "✓" : " "}${marker}`.padStart(FILE_TREE_STATUS_WIDTH)
return status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
}
// Collapsed chains drop whole leading segments instead of squeezing
// mid-segment, so "a/b/c/d" narrows to "…/c/d" rather than "…/b…/c/d".
function truncateDirectoryChain(name: string, maxWidth: number) {
if (stringWidth(name) <= maxWidth) return name
const kept: string[] = []
let width = stringWidth("…/")
for (const segment of name.split("/").toReversed()) {
const next = stringWidth(segment) + (kept.length ? 1 : 0)
if (width + next > maxWidth) break
kept.unshift(segment)
width += next
}
if (kept.length === 0) return truncateFilePath(name, maxWidth)
return `…/${kept.join("/")}`
}
@@ -0,0 +1,96 @@
/** @jsxImportSource @opentui/solid */
import { useTerminalDimensions } from "@opentui/solid"
import type { MouseEvent } from "@opentui/core"
import { createResource, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { DialogImagePreview } from "../../component/dialog-image-preview"
import { useTheme } from "../../context/theme"
import { useDialog } from "../../ui/dialog"
export function isDiffImageFile(file: string) {
return /\.(png|jpe?g|webp|gif)$/i.test(file)
}
export function DiffViewerImage(props: {
file: string
load: (file: string, signal: AbortSignal) => Promise<Uint8Array>
label?: string
}) {
const theme = useTheme()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const height = () => Math.max(3, Math.min(8, Math.floor(dimensions().height / 4)))
const [image] = createResource(
() => {
const controller = new AbortController()
onCleanup(() => controller.abort())
return { file: props.file, signal: controller.signal }
},
(input) => props.load(input.file, input.signal),
)
return (
<box width="100%" flexShrink={0} gap={1} paddingLeft={1} paddingRight={1} paddingBottom={1}>
<text fg={theme.text.subdued}>{props.label ?? "Working tree preview"}</text>
<box height={height() + 2} flexShrink={0} gap={1}>
<Switch>
<Match when={image.error}>
<text fg={theme.text.feedback.error.default}>Could not load image</text>
</Match>
<Match when={image.loading}>
<text fg={theme.text.subdued}>Loading image...</text>
</Match>
<Match when={!image.error && image()} keyed>
{(bytes) => {
const [failed, setFailed] = createSignal(false)
const [size, setSize] = createSignal<string>()
const open = (event: MouseEvent) => {
if (event.button !== 0 || !size() || failed()) return
event.stopPropagation()
dialog.replace(() => (
<DialogImagePreview
images={[
{
uri: `data:application/octet-stream;base64,${Buffer.from(bytes).toString("base64")}`,
mention: { text: props.file },
},
]}
initial={0}
/>
))
}
return (
<Show
when={!failed()}
fallback={<text fg={theme.text.feedback.error.default}>Could not decode image</text>}
>
<box width="100%" height={height()} onMouseUp={open}>
<image
id={`diff-image-${props.file}`}
source={bytes}
fit="fit"
protocol="auto"
width="100%"
height="100%"
onLoad={(loaded) => setSize(`${loaded.width} x ${loaded.height}`)}
onError={() => setFailed(true)}
/>
</box>
<Show when={size()}>
{(value) => (
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}>{value()}</text>
<text fg={theme.text.action.secondary.default} onMouseUp={open}>
Click to enlarge
</text>
</box>
)}
</Show>
</Show>
)
}}
</Match>
</Switch>
</box>
</box>
)
}
@@ -1,108 +0,0 @@
import type { BorderSides, ColorInput } from "@opentui/core"
import type { Plugin } from "@opencode-ai/plugin/tui"
import type { JSX } from "@opentui/solid"
import { createContext, Show, splitProps, useContext } from "solid-js"
export type Axis = "x" | "y"
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
export type PanelBorder = "start" | "end" | "both" | "none"
const PanelGroupContext = createContext<{ axis: Axis; context: Plugin.Context }>()
function crossAxis(axis: Axis) {
return axis === "x" ? "y" : "x"
}
function usePanelGroup() {
return useContext(PanelGroupContext)
}
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis; context: Plugin.Context }) {
const [local, boxProps] = splitProps(props, ["axis", "context", "children"])
return (
<PanelGroupContext.Provider value={{ axis: local.axis, context: local.context }}>
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
{local.children}
</box>
</PanelGroupContext.Provider>
)
}
export function Panel(
props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder; context?: Plugin.Context },
) {
const group = usePanelGroup()
const [local, boxProps] = splitProps(props, ["border", "context"])
const context = local.context ?? group?.context
if (!context) throw new Error("Panel context is missing")
const theme = context.theme
const border = local.border ?? "start"
const borderProps =
border === "none"
? {}
: {
border: panelBorderSides(group?.axis ?? "y", border),
borderColor: theme.border.default,
}
return (
<box
minWidth={0}
minHeight={0}
flexDirection={crossAxis(group?.axis ?? "y") === "x" ? "row" : "column"}
{...borderProps}
{...boxProps}
/>
)
}
function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): BorderSides[] {
if (axis === "x") return border === "both" ? ["top", "bottom"] : [border === "start" ? "top" : "bottom"]
return border === "both" ? ["left", "right"] : [border === "start" ? "left" : "right"]
}
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
const group = usePanelGroup()
if (!group) throw new Error("PanelGroup is missing")
const theme = group.context.theme
const color = () => props.color ?? theme.border.default
const axis = () => props.axis ?? crossAxis(group.axis)
if (axis() === "y") {
return (
<Show
when={props.start || props.end}
fallback={<box width={1} flexShrink={0} border={["left"]} borderColor={color()} />}
>
<box width={1} flexShrink={0} flexDirection="column">
<Show when={props.start}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "start")}</text>}</Show>
<box flexGrow={1} border={["left"]} borderColor={color()} />
<Show when={props.end}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "end")}</text>}</Show>
</box>
</Show>
)
}
return (
<Show
when={props.start || props.end}
fallback={<box height={1} flexShrink={0} border={["top"]} borderColor={color()} />}
>
<box height={1} flexShrink={0} flexDirection="row">
<Show when={props.start}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "start")}</text>}</Show>
<box flexGrow={1} border={["top"]} borderColor={color()} />
<Show when={props.end}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "end")}</text>}</Show>
</box>
</Show>
)
}
function horizontalEdge(edge: SeparatorEdge, side: "start" | "end") {
if (edge === "edge") return side === "start" ? "├" : "┤"
if (edge === "edge-in") return "┴"
return "┬"
}
function verticalEdge(edge: SeparatorEdge, side: "start" | "end") {
if (edge === "edge") return side === "start" ? "┬" : "┴"
if (edge === "edge-in") return "┤"
return "├"
}
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,10 @@
import type { PluginInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import { DialogErrorDetails } from "../../component/dialog-error-details"
import { usePlugin } from "../../plugin/context"
import { localSource } from "../../plugin/discovery"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "../../ui/dialog-select"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { errorMessage } from "../../util/error"
const id = "opencode.plugins"
@@ -21,7 +17,6 @@ type Entry =
readonly target: string
readonly status: "active" | "inactive" | "failed"
readonly error?: string
readonly revision?: string
}
export function PluginsDialog(props: {
@@ -30,24 +25,15 @@ export function PluginsDialog(props: {
server?: () => readonly PluginInfo[]
}) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const [locked, setLocked] = createSignal(false)
const [busy, setBusy] = createSignal<string>()
const [list, setList] = createSignal<DialogSelectRef<string>>()
const [detail, setDetail] = createSignal<string>()
const [errorDetail, setErrorDetail] = createSignal(false)
const [errors, setErrors] = createSignal<Record<string, string>>({})
const [checks, setChecks] = props.context.storage.memory("updates", {
initial: { packages: {} as Record<string, PackageStatus> },
})
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [initial, setInitial] = createSignal<string>()
const [server, { refetch }] = createResource(
() => (props.server ? undefined : props.context.data.location.default()),
const [server] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
)
onMount(() => dialog.setSize("medium"))
onCleanup(props.context.data.on("plugin.updated", () => void refetch()))
onCleanup(props.context.data.on("server.connected", () => void refetch()))
const entries = createMemo<Entry[]>(() => {
const builtins: Entry[] = props.plugins
.registered()
@@ -68,8 +54,7 @@ export function PluginsDialog(props: {
id: plugin.id,
target: plugin.target,
status: plugin.status,
error: plugin.error,
revision: plugin.revision,
error: plugin.status === "failed" ? plugin.error : undefined,
}))
const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({
key: `server:${plugin.id ?? source(plugin, props.context)}`,
@@ -81,129 +66,12 @@ export function PluginsDialog(props: {
...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
]
})
const management = (entry: Entry | undefined) => {
if (!entry) return undefined
if (entry.runtime === "server") {
if (entry.plugin.source.type === "package")
return { runtime: "server" as const, target: entry.plugin.source.package, package: true }
if (entry.plugin.source.type === "local")
return { runtime: "server" as const, target: entry.plugin.source.path, package: false }
return undefined
}
if (props.plugins.registered().some((plugin) => plugin.id === entry.id && plugin.source === "builtin"))
return undefined
const companion = (props.server?.() ?? server() ?? []).find(
(plugin) => plugin.source.type === "package" && plugin.source.package === entry.target,
)
return {
runtime: companion ? ("server" as const) : ("tui" as const),
target: entry.target,
package: !localSource(entry.target, "."),
}
}
const checkKey = (entry: Entry | undefined) => {
const owner = management(entry)
if (!owner) return ""
const location = props.context.data.location.default()
return JSON.stringify([owner.runtime, location, owner.target])
}
const checked = (entry: Entry | undefined): PackageStatus | undefined => checks.packages[checkKey(entry)]
const revision = (entry: Entry) => (entry.runtime === "server" ? entry.plugin.revision : entry.revision)
const available = (entry: Entry | undefined) => {
const value = checked(entry)
return Boolean(
entry && value?.mutable && value.available && value.available !== (revision(entry) ?? value.installed),
)
}
const entryError = (entry: Entry | undefined) => errors()[checkKey(entry)] ?? pluginError(entry)
const detailEntry = createMemo(() => entries().find((entry) => entry.key === detail()))
const manage = (entry: Entry | undefined, action: "check" | "update" | "reload") => {
const owner = management(entry)
if (locked() || !owner || !entry || (action !== "reload" && !owner.package)) return
const key = checkKey(entry)
const location = props.context.data.location.default()
setLocked(true)
setBusy(
action === "check"
? "Checking for updates..."
: action === "update"
? "Updating; waiting for running work..."
: "Reloading; waiting for running work...",
)
setErrors((items) => {
const next = { ...items }
delete next[key]
return next
})
const task = async () => {
if (action === "check") {
const result =
owner.runtime === "server"
? (await props.context.client.plugin.check({ target: owner.target, location })).data
: await props.plugins.check(owner.target)
setChecks((draft) => {
draft.packages[key] = result
})
return
}
if (owner.runtime === "server") {
const result = await props.context.client.plugin[action]({ target: owner.target, location })
await Promise.all([refetch(), props.plugins.sync()])
const companion = props.plugins.list().find((plugin) => plugin.target === owner.target)
if (companion && companion.status !== "unsupported" && companion.error) throw new Error(companion.error)
const attempted = result.data.find((plugin) =>
plugin.source.type === "package"
? plugin.source.package === owner.target
: plugin.source.type === "local" && plugin.source.path === owner.target,
)
if (attempted?.error) throw new Error(attempted.error)
if (action === "update" && attempted?.revision && checks.packages[key])
setChecks((draft) => {
draft.packages[key] = {
mutable: draft.packages[key].mutable,
installed: attempted.revision,
available: attempted.revision,
}
})
} else {
await props.plugins[action](owner.target)
const loaded = props.plugins.list().find((plugin) => plugin.target === owner.target)
if (action === "update" && loaded && loaded.status !== "unsupported" && loaded.revision && checks.packages[key])
setChecks((draft) => {
draft.packages[key] = {
mutable: draft.packages[key].mutable,
installed: loaded.revision,
available: loaded.revision,
}
})
}
props.context.ui.toast.show({
variant: "success",
message: action === "update" ? "Plugin update applied" : "Plugin reloaded",
})
}
void task()
.catch((cause) => {
const message = errorMessage(cause)
setErrors((items) => ({ ...items, [key]: message }))
props.context.ui.toast.show({
variant: "error",
message:
action === "check" ? "Could not check plugin updates" : `Plugin ${action} failed; view error details`,
})
})
.finally(() => {
setBusy()
setLocked(false)
})
}
createEffect(() => {
if (initial()) return
const first = entries().find((entry) => entry.runtime === "tui")
if (!first) return
setInitial(first.key)
setFocused(first.key)
})
const options = createMemo(() =>
@@ -213,16 +81,9 @@ export function PluginsDialog(props: {
value: entry.key,
category: entry.runtime === "tui" ? "TUI" : "Server",
searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context),
footer: available(entry)
? "↑ update"
: entryError(entry) && status(entry) === "active"
? "previous active"
: status(entry) === "active"
? undefined
: status(entry),
footerColor: available(entry)
? props.context.theme.text.feedback.info.default
: entryError(entry)
footer: status(entry) === "active" ? undefined : status(entry),
footerColor:
status(entry) === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
gutter:
@@ -234,10 +95,11 @@ export function PluginsDialog(props: {
}),
),
)
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === list()?.selected?.value))
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === focused()))
const focusedTui = createMemo(() => {
const entry = focusedEntry()
return entry?.runtime === "tui" && entry.id ? entry : undefined
if (entry?.runtime !== "tui" || !entry.id) return
return entry
})
const toggleTitle = createMemo(() => {
const entry = focusedTui()
@@ -263,16 +125,10 @@ export function PluginsDialog(props: {
.finally(() => setLocked(false))
}
const back = () => {
setDetail()
setErrorDetail(false)
dialog.setSize("medium")
}
return (
<box>
<Show
when={detailEntry()}
when={detail()}
fallback={
<DialogSelect
title="Plugins"
@@ -280,179 +136,45 @@ export function PluginsDialog(props: {
current={initial()}
locked={locked()}
preserveSelection={true}
ref={setList}
onMove={(option) => setFocused(option.value)}
onSelect={(option) => {
const entry = entries().find((entry) => entry.key === option.value)
if (entry) setDetail(entry.key)
if (pluginError(entry)) setDetail(entry)
}}
actions={[
...(focusedTui()
actions={
focusedTui()
? [
{
title: toggleTitle(),
command: "plugins.toggle",
onTrigger: (option: DialogSelectOption<string>) =>
toggle(entries().find((entry) => entry.key === option.value)),
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
},
]
: []),
...(management(focusedEntry())?.package
? [
{
title: "check",
command: "plugins.check",
onTrigger: (option: DialogSelectOption<string>) =>
manage(
entries().find((entry) => entry.key === option.value),
"check",
),
},
]
: []),
]}
: []
}
footer={
<Show when={!busy()} fallback={<text fg={props.context.theme.text.subdued}>{busy()}</text>}>
<Show when={dimensions().width >= 60}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
</span>
<span style={{ fg: props.context.theme.text.subdued }}> details</span>
</text>
</Show>
<Show when={pluginError(focusedEntry())}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
</span>
<span style={{ fg: props.context.theme.text.subdued }}> view error</span>
</text>
</Show>
}
/>
}
>
{(entry) => (
<Show
when={errorDetail()}
fallback={
<DialogSelect
title={label(entry(), props.context)}
renderFilter={false}
locked={locked()}
preserveSelection={true}
titleView={
<box flexGrow={1} flexShrink={1} flexBasis={0} minWidth={0} gap={1}>
<text fg={props.context.theme.text.default} truncate>
<b>{label(entry(), props.context)}</b>
</text>
<scrollbox
width="100%"
height={Math.min(8, Math.max(1, Math.floor(dimensions().height / 3) - 3))}
scrollbarOptions={{ visible: false }}
>
<text width="100%" fg={props.context.theme.text.subdued} wrapMode="word">
{[
`Runtime ${entry().runtime === "server" ? "Server" : "This terminal"}`,
`Status ${status(entry())}`,
...(management(entry())?.package
? [
`Loaded ${revisionLabel(revision(entry())) ?? "Unknown"}`,
`Installed ${revisionLabel(checked(entry())?.installed) ?? "Not checked"}`,
`Available ${revisionLabel(checked(entry())?.available) ?? "Not checked"}${checked(entry())?.mutable === false ? " (pinned)" : ""}`,
]
: [
management(entry())
? "Local source; reload does not fetch packages."
: "Updates with OpenCode itself.",
]),
`Scope ${props.context.ui.format.path(props.context.data.location.default().directory)}`,
`Source ${pluginSource(entry(), props.context)}`,
...(management(entry())?.package
? [
`Provides ${entries()
.filter((item) => management(item)?.target === management(entry())?.target)
.map((item) => label(item, props.context))
.join(", ")}`,
]
: []),
].join("\n")}
</text>
</scrollbox>
</box>
}
options={[
...(management(entry())?.package
? [
{
title: "Check for updates",
value: "check",
footer: checked(entry())?.mutable === false ? "pinned" : undefined,
},
...(checked(entry())?.mutable === false
? []
: [
{
title: "Update package",
value: "update",
footer: available(entry()) ? "↑ available" : undefined,
},
]),
]
: []),
...(management(entry()) ? [{ title: "Reload installed code", value: "reload" }] : []),
...("id" in entry()
? [
{
title: status(entry()) === "active" ? "Disable in this terminal" : "Enable in this terminal",
value: "toggle",
},
]
: []),
...(entryError(entry())
? [
{
title: "View error details",
value: "error",
footer: "error",
footerColor: props.context.theme.text.feedback.error.default,
},
]
: []),
{ title: "Back to plugins", value: "back" },
]}
onSelect={(option) => {
if (option.value === "back") return back()
if (option.value === "error") return setErrorDetail(true)
if (option.value === "toggle") return toggle(entry())
if (option.value === "check" || option.value === "update" || option.value === "reload")
manage(entry(), option.value)
}}
bindings={[{ bind: "escape", title: "Back", group: "Dialog", run: back }]}
footer={
<text
fg={
entryError(entry())
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued
}
>
{busy() ??
(entryError(entry())
? "Operation failed; view error details."
: available(entry())
? "↑ A newer revision is available."
: checked(entry())
? "No newer revision at last check."
: "Enter to select an action")}
</text>
}
/>
}
>
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
error={entryError(entry()) ?? "Unknown plugin error"}
context={`Status: ${status(entry())}\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setErrorDetail(false)
dialog.setSize("medium")
}}
/>
</Show>
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
error={pluginError(entry()) ?? "Unknown plugin error"}
context={`Status: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setDetail()
dialog.setSize("medium")
}}
/>
)}
</Show>
</box>
@@ -464,10 +186,6 @@ function label(entry: Entry, context: Plugin.Context) {
return entry.plugin.id ?? source(entry.plugin, context)
}
function revisionLabel(value: string | undefined) {
return value && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(value) ? value.slice(0, 7) : value
}
function pluginSource(entry: Entry, context: Plugin.Context) {
if (entry.runtime === "tui") return entry.target
return source(entry.plugin, context)
@@ -485,7 +203,7 @@ function status(entry: Entry) {
}
function pluginError(entry: Entry | undefined) {
if (entry?.runtime === "server") return entry.plugin.error
if (entry?.runtime === "server") return entry.plugin.status === "failed" ? entry.plugin.error : undefined
return entry?.error
}
+4 -1
View File
@@ -236,7 +236,10 @@ function settle<T>(resolve: (value: T) => void) {
}
}
function createDialogApi(dialog: ReturnType<typeof useDialog>, provide: (render: () => JSX.Element) => JSX.Element) {
export function createDialogApi(
dialog: ReturnType<typeof useDialog>,
provide: (render: () => JSX.Element) => JSX.Element,
) {
const api: Dialog = {
show(render, onClose) {
dialog.replace(() => provide(render), onClose)
+121 -295
View File
@@ -1,6 +1,5 @@
import type { PluginInfo } from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin/tui"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
import {
batch,
@@ -34,35 +33,13 @@ import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string, install?: boolean, revision?: string) => Promise<PackageEntry | string | undefined>
readonly check: (spec: string) => Promise<PackageStatus>
readonly update: (spec: string) => Promise<PackageEntry>
readonly reload: (
spec: string,
options?: { readonly revision?: string; readonly generation?: string },
) => Promise<PackageEntry>
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
}
export type PackageEntry = { readonly entrypoint?: string; readonly revision?: string; readonly generation: string }
type State = {
readonly revision?: string
readonly generation?: string
} & (
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive"; readonly error?: string }
type State =
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
| { readonly target: string; readonly status: "unsupported" }
| { readonly target: string; readonly id?: string; readonly status: "failed"; readonly error: string }
)
type Companion = Extract<PluginInfo, { readonly status: "active" }> & {
readonly source: { readonly type: "package" }
}
type CompanionGraph = {
readonly revision?: string
readonly generation?: string
readonly entry: PackageEntry | string | undefined
}
type RegisteredPlugin = {
readonly id: string
@@ -83,10 +60,6 @@ type Value = {
readonly markdown: () => MarkdownOptions["renderNode"]
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
readonly check: (target: string) => Promise<PackageStatus>
readonly update: (target: string) => Promise<void>
readonly reload: (target: string) => Promise<void>
readonly sync: () => Promise<void>
}
type Registration = {
@@ -94,9 +67,6 @@ type Registration = {
source: RegisteredPlugin["source"]
target?: string
version: string
revision?: string
generation?: string
serverGeneration?: string
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
@@ -106,10 +76,7 @@ type Registration = {
}
// One entry of the desired plugin generation produced by the resolve phase.
type Desired = Pick<
Registration,
"plugin" | "source" | "target" | "version" | "options" | "revision" | "generation" | "serverGeneration"
> & { enabled: boolean }
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
const PluginContext = createContext<Value>()
let sourceVersion = Date.now()
@@ -131,16 +98,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const lifecycle = useTuiLifecycle()
const client = useClient()
const data = useData()
const [serverPlugins, setServerPlugins] = createSignal<ReadonlyArray<Companion>>([])
const [registrationOrder, setRegistrationOrder] = createSignal<string[]>([], { equals: isDeepEqual })
const disabled = new Set<string>()
const stagedPackages = new Map<string, PackageEntry>()
const companionGraphs = new Map<string, CompanionGraph>()
const managementFailures = new Map<string, string>()
// Disabled imports remain candidates until setup succeeds. Keep one working
// definition for failures when the user later enables a candidate.
const lastGood = new Map<string, Desired>()
let inventoryLoaded = false
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,
@@ -161,10 +123,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
}
const markdown = createMemo(() =>
combineMarkdownRenderers(
registrationOrder().flatMap((id) => {
const registration = store.registrations[id]
return registration?.active ? [registration.markdown] : []
}),
Object.values(store.registrations).flatMap((registration) =>
registration.active ? [registration.markdown] : [],
),
),
)
const clearContributions = (id: string) => {
@@ -222,11 +183,10 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
setStore("registrations", id, "active", true)
setStore("states", (items) =>
items.map((state) =>
"id" in state && state.id === id ? pluginState(state.target, store.registrations[id]) : state,
"id" in state && state.id === id ? { target: state.target, id, status: "active" } : state,
),
)
})
lastGood.set(id, { ...toDesired(store.registrations[id]), options: snapshotOptions(item.options) })
return true
}
@@ -245,7 +205,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
}
setStore("states", (items) =>
items.map((state) =>
"id" in state && state.id === id ? { ...state, id, status: "inactive" as const } : state,
"id" in state && state.id === id ? { target: state.target, id, status: "inactive" } : state,
),
)
}),
@@ -294,8 +254,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// core plugin registry: fold the ordered entries into a desired end state
// (importing only new or changed sources, before anything running is
// touched), no-op when the generation is unchanged, and restart only the
// plugins that differ. Order is tracked separately from registration lifetime
// so an inventory change cannot unmount the built-in management dialog.
// plugins that differ. Membership or order changes rebuild the whole
// generation to preserve slot-order semantics.
// Package resolution failures would otherwise retry a full npm install on
// every watch event; remember them until the configuration changes.
const npmFailures = new Map<string, string>()
@@ -333,43 +293,38 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
if (local) await watcher.add(fileURLToPath(local))
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
const memo = local ? undefined : npmFailures.get(target)
const companion = serverPlugins().find((plugin) => plugin.source.package === target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(
target,
local,
options,
previous,
props.packages,
source.install,
sourceGeneration,
companion,
stagedPackages.get(target),
companionGraphs,
).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}))
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sourceGeneration).catch(
(error) => ({
status: "failed" as const,
error: errorMessage(error),
}),
)
if (resolved.status === "unsupported") {
if (previous) {
failures.push(pluginState(target, previous, "Updated package has no TUI entrypoint"))
desired.set(previous.plugin.id, toDesired(previous))
continue
}
if (source.server) continue
failures.push({ target, status: "unsupported" })
continue
}
if (resolved.status === "failed") {
if (!local && !previous) npmFailures.set(target, resolved.error)
failures.push(pluginState(target, previous, resolved.error))
failures.push({
target,
id: previous?.plugin.id,
status: "failed",
error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
})
if (previous) desired.set(previous.plugin.id, toDesired(previous))
continue
}
const setupFailure = setupFailures.get(target)
if (setupFailure && sameGeneration(setupFailure, { version: resolved.version, options }) && previous) {
failures.push(pluginState(target, previous, setupFailure.error))
failures.push({
target,
id: previous.plugin.id,
status: "failed",
error: previous.active ? `${setupFailure.error} (previous version still active)` : setupFailure.error,
})
desired.set(previous.plugin.id, toDesired(previous))
continue
}
@@ -379,34 +334,35 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
source: "external",
target,
version: resolved.version,
revision: resolved.revision,
generation: resolved.generation,
serverGeneration: companion?.generation,
options,
enabled: true,
})
}
for (const item of desired.values()) if (disabled.has(item.plugin.id)) item.enabled = false
// Compare: unchanged plugins are never touched, and a fully unchanged
// generation is a no-op, so spurious watch events cost nothing.
const currentIds = Object.keys(store.registrations)
const desiredIds = [...desired.keys()]
for (const id of Object.keys(store.registrations).filter((id) => !desired.has(id))) {
await deactivateNoisily(id)
lastGood.delete(id)
setStore(
"registrations",
produce((registrations) => {
delete registrations[id]
}),
const structural =
currentIds.length !== desiredIds.length || currentIds.some((id, index) => desiredIds[index] !== id)
if (structural) {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivateNoisily(id)),
)
setStore("registrations", reconcileStore({}))
}
setRegistrationOrder(desiredIds)
const changed = desiredIds.filter((id) => {
const registration = store.registrations[id]
const item = desired.get(id)!
return !sameGeneration(registration, item) || registration?.active !== item.enabled
})
const changed = structural
? desiredIds
: desiredIds.filter((id) => {
const registration = store.registrations[id]!
const item = desired.get(id)!
// enabled derives from config directives alone, so config wins over
// manual dialog toggles on every reconcile — the same semantics
// config saves had before hot reload existed, just more frequent.
return !sameGeneration(registration, item) || registration.active !== item.enabled
})
// Swap: cleanup failures surface as a toast, never propagate, so one
// broken plugin cannot take the rest of the generation down.
@@ -415,29 +371,31 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const item = desired.get(id)!
const registration = store.registrations[id]
const replaced = !registration || !sameGeneration(registration, item)
const fallback = lastGood.get(id)
const wasActive = registration?.active ?? false
// Snapshot the running version before it is overwritten: an import
// failure keeps last-good in the resolve phase, and a setup failure
// must not cost the previous version either.
const fallback = replaced && registration ? toDesired(registration) : undefined
if (replaced) {
if (registration) await deactivateNoisily(id)
// Replace only this plugin's contributions, leaving mounted built-ins
// and other plugin slots alive.
// In-place replacement keeps the registration's key position, which
// slot ordering (mode "replace" takes the last one) depends on.
setStore("registrations", id, toRegistration(item))
}
if (!item.enabled) {
await deactivateNoisily(id)
if (item.target) managementFailures.delete(item.target)
continue
}
const error = await activate(id).then(() => undefined, errorMessage)
if (!error) {
if (item.target) managementFailures.delete(item.target)
continue
}
if (!error) continue
errors.set(id, error)
if (!fallback) continue
setStore("registrations", id, toRegistration(fallback))
if (!wasActive) continue
await activate(id).catch(() => undefined)
if (!fallback.enabled) continue
const restored = await activate(id).then(
() => true,
() => false,
)
if (restored) errors.set(id, `${error} (previous version still active)`)
}
const failedTargets = new Set(failures.map((failure) => failure.target))
@@ -446,25 +404,20 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
if (item.target === undefined) return []
// A failed reload keeps this item running; the failure entry covers it.
if (failedTargets.has(item.target)) return []
const error = errors.get(item.plugin.id) ?? managementFailures.get(item.target)
return [pluginState(item.target, store.registrations[item.plugin.id], error)]
const error = errors.get(item.plugin.id)
if (error) return [{ target: item.target, id: item.plugin.id, status: "failed", error }]
const status = store.registrations[item.plugin.id]?.active ? "active" : "inactive"
return [{ target: item.target, id: item.plugin.id, status }]
}),
...failures.map((state) =>
"error" in state
? pluginState(
state.target,
Object.values(store.registrations).find((item) => item.target === state.target),
state.error,
)
: state,
),
...failures,
]
// Surface newly failing plugins; repeated reconciles stay silent.
for (const state of states)
if (
"error" in state &&
state.error &&
!store.states.some((prev) => "error" in prev && prev.target === state.target && prev.error === state.error)
state.status === "failed" &&
!store.states.some(
(prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error,
)
)
host.toast.show({
variant: "error",
@@ -490,13 +443,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
}),
)
}
// Claims come back in desired generation order across plugins, then
// registration order within one plugin. The resolver's last-wins rules depend on it.
// Claims come back in enable order: registration-store key order across
// plugins (generations preserve key positions in place), then registration
// order within one plugin. The resolver's last-wins rules depend on it.
const claims = createMemo(() =>
registrationOrder().flatMap((id) => {
const registration = store.registrations[id]
if (!registration) return []
return Object.entries(registration.active ? registration.slots : {}).map(([key, slot]) => {
Object.entries(store.registrations).flatMap(([id, registration]) =>
Object.entries(registration.active ? registration.slots : {}).map(([key, slot]) => {
// Rows downstream diff by reference; a stable claim per render
// function keeps untouched plugins' slot rows (and their state)
// alive across other plugins' reloads.
@@ -507,82 +459,47 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const item = { key: `${id}/${key}`, plugin: id, placement: unwrap(slot.placement), render: slot.render }
slotItems.set(slot.render, item)
return item
})
}),
}),
),
)
// Object.keys tracks the store's keys node only: refcount changes on an
// already-mounted path (a second tab's composer) skip re-resolution.
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()
// Discover server ownership before resolving CLI config duplicates:
// otherwise startup could briefly activate a newer local companion.
void enqueue(() => (inventoryLoaded ? reconcile() : syncServerPlugins().catch(() => reconcile()))).then(
void enqueue(reconcile).then(
() => setStore("ready", true),
() => setStore("ready", true),
)
},
),
)
const syncServerPlugins = async () => {
const response = await client.api.plugin.list({ location: data.location.default() })
const companions = response.data.filter(
(plugin): plugin is Companion => plugin.status === "active" && plugin.tui && plugin.source.type === "package",
)
if (!isDeepEqual(serverPlugins(), companions)) npmFailures.clear()
setServerPlugins(companions)
inventoryLoaded = true
await reconcile()
}
const sync = () => enqueue(syncServerPlugins)
const syncQuietly = () => void sync().catch(() => undefined)
const manage = (target: string, operation: "update" | "reload") =>
enqueue(async () => {
const previous = Object.values(store.registrations).find((item) => item.target === target)
managementFailures.delete(target)
await (async () => {
if (serverPlugins().some((plugin) => plugin.source.package === target)) {
const response = await client.api.plugin[operation]({ target, location: data.location.default() })
await syncServerPlugins()
const failed = response.data.find(
(plugin) => plugin.source.type === "package" && plugin.source.package === target && plugin.error,
)
if (failed?.error) throw new Error(failed.error)
return
}
const local = localSource(target, directory)
if (local) {
if (operation === "update") throw new Error("Local plugins cannot be updated")
const entrypoint = await resolveLocal(local)
if (entrypoint) sourceVersions.delete(entrypoint)
} else {
stagedPackages.set(
target,
operation === "reload"
? await props.packages.reload(target, { revision: previous?.revision, generation: previous?.generation })
: await props.packages.update(target),
)
}
npmFailures.delete(target)
setupFailures.delete(target)
await reconcile()
})().catch((error) => {
managementFailures.set(target, errorMessage(error))
setStore("states", (states) => [
...states.filter((state) => state.target !== target),
pluginState(target, previous, errorMessage(error)),
])
throw error
})
const state = store.states.find((state) => state.target === target)
if (state && "error" in state && state.error) throw new Error(state.error)
})
createEffect(on(() => JSON.stringify(data.location.default()), syncQuietly))
onCleanup(client.event.on("plugin.updated", syncQuietly))
onCleanup(client.event.on("server.connected", syncQuietly))
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 = () => {
@@ -613,47 +530,18 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
ready: () => store.ready,
list: () => store.states,
registered: () =>
registrationOrder().flatMap((id) => {
const plugin = store.registrations[id]
return plugin ? [{ id, source: plugin.source, active: plugin.active }] : []
}),
Object.entries(store.registrations).map(([id, plugin]) => ({
id,
source: plugin.source,
active: plugin.active,
})),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slots: { register: registerSlot, resolved },
markdown,
// Manual dialog toggles join the same chain as reconciles so a
// toggle mid-reload cannot mix registrations across generations.
activate: (id) =>
enqueue(async () => {
const fallback = lastGood.get(id)
const result = await activate(id).catch((error) => {
if (fallback) setStore("registrations", id, toRegistration(fallback))
const item = store.registrations[id]
if (item?.target)
setStore("states", (states) =>
states.map((state) =>
state.target === item.target ? pluginState(state.target, item, errorMessage(error)) : state,
),
)
throw error
})
disabled.delete(id)
return result
}),
deactivate: (id) =>
enqueue(() => {
disabled.add(id)
return deactivate(id)
}),
check: (target) =>
enqueue(async () => {
if (serverPlugins().some((plugin) => plugin.source.package === target))
return (await client.api.plugin.check({ target, location: data.location.default() })).data
if (localSource(target, directory)) return { mutable: false }
return props.packages.check(target)
}),
update: (target) => manage(target, "update"),
reload: (target) => manage(target, "reload"),
sync,
activate: (id) => enqueue(() => activate(id)),
deactivate: (id) => enqueue(() => deactivate(id)),
}}
>
{props.children}
@@ -688,45 +576,12 @@ async function resolvePlugin(
packages: PackageResolver,
install: boolean,
sourceGeneration: (entrypoint: string) => Promise<number>,
companion: Companion | undefined,
staged: PackageEntry | undefined,
companionGraphs: Map<string, CompanionGraph>,
) {
// An unchanged advertised graph reuses the loaded module, never the cache's
// current pointer: a remote server and this client can have different caches.
if (
!local &&
previous &&
sameOptions(previous.options, options) &&
(companion || !staged || staged.generation === previous.generation) &&
(!companion ||
((companion.revision === undefined || companion.revision === previous.revision) &&
companion.generation === previous.serverGeneration))
)
return {
status: "unchanged" as const,
plugin: previous.plugin,
version: previous.version,
revision: previous.revision,
generation: previous.generation,
}
const cached = companionGraphs.get(spec)
const pkg = local
? undefined
: companion
? cached && cached.revision === companion.revision && cached.generation === companion.generation
? cached.entry
: previous &&
companion.generation !== previous.serverGeneration &&
(companion.revision === undefined || companion.revision === previous.revision)
? await packages.reload(spec, { revision: companion.revision ?? previous.revision })
: await packages.resolve(spec, companion.revision !== undefined, companion.revision)
: (staged ?? (await packages.resolve(spec, install)))
if (companion?.revision !== undefined && (typeof pkg === "string" || pkg?.revision !== companion.revision))
throw new Error(`TUI companion revision does not match server revision ${companion.revision}: ${spec}`)
if (companion)
companionGraphs.set(spec, { revision: companion.revision, generation: companion.generation, entry: pkg })
const entrypoint = local ? await resolveLocal(local) : typeof pkg === "string" ? pkg : pkg?.entrypoint
// 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, install)
if (!entrypoint) return { status: "unsupported" as const }
// Content remains stable across the several mtimes one save may expose to
// filesystem watchers, while the generation keeps reverted modules fresh.
@@ -734,13 +589,7 @@ async function resolvePlugin(
while (true) {
const version = generation === undefined ? entrypoint : freshSpecifier(entrypoint, generation)
if (previous && previous.version === version && sameOptions(previous.options, options))
return {
status: "unchanged" as const,
plugin: previous.plugin,
version,
revision: previous.revision,
generation: previous.generation,
}
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(version)
if (generation !== undefined) {
const observed = await sourceGeneration(entrypoint)
@@ -752,16 +601,7 @@ async function resolvePlugin(
}
}
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
if (previous && mod.default.id !== previous.plugin.id)
throw new Error(`Plugin ID cannot change for ${spec}: ${previous.plugin.id} -> ${mod.default.id}`)
return {
status: "loaded" as const,
plugin: mod.default,
version,
revision: typeof pkg === "string" ? undefined : pkg?.revision,
generation:
generation === undefined ? (typeof pkg === "string" ? undefined : pkg?.generation) : String(generation),
}
return { status: "loaded" as const, plugin: mod.default, version }
}
}
@@ -771,9 +611,6 @@ function toRegistration(item: Desired): Registration {
source: item.source,
target: item.target,
version: item.version,
revision: item.revision,
generation: item.generation,
serverGeneration: item.serverGeneration,
options: snapshotOptions(item.options),
active: false,
routes: {},
@@ -789,9 +626,6 @@ function toDesired(item: Registration): Desired {
source: item.source,
target: item.target,
version: item.version,
revision: item.revision,
generation: item.generation,
serverGeneration: item.serverGeneration,
options: item.options,
enabled: item.active,
}
@@ -801,14 +635,6 @@ function sameOptions(a: Registration["options"], b: Registration["options"]) {
return isDeepEqual(a ?? null, b ?? null)
}
function pluginState(target: string, registration: Registration | undefined, error?: string): State {
const metadata = { revision: registration?.revision, generation: registration?.generation }
if (error && !registration?.active)
return { target, id: registration?.plugin.id, status: "failed", error, ...metadata }
if (!registration) return { target, status: "unsupported" }
return { target, id: registration.plugin.id, status: registration.active ? "active" : "inactive", error, ...metadata }
}
function sameGeneration(
a: Pick<Registration, "version" | "options"> | undefined,
b: Pick<Registration, "version" | "options">,
-4
View File
@@ -93,7 +93,6 @@ export function dialogSelectContentWidth(dialogWidth: number) {
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
readonly selected: DialogSelectOption<T> | undefined
moveTo(value: T): void
}
@@ -527,9 +526,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
get filtered() {
return filtered()
},
get selected() {
return selected()
},
moveTo(value) {
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
if (index >= 0) moveTo(index, true)
+12 -13
View File
@@ -7,7 +7,6 @@ import { Global } from "@opencode-ai/util/global"
import path from "node:path"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import { noPackages } from "./fixture/tui-packages"
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -33,7 +32,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: noPackages,
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
@@ -109,7 +108,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: noPackages,
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -183,7 +182,7 @@ test("session title generated while an untitled session is loading remains visib
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: noPackages,
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -274,7 +273,7 @@ test("session startup prompt is submitted exactly once", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: noPackages,
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy", prompt: "RESUME_READY" },
log: () => {},
@@ -355,7 +354,7 @@ test.each([false, true])("uses the resolved launch directory for new prompts (fa
get: async () => ({ animations: false, tabs: { enabled: false }, keybinds: { "session.new": "f6" } }),
update: async () => ({}),
},
packages: noPackages,
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
@@ -429,7 +428,7 @@ test("error investigations repeatedly seed editable home drafts without creating
get: async () => ({ animations: false, keybinds: { "mcp.list": "f6" } }),
update: async () => ({}),
},
packages: noPackages,
packages: { resolve: async () => undefined },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
@@ -510,7 +509,7 @@ test("shows jump to latest after scrolling one line above the final message", as
}),
update: async () => ({}),
},
packages: noPackages,
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -598,7 +597,7 @@ test("new session inherits the active session model", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: noPackages,
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -688,7 +687,7 @@ test("keeps the prompt display stable while a new location catalog loads", async
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: noPackages,
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
@@ -769,7 +768,7 @@ test("configured app bindings execute settings and permission commands", async (
}),
update: async () => ({}),
},
packages: noPackages,
packages: { resolve: async () => undefined },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -823,7 +822,7 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: noPackages,
packages: { resolve: async () => undefined },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -935,7 +934,7 @@ test.each(["manual", "select"] as const)(
}),
update: async () => ({}),
},
packages: noPackages,
packages: { resolve: async () => undefined },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},

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