Compare commits

..
141 changed files with 2537 additions and 6286 deletions
+38 -1
View File
@@ -247,6 +247,8 @@ it does not repair or truncate them.
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. Its `replacement` contains the next context window, including retained user messages. The client returns that result and usage directly, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
For `compact(request, { mechanism: "trigger" })`, script a `CompactionCheckpointResponse` instead. It carries `checkpoint`, `responseID`, and optional `usage`. Endpoint and trigger calls reject each other's fixtures; both share the same queue, gates, lazy recording, and fallback controls.
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
`testLayer`.
@@ -259,7 +261,7 @@ This is different from prompt caching, server-side history storage, or truncatio
### Explicit compaction
`LLMClient.compact(request)` is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response.
`LLMClient.compact(request)` (equivalently, `{ mechanism: "endpoint" }`) is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response. This mechanism does not accept a WebSocket executor.
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
@@ -279,6 +281,41 @@ Generation-only body overlays such as `stream` and `store` are not sent to the c
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. Anthropic does not expose this operation in this package; its in-band compaction remains available below. Compatible routes do not inherit an explicit compact endpoint simply because they use a Responses protocol.
### Streamed checkpoint compaction
OpenAI Responses also exposes a separate, explicitly selected mechanism:
```ts
const result =
yield *
LLMClient.compact(request, {
mechanism: "trigger",
webSocket, // Optional: without it, the request uses HTTP/SSE.
})
result.checkpoint // Successful encrypted CompactionPart.
result.responseID
result.usage
```
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request. It follows the [Codex V2 request shape](https://github.com/openai/codex/blob/728cb12/codex-rs/core/src/compact_remote_v2_attempt.rs), with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Session/cache identifiers, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
Only a successful `response.completed` with a response ID and exactly one logical encrypted checkpoint succeeds. Repeated item events are correlated by ID/output slot, including ID-less checkpoints. Other output is ignored, not returned as assistant text or dispatched as tools. Failed, incomplete, malformed, and interrupted responses return errors rather than partial checkpoints.
The result is **not a replacement window**. The caller selects retained history, combines it with `result.checkpoint`, and durably installs it before continuing. The operation does not choose a retention budget, prune messages, or modify the original request.
The supplied WebSocket executor can reuse a compatible append baseline for the compaction request. On completion the protocol supplies no continuation checkpoint, clearing the old baseline so the next generation sends the newly installed window in full. Validation occurs before transport completion is acknowledged. There is no operation-level retry or fallback to `/responses/compact`; existing safe transport fallback may use SSE, with full history and no connection-local response ID.
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
```ts
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
}
```
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
### Advanced: in-band compaction
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
@@ -42,6 +42,7 @@ const canonical = (value: unknown): string => {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
if (!ProviderShared.isRecord(value)) return ProviderShared.encodeJson(value)
return `{${Object.keys(value)
.filter((key) => value[key] !== undefined)
.sort()
.map((key) => `${ProviderShared.encodeJson(key)}:${canonical(value[key])}`)
.join(",")}}`
@@ -149,6 +150,12 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
}
if (observation.type !== "completed") return observation
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
if (
Array.isArray(request.input) &&
request.input.some((item) => ProviderShared.isRecord(item) && item.type === "compaction_trigger")
)
return observation
const responseID = event.response?.id
if (!responseID || responseID.trim().length === 0) return observation
return {
+7 -3
View File
@@ -920,7 +920,7 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
const outputItemID = (state: ParserState, event: Event) =>
const outputItemID = (state: Pick<ParserState, "outputItems">, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
@@ -932,7 +932,11 @@ const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
// An item without an id adopts the id already open in its output slot,
// otherwise it gets a locally minted one.
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
const resolveItem = (
state: Pick<ParserState, "outputItems">,
item: StreamItem,
index: number | undefined,
): OutputItem => ({
...item,
id:
item.id ??
@@ -942,7 +946,7 @@ const resolveItem = (state: ParserState, item: StreamItem, index: number | undef
// Registered output slots are authoritative for `item_id` routing, and items
// are resolved here so everything downstream can rely on `item.id`.
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
export const normalize = (state: Pick<ParserState, "outputItems">, input: Event): NormalizedEvent => ({
...input,
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
+44 -2
View File
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import type { LLMRequest, JsonSchema, ToolDefinition } from "../schema/index.js"
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
@@ -13,6 +13,7 @@ import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js"
const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses"
@@ -103,6 +104,18 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
/** Request control, never conversation content. */
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
const CheckpointBody = Schema.Struct({
...OpenAIResponsesBody.fields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
store: Schema.Literal(false),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
),
})
const adapter = {
id: ADAPTER,
name: NAME,
@@ -162,6 +175,35 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
})
})
const checkpointBody = {
schema: CheckpointBody,
from: Effect.fn("OpenAIResponses.checkpointBody")(function* (request: LLMRequest) {
const native = yield* fromRequest(LLMRequest.update(request, { toolChoice: undefined }))
const overlay = request.http?.body
// Complete history is required for stateless replay and SSE recovery. Raw input overrides bypass that contract.
if (
overlay?.input !== undefined ||
overlay?.previous_response_id !== undefined ||
overlay?.conversation !== undefined
)
return yield* ProviderShared.invalidRequest(
"Trigger compaction requires complete canonical history, not an input or continuation override",
)
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(CheckpointBody))({
...mergeJsonRecords(native, overlay),
input: [...native.input, { type: "compaction_trigger" }],
stream: true,
store: false,
parallel_tool_calls: true,
tool_choice: undefined,
context_management: undefined,
text: undefined,
max_output_tokens: undefined,
max_tool_calls: undefined,
})
}),
}
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
const isError = item.error !== undefined && item.error !== null
if (item.type === "image_generation_call" && item.result) {
@@ -239,7 +281,7 @@ export const transport = channelTransport({
})
export const route = Route.make({
compact: ResponsesCompaction.make(adapter),
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
@@ -0,0 +1,122 @@
import { Effect, Schema, Stream } from "effect"
import { Route, type RouteBody, type TriggerCompactOperation } from "../../route/client.js"
import { Protocol } from "../../route/protocol.js"
import { CompactionCheckpointResponse, HttpOptions, LLMEvent, LLMRequest } from "../../schema/index.js"
import { OpenResponses } from "../open-responses.js"
import { ProviderShared } from "../shared.js"
interface State {
readonly parser: Pick<OpenResponses.ParserState, "id" | "provider" | "outputItems">
readonly checkpoints: Readonly<Record<string, CompactionCheckpointResponse["checkpoint"]>>
readonly responseID?: string
}
const onOutputItem = Effect.fn("ResponsesCheckpoint.onOutputItem")(function* (
state: State,
input: OpenResponses.Event,
) {
const event = OpenResponses.normalize(state.parser, input)
const item = event.item
if (!item) return state
const parser =
event.output_index === undefined || state.parser.outputItems[event.output_index] === item.id
? state.parser
: { ...state.parser, outputItems: { ...state.parser.outputItems, [event.output_index]: item.id } }
const next = parser === state.parser ? state : { ...state, parser }
if (event.type === "response.output_item.added" || item.type !== "compaction") return next
if (
event.output_index !== undefined &&
Object.entries(state.parser.outputItems).some(
([index, id]) => id === item.id && Number(index) !== event.output_index,
)
)
return yield* ProviderShared.eventError(parser.id, "Compaction checkpoint appeared in multiple output slots")
if (!item.encrypted_content)
return yield* ProviderShared.eventError(parser.id, "Compaction output is missing its encrypted content")
const previous = state.checkpoints[item.id]
if (previous && previous.encrypted !== item.encrypted_content)
return yield* ProviderShared.eventError(parser.id, "Compaction output changed after completion")
if (previous) return next
return {
...next,
checkpoints: {
...state.checkpoints,
[item.id]: { type: "compaction", provider: parser.provider, id: item.id, encrypted: item.encrypted_content },
},
} satisfies State
})
/** Collect a trigger response before acknowledging transport completion. No generation output escapes. */
export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
Effect.fn("ResponsesCheckpoint.execute")(function* (request, executor, options) {
const source = request.model.route
let result: CompactionCheckpointResponse | undefined
// Route registries erase the frame type. The codec validates that boundary before parsing.
const event: Schema.Codec<OpenResponses.Event, unknown> = OpenResponses.protocol.stream.event
const protocol = Protocol.make({
id: source.protocol,
body,
stream: {
event,
initial: (request: LLMRequest): State => ({
parser: { id: source.id, provider: request.model.provider, outputItems: {} },
checkpoints: {},
}),
terminal: OpenResponses.terminal,
step: Effect.fn("ResponsesCheckpoint.step")(function* (state: State, event: OpenResponses.Event) {
if (event.response?.id && state.responseID && event.response.id !== state.responseID)
return yield* ProviderShared.eventError(source.id, "Compaction response ID changed during execution")
if (event.type === "response.created") return [{ ...state, responseID: event.response?.id }, []] as const
if (event.type === "error" || event.type === "response.failed")
return yield* OpenResponses.providerFailure(event, "Compaction request failed")
if (event.type === "response.incomplete")
return yield* ProviderShared.eventError(source.id, "Compaction response was incomplete")
if (event.type === "response.output_item.added" || event.type === "response.output_item.done")
return [yield* onOutputItem(state, event), []] as const
if (event.type !== "response.completed") return [state, []] as const
const responseID = event.response?.id
if (!responseID?.trim())
return yield* ProviderShared.eventError(source.id, "Compaction response is missing its response ID")
if (event.response?.status !== undefined && event.response.status !== "completed")
return yield* ProviderShared.eventError(source.id, "Compaction response did not complete successfully")
let next = state
for (const [index, item] of (event.response?.output ?? []).entries()) {
next = yield* onOutputItem(next, { type: "response.output_item.done", output_index: index, item })
}
const checkpoints = Object.values(next.checkpoints)
const checkpoint = checkpoints[0]
if (checkpoints.length !== 1 || !checkpoint)
return yield* ProviderShared.eventError(
source.id,
"Compaction response must contain exactly one checkpoint",
)
result = new CompactionCheckpointResponse({
checkpoint,
responseID,
usage: OpenResponses.mapUsage(event.response?.usage, OpenResponses.metadataKey(request.model)),
})
return [next, [LLMEvent.finish({ reason: { normalized: "stop" } })]] as const
}),
},
})
const route = Route.make({
id: source.id,
provider: source.provider,
providerMetadataKey: source.providerMetadataKey,
protocol,
endpoint: source.endpoint,
auth: source.auth,
transport: source.transport,
})
const native = yield* body.from(request)
// The body builder already applied and validated overlays. Do not let transport reapply them.
const preparedRequest = LLMRequest.update(request, {
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
})
const prepared = yield* route.prepareTransport(native, preparedRequest, options)
yield* route.streamPrepared(prepared, preparedRequest, { http: executor }, options).pipe(Stream.runDrain)
if (!result) return yield* ProviderShared.eventError(source.id, "Compaction response ended without a checkpoint")
return result
})
export * as ResponsesCheckpoint from "./responses-checkpoint.js"
+2 -2
View File
@@ -1,5 +1,5 @@
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
import type { CompactOperation } from "./route/client.js"
import type { CompactionOperations } from "./route/client.js"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
@@ -10,7 +10,7 @@ export interface Settings extends Readonly<Record<string, unknown>> {
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
}
+4 -3
View File
@@ -1,7 +1,7 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -39,6 +39,7 @@ export type Settings = ProviderPackage.Settings &
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
const responsesRoute = OpenAIResponses.route.with({
compact: { endpoint: OpenAIResponses.route.compact.endpoint },
id: "azure-openai-responses",
provider: id,
auth: routeAuth,
@@ -102,7 +103,7 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
modelID: string | ModelID,
@@ -168,7 +169,7 @@ const config = (settings: Settings): Config => {
export const responsesModel: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
CompactOperation
typeof responsesRoute.compact
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
+7 -6
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -73,7 +73,7 @@ const defaults = (input: Config) => {
return rest
}
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
) =>
@@ -132,10 +132,11 @@ const config = (settings: Settings): Config => {
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) => {
export const model: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
typeof OpenAIResponses.route.compact
>["model"] = (modelID, settings) => {
return configure(config(settings)).responses(modelID)
}
+7 -6
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
@@ -32,7 +32,7 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
compact: XAIResponses.compact,
compact: { endpoint: XAIResponses.compact },
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
@@ -103,10 +103,11 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<
Settings,
XAIProviderOptionsInput,
typeof responsesRoute.compact
>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
+114 -39
View File
@@ -14,6 +14,7 @@ import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
AIError,
CompactionResponse,
CompactionCheckpointResponse,
AIErrorReason,
GenerationOptions,
HttpOptions,
@@ -38,7 +39,7 @@ export interface RouteBody<Body> {
export interface Route<
Body,
Prepared = unknown,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
readonly compact: Compact
readonly id: string
@@ -53,7 +54,15 @@ export interface Route<
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
readonly with: {
<Next extends CompactionOperations | undefined>(
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
): Route<Body, Prepared, Next>
(
patch: Omit<RoutePatch<Body, Prepared>, "compact"> & { readonly compact?: undefined },
): Route<Body, Prepared, Compact>
(patch: RoutePatch<Body, Prepared>): Route<Body, Prepared>
}
readonly model: <Options extends ProviderOptions = ProviderOptions>(
input: RouteMappedLanguageModelInput,
) => LanguageModel<Options, Compact>
@@ -74,7 +83,7 @@ export interface Route<
// Normal call sites use `OpenAIChat.route`; callers only need body types
// when preparing a request with a protocol-specific type assertion.
// oxlint-disable-next-line typescript-eslint/no-explicit-any
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
export type AnyRoute<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Route<
any,
any,
Compact
@@ -101,6 +110,7 @@ export interface RouteDefaultsInput {
}
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly compact?: CompactionOperations
readonly id?: string
readonly provider?: string | ProviderID
readonly providerMetadataKey?: string
@@ -111,7 +121,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
route: AnyRoute<Compact>,
mapped: RouteMappedLanguageModelInput,
) => {
@@ -162,10 +172,7 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
}
export interface Interface {
readonly compact: (
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
readonly compact: CompactMethod
readonly stream: StreamMethod
readonly generate: GenerateMethod
}
@@ -189,12 +196,64 @@ export type CompactOperation = (
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
export type CompactionRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, CompactOperation>
export type TriggerCompactOperation = (
request: LLMRequest,
executor: RequestExecutor.Interface,
options: TriggerCompactOptions,
) => Effect.Effect<CompactionCheckpointResponse, AIError>
/** Protocol capabilities, not deployment/model eligibility. */
export interface CompactionOperations {
readonly endpoint?: CompactOperation
readonly trigger?: TriggerCompactOperation
}
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
request.model.route.compact !== undefined
export interface EndpointCompactOptions extends Pick<StreamOptions, "http"> {
readonly mechanism?: "endpoint"
readonly webSocket?: never
}
export interface TriggerCompactOptions extends StreamOptions {
readonly mechanism: "trigger"
}
// Keep the required route shape explicit: the schema class's self type erases its model parameter in assignability.
export type CompactionRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, { readonly endpoint: CompactOperation }>
}
export type CheckpointRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, { readonly trigger: TriggerCompactOperation }>
}
export interface CompactMethod<R = never> {
(request: CheckpointRequest, options: TriggerCompactOptions): Effect.Effect<CompactionCheckpointResponse, AIError, R>
(request: CompactionRequest, options?: EndpointCompactOptions): Effect.Effect<CompactionResponse, AIError, R>
}
export function canCompact(
request: LLMRequest,
options?: { readonly mechanism?: "endpoint" },
): request is CompactionRequest
export function canCompact(
request: LLMRequest,
options: { readonly mechanism: "trigger" },
): request is CheckpointRequest
export function canCompact(request: LLMRequest, options?: { readonly mechanism?: string }) {
if (options?.mechanism === "trigger") return request.model.route.compact?.trigger !== undefined
if (options?.mechanism !== undefined && options.mechanism !== "endpoint") return false
return request.model.route.compact?.endpoint !== undefined
}
const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefined) => {
if (mechanism !== undefined && mechanism !== "endpoint" && mechanism !== "trigger")
return ProviderShared.invalidRequest(`Unknown compaction mechanism: ${mechanism}`)
return ProviderShared.unsupportedOperation({
operation: mechanism === "trigger" ? "compact.trigger" : "compact",
provider: request.model.provider,
route: request.model.route.id,
message: `${request.model.provider}/${request.model.route.id} does not support ${mechanism === "trigger" ? "trigger" : "explicit"} compaction`,
})
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -216,7 +275,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
}
export interface MakeInput<Body, Frame, Event, State> {
readonly compact?: CompactOperation
readonly compact?: CompactionOperations
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -238,7 +297,7 @@ export interface MakeInput<Body, Frame, Event, State> {
}
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
readonly compact?: CompactOperation
readonly compact?: CompactionOperations
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -326,9 +385,10 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: routeInput.defaults ?? {},
body: protocol.body,
with: (patch: RoutePatch<Body, Prepared>) => {
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
return build({
...routeInput,
compact: "compact" in patch ? compact : routeInput.compact,
id: id ?? routeInput.id,
provider: provider ?? routeInput.provider,
providerMetadataKey:
@@ -343,7 +403,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
})
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
makeRouteLanguageModel<Options, CompactionOperations | undefined>(route, input),
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
@@ -440,12 +500,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
}
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, Prepared, CompactOperation>
export function make<Body, Frame, Event, State>(
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
export function make<Body, Prepared, Frame, Event, State, Compact extends CompactionOperations>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: Compact },
): Route<Body, Prepared, Compact>
export function make<Body, Frame, Event, State, Compact extends CompactionOperations>(
input: MakeInput<Body, Frame, Event, State> & { readonly compact: Compact },
): Route<Body, HttpTransport.HttpPrepared<Frame>, Compact>
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared>
@@ -557,14 +617,23 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
})
}
export const compact = (
export function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError, Service>
export function compact(
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
): Effect.Effect<CompactionResponse, AIError, Service> =>
Effect.gen(function* () {
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError, Service>
export function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
return Effect.gen(function* () {
const client = yield* Service
return yield* client.compact(request, options)
if (options?.mechanism === "trigger" && canCompact(request, options)) return yield* client.compact(request, options)
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
return yield* client.compact(request, options)
return yield* unsupportedCompaction(request, options?.mechanism)
})
}
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
@@ -578,21 +647,27 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const stream = streamRequestWith({ http: executor })
function compact(
request: CompactionRequest,
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError>
function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError>
function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
return Effect.suspend((): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> => {
if (options?.mechanism === "trigger" && canCompact(request, options))
return request.model.route.compact.trigger(prepareRequest(request), executor, options)
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
return request.model.route.compact.endpoint(prepareRequest(request), executor, options)
return unsupportedCompaction(request, options?.mechanism)
})
}
return Service.of({
stream,
generate: generateWith(stream),
compact: (request, options) =>
Effect.suspend(() => {
const operation = request.model.route.compact
if (!operation)
return ProviderShared.unsupportedOperation({
operation: "compact",
provider: request.model.provider,
route: request.model.route.id,
message: `${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
})
return operation(prepareRequest(request), executor, options)
}),
compact,
})
}),
)
+6
View File
@@ -9,6 +9,12 @@ export type {
Interface as LLMClientShape,
Service as LLMClientService,
StreamOptions,
CompactMethod,
CompactionOperations,
CompactionRequest,
CheckpointRequest,
EndpointCompactOptions,
TriggerCompactOptions,
} from "./client.js"
export * from "./executor.js"
export { Auth } from "./auth.js"
+15
View File
@@ -97,6 +97,21 @@ export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.Co
usage: Schema.optional(Usage),
}) {}
/** A checkpoint only; retained history and replacement-window construction belong to the caller. */
export class CompactionCheckpointResponse extends Schema.Class<CompactionCheckpointResponse>(
"LLM.CompactionCheckpointResponse",
)({
checkpoint: CompactionPart.pipe(
Schema.refine(
(part): part is CompactionPart & { readonly encrypted: string; readonly text?: never } =>
part.encrypted !== undefined && part.encrypted.length > 0,
{ message: "A checkpoint response requires encrypted compaction content" },
),
),
responseID: Schema.String.check(Schema.isPattern(/\S/)),
usage: Schema.optional(Usage),
}) {}
export const StepStart = Schema.Struct({
type: Schema.tag("step-start"),
index: Schema.Number,
+8 -8
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { ModelID, ProviderID } from "./ids.js"
import type { AnyRoute, CompactOperation } from "../route/client.js"
import type { AnyRoute, CompactionOperations } from "../route/client.js"
import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
@@ -175,7 +175,7 @@ export namespace LanguageModelCompatibility {
export class LanguageModel<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
@@ -194,7 +194,7 @@ export class LanguageModel<
static make<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
>(input: LanguageModel.Input<Compact>) {
return new LanguageModel<Options, Compact>({
id: ModelID.make(input.id),
@@ -206,7 +206,7 @@ export class LanguageModel<
})
}
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
static input<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
model: LanguageModel<Options, Compact>,
): LanguageModel.ConstructorInput<Compact> {
return {
@@ -218,11 +218,11 @@ export class LanguageModel<
}
}
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
model: LanguageModel<Options, Compact>,
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
): LanguageModel<Options, Compact>
@@ -241,7 +241,7 @@ export class LanguageModel<
}
export namespace LanguageModel {
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
export type ConstructorInput<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute<Compact>
@@ -249,7 +249,7 @@ export namespace LanguageModel {
readonly compatibility?: LanguageModelCompatibility
}
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
export type Input<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Omit<
ConstructorInput<Compact>,
"id" | "provider" | "defaults" | "compatibility"
> & {
+39 -11
View File
@@ -1,10 +1,17 @@
export * as TestLLM from "./testing.js"
import { LLMClient } from "./route/client.js"
import {
LLMClient,
type CompactionRequest,
type CheckpointRequest,
type EndpointCompactOptions,
type TriggerCompactOptions,
} from "./route/client.js"
import {
LLMEvent,
LLMResponse,
CompactionResponse,
CompactionCheckpointResponse,
type FinishReasonDetails,
type AIError,
type LLMRequest,
@@ -13,7 +20,11 @@ import {
} from "./schema/index.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
export type Response =
| readonly LLMEvent[]
| Stream.Stream<LLMEvent, AIError>
| CompactionResponse
| CompactionCheckpointResponse
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
@@ -132,21 +143,38 @@ const make = (options: LayerOptions) =>
Stream.unwrap(
take(request).pipe(
Effect.map((response) => {
if (response instanceof CompactionResponse)
if (response instanceof CompactionResponse || response instanceof CompactionCheckpointResponse)
return Stream.die("TestLLM generation requires an event response")
return Stream.isStream(response) ? response : Stream.fromIterable(response)
}),
),
)
const test = Test.of({
compact: (request) =>
take(request).pipe(
Effect.flatMap((response) =>
response instanceof CompactionResponse
function compact(
request: CompactionRequest,
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError>
function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError>
function compact(
request: LLMRequest,
options?: EndpointCompactOptions | TriggerCompactOptions,
): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> {
return take(request).pipe(
Effect.flatMap((response): Effect.Effect<CompactionResponse | CompactionCheckpointResponse> => {
if (options?.mechanism === "trigger")
return response instanceof CompactionCheckpointResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse"),
),
),
: Effect.die("TestLLM trigger compaction requires a CompactionCheckpointResponse")
return response instanceof CompactionResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse")
}),
)
}
const test = Test.of({
compact,
stream,
generate: (request) =>
stream(request).pipe(
@@ -0,0 +1,146 @@
import { Effect } from "effect"
import {
CompactionCheckpointResponse,
CompactionResponse,
LanguageModel,
LLM,
LLMClient,
LLMRequest,
} from "../../src/index.js"
import {
Anthropic,
Azure,
AmazonBedrock,
AmazonBedrockMantle,
OpenAI,
OpenAICompatibleResponses,
XAI,
} from "../../src/providers.js"
import type { WebSocketChannelExecutor } from "../../src/route.js"
import type { RoutePatch } from "../../src/route/client.js"
import type { OpenAIResponsesBody } from "../../src/protocols/openai-responses.js"
import type { Prepared } from "../../src/protocols/open-responses-channel.js"
declare const webSocket: WebSocketChannelExecutor
const model = OpenAI.configure().responses("fixture")
const request = LLM.request({ model, prompt: "hello" })
LLMClient.compact(request).pipe(Effect.map((result) => result satisfies CompactionResponse))
LLMClient.compact(request, { mechanism: "endpoint" }).pipe(Effect.map((result) => result satisfies CompactionResponse))
LLMClient.compact(request, { mechanism: "trigger", webSocket }).pipe(
Effect.map((result) => {
result satisfies CompactionCheckpointResponse
result.checkpoint.encrypted satisfies string
result.responseID satisfies string
// @ts-expect-error A trigger does not return replacement history.
result.replacement
}),
)
// @ts-expect-error Endpoint compaction does not accept a WebSocket executor.
LLMClient.compact(request, { mechanism: "endpoint", webSocket })
// @ts-expect-error Omitting mechanism selects the HTTP endpoint.
LLMClient.compact(request, { webSocket })
// @ts-expect-error Unknown mechanisms do not have a permissive fallback overload.
LLMClient.compact(request, { mechanism: "other" })
for (const selected of [
model,
OpenAI.model("fixture", {}),
model.route.with({ headers: { fixture: "test" } }).model({ id: "fixture" }),
LanguageModel.make(LanguageModel.input(model)),
LanguageModel.update(model, { defaults: { generation: { maxTokens: 100 } } }),
]) {
LLMClient.compact(LLM.request({ model: selected }), { mechanism: "trigger" })
}
LLMClient.compact(new LLMRequest(LLMRequest.input(request)), { mechanism: "trigger" })
LLMClient.compact(LLMRequest.update(request, { messages: [] }), { mechanism: "trigger" })
const azure = Azure.configure({ resourceName: "fixture" }).responses("fixture")
const xai = XAI.configure().responses("fixture")
LLMClient.compact(LLM.request({ model: azure }))
LLMClient.compact(LLM.request({ model: xai }))
// @ts-expect-error Azure must not inherit OpenAI's trigger operation.
LLMClient.compact(LLM.request({ model: azure }), { mechanism: "trigger" })
LLMClient.compact(LLM.request({ model: Azure.responsesModel("fixture", { resourceName: "fixture" }) }), {
// @ts-expect-error Azure's package entrypoint must preserve its narrower capability.
mechanism: "trigger",
})
// @ts-expect-error xAI endpoint support does not imply trigger support.
LLMClient.compact(LLM.request({ model: xai }), { mechanism: "trigger" })
// @ts-expect-error xAI's package entrypoint must preserve its narrower capability.
LLMClient.compact(LLM.request({ model: XAI.model("fixture", {}) }), { mechanism: "trigger" })
const unsupported = {
bedrock: LLM.request({ model: AmazonBedrock.configure().model("fixture") }),
mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }),
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
openai: LLM.request({ model: OpenAI.configure().chat("fixture") }),
azure: LLM.request({ model: Azure.configure({ resourceName: "fixture" }).chat("fixture") }),
xai: LLM.request({ model: XAI.configure().chat("fixture") }),
compatible: LLM.request({
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
}),
}
// @ts-expect-error Bedrock does not expose endpoint compaction.
LLMClient.compact(unsupported.bedrock)
// @ts-expect-error Bedrock does not expose trigger compaction.
LLMClient.compact(unsupported.bedrock, { mechanism: "trigger" })
// @ts-expect-error Mantle does not expose endpoint compaction.
LLMClient.compact(unsupported.mantle)
// @ts-expect-error Mantle must not inherit trigger support from OpenAI's protocol.
LLMClient.compact(unsupported.mantle, { mechanism: "trigger" })
// @ts-expect-error Anthropic does not expose endpoint compaction.
LLMClient.compact(unsupported.anthropic)
// @ts-expect-error Anthropic does not expose trigger compaction.
LLMClient.compact(unsupported.anthropic, { mechanism: "trigger" })
// @ts-expect-error OpenAI Chat does not expose endpoint compaction.
LLMClient.compact(unsupported.openai)
// @ts-expect-error OpenAI Chat does not expose trigger compaction.
LLMClient.compact(unsupported.openai, { mechanism: "trigger" })
// @ts-expect-error Azure Chat does not expose endpoint compaction.
LLMClient.compact(unsupported.azure)
// @ts-expect-error Azure Chat does not expose trigger compaction.
LLMClient.compact(unsupported.azure, { mechanism: "trigger" })
// @ts-expect-error xAI Chat does not expose endpoint compaction.
LLMClient.compact(unsupported.xai)
// @ts-expect-error xAI Chat does not expose trigger compaction.
LLMClient.compact(unsupported.xai, { mechanism: "trigger" })
// @ts-expect-error Generic protocol compatibility does not grant endpoint support.
LLMClient.compact(unsupported.compatible)
// @ts-expect-error Generic protocol compatibility does not grant trigger support.
LLMClient.compact(unsupported.compatible, { mechanism: "trigger" })
// @ts-expect-error Changing the model replaces its capability.
LLMClient.compact(LLMRequest.update(request, { model: azure }), { mechanism: "trigger" })
// @ts-expect-error Changing the route replaces its capability.
LLMClient.compact(LLM.request({ model: LanguageModel.update(model, { route: azure.route }) }), { mechanism: "trigger" })
LLMClient.compact(
LLM.request({
model: model.route.with({ compact: { endpoint: model.route.compact.endpoint } }).model({ id: "fixture" }),
}),
// @ts-expect-error Replacing route operations does not retain the old trigger capability.
{ mechanism: "trigger" },
)
declare const dynamic: LLMRequest
declare const patch: Partial<LLMRequest.Input>
declare const routePatch: RoutePatch<OpenAIResponsesBody, Prepared>
// @ts-expect-error A dynamic operation override cannot preserve trigger support.
LLMClient.compact(LLM.request({ model: model.route.with(routePatch).model({ id: "fixture" }) }), {
mechanism: "trigger",
})
// @ts-expect-error Explicitly removing operations removes trigger support.
LLMClient.compact(LLM.request({ model: model.route.with({ compact: undefined }).model({ id: "fixture" }) }), {
mechanism: "trigger",
})
// @ts-expect-error Dynamic models must be narrowed.
LLMClient.compact(dynamic, { mechanism: "trigger" })
if (LLMClient.canCompact(dynamic, { mechanism: "trigger" })) {
LLMClient.compact(dynamic, { mechanism: "trigger" })
LLMClient.Service.use((client) => client.compact(dynamic, { mechanism: "trigger" }))
}
if (LLMClient.canCompact(dynamic)) {
LLMClient.compact(dynamic)
// @ts-expect-error Endpoint narrowing does not grant trigger support.
LLMClient.compact(dynamic, { mechanism: "trigger" })
}
// @ts-expect-error A dynamic model override cannot preserve trigger support.
LLMClient.compact(LLMRequest.update(request, patch), { mechanism: "trigger" })
@@ -0,0 +1,374 @@
import { expect, test } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { CompactionCheckpointResponse, LLM, LLMClient, LLMRequest, LanguageModel, SystemPart } from "../../src/index.js"
import { Anthropic, Azure, OpenAI, XAI } from "../../src/providers.js"
import { Route } from "../../src/route/client.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse, scriptedResponses, truncatedStream } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
const request = LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("fixture"), prompt: "hello" })
const trigger = { mechanism: "trigger" } as const
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(new URL(request.url).pathname).toBe("/v1/responses")
expect(new URL(request.url).searchParams.get("deployment")).toBe("fixture")
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
expect(request.headers.authorization).toBe("Bearer fixture")
expect(request.headers["chatgpt-account-id"]).toBe("fixture-account")
expect(request.headers["x-codex-beta-features"]).toBe("remote_compaction_v2")
expect(request.headers["x-deployment"]).toBe("resolved")
const body = JSON.parse(text)
expect(body).toMatchObject({
model: "fixture",
stream: true,
store: false,
instructions: "system\noperator",
parallel_tool_calls: true,
prompt_cache_key: "session-key",
service_tier: "priority",
reasoning: { effort: "high", summary: "auto" },
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "session", ttl: "1h" },
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }, { type: "compaction_trigger" }],
})
expect(body.tools).toHaveLength(1)
expect(body.tools[0].name).toBe("lookup")
expect(body.tool_choice).toBeUndefined()
expect(body.context_management).toBeUndefined()
expect(body.text).toBeUndefined()
expect(body.max_output_tokens).toBeUndefined()
expect(body.previous_response_id).toBeUndefined()
return respond(
sseEvents({
type: "response.completed",
response: {
id: "resp_1",
output: [checkpoint],
usage: {
input_tokens: 100,
input_tokens_details: { cached_tokens: 40 },
output_tokens: 5,
total_tokens: 105,
},
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
).effect("trigger uses normal request preparation, configured deployment, and supplied subscription headers", () =>
Effect.gen(function* () {
const calls: string[] = []
const route = Route.make({
id: "fixture-responses",
provider: "openai",
protocol: OpenAIResponses.protocol,
compact: OpenAIResponses.route.compact,
endpoint: OpenAIResponses.route.endpoint,
auth: request.model.route.auth,
transport: OpenAIResponses.transport,
headers: () => {
calls.push("headers")
return { "x-deployment": "resolved" }
},
}).with({ endpoint: { query: { deployment: "fixture" } } })
const input = LLM.request({
model: route.model({ id: "fixture" }),
system: [SystemPart.make("system"), SystemPart.make("operator")],
prompt: "hello",
promptCacheKey: "session-key",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
toolChoice: { type: "tool", name: "lookup" },
generation: { maxTokens: 1 },
providerOptions: {
store: true,
reasoningEffort: "high",
reasoningSummary: "auto",
contextManagement: [{ type: "compaction" }],
},
http: {
headers: { "chatgpt-account-id": "fixture-account", "x-codex-beta-features": "remote_compaction_v2" },
query: { trace: "request" },
body: {
service_tier: "priority",
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "session", ttl: "1h" },
store: true,
stream: false,
text: { format: { type: "json_object" } },
tool_choice: "required",
},
},
})
const original = LLMRequest.input(input)
const result = yield* LLMClient.compact(input, {
...trigger,
http: (request, next) => {
calls.push("http")
return next(request)
},
})
expect(result).toBeInstanceOf(CompactionCheckpointResponse)
expect(result.checkpoint).toMatchObject({
type: "compaction",
provider: "openai",
id: "cmp_1",
encrypted: "opaque",
})
expect(result.responseID).toBe("resp_1")
expect(result.usage).toMatchObject({
inputTokens: 100,
outputTokens: 5,
totalTokens: 105,
cacheReadInputTokens: 40,
})
expect(LLMRequest.input(input)).toEqual(original)
expect(calls).toEqual(["headers", "http"])
const codec = Schema.fromJsonString(CompactionCheckpointResponse)
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(result))).toEqual(result)
expect("replacement" in result).toBe(false)
}),
)
for (const id of ["cmp_1", undefined]) {
for (const added of [true, false]) {
const item = { type: "compaction", id, encrypted_content: "opaque" }
testEffect(
fixedResponse(
sseEvents(
{ type: "response.created", response: { id: "resp_1" } },
...(added ? [{ type: "response.output_item.added", output_index: 0, item: { type: "compaction", id } }] : []),
{ type: "response.output_item.done", output_index: 0, item },
{ type: "response.output_item.done", output_index: 0, item },
{ type: "response.completed", response: { id: "resp_1", output: [item] } },
),
),
).effect(`correlates repeated checkpoint events: id=${id}, added=${added}`, () =>
Effect.gen(function* () {
const result = yield* LLMClient.compact(request, trigger)
expect(result.checkpoint.encrypted).toBe("opaque")
expect(result.checkpoint.id).toBeString()
}),
)
}
}
testEffect(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" },
},
{ type: "response.output_text.delta", delta: "do not expose this" },
{
type: "response.completed",
response: {
id: "resp_1",
output: [{ type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" }, checkpoint],
},
},
),
),
).effect("ignores other output rather than generating an answer or dispatching tools", () =>
Effect.gen(function* () {
const result = yield* LLMClient.compact(request, trigger)
expect(result.checkpoint.encrypted).toBe("opaque")
expect("message" in result).toBe(false)
}),
)
for (const [name, events] of Object.entries({
missing: [{ type: "response.completed", response: { id: "resp_1", output: [] } }],
multiple: [
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, { ...checkpoint, id: "cmp_2" }] } },
],
duplicateSlots: [{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, checkpoint] } }],
malformed: [
{ type: "response.completed", response: { id: "resp_1", output: [{ type: "compaction", id: "cmp_1" }] } },
],
empty: [
{ type: "response.completed", response: { id: "resp_1", output: [{ ...checkpoint, encrypted_content: "" }] } },
],
wrongType: [
{ type: "response.completed", response: { id: "resp_1", output: [{ ...checkpoint, encrypted_content: 42 }] } },
],
noResponseID: [{ type: "response.completed", response: { output: [checkpoint] } }],
changedID: [
{ type: "response.created", response: { id: "resp_1" } },
{ type: "response.completed", response: { id: "resp_2", output: [checkpoint] } },
],
changedCheckpoint: [
{ type: "response.output_item.done", item: checkpoint },
{
type: "response.completed",
response: { id: "resp_1", output: [{ ...checkpoint, encrypted_content: "changed" }] },
},
],
incomplete: [
{ type: "response.output_item.done", item: checkpoint },
{ type: "response.incomplete", response: { id: "resp_1", incomplete_details: { reason: "max_output_tokens" } } },
],
failed: [
{ type: "response.output_item.done", item: checkpoint },
{ type: "response.failed", response: { id: "resp_1", error: { code: "server_error", message: "failed" } } },
],
wrongStatus: [{ type: "response.completed", response: { id: "resp_1", status: "incomplete", output: [checkpoint] } }],
})) {
const wire = events.map((event) => ({ ...event, fixture_extra: "preserved" }))
testEffect(
fixedResponse(sseEvents(...wire), { headers: { "content-type": "text/event-stream", "x-fixture": "preserved" } }),
).effect(`rejects ${name} checkpoint response and preserves original error context`, () =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
expect(error.reason.body).toBe(JSON.stringify(wire.at(-1)))
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-fixture": "preserved" } })
}),
)
}
testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: checkpoint }))).effect(
"rejects clean EOF without response.completed",
() =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
)
testEffect(truncatedStream([sseEvents({ type: "response.output_item.done", item: checkpoint })])).effect(
"does not return a checkpoint from an interrupted stream",
() =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
expect(error.reason._tag).toBe("Transport")
}),
)
for (const body of [{ input: [] }, { previous_response_id: "stale" }, { conversation: "stored" }]) {
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
`rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`,
() =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(LLMRequest.update(request, { http: { body } }), trigger).pipe(
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
}
test("trigger capability follows selected routes, independently of endpoint support", () => {
expect(LLMClient.canCompact(request, trigger)).toBe(true)
for (const model of [
Azure.configure({ resourceName: "fixture" }).responses("fixture"),
XAI.configure().responses("fixture"),
]) {
expect(LLMClient.canCompact(LLM.request({ model }))).toBe(true)
expect(LLMClient.canCompact(LLM.request({ model }), trigger)).toBe(false)
expect(LLMClient.canCompact(LLMRequest.update(request, { model }), trigger)).toBe(false)
expect(
LLMClient.canCompact(
LLM.request({ model: LanguageModel.update(request.model, { route: model.route }) }),
trigger,
),
).toBe(false)
}
})
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
"untyped unsupported mechanisms and routes fail locally in both client surfaces",
() =>
Effect.gen(function* () {
const client = yield* LLMClient.Service
for (const model of [
Anthropic.configure().model("fixture"),
Azure.configure({ resourceName: "fixture" }).responses("fixture"),
]) {
const unsupported = LLM.request({ model })
// @ts-expect-error Exercise untyped consumers; runtime must still reject unsupported routes.
const error = yield* LLMClient.compact(unsupported, trigger).pipe(Effect.flip)
expect(error.reason._tag).toBe("UnsupportedOperation")
// @ts-expect-error The service has the same runtime guard.
const serviceError = yield* client.compact(unsupported, trigger).pipe(Effect.flip)
expect(serviceError.reason._tag).toBe("UnsupportedOperation")
}
// @ts-expect-error Exercise an unknown mechanism supplied by JavaScript.
const error = yield* LLMClient.compact(request, { mechanism: "other" }).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
// @ts-expect-error An empty string is not the default mechanism.
const empty = yield* client.compact(request, { mechanism: "" }).pipe(Effect.flip)
expect(empty.reason._tag).toBe("InvalidRequest")
}),
)
for (const valid of [true, false]) {
testEffect(fixedResponse("must not use HTTP")).effect(
`acknowledges channel completion only after validation: valid=${valid}`,
() =>
Effect.gen(function* () {
let completed = 0
const operation = LLMClient.compact(request, {
mechanism: "trigger",
webSocket: {
execute: () =>
Effect.succeed({
frames: Stream.make(
JSON.stringify({
type: "response.completed",
response: { id: "resp_1", output: valid ? [checkpoint] : [] },
}),
),
complete: Effect.sync(() => {
completed++
}),
}),
},
})
const result = yield* Effect.result(operation)
expect(result._tag).toBe(valid ? "Success" : "Failure")
expect(completed).toBe(valid ? 1 : 0)
}),
)
}
test("checkpoint result schema rejects failed or unencrypted representations", () => {
const decode = Schema.decodeUnknownSync(CompactionCheckpointResponse)
for (const checkpoint of [
{ type: "compaction", provider: "anthropic", text: null },
{ type: "compaction", provider: "anthropic", text: "summary" },
{ type: "compaction", provider: "openai", encrypted: "" },
])
expect(() => decode({ checkpoint, responseID: "resp_1" })).toThrow()
expect(() =>
decode({ checkpoint: { type: "compaction", provider: "openai", encrypted: "opaque" }, responseID: " " }),
).toThrow()
})
testEffect(
scriptedResponses([
sseEvents(
{ type: "response.created", response: { id: "resp_discarded" } },
{ type: "response.output_item.done", item: { ...checkpoint, encrypted_content: "discarded" } },
{ type: "response.incomplete", response: { id: "resp_discarded", usage: { input_tokens: 999 } } },
),
sseEvents({
type: "response.completed",
response: { id: "resp_success", output: [checkpoint], usage: { input_tokens: 12 } },
}),
]),
).effect("an explicitly retried effect does not reuse failed-attempt checkpoint or metadata", () =>
Effect.gen(function* () {
const operation = LLMClient.compact(request, trigger)
yield* operation.pipe(Effect.flip)
const result = yield* operation
expect(result.checkpoint.encrypted).toBe("opaque")
expect(result.responseID).toBe("resp_success")
expect(result.usage?.inputTokens).toBe(12)
}),
)
+42
View File
@@ -3,6 +3,7 @@ import {
AIError,
CompactionPart,
CompactionResponse,
CompactionCheckpointResponse,
LanguageModel,
LLM,
LLMClient,
@@ -79,6 +80,47 @@ describe("TestLLM legacy client", () => {
})
describe("TestLLM first-class client", () => {
it.effect("scripts trigger checkpoints lazily with gates, queue order, and fallbacks", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
const request = LLM.request({ model: OpenAI.configure().responses("fixture"), prompt: "hello" })
const checkpoint = new CompactionCheckpointResponse({
checkpoint: { type: "compaction", provider: ProviderID.make("openai"), encrypted: "opaque" },
responseID: "resp_fixture",
})
const endpoint = new CompactionResponse({ replacement: [] })
yield* client.push(checkpoint, endpoint)
const operation = LLMClient.compact(request, { mechanism: "trigger" })
expect(yield* client.requests()).toEqual([])
const gate = yield* client.gate()
const fiber = yield* operation.pipe(Effect.forkChild({ startImmediately: true }))
yield* gate.started
yield* client.wait(1)
expect(fiber.pollUnsafe()).toBeUndefined()
yield* gate.release
expect(yield* Fiber.join(fiber)).toBe(checkpoint)
expect(yield* client.compact(request)).toBe(endpoint)
yield* client.serve((observed) => {
expect(observed).toBe(request)
return checkpoint
})
expect(yield* LLMClient.compact(request, { mechanism: "trigger" })).toBe(checkpoint)
yield* client.always(checkpoint)
expect(yield* LLMClient.compact(request, { mechanism: "trigger" })).toBe(checkpoint)
yield* client.push(endpoint, checkpoint, checkpoint)
expect(yield* client.compact(request, { mechanism: "trigger" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM trigger compaction requires a CompactionCheckpointResponse",
)
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM compaction requires a CompactionResponse",
)
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM generation requires an event response",
)
expect(yield* client.requests()).toHaveLength(7)
}),
)
it.effect("rejects response fixtures for the wrong operation", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
+1 -1
View File
@@ -5,5 +5,5 @@
"noEmit": true,
"rootDir": "."
},
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
"include": ["test/**/*.types.ts", "test/testing.test.ts", "test/provider/checkpoint.test.ts"]
}
@@ -78,7 +78,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
const notices = page.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toHaveText(/^Agent changed\s*Explore$/)
await expect(notices.nth(0)).toContainText("Agent · explore")
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
@@ -182,15 +182,20 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await expect(card).not.toContainText("(background)")
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
const hint = page.getByRole("button", { name: /move running work to the background/i })
const hint = page.locator('[data-component="session-background-hint"]')
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
await expect(hint).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect
.poll(async () => {
const [cardBox, hintBox] = await Promise.all([card.boundingBox(), hint.boundingBox()])
if (!cardBox || !hintBox) return undefined
const [cardBox, hintBox, prefixBox] = await Promise.all([
card.boundingBox(),
hint.boundingBox(),
hintPrefix.boundingBox(),
])
if (!cardBox || !hintBox || !prefixBox) return undefined
return {
aligned: Math.abs(cardBox.x - hintBox.x) < 2,
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
ordered: cardBox.y < hintBox.y,
}
})
@@ -215,10 +220,10 @@ test("navigates from a running subagent card and hides background controls in th
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.getByRole("button", { name: /move running work to the background/i })).toHaveCount(0)
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
})
for (const name of ["shell", "subagent"] as const) {
@@ -262,7 +267,7 @@ for (const name of ["shell", "subagent"] as const) {
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
await expect(group).toBeVisible()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
@@ -281,9 +286,9 @@ test("shows a badge for active background work", async ({ page }) => {
})
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "1 background task running", exact: true })
const summary = page.getByRole("button", { name: "1 item running in background" })
await expect(summary).toContainText("1")
await expect(summary).toContainText("1 background task running")
await expect(summary).toContainText("Running work in background")
await summary.click()
await expect(
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
@@ -382,7 +387,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
},
})
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
const used = page
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
@@ -391,7 +396,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
const summary = page.getByRole("button", { name: "2 items running in background" })
await expect(summary).toContainText("2")
await summary.click()
const list = page.locator('[data-component="session-background-list"]')
@@ -173,7 +173,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
"href",
"#opencode-v2-icon-outline-hexagonal-warning",
"#opencode-v2-icon-circle-exclamation",
)
await expect
.poll(() =>
@@ -134,7 +134,7 @@ for (const name of ["read", "shell", "subagent"] as const) {
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeInViewport()
if (name !== "read") {
const hint = page.getByRole("button", { name: /move running work to the background/i })
const hint = page.locator('[data-component="session-background-hint"]')
await expect(hint).toBeInViewport()
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
await page.screenshot({ path: testInfo.outputPath(`working-grouped-${name}.png`) })
@@ -1,60 +1,12 @@
import { describe, expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query"
import { OpenCode } from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store"
import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerSync } from "@/runtime/server/sync"
type ProjectApi = ServerApi["project"]
type WorktreeApi = ServerApi["worktree"]
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
if (url.pathname === "/api/location")
return Response.json({
directory: "/repo",
project: { id: "project", directory: "/repo", canonical: "/repo" },
})
if (url.pathname === "/api/project")
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
throw new Error(`Unexpected request: ${url.pathname}`)
},
{ preconnect() {} },
),
})
const [store, setStore] = createStore<ServerSync["data"]>({
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
provider_auth: {},
config: {},
reload: undefined,
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
try {
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
setStore("project", (projects) => projects.map((project) => ({ ...project, name: "Renamed" })))
expect(store.project[0]?.name).toBe("Renamed")
setStore("project", [])
expect(store.project).toEqual([])
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
expect(store.config).toEqual({})
} finally {
queryClient.clear()
}
})
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const location = {} as ServerApi["location"]
+23 -3
View File
@@ -79,13 +79,25 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
})
const queryClient = useQueryClient()
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
setGlobalStore("project", next)
}
const setBootStore = ((...input: unknown[]) => {
if (input[0] === "project" && Array.isArray(input[1])) {
setProjects(input[1] as Project[])
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const bootstrap = useQuery(() => ({
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverAPI: serverSDK.api,
scope: serverSDK.scope,
setGlobalStore,
setGlobalStore: setBootStore,
queryClient,
})
return Date.now()
@@ -93,6 +105,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
enabled: connected(),
}))
const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const paused = () => untrack(() => globalStore.reload) !== undefined
const queue = createRefreshQueue({
@@ -196,7 +216,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
}
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
setGlobalStore("project", (projects) =>
setProjects((projects) =>
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
)
}
@@ -255,7 +275,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
return {
data: globalStore,
set: setGlobalStore,
set,
child: children.child,
disableMcp: children.disableMcp,
// bootstrap,
@@ -47,6 +47,7 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
if (!server.service) yield* updater.check().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -82,14 +83,11 @@ export default Runtime.handler(Commands, (input) =>
get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)),
},
updater: {
monitor: (notify, signal) =>
runPromise(
updater.monitor((version) => Effect.sync(() => notify(version))),
{ signal },
),
apply: (version) => runPromise(updater.apply(version)),
},
updater: service
? {
apply: (version) => runPromise(updater.apply(version)),
}
: undefined,
packages: {
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
},
+55 -6
View File
@@ -7,12 +7,14 @@ import { Global } from "@opencode-ai/util/global"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
import { spawn } from "node:child_process"
import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
export type Mode = "default" | "service" | "stdio"
@@ -27,6 +29,7 @@ export type Options = {
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
export const run = Effect.fnUntraced(function* (options: Options) {
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
replacements: [
@@ -51,7 +54,8 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
return yield* Effect.scoped(
const replacement = yield* Deferred.make<PersistentPty.Handoff | null>()
const next = yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default"
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
@@ -62,7 +66,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
serviceOptions !== undefined && port !== undefined
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (incumbent !== undefined) return
if (incumbent !== undefined) return Option.none<PersistentPty.Handoff | null>()
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
@@ -159,17 +163,62 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
}),
)
if (server === undefined) return
if (server === undefined) return Option.none<PersistentPty.Handoff | null>()
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater
.monitor({
url,
password,
managed: options.mode === "service",
notify: server.updateAvailable,
restart: (handoff) => Deferred.succeed(replacement, handoff).pipe(Effect.asVoid),
})
.pipe(Effect.forkScoped)
return yield* options.mode === "service"
? server.shutdown
? Effect.raceFirst(
server.shutdown.pipe(Effect.as(Option.none<PersistentPty.Handoff | null>())),
Deferred.await(replacement).pipe(Effect.map(Option.some)),
)
: options.mode === "stdio"
? waitForStdinClose()
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
: Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
)
if (Option.isNone(next)) return
yield* spawnReplacement(next.value)
})
const spawnReplacement = Effect.fnUntraced(function* (handoff: PersistentPty.Handoff | null) {
const options = yield* ServiceConfig.options()
const [command, ...args] = options.command
if (!command) return yield* Effect.fail(new Error("Failed to resolve CLI command for restart"))
// We do not monitor the replacement after spawn. A managed TUI
// recovers with Service.ensure if startup fails; a future client
// restart signal could coordinate that recovery instead.
yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
env: {
...process.env,
...options.env,
OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined,
},
})
child.once("spawn", () => {
child.unref()
resolve()
})
child.once("error", reject)
}),
catch: (cause) => new Error("Failed to start replacement server", { cause }),
})
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
+6 -3
View File
@@ -1,5 +1,5 @@
export type Policy = "disable" | "notify"
export type Action = "none" | "notify"
export type Policy = "disable" | "notify" | "auto"
export type Action = "none" | "notify" | "upgrade"
const maximumComponent = "9007199254740991"
const versionPattern =
@@ -10,7 +10,10 @@ export function action(current: string, latest: string, policy: Policy): Action
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
return "notify"
if (policy === "notify") return "notify"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "notify"
return "upgrade"
}
export function parseReleaseVersion(input: string) {
+29 -20
View File
@@ -6,17 +6,22 @@ describe("updater", () => {
test("reads update policy from JSONC", () => {
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
})
test("maps the v1 update policy", () => {
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
})
test("reports every available release", () => {
test("automatically updates patches and minors", () => {
expect(action("1.2.3", "1.2.4", "auto")).toBe("upgrade")
expect(action("1.2.3", "1.3.0", "auto")).toBe("upgrade")
})
test("reports patches and minors without automatically installing them", () => {
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
expect(action("1.2.3", "2.0.0", "notify")).toBe("notify")
@@ -27,21 +32,25 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
})
test("reports up-to-date only when versions match", () => {
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
test("reports majors instead of automatically installing them", () => {
expect(action("1.2.3", "2.0.0", "auto")).toBe("notify")
})
test("reports when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", "notify")).toBe("notify")
test("reports up-to-date only when versions match", () => {
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
})
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade")
})
test("accepts strict release version variants", () => {
expect(action("v1.2.3", " 1.2.4\n", "notify")).toBe("notify")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "notify")).toBe("notify")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "notify")).toBe("notify")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "notify")).toBe("notify")
expect(action("1.2.3+old", "1.2.3+new", "notify")).toBe("none")
expect(action("v1.2.3+old", "1.2.3", "notify")).toBe("none")
expect(action("v1.2.3", " 1.2.4\n", "auto")).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "auto")).toBe("upgrade")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "auto")).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "auto")).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", "auto")).toBe("none")
expect(action("v1.2.3+old", "1.2.3", "auto")).toBe("none")
})
test("preserves strict validity", () => {
@@ -62,21 +71,21 @@ describe("updater", () => {
"0.9007199254740992.0",
"0.0.9007199254740992",
]
invalid.forEach((version) => expect(action("1.2.3", version, "notify"), version).toBe("none"))
invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none"))
})
test("handles numeric limits without losing precision", () => {
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "notify")).toBe("notify")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "notify")).toBe("notify")
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify")
})
test("preserves equality for oversized numeric prerelease identifiers", () => {
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "notify")).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "notify")).toBe("notify")
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "auto")).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "auto")).toBe("upgrade")
})
test("rejects versions longer than semver's limit before trimming", () => {
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "notify")).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "notify")).toBe("notify")
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "auto")).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "auto")).toBe("upgrade")
})
})
+165 -28
View File
@@ -1,36 +1,154 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OpenCode } from "@opencode-ai/client"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export interface Interface {
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
readonly check: () => Effect.Effect<void>
readonly monitor: (input: {
readonly url: string
readonly password: string
readonly managed: boolean
readonly notify: (version: string) => Effect.Effect<void>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
}) => Effect.Effect<void>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
}
export const monitorUpdates = Effect.fnUntraced(function* (input: {
readonly inspect: () => Effect.Effect<string | undefined, Error>
readonly notify: (version: string) => Effect.Effect<void>
readonly initialDelay?: Duration.Input
export type Inspection =
| { readonly action: "none" }
| { readonly action: Exclude<Action, "none">; readonly version: string }
type State =
| { readonly type: "current" }
| { readonly type: "available"; readonly version: string; readonly availableSince: number }
| { readonly type: "ready-to-restart"; readonly version: string }
export interface MonitorInput {
readonly url: string
readonly password: string
readonly managed: boolean
readonly inspect: () => Effect.Effect<Inspection, Error>
readonly install: (version: string) => Effect.Effect<boolean, Error>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
readonly interval?: Duration.Input
}) {
const interval = input.interval ?? "10 minutes"
const initialDelay = input.initialDelay ?? "90 seconds"
const check = Effect.gen(function* () {
const version = yield* input.inspect()
if (version !== undefined) yield* input.notify(version)
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
readonly notificationThreshold?: Duration.Input
readonly notify: (version: string) => Effect.Effect<void>
}
export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) {
const state = yield* Ref.make<State>({ type: "current" })
const applyLock = yield* Semaphore.make(1)
const client = OpenCode.make({
baseUrl: input.url,
headers: { authorization: `Basic ${btoa(`opencode:${input.password}`)}` },
})
const applyIfIdle = () =>
applyLock.withPermit(
Effect.gen(function* () {
const pending = yield* Ref.get(state)
if (pending.type !== "available") return
const active = yield* Effect.tryPromise({
try: () => client.session.active(),
catch: (cause) => new Error("Failed to read active sessions", { cause }),
})
if (Object.keys(active).length > 0) return
const latest = yield* input.inspect()
if (latest.action !== "upgrade") {
yield* Ref.set(state, { type: "current" })
return
}
const installed = yield* input
.install(latest.version)
.pipe(
Effect.catch((error) =>
Effect.logWarning("automatic update failed", { cause: error }).pipe(Effect.as(false)),
),
)
if (!installed) return
const handoff = input.managed
? yield* Effect.tryPromise({
try: () => client.experimental.persistentPty.handoff(),
catch: (cause) => new Error("Failed to prepare persistent terminals for restart", { cause }),
})
: undefined
yield* Ref.set(state, { type: "ready-to-restart", version: latest.version })
if (handoff) yield* input.restart(handoff.handoff)
}),
)
const checkServer = Effect.gen(function* () {
const result = yield* input.inspect()
if (result.action === "notify") {
yield* input.notify(result.version)
return
}
if (result.action !== "upgrade") {
yield* Ref.update(
state,
(current): State => (current.type === "ready-to-restart" ? current : { type: "current" }),
)
return
}
yield* Ref.update(state, (current): State => {
if (current.type === "ready-to-restart" && current.version === result.version) return current
return {
type: "available",
version: result.version,
availableSince: current.type === "available" ? current.availableSince : Date.now(),
}
})
yield* applyIfIdle()
const pending = yield* Ref.get(state)
if (
pending.type === "available" &&
Date.now() - pending.availableSince >= Duration.toMillis(input.notificationThreshold ?? "3 days")
)
yield* input.notify(pending.version)
}).pipe(Effect.catch((cause) => Effect.logWarning("automatic update check failed", { cause })))
const subscribe = Effect.suspend(() =>
Stream.fromAsyncIterable(
client.event.subscribe(),
(cause) => new Error("Update event stream failed", { cause }),
).pipe(
Stream.runForEach((event) => {
if (event.type === "server.connected") return applyIfIdle()
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.failed" &&
event.type !== "session.execution.interrupted"
)
return Effect.void
return Effect.tryPromise({
try: () => client.session.wait({ sessionID: event.data.sessionID }),
catch: (cause) => new Error(`Failed to wait for Session ${event.data.sessionID}`, { cause }),
}).pipe(Effect.andThen(applyIfIdle()))
}),
Effect.catch((cause) => Effect.logWarning("update event stream disconnected", { cause })),
),
).pipe(Effect.repeat(Schedule.spaced("1 second")))
return yield* Effect.all(
[checkServer.pipe(Effect.repeat(Schedule.spaced(input.interval ?? "10 minutes"))), subscribe],
{
concurrency: "unbounded",
discard: true,
},
)
})
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -43,14 +161,13 @@ export function decodePolicy(text: string): Policy | undefined {
if (errors.length || typeof input !== "object" || input === null) return
if ("update" in input) {
const value = input.update
if (value === "disable" || value === "notify") return value
if (value === "auto") return "notify"
if (value === "disable" || value === "notify" || value === "auto") return value
return
}
if (!("autoupdate" in input)) return
if (input.autoupdate === false) return "disable"
if (input.autoupdate === "notify") return "notify"
if (input.autoupdate === true) return "notify"
if (input.autoupdate === true) return "auto"
}
const make = Effect.gen(function* () {
@@ -75,7 +192,7 @@ const make = Effect.gen(function* () {
Effect.orElseSucceed(() => undefined),
),
)
return values.findLast((value) => value !== undefined) ?? "notify"
return values.findLast((value) => value !== undefined) ?? "auto"
})
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
@@ -185,19 +302,19 @@ const make = Effect.gen(function* () {
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
const inspect = Effect.fnUntraced(function* () {
const inspect = Effect.fnUntraced(function* (): Effect.fn.Return<Inspection, Error> {
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) {
yield* Effect.logInfo("update check skipped", {
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
})
return undefined
return { action: "none" }
}
const policy = yield* readPolicy()
if (policy === "disable") {
yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
return undefined
return { action: "none" }
}
const version = yield* latest()
@@ -208,16 +325,19 @@ const make = Effect.gen(function* () {
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") {
yield* Effect.logInfo("update check done", { action: "up-to-date" })
return undefined
return { action: "none" }
}
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return version
if (next === "notify") {
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return { action: next, version }
}
return { action: next, version }
})
const install = Effect.fnUntraced(function* (version: string) {
const detected = yield* method()
if (!detected) {
yield* Effect.logWarning("update skipped: installation method not found")
yield* Effect.logWarning("automatic update skipped: installation method not found")
return false
}
yield* upgrade(detected, version)
@@ -229,9 +349,26 @@ const make = Effect.gen(function* () {
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
})
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
const check = Effect.fn("cli.updater.check")(
function* () {
const result = yield* inspect()
if (result.action !== "upgrade") return
yield* install(result.version)
},
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
)
return Service.of({ monitor, apply, method, latest, upgrade })
const monitor = Effect.fn("cli.updater.monitor")(function* (input: {
readonly url: string
readonly password: string
readonly managed: boolean
readonly notify: (version: string) => Effect.Effect<void>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
}) {
return yield* monitorServer({ ...input, inspect, install })
})
return Service.of({ check, monitor, apply, method, latest, upgrade })
})
export const layer = Layer.effect(Service, make)
+2 -1
View File
@@ -12,8 +12,9 @@ await Effect.runPromise(
process.argv.slice(2),
).pipe(
Effect.provideService(Updater.Service, {
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
apply: () => Effect.die("Manual upgrades must not apply automatic updates"),
method: () =>
Effect.sync(() => {
record("method")
+94 -27
View File
@@ -1,40 +1,107 @@
import { expect } from "bun:test"
import { Effect, Layer, Queue } from "effect"
import { TestClock } from "effect/testing"
import { Deferred, Effect, Layer, Option } from "effect"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
it.live("installs and restarts after the final Session settles", () =>
Effect.gen(function* () {
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed("2.0.0"),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
const fixture = yield* Effect.acquireRelease(Effect.sync(makeServer), (server) => Effect.sync(() => server.stop()))
const installed = yield* Deferred.make<string>()
const restarted = yield* Deferred.make<void>()
yield* Updater.monitorServer({
url: fixture.url,
password: "test",
managed: true,
inspect: () => Effect.succeed({ action: "upgrade", version: "1.1.0" }),
install: (version) => Deferred.succeed(installed, version).pipe(Effect.as(true)),
restart: () => Deferred.succeed(restarted, undefined).pipe(Effect.asVoid),
notify: () => Effect.void,
}).pipe(Effect.forkScoped)
yield* wait(fixture.activeRead, () => "Updater did not check active Sessions")
yield* wait(fixture.eventOpened, () => "Updater did not open the server event stream")
expect(Option.isNone(yield* Deferred.poll(installed))).toBe(true)
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
yield* TestClock.adjust("89 seconds")
expect(yield* Queue.size(updates)).toBe(0)
yield* TestClock.adjust("1 second")
expect(yield* Queue.take(updates)).toBe("2.0.0")
yield* Effect.yieldNow
yield* TestClock.adjust("10 minutes")
expect(yield* Queue.take(updates)).toBe("2.0.0")
fixture.settle()
yield* wait(fixture.waited, () => "Updater did not receive the settlement event")
expect(
yield* Effect.raceFirst(
Deferred.await(installed),
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not install the update")))),
),
).toBe("1.1.0")
yield* Effect.raceFirst(
Deferred.await(restarted),
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not restart the server")))),
)
}),
)
it.effect("does not notify when no update is available", () =>
Effect.gen(function* () {
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed(undefined),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
}).pipe(Effect.forkScoped)
const wait = (promise: Promise<unknown>, message: () => string) =>
Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))]))
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
}),
)
function makeServer() {
const encoder = new TextEncoder()
const activeRead = Promise.withResolvers<void>()
const eventOpened = Promise.withResolvers<void>()
const waited = Promise.withResolvers<void>()
let active = true
let events: ReadableStreamDefaultController<Uint8Array> | undefined
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/session/active") {
activeRead.resolve()
return Response.json({ data: active ? { ses_test: { type: "running" } } : {} })
}
if (url.pathname === "/api/session/ses_test/wait" && request.method === "POST") {
waited.resolve()
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/experimental/persistent-pty/handoff" && request.method === "POST") {
return Response.json({ handoff: null })
}
if (url.pathname === "/api/event") {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
events = controller
eventOpened.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
return new Response("Not found", { status: 404 })
},
})
return {
url: server.url.origin,
activeRead: activeRead.promise,
eventOpened: eventOpened.promise,
waited: waited.promise,
settle() {
active = false
events?.enqueue(
encoder.encode(
`data: ${JSON.stringify({
id: "evt_settled",
created: Date.now(),
type: "session.execution.succeeded",
durable: { aggregateID: "ses_test", seq: 0, version: 1 },
data: { sessionID: "ses_test" },
})}\n\n`,
),
)
events?.close()
events = undefined
},
stop() {
server.stop(true)
},
}
}
-2
View File
@@ -965,8 +965,6 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly model?: Model.Ref | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly text: string
readonly recent: string
}
+22 -38
View File
@@ -138,6 +138,17 @@ export type SessionMessageCompactionRunning = {
recent: string
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
summary: string
recent: string
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -510,19 +521,6 @@ export type SessionMessageAssistantReasoning = {
time?: { created: number; completed?: number }
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
summary: string
recent: string
}
export type ToolContent = ToolTextContent | ToolFileContent
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
@@ -811,6 +809,16 @@ export type SessionCompactionStarted = {
data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string }
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
}
export type SessionCompactionFailed = {
id: string
created: number
@@ -1343,23 +1351,6 @@ export type SessionToolCalled = {
}
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
text: string
recent: string
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
@@ -1892,7 +1883,7 @@ export type ConfigEntry =
shell?: string
model?: string | { providerID: string; model: string; variant?: string }
default_agent?: string
update?: "disable" | "notify"
update?: "disable" | "notify" | "auto"
share?: "manual" | "auto" | "disabled"
enterprise?: { url?: string }
username?: string
@@ -1980,7 +1971,6 @@ export type ConfigEntry =
description?: string
agent?: string
model?: string | { providerID: string; model: string; variant?: string }
subagent?: boolean
subtask?: boolean
}
}
@@ -3073,8 +3063,6 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
}
@@ -3352,8 +3340,6 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
}
@@ -3631,8 +3617,6 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
}
+9 -6
View File
@@ -574,7 +574,7 @@ export function createData(config: CreateDataInput) {
.location.get({ location: locationQuery(defaultLocation()) })
.then((location) => {
const key = locationKey(location)
setStore("location", key, { info: location })
setStore("location", key, { ...store.location[key], info: location })
})
.catch((error) => console.error("Failed to preload location", error))
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
@@ -1038,8 +1038,6 @@ export function createData(config: CreateDataInput) {
Object.assign(current, {
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
recent: event.data.recent,
})
@@ -1050,8 +1048,6 @@ export function createData(config: CreateDataInput) {
type: "compaction",
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.created },
@@ -1109,6 +1105,7 @@ export function createData(config: CreateDataInput) {
return
}
setStore("location", key, (data) => ({
...data,
integration: data?.integration?.map((integration) => {
if (integration.id !== event.data.integrationID) return integration
const active = integration.connections.find(
@@ -1150,6 +1147,7 @@ export function createData(config: CreateDataInput) {
break
case "vcs.branch.updated":
setStore("location", locationKey(location), (data) => ({
...data,
vcs: {
branch: {
...data?.vcs?.branch,
@@ -1167,6 +1165,7 @@ export function createData(config: CreateDataInput) {
break
case "shell.created":
setStore("location", locationKey(location), (data) => ({
...data,
shell: {
...data?.shell,
[event.data.info.id]: { ...event.data.info, location },
@@ -1176,6 +1175,7 @@ export function createData(config: CreateDataInput) {
case "shell.exited":
case "shell.deleted":
setStore("location", locationKey(location), (data) => ({
...data,
shell: Object.fromEntries(Object.entries(data?.shell ?? {}).filter(([id]) => id !== event.data.id)),
}))
break
@@ -1801,7 +1801,10 @@ export function createData(config: CreateDataInput) {
const input = { location: locationQuery(ref ?? defaultLocation()) }
const providers = await api().websearch.providers(input)
const key = locationKey(providers.location)
setStore("location", key, { websearch: providers.data })
setStore("location", key, {
...store.location[key],
websearch: providers.data,
})
},
},
skill: locationResource("skill", (location) => api().skill.list({ location })),
@@ -98,15 +98,13 @@ test.each(["started", "cancelled", "failed"])(
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
if (kind === "started") {
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
const model = { providerID: "demo", id: "model" }
const providerState = { responseId: "summary-response" }
fixture.emit({
...event,
type: "session.compaction.ended",
data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" },
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
})
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ type: "compaction", status: "completed", summary: "Summary", model, providerState },
{ type: "compaction", status: "completed", summary: "Summary" },
])
}
},
-98
View File
@@ -355,10 +355,8 @@ test("refreshes global credential events across every loaded location and worksp
setup.data.location.integration.sync(location),
setup.data.location.model.sync(location),
setup.data.location.provider.sync(location),
setup.data.location.reference.sync(location),
]),
)
const references = locations.map((location) => setup.data.location.reference.list(location))
requests.length = 0
const updated: OpenCodeEvent = {
@@ -404,9 +402,6 @@ test("refreshes global credential events across every loaded location and worksp
["/api/provider", "/other", "workspace-other"],
]),
)
locations.forEach((location, index) =>
expect(setup.data.location.reference.list(location)).toBe(references[index]),
)
requests.length = 0
}
} finally {
@@ -474,99 +469,6 @@ test("refreshes references for the location an update names", async () => {
}
})
test("preserves sibling catalogs through location preload, branch, shell, and websearch updates", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const location = { directory: "/project", project: { id: "project", directory: "/project", canonical: "/project" } }
const requests: string[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const pathname = new URL((input instanceof Request ? input : new Request(input, init)).url).pathname
requests.push(pathname)
if (pathname === "/api/session/active") return Response.json({})
if (pathname === "/api/project") return Response.json([])
if (pathname === "/api/location") return Response.json(location)
if (pathname === "/api/vcs")
return Response.json({ location, data: { branch: { current: "main", default: "main" } } })
if (pathname === "/api/reference")
return Response.json({
location,
data: [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
})
if (pathname === "/api/websearch/provider")
return Response.json({ location, data: [{ id: "search", name: "Search" }] })
throw new Error(`Unexpected request: ${pathname}`)
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: location.directory,
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
const emit = (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details }))
const shell = {
id: "sh_first",
status: "running" as const,
command: "echo hello",
cwd: location.directory,
shell: "/bin/sh",
file: "/shell-output",
metadata: {},
time: { started: 1 },
}
try {
// A live event may arrive before any location reads have populated this key.
emit({ type: "shell.created", location, data: { info: shell } })
expect(setup.data.shell.get(shell.id)).toMatchObject(shell)
const first = setup.data.shell.get(shell.id)
await Promise.all([setup.data.location.reference.sync(), setup.data.location.vcs.sync()])
const references = setup.data.location.reference.list()
expect(references?.map((reference) => [reference.name, reference.path])).toEqual([["docs", "/docs"]])
expect(setup.data.shell.get(shell.id)).toBe(first)
emit({ type: "vcs.branch.updated", location, data: { branch: "feature" } })
expect(setup.data.location.vcs.info()?.branch).toEqual({ current: "feature", default: "main" })
expect(setup.data.location.reference.list()).toBe(references)
emit({ type: "shell.created", location, data: { info: { ...shell, id: "sh_second" } } })
expect(setup.data.shell.list().map((shell) => shell.id)).toEqual(["sh_first", "sh_second"])
expect(setup.data.location.reference.list()).toBe(references)
emit({ type: "shell.deleted", location, data: { id: "sh_second" } })
expect(setup.data.shell.list().map((shell) => shell.id)).toEqual(["sh_first"])
expect(setup.data.shell.get(shell.id)).toBe(first)
expect(setup.data.location.reference.list()).toBe(references)
await setup.data.location.websearch.refresh()
expect(setup.data.location.websearch.list()).toEqual([{ id: "search", name: "Search" }])
expect(setup.data.location.reference.list()).toBe(references)
emit({ type: "server.connected", data: {} })
await wait(() => setup.data.location.info() !== undefined)
expect(setup.data.location.info()).toMatchObject(location)
expect(setup.data.location.reference.list()).toBe(references)
expect(setup.data.shell.get(shell.id)).toBe(first)
expect(setup.data.location.vcs.info()?.branch).toEqual({ current: "feature", default: "main" })
expect(requests.toSorted()).toEqual([
"/api/location",
"/api/project",
"/api/reference",
"/api/session/active",
"/api/vcs",
"/api/websearch/provider",
])
} finally {
setup.dispose()
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
+1 -1
View File
@@ -8,7 +8,7 @@ import { CodeModeCatalog } from "./catalog.js"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools callable inside \`execute\`. It does not affect tools exposed directly outside Code Mode.${hasMoreTools ? `
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
## Search
+2 -9
View File
@@ -73,11 +73,6 @@ export function normalize(input: unknown): Result {
const legacyUpdate = own(input, "autoupdate")
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
: undefined
const nativeUpdate = own(input, "update")
? input.update === "auto"
? "notify"
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
: undefined
const legacyShare = own(input, "autoshare")
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
? "auto"
@@ -91,10 +86,7 @@ export function normalize(input: unknown): Result {
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
}
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
const migratedUpdate =
legacyUpdate === undefined ? undefined : ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
const update = prefer(migratedUpdate, nativeUpdate, ["update"], diagnostics)
if (update !== undefined) encoded.update = update
if (legacyUpdate !== undefined) encoded.update = ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
@@ -204,6 +196,7 @@ export function normalize(input: unknown): Result {
shell: Info.fields.shell,
model: Info.fields.model,
default_agent: Info.fields.default_agent,
update: Info.fields.update,
share: Info.fields.share,
enterprise: Info.fields.enterprise,
username: Info.fields.username,
+14 -46
View File
@@ -1,6 +1,7 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
@@ -9,11 +10,8 @@ import { AppProcess } from "@opencode-ai/util/process"
import path from "path"
import { Effect, Option, PubSub, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { Location } from "../../location.js"
import { Session } from "../../session.js"
import { SubagentJob } from "../../session/subagent-job.js"
import { ShellSelect } from "../../shell/select.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigMarkdown } from "../markdown.js"
@@ -34,9 +32,6 @@ export const Plugin = define({
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
const sessions = yield* Session.Service
const agents = yield* Agent.Service
const subagents = yield* SubagentJob.make
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
@@ -72,14 +67,18 @@ export const Plugin = define({
yield* ctx.command.transform((editor) => {
for (const document of loaded.documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
const subagent = command.subagent ?? command.subtask
editor.add({
name,
description: command.description,
execute: (input) =>
Effect.gen(function* () {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = agent === undefined ? undefined : (yield* ctx.agent.get({ agentID: agent })).data
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
@@ -90,46 +89,15 @@ export const Plugin = define({
? {}
: { variant: Model.VariantID.make(command.model.variant) }),
}
const text = yield* evaluateTemplate(command.template, input.prompt.text, {
location,
processes,
shell,
})
if (subagent ?? commandAgent?.mode === "subagent") {
const parent = yield* sessions.get(input.sessionID)
const selected = yield* agents.select(agent ?? parent.agent)
const child = yield* sessions.create({
parentID: parent.id,
title: command.description ?? name,
agent: selected.id,
model: model ?? selected.info?.model ?? parent.model,
})
yield* sessions.prompt({
...input.prompt,
sessionID: child.id,
text: ["You are a subagent spawned by another session.", text].join("\n"),
resume: false,
})
const recovery = {
kind: "subagent" as const,
parentSessionID: parent.id,
childSessionID: child.id,
agent: selected.id,
description: command.description ?? name,
}
yield* subagents.start(recovery)
yield* subagents.background(recovery)
return
}
if (agent !== undefined) {
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text,
text: yield* evaluateTemplate(command.template, input.prompt.text, {
location,
processes,
shell,
}),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
@@ -228,8 +196,8 @@ function evaluateTemplate(
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError(
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
Effect.mapError((error) =>
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
+2 -6
View File
@@ -18,9 +18,7 @@ const RemoteModel = Schema.Struct({
Schema.Struct({
batch_size: Schema.Number,
default: Schema.Struct({
// API version 2026-08-01 renamed cache_price to cache_read_price.
cache_price: Schema.optional(Schema.Number),
cache_read_price: Schema.optional(Schema.Number),
cache_price: Schema.Number,
input_price: Schema.Number,
output_price: Schema.Number,
}),
@@ -168,9 +166,7 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
input: Money.USDPerMillionTokens.make((prices?.default.input_price ?? 0) * usdPerMillion),
output: Money.USDPerMillionTokens.make((prices?.default.output_price ?? 0) * usdPerMillion),
cache: {
read: Money.USDPerMillionTokens.make(
(prices?.default.cache_read_price ?? prices?.default.cache_price ?? 0) * usdPerMillion,
),
read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion),
write: Money.USDPerMillionTokens.zero,
},
},
+8 -5
View File
@@ -55,6 +55,7 @@ type Active = {
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
scope: Scope.Closeable
token: object
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
recovery?: Recovery
@@ -76,7 +77,7 @@ type BackgroundResult = {
backgrounded?: Deferred.Deferred<Info>
}
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
type BlockWait = {
done: Deferred.Deferred<Info>
@@ -183,14 +184,14 @@ export const make = Effect.gen(function* () {
})
})
const settle = Effect.fnUntraced(function* (id: string, scope: Scope.Closeable, exit: Exit.Exit<string, unknown>) {
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.scope !== scope) return [{}, jobs]
if (job.token !== token) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed"
@@ -240,6 +241,7 @@ export const make = Effect.gen(function* () {
return [{ info: snapshot(existing) }, jobs]
}
const scope = yield* Scope.fork(state.scope, "parallel")
const token = {}
const job = {
info: {
id,
@@ -253,17 +255,18 @@ export const make = Effect.gen(function* () {
done,
backgrounded,
scope,
token,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
}),
)
if ("scope" in result)
yield* restore(input.run).pipe(
Effect.exit,
Effect.flatMap((exit) => settle(id, result.scope, exit)),
Effect.flatMap((exit) => settle(id, result.token, exit)),
Effect.asVoid,
Effect.forkIn(result.scope, { startImmediately: true }),
)
+11 -38
View File
@@ -1,4 +1,5 @@
import { Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Option } from "effect"
import { Duration, Effect, Layer, LayerMap } from "effect"
import { existsSync } from "fs"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Instance } from "./instance.js"
import { Location } from "./location.js"
@@ -15,48 +16,20 @@ export function buildLocationServiceMap(
return Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const owner = yield* Effect.scope
const booting = MutableHashMap.empty<Location.Ref, object>()
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = yield* LayerMap.make(
(ref: Location.Ref) => {
const build = {}
MutableHashMap.set(booting, ref, build)
return Layer.fromBuild((memoMap, scope) =>
Effect.suspend(() =>
Layer.buildWithMemoMap(Instance.layer(ref, { replacements: bindings }), memoMap, scope),
).pipe(
Effect.onExit((exit) => {
const finish = Effect.suspend(() => {
// An explicitly invalidated build must not evict its replacement.
if (Option.getOrUndefined(MutableHashMap.get(booting, ref)) !== build) return Effect.void
MutableHashMap.remove(booting, ref)
// Evict once per failed build, before its result reaches borrowers.
return Exit.isFailure(exit) ? inner.invalidate(ref) : Effect.void
})
// With no borrowers, invalidation closes the entry's scope and
// joins this lookup fiber. Let the owner finish that cleanup.
return Exit.isFailure(exit)
? finish.pipe(Effect.forkIn(owner, { startImmediately: true }), Effect.asVoid)
: finish
}),
),
)
},
// Retain healthy graphs. Boot failures, not local filesystem probes,
// decide whether a location (including workspace placement) can retry.
{ idleTimeToLive: Duration.infinity },
)
const inner = yield* LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements: bindings }), {
// Workspace-placed directories exist only inside the workspace, so a
// local stat consults the wrong filesystem. Workspace liveness is
// owned by placement; do not probe the sandbox here, which would
// provision lazily-idle workspaces.
idleTimeToLive: (ref) =>
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
})
const map = {
...inner,
get: (ref: Location.Ref) => inner.get(LocationServiceMap.canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(LocationServiceMap.canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(LocationServiceMap.canonical(ref)),
invalidate: (ref: Location.Ref) =>
Effect.suspend(() => {
const key = LocationServiceMap.canonical(ref)
MutableHashMap.remove(booting, key)
return inner.invalidate(key)
}),
invalidate: (ref: Location.Ref) => inner.invalidate(LocationServiceMap.canonical(ref)),
}
// Cached instances borrow their owner instead of retaining its Layer scope.
const bindings: LayerNode.Replacements = [
-141
View File
@@ -1,141 +0,0 @@
export * as ModalModels from "./models.js"
import { Money } from "@opencode-ai/schema/money"
import { Option, Schema } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
const providerID = Provider.ID.make("modal")
const ReasoningOption = Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
})
const RemoteModel = Schema.Struct({
id: Schema.String,
base_model_id: Schema.optional(Schema.String),
hugging_face_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
input_modalities: Schema.optional(Schema.Array(Schema.String)),
output_modalities: Schema.optional(Schema.Array(Schema.String)),
context_length: Schema.optional(Schema.Number),
max_output_length: Schema.optional(Schema.Number),
pricing: Schema.optional(
Schema.Struct({
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
}),
),
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
supported_features: Schema.optional(Schema.Array(Schema.String)),
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Boolean,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
})
const Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeResponse = Schema.decodeUnknownSync(Response)
const decodeModel = Schema.decodeUnknownOption(RemoteModel)
type RemoteModel = typeof RemoteModel.Type
export async function get(baseURL: string, apiKey: string, existing: readonly Model.Info[]) {
const response = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(3_000),
})
if (!response.ok) throw new Error(`Failed to fetch Modal models: ${response.status}`)
// Decode each item tolerantly so one malformed entry cannot discard the
// whole inventory. A malformed envelope still fails the fetch.
const remote = decodeResponse(await response.json()).data.flatMap((raw) => {
const model = Option.getOrUndefined(decodeModel(raw))
return model ? [model] : []
})
const templates = new Map(existing.map((model) => [model.id, model]))
const result = new Map<Model.ID, Model.Info>()
for (const item of remote) {
const template = templates.get(Model.ID.make(item.base_model_id ?? item.hugging_face_id ?? item.id))
const id = Model.ID.make(item.id)
result.set(id, build(id, item, baseURL, template))
}
return result
}
function price(value: string | number | undefined, fallback: Money.USDPerMillionTokens) {
if (value === undefined) return fallback
const parsed = Number(value) * 1_000_000
return Number.isFinite(parsed) ? Money.USDPerMillionTokens.make(parsed) : fallback
}
function limit(value: number | undefined, fallback: number) {
const parsed = value === undefined ? fallback : Math.trunc(value)
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
}
function build(id: Model.ID, remote: RemoteModel, baseURL: string, previous?: Model.Info) {
const cost = previous?.cost[0]
const input = previous?.limit.input
return Model.Info.make({
...Model.Info.default(providerID, id),
id,
modelID: Model.ID.make(remote.id),
providerID,
name: remote.name ?? previous?.name ?? remote.id,
family: previous?.family,
compatibility:
remote.interleaved === undefined
? previous?.compatibility
: (Model.compatibility(remote.interleaved) ?? previous?.compatibility),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: Provider.mergeOverlay(previous?.settings, { baseURL }),
headers: previous?.headers,
body: previous?.body,
capabilities: {
tools: remote.supported_features?.includes("tools") ?? previous?.capabilities.tools ?? true,
input: remote.input_modalities ?? previous?.capabilities.input ?? ["text"],
output: remote.output_modalities ?? previous?.capabilities.output ?? ["text"],
},
variants: remote.reasoning_options === undefined ? (previous?.variants ?? []) : variants(remote),
time: previous?.time ?? { released: 0 },
cost: [
{
input: price(remote.pricing?.prompt, cost?.input ?? Money.USDPerMillionTokens.zero),
output: price(remote.pricing?.completion, cost?.output ?? Money.USDPerMillionTokens.zero),
cache: {
read: price(remote.pricing?.input_cache_read, cost?.cache.read ?? Money.USDPerMillionTokens.zero),
write: cost?.cache.write ?? Money.USDPerMillionTokens.zero,
},
},
],
status: previous?.status ?? "active",
enabled: previous?.enabled ?? true,
limit: {
context: limit(remote.context_length, previous?.limit.context ?? 0),
...(input === undefined ? {} : { input }),
output: limit(remote.max_output_length, previous?.limit.output ?? 0),
},
})
}
function variants(remote: RemoteModel): Model.Info["variants"] {
const seen = new Map<string, Model.Info["variants"][number]>()
for (const option of remote.reasoning_options ?? []) {
for (const value of option.values) {
const effort = value ?? "none"
if (!seen.has(effort))
seen.set(effort, { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } })
}
}
return [...seen.values()]
}
+30 -137
View File
@@ -5,7 +5,7 @@ import { Plugin } from "@opencode-ai/schema/plugin"
import { Node } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import type { PersistentPty } from "./persistent-pty.js"
import { Cause, Context, Effect, Exit, Latch, Layer, Logger, Queue, References, Scope, Semaphore } from "effect"
import { Cause, Context, Effect, Exit, Latch, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
import { PluginHost } from "./plugin/host.js"
@@ -26,64 +26,43 @@ const layer = Layer.effect(
const lock = Semaphore.makeUnsafe(1)
const ready = yield* Latch.make(true)
const pending = new Set<object>()
let closed = false
const holdUnsafe = () => {
if (closed) return Effect.void
const token = {}
pending.add(token)
ready.closeUnsafe()
return Effect.sync(() => {
if (pending.delete(token) && pending.size === 0) ready.openUnsafe()
const hold = () =>
Effect.sync(() => {
const token = {}
pending.add(token)
ready.closeUnsafe()
return Effect.sync(() => {
if (pending.delete(token) && pending.size === 0) ready.openUnsafe()
})
})
}
const hold = () => Effect.sync(holdUnsafe)
const pendingFailures = yield* Queue.unbounded<PendingFailure>()
let discovered: readonly Failure[] = []
let inventory: Plugin.Info[] = []
const list = Effect.fn("Plugin.list")(function* () {
return inventory
})
const host = yield* PluginHost.make({ list })
const load = Effect.fnUntraced(function* (plugin: Generation) {
const activation: Activation = { plugin, scope: yield* Scope.fork(scope) }
const child = yield* Scope.fork(scope)
const inherit = yield* State.inherit()
const grouped = State.group((failure, refresh) => {
activation.failure = {
error: `Plugin disabled after ${failure.state}.transform failed. Check server logs for details.`,
ref: `err_${crypto.randomUUID().slice(0, 8)}`,
}
Queue.offerUnsafe(pendingFailures, {
plugin,
scope: activation.scope,
failure,
refresh,
ref: activation.failure.ref,
release: holdUnsafe(),
})
})
const exit = yield* Effect.suspend(() =>
const loaded = yield* Effect.suspend(() =>
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
).pipe(
grouped,
inherit,
Effect.updateContext((context: Context.Context<never>) =>
Context.make(Scope.Scope, activation.scope).pipe(
Context.make(Scope.Scope, child).pipe(
Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)),
Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)),
),
),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
Effect.onExit((exit) =>
Exit.isFailure(exit) && !activation.failure ? Scope.close(activation.scope, exit) : Effect.void,
),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
if (activation.failure || Exit.isSuccess(exit)) return { activation } as const
if (Exit.isSuccess(loaded)) return { scope: child } as const
yield* Effect.logWarning("failed to load plugin", {
"plugin.id": plugin.id,
cause: exit.cause,
cause: loaded.cause,
})
return { error: Cause.pretty(exit.cause) } as const
return { error: Cause.pretty(loaded.cause) } as const
})
const activate = Effect.fn("Plugin.activate")(function* (
@@ -102,8 +81,6 @@ const layer = Layer.effect(
() =>
lock.withPermit(
Effect.gen(function* () {
if (closed) return
discovered = failures
const current = Array.from(active.values())
const changed = definitions.findIndex((definition, index) => {
const entry = current[index]
@@ -131,36 +108,29 @@ const layer = Layer.effect(
([id, slot]) =>
Effect.gen(function* () {
active.delete(id)
if (slot.activation && !slot.activation.failure)
yield* Scope.close(slot.activation.scope, Exit.void)
if (slot.loaded) yield* Scope.close(slot.loaded.scope, Exit.void)
}),
{ discard: true },
)
for (const definition of definitions.slice(prefix)) {
const slot = previous.get(definition.id)
// Reordering healthy registrations does not authorize retrying a failed revision.
if (slot?.activation?.failure && slot.plugin.revision === definition.revision) {
active.set(definition.id, { ...slot, plugin: definition })
continue
}
const result = yield* load(definition)
if (result.activation !== undefined) {
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, {
plugin: definition,
activation: result.activation,
loaded: { plugin: definition, scope: loaded.scope },
})
continue
}
active.set(definition.id, { plugin: definition, error: result.error })
active.set(definition.id, { plugin: definition, error: loaded.error })
const fallback = slot?.activation
if (!fallback || fallback.failure) continue
const fallback = previous.get(definition.id)?.loaded
if (!fallback) continue
const restored = yield* load(fallback.plugin)
if (restored.activation !== undefined) {
if (restored.scope !== undefined) {
active.set(definition.id, {
plugin: definition,
activation: restored.activation,
error: result.error,
loaded: { plugin: fallback.plugin, scope: restored.scope },
error: loaded.error,
})
continue
}
@@ -179,68 +149,9 @@ const layer = Layer.effect(
)
})
yield* Queue.take(pendingFailures).pipe(
Effect.flatMap((item) =>
Effect.gen(function* () {
yield* Effect.logWarning("disabled plugin after transform failure", {
"plugin.id": item.plugin.id,
state: item.failure.state,
ref: item.ref,
cause: Cause.die(item.failure.cause),
})
yield* lock.withPermit(
Effect.gen(function* () {
if (closed) return
// Failure is already recorded on its exact activation, so an old queued item
// cannot disable a replacement and teardown need not wait for this worker.
inventory = [...Array.from(active.values()).map(slotInfo), ...discovered]
const refreshed = yield* State.batch(item.refresh).pipe(Effect.exit)
yield* bus.publish(Plugin.Event.Updated, {})
if (Exit.isFailure(refreshed))
yield* Effect.logWarning("failed to refresh state after disabling plugin", {
"plugin.id": item.plugin.id,
ref: item.ref,
cause: refreshed.cause,
})
}),
)
}).pipe(
// Cleanup must also be scheduled if an inventory observer fails. User finalizers
// may await readiness, so never join them under the activation lock or readiness hold.
Effect.ensuring(
Scope.close(item.scope, Exit.void).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to clean up disabled plugin", {
"plugin.id": item.plugin.id,
ref: item.ref,
cause,
}),
),
Effect.forkScoped({ startImmediately: true }),
),
),
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) =>
Effect.logError("failed to report disabled plugin", {
"plugin.id": item.plugin.id,
ref: item.ref,
cause,
}),
),
Effect.ensuring(item.release),
),
),
Effect.forever,
Effect.forkScoped,
)
const close = (exit: Exit.Exit<unknown, unknown>) =>
lock.withPermit(
Effect.gen(function* () {
closed = true
pending.clear()
ready.openUnsafe()
active.clear()
yield* State.shutdown(Scope.close(scope, exit))
}),
@@ -257,37 +168,19 @@ const layer = Layer.effect(
}),
)
// `plugin` is the requested definition; `activation` is its last activation, which may have
// failed or be an older fallback while the requested revision keeps failing setup.
// `plugin` is the definition the slot was last asked to run; `loaded` is the generation actually
// running, which stays an older fallback while the requested revision keeps failing setup.
type Slot = {
readonly plugin: Generation
readonly activation?: Activation
readonly loaded?: { readonly plugin: Generation; readonly scope: Scope.Closeable }
readonly error?: string
}
// Share the activation across slot snapshots so teardown sees failures synchronously,
// including failures discovered after activate() has captured its previous slots.
type Activation = {
readonly plugin: Generation
readonly scope: Scope.Closeable
failure?: { readonly error: string; readonly ref: string }
}
type PendingFailure = {
readonly plugin: Generation
readonly scope: Scope.Closeable
readonly failure: State.Failure
readonly refresh: Effect.Effect<void>
readonly ref: string
readonly release: Effect.Effect<void>
}
function slotInfo(slot: Slot): Plugin.Info {
const failure = slot.activation?.failure ?? (slot.error === undefined ? undefined : { error: slot.error })
return {
id: Plugin.ID.make(slot.plugin.id),
source: slot.plugin.source ?? { type: "builtin" },
state: failure === undefined ? { status: "active" } : { status: "failed", ...failure },
state: slot.error === undefined ? { status: "active" } : { status: "failed", error: slot.error },
features: { server: true, ...slot.plugin.features },
}
}
-2
View File
@@ -15,7 +15,6 @@ import { KiloPlugin } from "./provider/kilo.js"
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
import { LMStudioPlugin } from "./provider/lmstudio.js"
import { MistralPlugin } from "./provider/mistral.js"
import { ModalPlugin } from "./provider/modal.js"
import { NvidiaPlugin } from "./provider/nvidia.js"
import { OllamaPlugin } from "./provider/ollama.js"
import { OpenAIPlugin } from "./provider/openai.js"
@@ -49,7 +48,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
LLMGatewayPlugin,
LMStudioPlugin,
MistralPlugin,
ModalPlugin,
NvidiaPlugin,
OllamaPlugin,
OpencodePlugin,
@@ -13,7 +13,7 @@ import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const clientID = "Ov23li8tweQw6odWQebz"
const apiVersion = "2026-08-01"
const apiVersion = "2026-06-01"
const userApiVersion = "2025-04-01"
const pollingSafetyMargin = 3000
const methodID = Integration.MethodID.make("device")
@@ -1,67 +0,0 @@
import { Effect, Semaphore, Stream } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Bus } from "../../bus.js"
import { Catalog } from "../../catalog.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { ModalModels } from "../../modal/models.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const providerID = Provider.ID.make("modal")
export const ModalPlugin = define({
id: "opencode.provider.modal",
effect: Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
const loading = Semaphore.makeUnsafe(1)
const loaded: {
baseURL?: string
models?: Map<Model.ID, Model.Info>
} = {}
const load = Effect.fn("ModalPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("modal")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const apiKey = credential?.type === "key" ? credential.key : process.env.MODAL_PROXY_TOKEN
const provider = yield* catalog.provider.get(providerID)
const baseURL = typeof provider?.settings?.baseURL === "string" ? provider.settings.baseURL : undefined
if (!apiKey || !baseURL) {
loaded.baseURL = undefined
loaded.models = undefined
return
}
loaded.baseURL = baseURL
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === providerID)
loaded.models = yield* Effect.tryPromise({
try: () => ModalModels.get(baseURL, apiKey, existing),
catch: (cause) => cause,
}).pipe(
Effect.catch((cause) => Effect.logWarning("failed to sync Modal models", { cause }).pipe(Effect.as(undefined))),
)
})
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
if (!loaded.models) return
for (const id of item.models.keys()) {
if (!loaded.models.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
}
for (const [id, model] of loaded.models) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("modal")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh().pipe(Effect.forkScoped)
}),
} satisfies PluginInternal.InternalPlugin)
+1 -9
View File
@@ -121,15 +121,7 @@ const layer = Layer.effect(
// The heterogeneous registry erases handlers after their selected schema validates input.
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
return execution
}).pipe(
Effect.catch((error) => encodeError(method, error)),
// Normalize handler bugs here so direct callers can recover just like HTTP callers.
Effect.catchDefect((defect) =>
Effect.logError("rpc handler failed", { rpc: rpcID, method: name, defect }).pipe(
Effect.andThen(Effect.fail(failure("rpc.internal", "RPC call failed"))),
),
),
)
}).pipe(Effect.catch((error) => encodeError(method, error)))
return yield* encode(method.output, result).pipe(
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
)
+57 -5
View File
@@ -16,7 +16,7 @@ import { Database } from "./database/database.js"
import { SessionProjector } from "./session/projector.js"
import { SessionMessageTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { RelativePath } from "./schema.js"
import { AbsolutePath, RelativePath } from "./schema.js"
import { Agent } from "@opencode-ai/schema/agent"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
@@ -42,6 +42,7 @@ import {
} from "./session/error.js"
import { Node } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
import { SessionInbox } from "./session/inbox.js"
import { InstructionState } from "./session/instruction-state.js"
@@ -61,6 +62,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { Job } from "./job.js"
import type { Command } from "./command.js"
import { Global } from "@opencode-ai/util/global"
import { SessionEnvironment } from "./session/environment.js"
import { InstructionEntry } from "./session/instruction-entry.js"
@@ -166,7 +168,15 @@ export interface Interface {
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly move: SessionMove.Interface["move"]
readonly move: (input: {
sessionID: SessionSchema.ID
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<
void,
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
>
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["prompt"]>
@@ -222,15 +232,18 @@ const layer = Layer.effect(
const db = database.db
const bus = yield* Bus.Service
const projects = yield* Project.Service
const global = yield* Global.Service
const execution = yield* SessionExecution.Service
const llm = yield* LLMClient.Service
const transport = yield* SessionModelTransport.Service
const store = yield* SessionStore.Service
const instances = yield* Instance.Service
const moves = yield* SessionMove.Service
const locations = yield* LocationServiceMap.Service
const fs = yield* FSUtil.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const sessions = yield* Session.make()
const admission = yield* SessionInbox.Service
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const result = Service.of({
@@ -397,7 +410,45 @@ const layer = Layer.effect(
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),
move: moves.move,
move: Effect.fn("Session.move")(function* (input) {
const session = yield* result.get(input.sessionID)
const payload = yield* SessionMove.prepare({ ...input, session }).pipe(
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(Global.Service, global),
Effect.provideService(Project.Service, projects),
Effect.provideService(LocationServiceMap.Service, locations),
)
const item = SessionInbox.Item.make({
type: "move",
payload,
delivery: input.delivery ?? "steer",
})
yield* SessionInbox.serialized(
input.sessionID,
Effect.gen(function* () {
const latest = yield* result.get(input.sessionID)
const source = yield* fs.stat(latest.location.directory).pipe(Effect.orElseSucceed(() => undefined))
// Active runners must hand off at a step boundary to retain their continuation.
if ((!source || source.type !== "Directory") && !(yield* execution.isActive(input.sessionID))) {
const cancellations = (yield* SessionInbox.moveIDs(db, input.sessionID)).map(
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
)
const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...payload }] as const
const first = cancellations[0]
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid)
return yield* bus.publishAll([first, ...cancellations.slice(1), moved])
}
yield* admission
.admit({
id: SessionMessage.ID.create(),
sessionID: input.sessionID,
item,
})
.pipe(Effect.orDie)
}),
)
yield* execution.wake(input.sessionID)
}),
compact: (input) => sessions.forSession(input.sessionID).compact(input),
wait: (sessionID) => sessions.forSession(sessionID).wait(),
active: execution.active,
@@ -448,9 +499,10 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionStore.node,
Instance.node,
SessionInbox.node,
SessionMove.node,
LocationServiceMap.node,
SessionProjector.node,
FSUtil.node,
Global.node,
App.node,
],
})
-8
View File
@@ -367,7 +367,6 @@ export const layer = Layer.effect(
const chunks: string[] = []
let failure: SessionError.Error | undefined
let usage: SessionUsage.Recorded | undefined
let providerState: SessionMessage.ProviderState | undefined
const recordUsage = Effect.suspend(() =>
usage
? bus.publish(SessionEvent.UsageRecorded, {
@@ -408,7 +407,6 @@ export const layer = Layer.effect(
// Ignored tool calls never enter the follow-up history or need fabricated results.
for (let attempt = 0; attempt < 2; attempt++) {
chunks.length = 0
providerState = undefined
yield* llm
.stream(
attempt === 0
@@ -438,10 +436,6 @@ export const layer = Layer.effect(
})
}
if (LLMEvent.is.stepFinish(event)) {
providerState =
event.providerMetadata?.[
context.model.model.route.providerMetadataKey ?? context.model.model.provider
]
const step = SessionUsage.record(event.usage, context.model.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
@@ -488,8 +482,6 @@ export const layer = Layer.effect(
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID: context.session.id,
reason: input.reason,
model: context.model.ref,
providerState,
text: summary,
recent: history.recent,
})
@@ -176,7 +176,13 @@ export const layer = (options?: Options) =>
(message) =>
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
)
return SubagentCompletion.text(assistant)
if (assistant?.type !== "assistant") return "Subagent completed without a text response."
return (
assistant.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("") || "Subagent completed without a text response."
)
}),
),
})
@@ -410,8 +410,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
...current,
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
recent: event.data.recent,
})
@@ -424,8 +422,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
status: "completed",
metadata: event.metadata,
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
recent: event.data.recent,
time: { created },
+24 -15
View File
@@ -28,9 +28,11 @@ const events = Metric.counter("opencode_session_websocket_events_total", {
const metric = (event: string, attributes: Record<string, string> = {}) =>
Metric.update(events.pipe(Metric.withAttributes({ event, ...attributes })), 1)
type Delivery = "queued" | "connecting" | "ready" | "send-attempted" | "provider-observed" | "terminal"
interface Active {
readonly queue: Queue.Queue<string, AIError>
delivery: "send-attempted" | "provider-observed" | "terminal"
readonly lifecycle: { delivery: Delivery }
}
interface Channel {
@@ -128,9 +130,14 @@ export const makeLayer = (connector: WebSocketConnector) =>
code: "close",
phase: "close",
delivery:
channel.active.delivery === "provider-observed" || channel.active.delivery === "terminal"
? "accepted"
: "ambiguous",
channel.active.lifecycle.delivery === "queued" ||
channel.active.lifecycle.delivery === "connecting" ||
channel.active.lifecycle.delivery === "ready"
? "not-sent"
: channel.active.lifecycle.delivery === "provider-observed" ||
channel.active.lifecycle.delivery === "terminal"
? "accepted"
: "ambiguous",
}),
),
)
@@ -191,7 +198,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
code: "idle-data",
phase: "receive",
})
active.delivery = "provider-observed"
active.lifecycle.delivery = "provider-observed"
if (typeof message !== "string")
return yield* transportError("Unsupported binary WebSocket frame", {
url: exchange.connect.url,
@@ -219,8 +226,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
phase:
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
delivery:
channel.active?.delivery === "provider-observed" ||
channel.active?.delivery === "terminal" ||
channel.active?.lifecycle.delivery === "provider-observed" ||
channel.active?.lifecycle.delivery === "terminal" ||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
? "accepted"
: error.reason._tag === "Transport" && error.reason.code === "1009"
@@ -249,6 +256,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
const start = Effect.fn("SessionModelTransport.start")(function* (
owner: State,
exchange: WebSocketChannelExchange,
lifecycle: { delivery: Delivery },
) {
if (owner.closed)
return yield* transportError("Session WebSocket owner is closed", {
@@ -280,6 +288,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
yield* closeChannel(owner, current)
}
lifecycle.delivery = owner.channel ? "ready" : "connecting"
if (owner.channel)
yield* Effect.logDebug("session websocket reused", {
sessionTransport: "websocket",
@@ -305,6 +314,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
),
)
if (!channel) return fallback(exchange)
lifecycle.delivery = "ready"
if (channel.pending) {
channel.pending = undefined
@@ -316,11 +326,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
Effect.onInterrupt(() => closeChannel(owner, channel)),
)
if (create.mode === "full") channel.checkpoint = undefined
const active: Active = {
queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY),
delivery: "send-attempted",
}
const active: Active = { queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY), lifecycle }
channel.active = active
lifecycle.delivery = "send-attempted"
const sent = yield* channel.connection.sendText(create.message).pipe(
Effect.withSpan("SessionModelTransport.send"),
Effect.onInterrupt(() => closeChannel(owner, channel)),
@@ -358,7 +366,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
operation: "read",
code: "idle-timeout",
phase: "receive",
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
}),
),
}),
@@ -367,7 +375,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
Effect.sync(() => {
if (!observationTerminal(observation)) return
terminal = observation
active.delivery = "terminal"
lifecycle.delivery = "terminal"
const staged = observation.type === "completed" ? observation.checkpoint : undefined
if (staged) channel.pending = { token, checkpoint: staged }
if (observation.type !== "completed" || !staged) channel.checkpoint = undefined
@@ -403,7 +411,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
operation: "read",
code: "incomplete",
phase: "receive",
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
})
yield* poison(owner, channel, error)
}),
@@ -440,6 +448,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
execute: (exchange) => {
const owner = state(sessionID)
const lifecycle = { delivery: "queued" as Delivery }
let execution: WebSocketChannelExecution | undefined
return Effect.succeed({
get http() {
@@ -447,7 +456,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
},
frames: Stream.unwrap(
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
Effect.andThen(start(owner, exchange)),
Effect.andThen(start(owner, exchange, lifecycle)),
Effect.tap((started) =>
Effect.sync(() => {
execution = started
+33 -146
View File
@@ -1,26 +1,15 @@
export * as SessionMove from "./move.js"
import type { Session } from "@opencode-ai/schema/session"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Cause, Context, Effect, Layer, Schema } from "effect"
import { Cause, Effect, Schema } from "effect"
import path from "path"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Instance } from "../instance/service.js"
import { Location } from "../location.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Project } from "../project.js"
import { AbsolutePath, RelativePath } from "../schema.js"
import { NotFoundError } from "./error.js"
import { SessionEvent } from "./event.js"
import { SessionExecution } from "./execution.js"
import { SessionInbox } from "./inbox.js"
import { SessionMessage } from "./message.js"
import { SessionProjector } from "./projector.js"
import { SessionRunner } from "./runner/index.js"
import { SessionStore } from "./store.js"
export class DestinationNotFoundError extends Schema.TaggedError<DestinationNotFoundError>()(
"Session.DestinationNotFoundError",
@@ -37,138 +26,36 @@ export class DestinationUnavailableError extends Schema.TaggedError<DestinationU
{ directory: AbsolutePath },
) {}
export interface Interface {
readonly move: (input: {
sessionID: Session.ID
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<
void,
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionMove") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const projects = yield* Project.Service
const locations = yield* LocationServiceMap.Service
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const instances = yield* Instance.Service
const admission = yield* SessionInbox.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const get = Effect.fn("SessionMove.get")(function* (sessionID: Session.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* new NotFoundError({ sessionID })
return session
})
const resolveDestination = Effect.fn("SessionMove.resolveDestination")(function* (
session: Session.Info,
input: Parameters<Interface["move"]>[0],
) {
const value = input.directory.trim()
const expanded =
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
const directory = AbsolutePath.make(path.resolve(session.location.directory, expanded))
const info = yield* fs.stat(directory).pipe(Effect.orElseSucceed(() => undefined))
if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
const project = yield* projects.resolve(directory)
const destination: SessionInbox.MovePayload = {
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
}
yield* locations.contextEffect(destination.location).pipe(
Effect.scoped,
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
)
}),
export const prepare = Effect.fn("SessionMove.prepare")(function* (input: {
session: Session.Info
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
}) {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const projects = yield* Project.Service
const locations = yield* LocationServiceMap.Service
const value = input.directory.trim()
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
const directory = AbsolutePath.make(path.resolve(input.session.location.directory, expanded))
const info = yield* fs.stat(directory).pipe(Effect.orElseSucceed(() => undefined))
if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
const project = yield* projects.resolve(directory)
const payload: SessionInbox.MovePayload = {
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
}
yield* Location.Service.pipe(
Effect.provide(locations.get(payload.location)),
Effect.scoped,
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
)
return destination
})
const sourceUnavailable = Effect.fn("SessionMove.sourceUnavailable")(function* (session: Session.Info) {
if (yield* execution.isActive(session.id)) return false
if (!(yield* fs.isDir(session.location.directory))) return true
return yield* SessionRunner.Service.pipe(
instances.provide(session),
Effect.as(false),
Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.succeed(true))),
)
})
return Service.of({
move: Effect.fn("SessionMove.move")(function* (input) {
const session = yield* get(input.sessionID)
const destination = yield* resolveDestination(session, input)
// Probe outside the inbox lock so cancellation remains available during initialization.
const unavailable = yield* sourceUnavailable(session)
const item = SessionInbox.Item.make({
type: "move",
payload: destination,
delivery: input.delivery ?? "steer",
})
yield* SessionInbox.serialized(
input.sessionID,
Effect.gen(function* () {
const latest = yield* get(input.sessionID)
// Only recover the placement we probed; active runners retain their step-boundary handoff.
if (
unavailable &&
latest.location.directory === session.location.directory &&
latest.location.workspaceID === session.location.workspaceID &&
!(yield* execution.isActive(input.sessionID))
) {
const cancellations = (yield* SessionInbox.moveIDs(database.db, input.sessionID)).map(
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
)
const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...destination }] as const
const first = cancellations[0]
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid)
return yield* bus.publishAll([first, ...cancellations.slice(1), moved])
}
yield* admission
.admit({
id: SessionMessage.ID.create(),
sessionID: input.sessionID,
item,
})
.pipe(Effect.orDie)
}),
)
yield* execution.wake(input.sessionID)
}),
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [
FSUtil.node,
Global.node,
Project.node,
LocationServiceMap.node,
SessionStore.node,
SessionExecution.node,
Instance.node,
SessionInbox.node,
Database.node,
Bus.node,
SessionProjector.node,
],
}),
)
return payload
})
@@ -90,8 +90,10 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}
const assistantMessageID = input.assistantMessageID
let stepStarted = false
let stepFailed = false
let providerFailed = false
let outputStarted = false
let stepStreamed = false
let stepFailure: SessionError.Error | undefined
let stepSettlement: StepRecord["finish"]
@@ -110,6 +112,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const currentAssistantMessageID = () =>
stepStarted ? Effect.succeed(assistantMessageID) : Effect.die(new Error("Tool event before assistant step start"))
const streamed = Effect.fnUntraced(function* () {
if (stepStreamed) return
stepStreamed = true
yield* bus.publish(SessionEvent.Step.Streamed, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
@@ -363,8 +367,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
readonly snapshot?: Snapshot.ID
readonly files?: readonly RelativePath[]
}) {
if (stepFailure === undefined) return
if (stepFailed || stepFailure === undefined) return
const assistantMessageID = yield* startAssistant()
stepFailed = true
yield* bus.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
assistantMessageID,
@@ -3,19 +3,6 @@ export * as SubagentCompletion from "./subagent-completion.js"
import { Effect } from "effect"
import type { Job } from "../job.js"
import type { Session } from "../session.js"
import type { SessionMessage } from "./message.js"
export const NO_TEXT = "Subagent completed without a text response."
export function text(message: SessionMessage.Info | undefined) {
if (message?.type !== "assistant") return NO_TEXT
return (
message.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("") || NO_TEXT
)
}
export const deliver = Effect.fnUntraced(function* (
sessions: Pick<Session.Interface, "synthetic">,
@@ -29,7 +16,7 @@ export const deliver = Effect.fnUntraced(function* (
const recovery = input.recovery
const text =
input.status === "completed"
? (input.output ?? NO_TEXT)
? (input.output ?? "Subagent completed without a text response.")
: input.status === "error"
? (input.error ?? "Subagent failed")
: "Subagent cancelled"
-60
View File
@@ -1,60 +0,0 @@
export * as SubagentJob from "./subagent-job.js"
import { Effect, Scope } from "effect"
import { Job } from "../job.js"
import { Session } from "../session.js"
import { SubagentCompletion } from "./subagent-completion.js"
type Recovery = Extract<Job.Recovery, { kind: "subagent" }>
interface Runner {
start: (recovery: Recovery) => Effect.Effect<Job.Info>
background: (recovery: Recovery) => Effect.Effect<void>
notify: (recovery: Recovery, startedAt: number) => Effect.Effect<void>
}
export const make: Effect.Effect<Runner, never, Session.Service | Job.Service | Scope.Scope> = Effect.gen(function* () {
const sessions = yield* Session.Service
const jobs = yield* Job.Service
const scope = yield* Scope.Scope
// One observer per job generation, including continuations of the same child.
const notifications = new Set<string>()
const notify = Effect.fn("SubagentJob.notify")(function* (recovery: Recovery, startedAt: number) {
const key = `${recovery.childSessionID}:${startedAt}`
if (notifications.has(key)) return
notifications.add(key)
yield* Effect.gen(function* () {
const info = (yield* jobs.wait({ id: recovery.childSessionID })).info
if (info) yield* SubagentCompletion.deliver(sessions, jobs, { ...info, recovery })
}).pipe(
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
Effect.forkIn(scope, { startImmediately: true }),
)
})
return {
start: (recovery: Recovery) =>
jobs.start({
id: recovery.childSessionID,
type: "subagent",
title: recovery.description,
metadata: {},
recovery,
run: Effect.gen(function* () {
yield* sessions.resume(recovery.childSessionID)
const messages = yield* sessions.messages({ sessionID: recovery.childSessionID, order: "desc", limit: 20 })
const assistant = messages.find(
(message) =>
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
)
return SubagentCompletion.text(assistant)
}),
}),
background: Effect.fn("SubagentJob.background")(function* (recovery: Recovery) {
const info = yield* jobs.background(recovery.childSessionID)
if (info) yield* notify(recovery, info.started_at)
}),
notify,
}
})
-3
View File
@@ -297,9 +297,6 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
metadata: meta,
summary: redact("compaction-summary", message.id, message.summary),
recent: redact("compaction-recent", message.id, message.recent),
...(message.status === "completed"
? { providerState: metadata("compaction-provider-state", message.id, message.providerState) }
: {}),
}
}
return { ...message, metadata: meta }
+6 -35
View File
@@ -149,7 +149,6 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
const char = input[index]
if (!wordStarted) wordStart = index
if (!quote && !wordStarted) {
if (char === " " || char === "\t") continue
const structure = structures.at(-1)
const token = /^[A-Za-z_][A-Za-z0-9_]*(?=[ \t\n;()<>]|$)/.exec(input.slice(index))?.[0]
if (structure?.kind === "case" && structure.phase === "header" && token === "in") {
@@ -167,24 +166,13 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
segment = index + 1
continue
}
if (structure?.kind === "for" && structure.phase === "header" && char === "(" && input[index + 1] !== "(") {
const values = bashExpansion(input, index, depth, "array")
if (!values) return { kind: "opaque", reason: "compound-command" }
finishCommand()
const failure = addSubstitutions(values)
if (failure) return failure
commands.push(...nestedCommands.splice(0))
// Zsh permits a sublist or brace group directly after the value list, without do/done.
structure.phase = "do"
if (!/^(?:[ \t\n;]|\\\n|#[^\n]*(?:\n|$))*do(?=[ \t\n;]|$)/.test(input.slice(values.end + 1))) structures.pop()
index = values.end
segment = index + 1
continue
}
if (!words.length && !hasRedirect && !compoundEnd) {
const definition = bashFunctionHead(input, index)
const definition =
/^(?:function[ \t]+[A-Za-z_][A-Za-z0-9_]*(?:[ \t]*\([ \t]*\))?|[A-Za-z_][A-Za-z0-9_]*[ \t]*\([ \t]*\))[ \t\n]*(?=[{(])/.exec(
input.slice(index),
)
if (definition && !header()) {
index += definition.length - 1
index += definition[0].length - 1
segment = index + 1
continue
}
@@ -548,14 +536,6 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
type BashExpansion = { source: string; end: number; substitutions?: string[] }
function bashFunctionHead(input: string, start: number) {
// Share recognition with delimiter scanning so case patterns in function bodies do not close the outer group.
// Names need not be variable identifiers. Zsh permits anonymous functions, including in an if condition.
return /^(?!if(?:[ \t]|\\\n)*\()(?:function[ \t]+(?:\\\n[ \t]*)*[A-Za-z_][A-Za-z0-9_.:-]*(?:(?:[ \t]|\\\n)*\([ \t]*\))?|(?:[A-Za-z_][A-Za-z0-9_.:-]*(?:[ \t]|\\\n)*)?\([ \t]*\))(?:[ \t\n]|\\\n|#[^\n]*(?:\n|$))*(?=[{(]|\[\[(?=[ \t\n])|(?:if|while|until|for|select|case)[ \t\n])/.exec(
input.slice(start),
)?.[0]
}
function bashDelimited(input: string, start: number, depth: number): BashExpansion | undefined {
if (depth >= MAX_SUBSTITUTION_DEPTH) return
const close = input[start] === "{" ? "}" : ")"
@@ -564,7 +544,6 @@ function bashDelimited(input: string, start: number, depth: number): BashExpansi
let commandStart = true
for (let index = start + 1; index < input.length; index++) {
const char = input[index]
if (char === " " || char === "\t") continue
if (char === "\\") {
if (input[index + 1] !== "\n") commandStart = false
index++
@@ -587,11 +566,6 @@ function bashDelimited(input: string, start: number, depth: number): BashExpansi
continue
}
const boundary = index === start + 1 || /[ \t\n;|&(){}]/.test(input[index - 1])
const definition = commandStart && boundary ? bashFunctionHead(input, index) : undefined
if (definition) {
index += definition.length - 1
continue
}
if (char === "#" && boundary) {
const newline = input.indexOf("\n", index)
if (newline < 0) return
@@ -751,10 +725,7 @@ function bashExpansion(
index = nested.end
continue
}
if (
((kind === "array" && "<>=".includes(char)) || (kind === "test" && "<>".includes(char))) &&
input[index + 1] === "("
) {
if (kind === "array" && "<>=".includes(char) && input[index + 1] === "(") {
const nested = bashDelimited(input, index + 1, depth + 1)
if (!nested) return
substitutions.push(nested.source)
+19 -86
View File
@@ -31,49 +31,6 @@ export interface Transformable<Editor> {
readonly reload: Reload
}
export interface Failure {
readonly state: string
readonly cause: unknown
}
type GroupedRegistration = {
readonly remove: () => boolean
readonly notify: Effect.Effect<void>
}
type RegistrationGroup = {
failed: boolean
readonly registrations: Set<GroupedRegistration>
readonly report: (failure: Failure, refresh: Effect.Effect<void>) => void
}
const CurrentGroup = Context.Reference<RegistrationGroup | undefined>("@opencode/State/CurrentGroup", {
defaultValue: () => undefined,
})
/**
* Groups registrations without coupling State to plugin identity or asynchronous cleanup.
* A failed group is detached synchronously; its supervisor must run refresh and close its scope.
*/
export function group(report: RegistrationGroup["report"]) {
const group: RegistrationGroup = { failed: false, registrations: new Set(), report }
return <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, CurrentGroup, group)
}
function disable(group: RegistrationGroup, failure: Failure) {
if (group.failed) return
group.failed = true
const notifications = new Set<Effect.Effect<void>>()
for (const registration of group.registrations) {
registration.remove()
notifications.add(registration.notify)
}
group.report(
failure,
Effect.forEach(notifications, (notify) => notify, { discard: true }),
)
}
type Batch = {
active: boolean
readonly shutdown: boolean
@@ -155,38 +112,19 @@ export interface Interface<State, Editor> extends Transformable<Editor> {
export function create<State, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
let state = options.initial()
const transforms = new Set<{ run: TransformCallback<Editor>; group: RegistrationGroup | undefined }>()
const transforms: { run: TransformCallback<Editor> }[] = []
let dirty = false
let closed = false
let version = 0
const invalidate = () => {
dirty = true
version++
}
const get = () => {
if (closed || !dirty) return state
while (true) {
const started = version
const next = options.initial()
const editor = options.editor(next)
for (const transform of transforms) {
try {
transform.run(editor)
} catch (cause) {
if (!transform.group) throw cause
disable(transform.group, { state: options.name ?? "anonymous", cause })
}
// A nested read can disable a group that already contributed to this candidate.
if (version !== started) break
}
if (version !== started) continue
// Ungrouped failures still propagate; grouped failures restart from a fresh candidate.
state = next
dirty = false
return state
}
const next = options.initial()
const editor = options.editor(next)
for (const transform of transforms) transform.run(editor)
// Only a complete fold becomes visible; a throwing callback leaves the previous value and stays dirty.
state = next
dirty = false
return state
}
// One stable value per State, so a batch's notification Set holds it at most once.
@@ -199,7 +137,7 @@ export function create<State, Editor>(options: Options<State, Editor>): Interfac
const changed = Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (closed) return
invalidate()
dirty = true
const batch = yield* CurrentBatch
if (batch?.active) {
if (batch.shutdown) {
@@ -218,23 +156,18 @@ export function create<State, Editor>(options: Options<State, Editor>): Interfac
transform: Effect.fn("State.transform")(function* (update) {
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
const scope = yield* Scope.Scope
const group = yield* CurrentGroup
if (group?.failed) return { dispose: Effect.void }
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const transform = { run: update, group }
const registration: GroupedRegistration = {
remove: () => {
if (!transforms.delete(transform)) return false
group?.registrations.delete(registration)
invalidate()
return true
},
notify: changed,
}
const dispose = Effect.uninterruptible(Effect.suspend(() => (registration.remove() ? changed : Effect.void)))
transforms.add(transform)
group?.registrations.add(registration)
const transform = { run: update }
const dispose = Effect.uninterruptible(
Effect.suspend(() => {
const index = transforms.indexOf(transform)
if (index < 0) return Effect.void
transforms.splice(index, 1)
return changed
}),
)
transforms.push(transform)
yield* Scope.addFinalizer(scope, dispose)
yield* changed
return { dispose }
+50 -11
View File
@@ -2,7 +2,7 @@ export * as SubagentTool from "./subagent.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema } from "effect"
import { Effect, Schema, Scope } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { Job } from "../../job.js"
@@ -10,10 +10,10 @@ import { Permission } from "../../permission.js"
import { Session } from "../../session.js"
import { SessionSchema } from "../../session/schema.js"
import { SubagentCompletion } from "../../session/subagent-completion.js"
import { SubagentJob } from "../../session/subagent-job.js"
export const name = "subagent"
const NO_TEXT = "Subagent completed without a text response."
const backgroundResult = (sessionID: SessionSchema.ID) => ({
sessionID,
status: "running" as const,
@@ -60,7 +60,42 @@ export const Plugin = {
const agents = yield* Agent.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
const subagents = yield* SubagentJob.make
const scope = yield* Scope.Scope
// One completion observer per job generation. Keyed by child plus start time so a fresh
// continuation job is observable even while a settled generation's observer is finalizing.
const notifications = new Set<string>()
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) {
const messages = yield* sessions.messages({ sessionID, order: "desc", limit: 20 })
const assistant = messages.find(
(message) =>
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
)
if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT
const text = assistant.content
.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("")
return text.length > 0 ? text : NO_TEXT
})
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
recovery: Extract<Job.Recovery, { kind: "subagent" }>,
startedAt: number,
) {
const key = `${recovery.childSessionID}:${startedAt}`
if (notifications.has(key)) return
notifications.add(key)
yield* Effect.gen(function* () {
const info = (yield* jobs.wait({ id: recovery.childSessionID })).info
if (info) yield* SubagentCompletion.deliver(sessions, jobs, { ...info, recovery })
}).pipe(
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
Effect.forkIn(scope, { startImmediately: true }),
)
})
yield* ctx.tool
.transform((editor) =>
@@ -190,10 +225,18 @@ export const Plugin = {
agent: agent.name,
description: input.description,
}
yield* subagents.start(recovery)
const info = yield* jobs.start({
id: child.id,
type: name,
title: input.description,
metadata: {},
recovery,
run: sessions.resume(child.id).pipe(Effect.andThen(latestAssistantText(child.id))),
})
if (background) {
yield* subagents.background(recovery)
yield* jobs.background(info.id)
yield* notifyWhenDone(recovery, info.started_at)
return backgroundResult(child.id)
}
@@ -205,7 +248,7 @@ export const Plugin = {
),
)
if (result?.type === "backgrounded") {
yield* subagents.notify(recovery, result.info.started_at)
yield* notifyWhenDone(recovery, result.info.started_at)
return backgroundResult(child.id)
}
// Failure surfaces keep the sessionID visible so the model can continue the child.
@@ -215,11 +258,7 @@ export const Plugin = {
})
if (result?.info.status === "cancelled")
return yield* new ToolFailure({ message: `Subagent cancelled (sessionID: ${child.id})` })
return {
sessionID: child.id,
status: "completed" as const,
output: result?.info.output ?? SubagentCompletion.NO_TEXT,
}
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
}).pipe(
Effect.map((output) => ({
output,
+5 -3
View File
@@ -29,9 +29,11 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
update:
info.autoupdate === false
? "disable"
: info.autoupdate === "notify" || info.autoupdate === true
: info.autoupdate === "notify"
? "notify"
: undefined,
: info.autoupdate === true
? "auto"
: undefined,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
@@ -170,7 +172,7 @@ export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>)
description: command.description,
agent: command.agent,
model: modelSelection(command.model, command.variant),
subagent: command.subtask,
subtask: command.subtask,
},
]),
)
@@ -47,7 +47,7 @@ describe("CodeModeInstructions", () => {
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [echo] }))
expect(initialized.text).toContain(
"This catalog is the complete set of tools callable inside `execute`. It does not affect tools exposed directly outside Code Mode.",
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
)
expect(initialized.text).toContain("## Available tools")
expect(initialized.text).not.toContain("## Search")
@@ -1,164 +0,0 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { LanguageModel } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "../fixture/global"
import { offlineModels } from "../fixture/models"
import { tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const llmLayer = TestLLM.testLayer({ fallback: TestLLM.text("Review complete", "review") })
const it = testEffect(
Layer.merge(
llmLayer,
AppNodeBuilder.build(LayerNode.group([Session.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
offlineModels,
Watcher.node.replace(Watcher.configured({ enabled: false })),
LayerNodePlatform.llmClient.replace(llmLayer),
SessionRunnerModel.node.replace(
Layer.succeed(SessionRunnerModel.Service, {
resolve: (session) =>
Effect.succeed(
SessionRunnerModel.resolved(
LanguageModel.make({ id: session.model?.id ?? "parent", provider: "test", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
},
),
),
}),
),
]),
),
)
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
describe("command subagents", () => {
for (const fixture of [
{
name: "native JSON",
format: "json",
command: { subagent: true, agent: "build", model: "test/override" },
agent: "build",
model: "override",
},
{
name: "legacy Markdown",
format: "markdown",
command: { subtask: true, agent: "build" },
agent: "build",
model: "parent",
},
{
name: "subagent mode by default",
format: "json",
command: { agent: "reviewer" },
agent: "reviewer",
model: "child",
},
] as const) {
it.live(`runs ${fixture.name} in the background without switching the parent`, () =>
Effect.gen(function* () {
const parent = yield* project(fixture.command, fixture.format)
const sessions = yield* Session.Service
const llm = yield* TestLLM.Test
const gate = yield* llm.gate()
// This must return while the child's model is still blocked.
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
yield* gate.started
const children = (yield* sessions.list({ parentID: parent.id })).data
expect(children).toHaveLength(1)
const child = children[0]
if (!child) return yield* Effect.die("Expected a child session")
expect(child).toMatchObject({ agent: fixture.agent, model: { id: fixture.model }, title: "Review code" })
expect(yield* sessions.get(parent.id)).toMatchObject({ agent: "build", model: parentModel })
expect(yield* sessions.context(parent.id)).toEqual([])
expect(yield* llm.requests()).toHaveLength(1)
expect((yield* sessions.context(child.id)).filter((message) => message.type === "user")).toMatchObject([
{ text: "You are a subagent spawned by another session.\nReview changes: ready" },
])
yield* gate.release
yield* llm.wait(2)
yield* sessions.wait(parent.id)
const notices = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
expect(notices).toMatchObject([{ metadata: { source: "subagent", childID: child.id, state: "completed" } }])
expect(notices[0]?.text).toContain("Review complete")
}),
)
}
it.live("subagent: false overrides subagent mode and the legacy alias", () =>
Effect.gen(function* () {
const parent = yield* project({ subagent: false, subtask: true, agent: "reviewer" }, "json")
const sessions = yield* Session.Service
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
yield* sessions.wait(parent.id)
expect((yield* sessions.list({ parentID: parent.id })).data).toEqual([])
expect(yield* sessions.get(parent.id)).toMatchObject({
agent: "reviewer",
model: { id: "child" },
})
expect((yield* sessions.context(parent.id)).filter((message) => message.type === "user")).toMatchObject([
{ text: "Review changes: ready" },
])
}),
)
})
function project(
command: { agent?: string; model?: string; subagent?: boolean; subtask?: boolean },
format: "json" | "markdown",
) {
return Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const definition = { description: "Review code", template: "Review $ARGUMENTS: !`printf ready`", ...command }
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
agents: { reviewer: { mode: "subagent", model: "test/child" } },
...(format === "markdown" ? {} : { commands: { review: definition } }),
}),
),
)
if (format === "markdown")
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, ".opencode/commands/review.md"),
[
"---",
"description: Review code",
...Object.entries(command).map(([key, value]) => `${key}: ${value}`),
"---",
definition.template,
].join("\n"),
),
)
const sessions = yield* Session.Service
return yield* sessions.create({
location: { directory: AbsolutePath.make(tmp.path) },
title: "Parent session",
agent: Agent.ID.make("build"),
model: parentModel,
})
})
}
+2 -20
View File
@@ -4,10 +4,7 @@ import { describe, expect } from "bun:test"
import { DateTime, Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { advance, drain } from "../lib/clock"
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { Job } from "@opencode-ai/core/job"
import { Agent } from "@opencode-ai/core/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Command } from "@opencode-ai/core/command"
@@ -30,8 +27,6 @@ import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
import { offlineModels } from "../fixture/models"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
@@ -46,25 +41,12 @@ const shellLayer = Layer.succeed(
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Command.node,
Bus.node,
FSUtil.node,
AppProcess.node,
Location.node,
ShellSelect.node,
Session.node,
Job.node,
Agent.node,
]),
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
[
Mcp.node.replace(emptyMcpLayer),
Config.node.replace(emptyConfigLayer),
Location.node.replace(testLocationLayer),
ShellSelect.node.replace(shellLayer),
Global.node.replace(tempGlobalLayer),
SessionExecution.node.replace(SessionExecution.noopLayer),
offlineModels,
],
),
)
+22 -33
View File
@@ -13,7 +13,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Credential } from "@opencode-ai/core/credential"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Bus } from "@opencode-ai/core/bus"
import { Global } from "@opencode-ai/util/global"
@@ -666,18 +665,10 @@ describe("Config", () => {
test("migrates the v1 update policy", () => {
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
})
test("normalizes the previous native auto update policy", () => {
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
type: "normalized",
encoded: { update: "notify" },
diagnostics: [],
})
})
test("migrates v1 provider lists to policies", () => {
expect(
ConfigMigrateV1.migrate({
@@ -837,32 +828,30 @@ describe("Config", () => {
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
})
for (const subtask of [true, false]) {
test(`migrates v1 command configuration with subtask: ${subtask}`, () => {
expect(
ConfigMigrateV1.migrate({
command: {
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask,
},
test("migrates v1 command configuration", () => {
expect(
ConfigMigrateV1.migrate({
command: {
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
},
}).commands,
).toEqual({
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: { providerID: "anthropic", model: "claude", variant: "high" },
subagent: subtask,
},
})
}).commands,
).toEqual({
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: { providerID: "anthropic", model: "claude", variant: "high" },
subtask: true,
},
})
}
})
test("normalizes renamed permission actions when migrating v1 permissions", () => {
expect(
+1 -2
View File
@@ -21,7 +21,6 @@ import { Provider } from "@opencode-ai/core/provider"
import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Job } from "@opencode-ai/core/job"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { Credential } from "@opencode-ai/core/credential"
@@ -38,7 +37,7 @@ import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
const it = testEffect(
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node, Job.node]))),
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))),
)
const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
@@ -118,40 +118,3 @@ test("defensively syncs advertised Copilot models", async () => {
await server.stop(true)
}
})
test("prices cache reads from either token price spelling", async () => {
// API version 2026-08-01 renamed cache_price to cache_read_price; older payloads still use cache_price.
const item = (id: string, prices: Record<string, number>) => ({
model_picker_enabled: true,
id,
name: id,
version: `${id}-2026-08-01`,
supported_endpoints: ["/chat/completions"],
billing: { token_prices: { batch_size: 1_000_000, default: { input_price: 250, output_price: 1500, ...prices } } },
capabilities: {
family: "gpt",
limits: { max_output_tokens: 1000, max_prompt_tokens: 8000 },
supports: { tool_calls: true },
},
})
const server = Bun.serve({
port: 0,
fetch: () =>
Response.json({
data: [
item("renamed", { cache_read_price: 25, cache_write_price: 0 }),
item("legacy", { cache_price: 25 }),
item("unpriced", {}),
],
}),
})
try {
const models = await CopilotModels.get(server.url.origin, {}, [])
expect(models.get(Model.ID.make("renamed"))?.cost[0]).toMatchObject({ input: 2.5, output: 15, cache: { read: 0.25 } })
expect(models.get(Model.ID.make("legacy"))?.cost[0]).toMatchObject({ input: 2.5, output: 15, cache: { read: 0.25 } })
expect(models.get(Model.ID.make("unpriced"))?.cost[0]).toMatchObject({ cache: { read: 0 } })
} finally {
await server.stop(true)
}
})
-65
View File
@@ -64,71 +64,6 @@ describe("Job", () => {
}),
)
it.live("reuses running work when started again with the same ID", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const output = yield* Deferred.make<string>()
const job = yield* jobs.start({ id: "job_reused", type: "test", run: Deferred.await(output) })
expect(
yield* jobs.start({ id: job.id, type: "duplicate", run: Effect.die("Duplicate work must not run") }),
).toEqual(job)
yield* Deferred.succeed(output, "original output")
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
type: "test",
status: "completed",
output: "original output",
})
}),
)
it.live("ignores an obsolete callback after a cancellation waiter starts a same-ID replacement", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const callback = yield* Deferred.make<() => void>()
const output = yield* Deferred.make<string>()
const finalized = yield* Deferred.make<void>()
const job = yield* jobs.start({
id: "job_replaced",
type: "test",
run: Effect.callback<string>((resume) => {
Deferred.doneUnsafe(
callback,
Effect.succeed(() => resume(Effect.succeed("obsolete output"))),
)
}),
})
const complete = yield* Deferred.await(callback)
// Cancellation wakes waiters before closing the old scope, allowing the old callback to race replacement.
const replacement = yield* jobs.wait({ id: job.id }).pipe(
Effect.tap((result) => Effect.sync(() => expect(result.info?.status).toBe("cancelled"))),
Effect.andThen(
jobs.start({
id: job.id,
type: "replacement",
run: Deferred.await(output).pipe(Effect.ensuring(Deferred.succeed(finalized, undefined))),
}),
),
Effect.andThen(Effect.sync(complete)),
Effect.forkChild({ startImmediately: true }),
)
yield* jobs.cancel(job.id)
yield* Fiber.join(replacement)
expect(yield* jobs.get(job.id)).toMatchObject({ type: "replacement", status: "running" })
expect(yield* Deferred.isDone(finalized)).toBe(false)
yield* Deferred.succeed(output, "replacement output")
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
type: "replacement",
status: "completed",
output: "replacement output",
})
expect(yield* Deferred.isDone(finalized)).toBe(true)
}),
)
it.live("returns finished from a blocking wait when completion wins", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
@@ -19,6 +19,7 @@ export interface WebSocketServerFixture {
}
export interface WebSocketServerOptions {
readonly http?: (request: Request) => Response | Promise<Response>
readonly upgrade?: (request: Request) => boolean
readonly open?: (socket: Bun.ServerWebSocket<ConnectionData>) => void
readonly message?: (socket: Bun.ServerWebSocket<ConnectionData>, message: string | Buffer) => void
@@ -34,6 +35,7 @@ export const makeWebSocketServer = (options: WebSocketServerOptions = {}) =>
port: 0,
fetch(request, server) {
state.headers.push(Object.fromEntries(request.headers.entries()))
if (request.headers.get("upgrade") !== "websocket" && options.http) return options.http(request)
if ((options.upgrade?.(request) ?? true) && server.upgrade(request, { data: { id: connection++ } }))
return undefined
return new Response("WebSocket upgrade required", {
+4 -193
View File
@@ -3,24 +3,7 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import {
Cause,
DateTime,
Deferred,
Duration,
Effect,
Equal,
Exit,
Fiber,
Hash,
Layer,
LayerMap,
Option,
RcMap,
Schema,
Scope,
Stream,
} from "effect"
import { DateTime, Duration, Effect, Equal, Hash, Layer, LayerMap, Option, RcMap, Schema, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
@@ -30,7 +13,6 @@ import { Global } from "@opencode-ai/util/global"
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
import { LocationActivity } from "@opencode-ai/core/location-activity"
import { Location } from "@opencode-ai/core/location"
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
import { Plugin } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
@@ -40,7 +22,7 @@ import { Session } from "@opencode-ai/core/session"
import { Workspace } from "@opencode-ai/core/workspace"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { testEffect } from "./lib/effect"
@@ -79,177 +61,6 @@ const itWithActivity = testEffect(
)
describe("LocationServiceMap", () => {
for (const failure of ["file", "permissions", "config reference"] as const) {
for (const invalidate of [false, true]) {
// The file-path fixture boots on Windows rather than failing during
// discovery. The config-reference case covers repair on every OS.
// Windows does not enforce POSIX directory modes, and root bypasses them.
const test =
(failure === "file" && process.platform === "win32") ||
(failure === "permissions" && (process.platform === "win32" || process.getuid?.() === 0))
? it.live.skip
: it.live
test(`retries after repairing ${failure}${invalidate ? " with explicit invalidation" : ""}`, () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const directory = path.join(dir.path, "repaired")
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const locations = yield* LocationServiceMap.Service
const load = Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
if (failure === "permissions") {
yield* Effect.promise(() => fs.mkdir(directory, { mode: 0o000 }))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(directory, 0o755)))
}
if (failure === "config reference") {
yield* Effect.promise(() => fs.mkdir(directory))
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
)
}
const first = yield* Effect.exit(load)
expect(Exit.isFailure(first)).toBe(true)
if (failure === "config reference" && Exit.isFailure(first)) {
expect(Cause.squash(first.cause)).toMatchObject({
name: "ConfigInvalidError",
data: { message: expect.stringContaining('bad file reference: "{file:username.txt}"') },
})
}
if (!invalidate) expect(yield* locations.contextEffectOption(ref).pipe(Effect.scoped)).toEqual(Option.none())
if (failure === "file") {
yield* Effect.promise(() => fs.rm(directory))
yield* Effect.promise(() => fs.mkdir(directory))
}
if (failure === "permissions") yield* Effect.promise(() => fs.chmod(directory, 0o755))
if (failure === "config reference") {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
}
expect((yield* Effect.promise(() => fs.stat(directory))).isDirectory()).toBe(true)
if (invalidate) yield* locations.invalidate(ref)
const repaired = yield* Effect.exit(load)
expect(Exit.isSuccess(repaired)).toBe(true)
// A successful graph remains cached after its last borrower releases.
expect(yield* load).toBe(yield* repaired)
}))
}
}
for (const failure of ["file", "missing", "config reference"] as const) {
// A file-path Location boots on Windows; use the missing config reference there.
const test = failure === "file" && process.platform === "win32" ? it.live.skip : it.live
test(`keeps the repaired graph after concurrent ${failure} failures release`, () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const directory = path.join(dir.path, "concurrent")
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const locations = yield* LocationServiceMap.Service
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
if (failure === "config reference") {
yield* Effect.promise(() => fs.mkdir(directory))
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
)
}
const scopes = yield* Effect.forEach(Array.from({ length: 8 }), () =>
Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void)),
)
const failures = yield* Effect.forEach(
scopes,
(scope) => locations.contextEffect(ref).pipe(Scope.provide(scope), Effect.exit),
{ concurrency: "unbounded" },
)
expect(failures.every(Exit.isFailure)).toBe(true)
if (failure === "file") yield* Effect.promise(() => fs.rm(directory))
if (failure !== "config reference") yield* Effect.promise(() => fs.mkdir(directory))
if (failure === "config reference") {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
}
const repaired = yield* locations.contextEffect(ref)
yield* Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void))
expect(yield* locations.contextEffect(ref)).toBe(repaired)
expect(Option.getOrThrow(yield* locations.contextEffectOption(ref))).toBe(repaired)
}))
}
for (const disposition of ["retry", "invalidate", "interrupt"] as const) {
testEffect(Layer.empty).live(
disposition === "invalidate"
? "does not let an invalidated boot failure evict its replacement"
: disposition === "interrupt"
? "finishes a failed boot after its acquisition scopes close"
: "shares a failed boot across acquisition APIs and retries",
() =>
Effect.gen(function* () {
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const builds = { started: 0 }
const finalized: number[] = []
const layer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
offlineModels,
LocationWatcher.node.replace(
LocationWatcher.node.mapLayer((layer) =>
layer.pipe(
Layer.tap(() =>
Effect.gen(function* () {
const build = ++builds.started
yield* Effect.addFinalizer(() => Effect.sync(() => finalized.push(build)))
if (build !== 1) return
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
yield* Effect.die("first boot failed")
}),
),
),
),
),
])
yield* Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const locations = yield* LocationServiceMap.Service
const first = yield* locations.contextEffect(ref).pipe(Effect.scoped, Effect.exit, Effect.forkScoped)
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
yield* Deferred.await(entered)
const scope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void))
const second = yield* Location.Service.pipe(
Effect.provide(locations.get(ref)),
Effect.exit,
Effect.forkScoped({ startImmediately: true }),
)
const third = yield* locations
.contextEffectOption(ref)
.pipe(Scope.provide(scope), Effect.exit, Effect.forkScoped({ startImmediately: true }))
if (disposition === "invalidate") yield* locations.invalidate(ref)
if (disposition === "interrupt") {
yield* Fiber.interrupt(first)
yield* Fiber.interrupt(second)
yield* Scope.close(scope, Exit.void)
}
const replacement = disposition === "invalidate" ? yield* locations.contextEffect(ref) : undefined
yield* Deferred.succeed(release, undefined)
if (disposition !== "interrupt") {
expect(Exit.isFailure(yield* Fiber.join(first))).toBe(true)
expect(Exit.isFailure(yield* Fiber.join(second))).toBe(true)
}
expect(Exit.isFailure(yield* Fiber.join(third).pipe(Effect.timeout("2 seconds")))).toBe(true)
expect(finalized).toEqual([1])
expect(builds.started).toBe(disposition === "invalidate" ? 2 : 1)
const recovered = yield* locations.contextEffect(ref)
if (replacement) expect(recovered).toBe(replacement)
yield* Scope.close(scope, Exit.void)
expect(yield* locations.contextEffect(ref)).toBe(recovered)
expect(builds.started).toBe(2)
}).pipe(Effect.provide(layer))
expect(finalized).toEqual([1, 2])
}),
)
}
itWithActivity.effect("does not refresh lifetime from inferred Session routing", () =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
@@ -339,8 +150,8 @@ describe("LocationServiceMap", () => {
expect(location.directory).toBe(directory)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
// A local ref with the same missing directory is dropped after its
// boot failure so a retry can rebuild it.
// A local ref with the same missing directory keeps the existing
// behavior: dropped as soon as it goes idle so a retry can rebuild it.
yield* Location.Service.pipe(Effect.provide(locations.get(localRef)), Effect.scoped, Effect.exit)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
}),
-137
View File
@@ -1,137 +0,0 @@
import { expect, test } from "bun:test"
import { ModalModels } from "@opencode-ai/core/modal/models"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Money } from "@opencode-ai/schema/money"
const providerID = Provider.ID.make("modal")
test("modal plugin is registered", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.modal")
})
function template(id: string, overrides: Partial<Model.Info> = {}) {
return Model.Info.make({
...Model.Info.default(providerID, Model.ID.make(id)),
name: `${id} catalog`,
family: Model.Family.make("catalog-family"),
...overrides,
})
}
test("maps live Modal models onto catalog templates", async () => {
const server = Bun.serve({
port: 0,
fetch: (request) => {
expect(request.headers.get("Authorization")).toBe("Bearer test-key")
expect(new URL(request.url).pathname).toBe("/v1/models")
return Response.json({
data: [
{
id: "live-model",
base_model_id: "base-model",
name: "Live Model",
input_modalities: ["text", "image"],
output_modalities: ["text"],
context_length: 128000,
max_output_length: 8192,
pricing: { prompt: "0.000001", completion: 0.000002, input_cache_read: "0.0000002" },
supported_sampling_parameters: ["temperature"],
supported_features: ["tools", "reasoning"],
reasoning_options: [{ type: "effort", values: ["low", "high", null] }],
interleaved: { field: "reasoning_content" },
},
{
id: "standalone",
context_length: 64000,
},
{ id: "malformed", context_length: "huge" },
],
})
},
})
try {
const base = template("base-model")
const stale = template("stale")
const models = await ModalModels.get(`${server.url.origin}/v1`, "test-key", [base, stale])
expect(models.has(Model.ID.make("stale"))).toBe(false)
expect(models.has(Model.ID.make("malformed"))).toBe(false)
const model = models.get(Model.ID.make("live-model"))
expect(model?.name).toBe("Live Model")
expect(model?.family).toBe(Model.Family.make("catalog-family"))
expect(model?.providerID).toBe(providerID)
expect(model?.modelID).toBe(Model.ID.make("live-model"))
expect(model?.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible"))
expect(model?.settings).toMatchObject({ baseURL: `${server.url.origin}/v1` })
expect(model?.compatibility).toMatchObject({ reasoningField: "reasoning_content" })
expect(model?.capabilities).toMatchObject({ tools: true, input: ["text", "image"], output: ["text"] })
expect(model?.cost[0]?.input).toBe(Money.USDPerMillionTokens.make(1))
expect(model?.cost[0]?.output).toBe(Money.USDPerMillionTokens.make(2))
expect(Number(model?.cost[0]?.cache.read)).toBeCloseTo(0.2, 10)
expect(model?.cost[0]?.cache.write).toBe(Money.USDPerMillionTokens.zero)
expect(model?.limit).toMatchObject({ context: 128000, output: 8192 })
expect(model?.variants.map((variant) => variant.id)).toEqual([
Model.VariantID.make("low"),
Model.VariantID.make("high"),
Model.VariantID.make("none"),
])
expect(model?.variants[0]?.settings).toMatchObject({ reasoningEffort: "low" })
expect(model?.status).toBe("active")
const fresh = models.get(Model.ID.make("standalone"))
expect(fresh?.name).toBe("standalone")
expect(fresh?.family).toBeUndefined()
expect(fresh?.capabilities).toMatchObject({ tools: true, input: ["text"], output: ["text"] })
expect(fresh?.variants).toEqual([])
expect(fresh?.limit).toMatchObject({ context: 64000, output: 0 })
} finally {
await server.stop(true)
}
})
test("keeps template cost and limits when the proxy omits them", async () => {
const server = Bun.serve({
port: 0,
fetch: () =>
Response.json({
data: [{ id: "sparse", hugging_face_id: "hf-base" }],
}),
})
try {
const base = template("hf-base", {
cost: [
{
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(10),
cache: { read: Money.USDPerMillionTokens.make(1), write: Money.USDPerMillionTokens.make(2) },
},
],
limit: { context: 1000, input: 500, output: 250 },
})
const models = await ModalModels.get(server.url.origin, "test-key", [base])
const model = models.get(Model.ID.make("sparse"))
expect(model?.name).toBe("hf-base catalog")
expect(model?.cost[0]).toMatchObject({ input: 5, output: 10, cache: { read: 1, write: 2 } })
expect(model?.limit).toMatchObject({ context: 1000, input: 500, output: 250 })
} finally {
await server.stop(true)
}
})
test("throws on proxy failure so the plugin can fail soft", async () => {
const server = Bun.serve({
port: 0,
fetch: () => new Response("nope", { status: 500 }),
})
try {
await expect(ModalModels.get(server.url.origin, "test-key", [])).rejects.toThrow()
} finally {
await server.stop(true)
}
})
-305
View File
@@ -1,305 +0,0 @@
import { expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Schema } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Command } from "@opencode-ai/core/command"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Rpc } from "@opencode-ai/core/rpc"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
it.live("removes a failed plugin's hooks and RPC handlers without affecting healthy plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const hooks = yield* PluginHooks.Service
const rpc = yield* Rpc.Service
const cleaned = yield* Deferred.make<void>()
const invoked: string[] = []
let fail = false
yield* plugins.activate(
["broken", "healthy"].map((id) => ({
id,
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
// Finalizers run in reverse order, so this signals after registration cleanup.
if (id === "broken") yield* Effect.addFinalizer(() => Deferred.succeed(cleaned, undefined))
yield* ctx.command.transform((editor) => {
editor.add({ name: id, execute: () => Effect.void })
if (id === "broken" && fail) throw new Error("transform failed")
})
yield* ctx.shell.hook("create.before", () => Effect.sync(() => void invoked.push(id)))
yield* ctx.rpc
.register(
Rpc.define({ id, methods: { check: { input: Schema.Struct({}), output: Schema.String } }, events: {} }),
{ check: () => Effect.succeed(id) },
)
.pipe(Effect.orDie)
if (id === "broken") yield* Effect.addFinalizer(() => plugins.awaitActivation)
}),
})),
)
const trigger = hooks.trigger("shell", "create.before", {
command: "echo fixture",
cwd: ".",
timeout: 1_000,
shell: "sh",
env: {},
})
yield* trigger
expect(invoked).toEqual(["broken", "healthy"])
expect(yield* rpc.call("broken", "check", {})).toBe("broken")
expect(yield* rpc.call("healthy", "check", {})).toBe("healthy")
expect((yield* commands.list()).map((command) => command.name)).toEqual(["broken", "healthy"])
fail = true
yield* commands.reload()
yield* Deferred.await(cleaned).pipe(Effect.timeout("1 second"))
invoked.length = 0
yield* trigger
expect(invoked).toEqual(["healthy"])
expect(yield* rpc.call("broken", "check", {}).pipe(Effect.flip)).toMatchObject({ type: "rpc.unavailable" })
expect(yield* rpc.call("healthy", "check", {})).toBe("healthy")
expect((yield* commands.list()).map((command) => command.name)).toEqual(["healthy"])
expect((yield* plugins.list()).map((plugin) => plugin.state.status)).toEqual(["failed", "active"])
}),
)
Array.of("reload", "teardown").forEach((boundary) =>
it.live(`does not join queued failed-plugin cleanup during ${boundary}`, () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const entered = yield* Deferred.make<void>()
const escape = yield* Deferred.make<void>()
const cleaned = yield* Deferred.make<void>()
let fail = false
yield* plugins.activate([
{
id: "changing",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.command.transform((editor) => {
editor.add({ name: "changing", description: "old", execute: () => Effect.void })
if (fail) throw new Error("changing failed")
})
yield* Effect.addFinalizer(() =>
Deferred.succeed(entered, undefined).pipe(
Effect.andThen(plugins.awaitActivation.pipe(Effect.raceFirst(Deferred.await(escape)))),
Effect.andThen(Deferred.succeed(cleaned, undefined)),
),
)
}),
},
{
id: "trigger",
revision: "1",
effect: () =>
Effect.addFinalizer(() =>
boundary === "teardown"
? commands.reload().pipe(Effect.andThen(commands.list()), Effect.asVoid)
: Effect.void,
),
},
])
fail = true
const activation = yield* (boundary === "reload" ? commands.reload() : Effect.void).pipe(
Effect.andThen(
plugins.activate([
{
id: "changing",
revision: "2",
effect: (ctx) =>
ctx.command
.transform((editor) =>
editor.add({ name: "changing", description: "new", execute: () => Effect.void }),
)
.pipe(Effect.asVoid),
},
]),
),
Effect.forkChild({ startImmediately: true }),
)
yield* Deferred.await(entered)
const result = yield* Fiber.join(activation).pipe(Effect.timeout("250 millis"), Effect.exit)
// Allow teardown to finish even if activation incorrectly joins the old finalizer.
yield* Deferred.succeed(escape, undefined)
yield* Fiber.join(activation)
yield* plugins.awaitActivation
yield* Deferred.await(cleaned)
expect(Exit.isSuccess(result)).toBe(true)
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
expect(yield* commands.get("changing")).toMatchObject({ description: "new" })
}),
),
)
it.live("does not restore a disabled generation with a pending failure when its replacement fails setup", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const loads: string[] = []
let fail = false
const generation = (revision: string): Plugin.Generation => ({
id: "replacement",
revision,
effect: (ctx) =>
Effect.gen(function* () {
loads.push(revision)
if (revision === "2") yield* Effect.die("setup failed")
yield* ctx.command.transform((editor) => {
editor.add({ name: "replacement", execute: () => Effect.void })
if (fail) throw new Error("replay failed")
})
}),
})
yield* plugins.activate([generation("1")])
fail = true
yield* commands.reload()
yield* plugins.activate([generation("2")])
yield* plugins.awaitActivation
expect(loads).toEqual(["1", "2"])
expect((yield* plugins.list())[0]?.state.status).toBe("failed")
expect(yield* commands.get("replacement")).toBeUndefined()
}),
)
Array.of("pending", "reported").forEach((status) =>
it.live(`preserves ${status} failures when an earlier plugin changes`, () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const loads: string[] = []
let fail = true
const generation = (id: string, revision: string): Plugin.Generation => ({
id,
revision,
effect: (ctx) =>
Effect.gen(function* () {
loads.push(`${id}@${revision}`)
yield* ctx.command.transform((editor) => {
editor.add({ name: id, execute: () => Effect.void })
if (id === "broken" && fail) throw new Error("broken failed")
})
}),
})
const broken = generation("broken", "1")
const later = generation("later", "1")
yield* plugins.activate([generation("earlier", "1"), broken, later])
if (status === "reported") yield* plugins.awaitActivation
fail = false
yield* plugins.activate([generation("earlier", "2"), broken, later])
yield* plugins.awaitActivation
expect(loads).toEqual(["earlier@1", "broken@1", "later@1", "earlier@2", "later@1"])
const failed = (yield* plugins.list())[1]?.state
expect(failed).toMatchObject({ status: "failed", ref: expect.stringMatching(/^err_/) })
expect((yield* commands.list()).map((entry) => entry.name)).toEqual(["earlier", "later"])
// Reordering and removing other plugins must preserve the same failure too.
yield* plugins.activate([later, broken, generation("earlier", "2")])
yield* plugins.awaitActivation
expect((yield* plugins.list())[1]?.state).toEqual(failed)
expect((yield* commands.list()).map((entry) => entry.name)).toEqual(["later", "earlier"])
yield* plugins.activate([broken, later])
yield* plugins.awaitActivation
expect((yield* plugins.list())[0]?.state).toEqual(failed)
expect(loads.filter((entry) => entry === "broken@1")).toHaveLength(1)
yield* plugins.activate([generation("broken", "2"), later])
yield* plugins.awaitActivation
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
expect(yield* commands.get("broken")).toBeDefined()
expect(loads.filter((entry) => entry === "broken@2")).toHaveLength(1)
}),
),
)
it.live("continues failure reporting and cleanup after a plugin update observer fails", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const bus = yield* Bus.Service
const cleaned: string[] = []
let fail = false
let failPublication = true
yield* plugins.activate(
["first", "second"].map((id) => ({
id,
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.sync(() => void cleaned.push(id)))
yield* ctx.command.transform((editor) => {
editor.add({ name: id, execute: () => Effect.void })
if (fail) throw new Error(`${id} failed`)
})
}),
})),
)
yield* Effect.acquireRelease(
bus.listen((event) => {
if (event.type !== Plugin.Event.Updated.type || !failPublication) return Effect.void
failPublication = false
return Effect.die("observer failed")
}),
(unsubscribe) => unsubscribe,
)
fail = true
yield* commands.reload()
const ready = yield* plugins.awaitActivation.pipe(Effect.timeout("250 millis"), Effect.exit)
expect(Exit.isSuccess(ready)).toBe(true)
expect((yield* plugins.list()).map((entry) => entry.state.status)).toEqual(["failed", "failed"])
expect(cleaned.toSorted()).toEqual(["first", "second"])
expect(yield* commands.list()).toEqual([])
}),
)
it.live("settles readiness before shutdown joins a disabled plugin's finalizers", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const entered = yield* Deferred.make<void>()
const escape = yield* Deferred.make<void>()
let fail = false
yield* plugins.activate([
{
id: "closing",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.command.transform((editor) => {
editor.add({ name: "closing", execute: () => Effect.void })
if (fail) throw new Error("closing failed")
})
yield* Effect.addFinalizer(() =>
Deferred.succeed(entered, undefined).pipe(
Effect.andThen(plugins.awaitActivation.pipe(Effect.raceFirst(Deferred.await(escape)))),
),
)
}),
},
])
fail = true
const shutdown = yield* commands
.reload()
.pipe(Effect.andThen(plugins.close(Exit.void)), Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(entered)
const result = yield* Fiber.join(shutdown).pipe(Effect.timeout("250 millis"), Effect.exit)
// Release the fixture even on the old implementation, rather than hanging test teardown.
yield* Deferred.succeed(escape, undefined)
yield* Fiber.join(shutdown)
expect(Exit.isSuccess(result)).toBe(true)
const release = yield* plugins.hold()
yield* plugins.awaitActivation
let restarted = false
yield* plugins.activate([
{ id: "after-close", revision: "1", effect: () => Effect.sync(() => void (restarted = true)) },
])
yield* release
expect(restarted).toBe(false)
}),
)
+1 -252
View File
@@ -1,14 +1,12 @@
import { expect } from "bun:test"
import path from "path"
import { Clock, Deferred, Effect } from "effect"
import { Clock, Effect } from "effect"
import { TestClock } from "effect/testing"
import { Command } from "@opencode-ai/core/command"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginModule } from "@opencode-ai/core/plugin/module"
import { fromPromise } from "@opencode-ai/plugin/promise/adapter"
import { Session } from "@opencode-ai/schema/session"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
@@ -156,57 +154,6 @@ it.effect("reports a failed plugin without blocking a healthy plugin", () =>
}),
)
it.effect("disables a plugin whose transform fails after setup without publishing its partial edits", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const integrations = yield* Integration.Service
let cleaned = false
yield* plugins.activate([
{
id: "before",
revision: "1",
effect: (ctx) =>
ctx.command
.transform((editor) => editor.add({ name: "shared", description: "original", execute: () => Effect.void }))
.pipe(Effect.asVoid),
},
{
id: "broken",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.sync(() => void (cleaned = true)))
yield* ctx.integration.transform((editor) => editor.update("broken", (entry) => (entry.name = "Broken")))
yield* ctx.command.transform((editor) => {
editor.add({ name: "shared", description: "partial", execute: () => Effect.void })
throw new Error("replay failed")
})
}),
},
{
id: "after",
revision: "1",
effect: (ctx) =>
ctx.command
.transform((editor) => editor.add({ name: "healthy", execute: () => Effect.void }))
.pipe(Effect.asVoid),
},
])
yield* plugins.awaitActivation
expect(cleaned).toBe(true)
expect((yield* plugins.list()).find((plugin) => plugin.id === "broken")?.state).toMatchObject({
status: "failed",
error: expect.stringContaining("command.transform failed"),
ref: expect.stringMatching(/^err_/),
})
expect(yield* commands.get("shared")).toMatchObject({ description: "original" })
expect(yield* commands.get("healthy")).toBeDefined()
expect(yield* integrations.get(Integration.ID.make("broken"))).toBeUndefined()
}),
)
it.effect("keeps the suffix after a failed plugin alive across identical activations", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -249,204 +196,6 @@ it.effect("keeps the suffix after a failed plugin alive across identical activat
}),
)
it.effect("attributes replay failure to the broken plugin rather than a later plugin reading the registry", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* plugins.activate([
{
id: "broken-plugin",
revision: "1",
effect: (ctx) =>
ctx.command
.transform(() => {
throw new Error("plugin failed")
})
.pipe(Effect.asVoid),
},
{
id: "reader",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.command.list().pipe(Effect.orDie)
yield* ctx.command.transform((editor) => editor.add({ name: "reader", execute: () => Effect.void }))
}),
},
])
yield* plugins.awaitActivation
expect((yield* plugins.list()).map((entry) => `${entry.id}:${entry.state.status}`)).toEqual([
"broken-plugin:failed",
"reader:active",
])
expect(yield* commands.get("reader")).toBeDefined()
}),
)
it.effect("disables plugins after runtime reload failures without retrying an unchanged generation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const bus = yield* Bus.Service
const reported: string[] = []
yield* Effect.acquireRelease(
bus.listen((event) =>
event.type === Plugin.Event.Updated.type
? plugins.list().pipe(
Effect.tap((items) => Effect.sync(() => void reported.push(items[0]?.state.status ?? "empty"))),
Effect.asVoid,
)
: Effect.void,
),
(unsubscribe) => unsubscribe,
)
let fail = false
let loads = 0
let reload = () => Effect.void
const generation = (revision: string): Plugin.Generation => ({
id: "runtime",
revision,
effect: (ctx) =>
Effect.gen(function* () {
loads++
reload = ctx.command.reload
yield* ctx.command.transform((editor) => {
editor.add({ name: "runtime", execute: () => Effect.void })
if (fail) throw new Error("private failure detail")
})
}),
})
const discovery = {
source: { type: "local" as const, path: "/missing" },
state: { status: "failed" as const, error: "Import failed" },
features: { server: true },
} satisfies Plugin.Info
yield* plugins.activate([generation("1")], [discovery])
expect(yield* commands.get("runtime")).toBeDefined()
fail = true
yield* reload()
yield* plugins.awaitActivation
const inventory = yield* plugins.list()
expect(inventory[0]?.state).toMatchObject({ status: "failed", ref: expect.stringMatching(/^err_/) })
expect(JSON.stringify(inventory[0]?.state)).not.toContain("private failure detail")
expect(reported.at(-1)).toBe("failed")
expect(inventory[1]).toEqual(discovery)
expect(yield* commands.get("runtime")).toBeUndefined()
fail = false
yield* plugins.activate([generation("1")], [discovery])
expect(loads).toBe(1)
expect(yield* commands.get("runtime")).toBeUndefined()
yield* plugins.activate([generation("2")], [discovery])
expect(loads).toBe(2)
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
expect(yield* commands.get("runtime")).toBeDefined()
}),
)
it.effect("disables plugins after replay failures discovered during setup without restoring the old generation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const cleaned = yield* Deferred.make<void>()
const loads: string[] = []
const generation = (revision: string): Plugin.Generation => ({
id: "replacement",
revision,
effect: (ctx) =>
Effect.gen(function* () {
loads.push(revision)
if (revision === "2")
yield* Effect.addFinalizer(() =>
plugins.awaitActivation.pipe(Effect.andThen(Deferred.succeed(cleaned, undefined))),
)
yield* ctx.command.transform((editor) => {
editor.add({ name: "replacement", execute: () => Effect.void })
if (revision === "2") throw new Error("replay failure")
})
if (revision === "2") {
yield* ctx.command.list().pipe(Effect.orDie)
yield* Effect.die("subsequent setup failure")
}
}),
})
yield* plugins.activate([generation("1")])
yield* plugins.activate([generation("2")])
yield* plugins.awaitActivation
yield* Deferred.await(cleaned)
expect(loads).toEqual(["1", "2"])
expect((yield* plugins.list())[0]?.state).toMatchObject({
status: "failed",
error: expect.stringContaining("command.transform"),
})
expect(yield* commands.get("replacement")).toBeUndefined()
}),
)
it.effect("does not let asynchronous plugin cleanup block recovered registry readiness", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const cleaned = yield* Deferred.make<void>()
yield* plugins.activate([
{
id: "async-cleanup",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.command.transform(() => {
throw new Error("failed")
})
yield* Effect.addFinalizer(() =>
plugins.awaitActivation.pipe(Effect.andThen(Deferred.succeed(cleaned, undefined))),
)
}),
},
{
id: "healthy",
revision: "1",
effect: (ctx) =>
ctx.command
.transform((editor) => editor.add({ name: "healthy", execute: () => Effect.void }))
.pipe(Effect.asVoid),
},
])
yield* plugins.awaitActivation
yield* Deferred.await(cleaned)
expect(yield* commands.get("healthy")).toBeDefined()
expect((yield* plugins.list())[0]?.state.status).toBe("failed")
}),
)
it.live("retains Promise plugin groups for later registrations and ignores a disabled group's attempts", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
let register = async () => {}
const definition = fromPromise({
id: "promise-plugin",
setup(ctx) {
register = async () => {
await ctx.command.transform((editor) => {
editor.add({ name: "late", execute: async () => {} })
throw new Error("late Promise failure")
})
}
},
})
yield* plugins.activate([{ ...definition, revision: "1" }])
yield* Effect.promise(register)
yield* plugins.awaitActivation
expect((yield* plugins.list())[0]?.state).toMatchObject({
status: "failed",
error: expect.stringContaining("command.transform"),
})
expect(yield* commands.get("late")).toBeUndefined()
yield* Effect.promise(register)
expect(yield* commands.get("late")).toBeUndefined()
}),
)
it.effect("reloading a plugin replaces its command implementation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -122,7 +122,7 @@ describe("GithubCopilotPlugin", () => {
expect(requests[0]?.has("x-api-key")).toBe(false)
expect(requests[0]?.get("x-initiator")).toBe("user")
expect(requests[0]?.get("copilot-vision-request")).toBe("true")
expect(requests[0]?.get("x-github-api-version")).toBe("2026-08-01")
expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
expect(requests[0]?.get("user-agent")).toBe("opencode/beta/1.2.3/test")
}),
)
@@ -145,7 +145,7 @@ describe("GithubCopilotPlugin", () => {
expect(event.request.headers.has("x-api-key")).toBe(false)
expect(event.request.headers.get("x-initiator")).toBe("user")
expect(event.request.headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14")
expect(event.request.headers.get("x-github-api-version")).toBe("2026-08-01")
expect(event.request.headers.get("x-github-api-version")).toBe("2026-06-01")
}),
)
@@ -1,62 +0,0 @@
import { expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Rpc } from "@opencode-ai/core/rpc"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Layer, Logger, Schema } from "effect"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(Rpc.node, [
Location.node.replace(Layer.succeed(Location.Service, location({ directory: AbsolutePath.make("/rpc-project") }))),
]),
)
const Broken = Rpc.define({
id: "broken",
methods: {
dies: { input: Schema.Undefined, output: Schema.String },
throws: { input: Schema.Undefined, output: Schema.String },
raw: { input: Schema.Undefined, output: Schema.String },
undeclared: { input: Schema.Undefined, output: Schema.String },
invalidError: {
input: Schema.Undefined,
output: Schema.String,
errors: { known: Schema.Struct({ count: Schema.Int }) },
},
},
events: {},
})
for (const method of ["dies", "throws", "raw", "undeclared", "invalidError"] as const) {
it.effect(`recovers from ${method} through the typed rpc.internal failure`, () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
yield* rpc.register(Broken, {
dies: () => Effect.die(new Error("handler defect")),
throws: () => {
throw new Error("handler threw")
},
// Raw Promise rejections reach this boundary as failed Effects.
// @ts-expect-error intentionally exercise an undeclared failure
raw: () => Effect.fail(new Error("raw failure")),
// @ts-expect-error intentionally exercise an undeclared error name
undeclared: (_input, context) => Effect.fail(context.error("unknown", "Unknown")),
invalidError: (_input, context) => Effect.fail(context.error("known", "Invalid count", { count: 1.5 })),
})
const logged: unknown[] = []
const result = yield* rpc
.client(Broken)
[method]()
.pipe(
Effect.catchIf(
(error) => "type" in error && error.type === "rpc.internal",
(error) => Effect.succeed(error),
),
Effect.provideService(Logger.CurrentLoggers, new Set([Logger.make((entry) => logged.push(entry.message))])),
)
expect(result).toEqual({ type: "rpc.internal", message: "RPC call failed" })
expect(logged).toHaveLength(1)
}),
)
}
@@ -0,0 +1,228 @@
import { expect } from "bun:test"
import { LLM, LLMRequest, Message } from "@opencode-ai/ai"
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { configure } from "@opencode-ai/ai/providers/openai"
import { SessionModelTransport } from "../src/session/model-transport"
import { WebSocketConstructor } from "../src/effect/websocket-constructor"
import { Session } from "@opencode-ai/schema/session"
import { Deferred, Effect, Fiber, Layer, Schema } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { testEffect } from "./lib/effect"
import { makeWebSocketServer } from "./lib/websocket-server"
const runtime = Layer.mergeAll(
LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer), Layer.provide(FetchHttpClient.layer)),
SessionModelTransport.layer.pipe(Layer.provide(WebSocketConstructor.layer)),
)
const it = testEffect(runtime)
const sessionID = Session.ID.make("ses_checkpoint_transport")
const checkpoint = { type: "compaction", encrypted_content: "opaque" }
type Mode =
| "success"
| "missing"
| "multiple"
| "incomplete"
| "disconnect"
| "cancel"
| "rejected"
| "ambiguous"
| "fallback"
const fixture = (mode: Mode) =>
Effect.gen(function* () {
const seen = yield* Deferred.make<void>()
const requests: Array<Record<string, unknown>> = []
const http: Array<Record<string, unknown>> = []
let disconnect: (() => void) | undefined
const server = yield* makeWebSocketServer({
upgrade: () => mode !== "fallback" || disconnect === undefined,
async http(request) {
http.push(Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Unknown))(await request.json()))
return new Response(
`data: ${JSON.stringify({
type: "response.completed",
response: {
id: "resp_http",
output: [checkpoint],
usage: { input_tokens: 70, output_tokens: 7 },
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream", "x-response": "http" } },
)
},
open(socket) {
disconnect = () => socket.close()
},
message(socket, message) {
const body = JSON.parse(message.toString())
requests.push(body)
const id = `resp_${requests.length}`
const send = (event: unknown) => socket.send(JSON.stringify(event))
if (body.input.at(-1)?.type !== "compaction_trigger") {
const item = {
type: "message",
id: `msg_${requests.length}`,
role: "assistant",
content: [{ type: "output_text", text: "Hello" }],
}
send({ type: "response.created", response: { id } })
send({ type: "response.output_item.added", output_index: 0, item })
send({ type: "response.output_text.delta", item_id: item.id, delta: "Hello" })
send({ type: "response.output_item.done", output_index: 0, item })
send({ type: "response.completed", response: { id, output: [item] } })
return
}
if (mode === "ambiguous") {
socket.close()
return
}
if (mode === "rejected") {
send({ type: "error", error: { code: "previous_response_not_found", message: "missing baseline" } })
return
}
send({ type: "response.created", response: { id } })
if (mode !== "missing") send({ type: "response.output_item.done", output_index: 0, item: checkpoint })
Deferred.doneUnsafe(seen, Effect.void)
if (mode === "cancel") return
if (mode === "disconnect") {
socket.close()
return
}
send({
type: mode === "incomplete" ? "response.incomplete" : "response.completed",
response: {
id,
output:
mode === "missing"
? []
: mode === "multiple"
? [checkpoint, { ...checkpoint, encrypted_content: "second" }]
: [checkpoint],
usage: { input_tokens: 100, output_tokens: 10 },
},
})
},
})
return {
requests,
headers: server.state.headers,
http,
seen,
opens: () => server.state.opens,
disconnect: () => disconnect?.(),
request: LLM.request({
model: configure({
apiKey: "fixture",
baseURL: server.url.replace(/^ws/, "http").replace(/responses$/, ""),
headers: { "chatgpt-account-id": "account", "x-codex-beta-features": "remote_compaction_v2" },
providerOptions: { parallelToolCalls: true },
}).responses("fixture"),
prompt: "First",
promptCacheKey: "session-key",
}),
}
})
it.live("trigger reuses the append baseline and clears it before the next generation", () =>
Effect.gen(function* () {
const server = yield* fixture("success")
const transport = yield* SessionModelTransport.Service
const webSocket = transport.bind(sessionID)
const first = yield* LLMClient.generate(server.request, { webSocket })
const compacted = yield* LLMClient.compact(
LLMRequest.update(server.request, {
messages: [...server.request.messages, first.message],
}),
{ mechanism: "trigger", webSocket },
)
expect(server.requests[1]).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "compaction_trigger" }],
})
expect(compacted.responseID).toBe("resp_2")
expect(compacted.usage).toMatchObject({ inputTokens: 100, outputTokens: 10 })
expect(compacted.checkpoint.encrypted).toBe("opaque")
const messages = [Message.assistant(compacted.checkpoint), Message.user("Continue")]
yield* LLMClient.generate(LLMRequest.update(server.request, { messages }), { webSocket })
expect(server.requests[2]?.previous_response_id).toBeUndefined()
expect(server.requests[2]?.input).toMatchObject([
{ type: "compaction", encrypted_content: "opaque" },
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
])
expect(server.requests[1]?.stream).toBeUndefined()
expect(server.requests[1]?.store).toBe(false)
expect(server.headers[0]).toMatchObject({
authorization: "Bearer fixture",
"chatgpt-account-id": "account",
"x-codex-beta-features": "remote_compaction_v2",
})
expect(server.opens()).toBe(1)
expect(server.http).toHaveLength(0)
}),
)
for (const mode of ["missing", "multiple", "incomplete", "disconnect", "rejected", "ambiguous"] as const) {
it.live(`trigger ${mode} does not retry, fall back, or commit a continuation checkpoint`, () =>
Effect.gen(function* () {
const server = yield* fixture(mode)
const transport = yield* SessionModelTransport.Service
const webSocket = transport.bind(sessionID)
const first = yield* LLMClient.generate(server.request, { webSocket })
const input = LLMRequest.update(server.request, { messages: [...server.request.messages, first.message] })
const error = yield* LLMClient.compact(input, { mechanism: "trigger", webSocket }).pipe(Effect.flip)
expect(error.reason._tag).toBe(
["disconnect", "ambiguous", "rejected"].includes(mode) ? "Transport" : "InvalidProviderOutput",
)
if (mode === "rejected") expect(error.reason).toMatchObject({ delivery: "rejected", recovery: "retry-full" })
if (mode === "ambiguous") expect(error.reason).toMatchObject({ delivery: "ambiguous" })
expect(server.requests).toHaveLength(2)
expect(server.http).toHaveLength(0)
yield* LLMClient.generate(input, { webSocket })
expect(server.requests[2]?.previous_response_id).toBeUndefined()
expect(server.requests[2]?.input).toHaveLength(2)
}),
)
}
it.live("cancelled trigger closes its connection without returning the partial checkpoint", () =>
Effect.gen(function* () {
const server = yield* fixture("cancel")
const transport = yield* SessionModelTransport.Service
const webSocket = transport.bind(sessionID)
const running = yield* LLMClient.compact(server.request, { mechanism: "trigger", webSocket }).pipe(
Effect.forkChild(),
)
yield* Deferred.await(server.seen)
yield* Fiber.interrupt(running)
yield* LLMClient.generate(server.request, { webSocket })
expect(server.requests).toHaveLength(2)
expect(server.opens()).toBe(2)
expect(server.requests[1]?.previous_response_id).toBeUndefined()
expect(server.http).toHaveLength(0)
}),
)
it.live("trigger recovers over SSE with complete input after the old socket closes and reconnect is rejected", () =>
Effect.gen(function* () {
const server = yield* fixture("fallback")
const transport = yield* SessionModelTransport.Service
const webSocket = transport.bind(sessionID)
const first = yield* LLMClient.generate(server.request, { webSocket })
server.disconnect()
// Let the real close event reach the connector before the next send.
yield* Effect.sleep("20 millis")
const result = yield* LLMClient.compact(
LLMRequest.update(server.request, {
messages: [...server.request.messages, first.message],
}),
{ mechanism: "trigger", webSocket },
)
expect(result.responseID).toBe("resp_http")
expect(result.usage).toMatchObject({ inputTokens: 70, outputTokens: 7 })
expect(server.http).toHaveLength(1)
expect(server.http[0]?.previous_response_id).toBeUndefined()
expect(server.http[0]?.stream).toBe(true)
expect(server.http[0]?.input).toHaveLength(3)
expect(server.requests).toHaveLength(1)
}),
)
@@ -1243,7 +1243,6 @@ describe("SessionTransfer", () => {
const runningCompactionID = SessionMessage.ID.create()
const completedCompactionID = SessionMessage.ID.create()
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
const providerState = { responseId: "summary-response" }
yield* transfer.import({
data: {
@@ -1298,8 +1297,6 @@ describe("SessionTransfer", () => {
type: "compaction",
status: "completed",
reason: "manual",
model,
providerState,
summary: "summary",
recent: "recent",
time: { created: DateTime.makeUnsafe(9) },
@@ -1316,11 +1313,6 @@ describe("SessionTransfer", () => {
completedCompactionID,
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, providerState })
expect((yield* transfer.export({ sessionID, sanitize: true })).messages.at(-1)).toMatchObject({
model,
providerState: { redacted: `compaction-provider-state:${completedCompactionID}` },
})
}),
)
@@ -445,17 +445,14 @@ describe("SessionModelTransport", () => {
)
})
test.each([false, true])("classifies active and queued close (observed: %s)", async (observed) => {
test("closes an active exchange without waiting for its Session permit", async () => {
const started = Deferred.makeUnsafe<void>()
const messages = queue<string | Uint8Array, AIError>()
let closed = 0
const connector: WebSocketConnector = {
open: () =>
Effect.succeed({
sendText: () =>
observed
? Queue.offer(messages, "frame").pipe(Effect.asVoid)
: Deferred.succeed(started, undefined).pipe(Effect.asVoid),
sendText: () => Deferred.succeed(started, undefined),
messages: Stream.fromQueue(messages),
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
}),
@@ -465,30 +462,17 @@ describe("SessionModelTransport", () => {
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session)
const item = exchange("active")
const running = yield* collect(executor, {
...item,
driver: {
...item.driver,
observe: (_create, frame) => Deferred.succeed(started, undefined).pipe(Effect.as({ type: "frame", frame })),
},
}).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(started)
const queued = yield* collect(executor, exchange("queued")).pipe(
Effect.result,
const running = yield* collect(transport.bind(session), exchange("active")).pipe(
Effect.forkChild({ startImmediately: true }),
)
yield* Deferred.await(started)
yield* transport.close(session)
const result = yield* Effect.result(Fiber.join(running))
expect(yield* Fiber.join(running)).toMatchObject({
expect(result).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", code: "close", delivery: observed ? "accepted" : "ambiguous" } },
})
expect(yield* Fiber.join(queued)).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "queue", delivery: "not-sent" } },
failure: { reason: { _tag: "Transport", code: "close", delivery: "ambiguous" } },
})
expect(closed).toBe(1)
}),
@@ -561,43 +545,6 @@ describe("SessionModelTransport", () => {
)
})
test("closes a connection returned after its owner closes during setup", async () => {
const connecting = Deferred.makeUnsafe<void>()
const release = Deferred.makeUnsafe<void>()
let closed = 0
const connector: WebSocketConnector = {
open: () =>
Deferred.succeed(connecting, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as({
sendText: () => Effect.die("Unexpected send after owner close"),
messages: Stream.never,
close: Effect.sync(() => closed++).pipe(Effect.asVoid),
}),
),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const running = yield* collect(
transport.bind(session),
exchange("first", { fallback: () => Stream.die("Unexpected fallback after owner close") }),
).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(connecting)
yield* transport.close(session)
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(running)).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "connect", delivery: "not-sent" } },
})
expect(closed).toBe(1)
}),
)
})
test("falls back once when connection setup fails before send", async () => {
let fallbacks = 0
const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) }
+4 -383
View File
@@ -1,14 +1,11 @@
import { describe, expect } from "bun:test"
import path from "path"
import { chmod, mkdir, readdir, rm } from "fs/promises"
import { Cause, Context, Deferred, Duration, Effect, Exit, Fiber, Layer, LayerMap, Queue } from "effect"
import { mkdir, rm } from "fs/promises"
import { Effect, Layer, LayerMap } from "effect"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Instance } from "@opencode-ai/core/instance"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
@@ -17,14 +14,10 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMove } from "@opencode-ai/core/session/move"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -81,372 +74,8 @@ const itWithUnavailableDestination = testEffect(
],
),
)
const itWithExecution = testEffect(
AppNodeBuilder.build(LayerNode.group([Session.node, SessionExecution.node]), [
Global.node.replace(tempGlobalLayer),
offlineModels,
]),
)
// Windows does not enforce POSIX mode bits, and root can traverse mode-000 directories.
const itWithPermissions =
process.platform === "win32" || process.getuid?.() === 0 ? itWithExecution.live.skip : itWithExecution.live
const itWithInstance = testEffect(Layer.empty)
const sourceProbe = (options: { execution?: boolean } = {}) =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const source = AbsolutePath.make(path.join(tmp.path, "source"))
const destination = AbsolutePath.make(tmp.path)
yield* Effect.promise(() => mkdir(source))
const probes = yield* Queue.unbounded<Deferred.Deferred<void>>()
const context = yield* Layer.build(
AppNodeBuilder.build(LayerNode.group([Session.node, Bus.node, SessionExecution.node]), [
Global.node.replace(tempGlobalLayer),
...(options.execution ? [] : [SessionExecution.node.replace(SessionExecution.noopLayer)]),
offlineModels,
Instance.node.replace(
makeGlobalNode({
service: Instance.Service,
deps: [LocationServiceMap.node],
layer: Layer.effect(
Instance.Service,
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
return Instance.Service.of({
provide: (session) => (effect) =>
Effect.gen(function* () {
if (session.location.directory === source) {
const release = yield* Deferred.make<void>()
yield* Queue.offer(probes, release)
yield* Deferred.await(release)
}
return yield* effect.pipe(Effect.provide(locations.get(session.location)))
}),
})
}),
),
}),
),
]),
)
return {
source,
destination,
probes,
session: Context.get(context, Session.Service),
bus: Context.get(context, Bus.Service),
execution: Context.get(context, SessionExecution.Service),
}
})
describe("Session.move", () => {
itWithInstance.live("moves through the bound service without depending on the Session facade", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = AbsolutePath.make(tmp.path)
const context = yield* Layer.build(
AppNodeBuilder.build(LayerNode.group([SessionMove.node, SessionStore.node, Bus.node, Project.node]), [
Global.node.replace(tempGlobalLayer),
Project.node.replace(globalProjectNode),
SessionExecution.node.replace(SessionExecution.noopLayer),
offlineModels,
]),
)
const moves = Context.get(context, SessionMove.Service)
const store = Context.get(context, SessionStore.Service)
const bus = Context.get(context, Bus.Service)
const projects = Context.get(context, Project.Service)
const sessionID = Session.ID.create()
// Call outside the construction context: the service owns all of its dependencies.
expect(yield* moves.move({ sessionID, directory }).pipe(Effect.flip)).toEqual(
new Session.NotFoundError({ sessionID }),
)
yield* projects.resolve(directory)
yield* bus.publish(SessionEvent.Created, {
sessionID,
slug: "move-service",
version: "test",
projectID: Project.ID.global,
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "missing")) }),
})
yield* moves.move({ sessionID, directory })
expect(yield* store.get(sessionID)).toMatchObject({
location: { directory },
projectID: Project.ID.global,
})
}),
)
itWithInstance.live("delegates to the provided move service", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = AbsolutePath.make(tmp.path)
const rejection = new Session.DestinationUnavailableError({ directory })
const context = yield* Layer.build(
AppNodeBuilder.build(Session.node, [
Global.node.replace(tempGlobalLayer),
Project.node.replace(globalProjectNode),
SessionExecution.node.replace(SessionExecution.noopLayer),
SessionMove.node.replace(Layer.succeed(SessionMove.Service, { move: () => Effect.fail(rejection) })),
offlineModels,
]),
)
const sessions = Context.get(context, Session.Service)
const created = yield* sessions.create({ location: Location.Ref.make({ directory }) })
expect(yield* sessions.move({ sessionID: created.id, directory }).pipe(Effect.flip)).toBe(rejection)
expect(yield* sessions.inbox(created.id)).toEqual([])
}),
)
for (const broken of [false, true]) {
itWithExecution.live(
`moves an idle session from ${broken ? "broken" : "healthy"} source configuration`,
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const source = AbsolutePath.make(path.join(tmp.path, "source"))
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
if (broken)
yield* Effect.promise(() =>
Bun.write(path.join(source, "opencode.json"), JSON.stringify({ instructions: ["{file:./missing.txt}"] })),
)
const session = yield* Session.Service
const execution = yield* SessionExecution.Service
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
yield* session.move({ sessionID: created.id, directory: destination })
yield* execution.awaitIdle(created.id)
expect((yield* session.get(created.id)).location.directory).toBe(destination)
expect(yield* session.inbox(created.id)).toEqual([])
}),
{ timeout: 15_000 },
)
}
itWithPermissions(
"recovers an idle session from an unreadable source directory",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const source = AbsolutePath.make(path.join(tmp.path, "source"))
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
const session = yield* Session.Service
const execution = yield* SessionExecution.Service
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
yield* Effect.addFinalizer(() => Effect.promise(() => chmod(source, 0o755)))
yield* Effect.promise(() => chmod(source, 0o000))
expect(
yield* Effect.promise(() =>
readdir(source).then(
() => false,
() => true,
),
),
).toBe(true)
yield* session.move({ sessionID: created.id, directory: destination })
yield* execution.awaitIdle(created.id)
expect((yield* session.get(created.id)).location.directory).toBe(destination)
expect(yield* session.inbox(created.id)).toEqual([])
}),
{ timeout: 15_000 },
)
for (const broken of [false, true]) {
itWithInstance.live(
`uses the ${broken ? "broken" : "healthy discovery-disabled"} selected instance rather than the default Location`,
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const source = Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "source")) })
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
yield* Effect.promise(() => Promise.all([mkdir(source.directory), mkdir(destination)]))
const config = JSON.stringify({ instructions: ["{file:./missing.txt}"] })
if (!broken) yield* Effect.promise(() => Bun.write(path.join(source.directory, "opencode.json"), config))
const selectedID = Session.ID.create()
const replacements: LayerNode.Replacements = [
Global.node.replace(tempGlobalLayer),
SessionExecution.node.replace(SessionExecution.noopLayer),
offlineModels,
Instance.node.replace(
makeGlobalNode({
service: Instance.Service,
deps: [LocationServiceMap.node],
layer: Layer.effect(
Instance.Service,
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const privateInstances = yield* LayerMap.make(
() =>
Instance.layer(source, {
discovery: false,
replacements: [
...bindings,
...(broken
? [
Config.node.replace(
Config.configured({ project: false, global: false, content: config }),
),
]
: []),
],
}),
{ idleTimeToLive: Duration.infinity },
)
const selector = Instance.Service.of({
provide: (session) =>
Effect.provide(
session.id === selectedID && session.location.directory === source.directory
? privateInstances.get(session.id)
: locations.get(session.location),
),
})
const bindings: LayerNode.Replacements = [
...replacements,
Instance.node.replace(Layer.succeed(Instance.Service, selector)),
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, locations)),
]
return selector
}),
),
}),
),
]
const context = yield* Layer.build(AppNodeBuilder.build(Session.node, replacements))
const session = Context.get(context, Session.Service)
const created = yield* session.create({ id: selectedID, location: source })
const pending = yield* session.synthetic({
sessionID: created.id,
text: "Keep pending",
delivery: "queue",
resume: false,
})
yield* session.move({ sessionID: created.id, directory: destination, delivery: "queue" })
expect((yield* session.get(created.id)).location.directory).toBe(broken ? destination : source.directory)
const inbox = yield* session.inbox(created.id)
expect(inbox[0]).toEqual(pending)
if (broken) expect(inbox).toEqual([pending])
if (!broken) expect(inbox.slice(1)).toMatchObject([{ type: "move", delivery: "queue" }])
}),
{ timeout: 15_000 },
)
}
for (const interrupt of ["source", "caller"] as const) {
itWithInstance.live(`does not recover or enqueue a move when the ${interrupt} interrupts the probe`, () =>
Effect.gen(function* () {
const fixture = yield* sourceProbe()
const created = yield* fixture.session.create({ location: Location.Ref.make({ directory: fixture.source }) })
const pending = yield* fixture.session.synthetic({ sessionID: created.id, text: "Keep pending", resume: false })
const moving = yield* fixture.session
.move({ sessionID: created.id, directory: fixture.destination })
.pipe(Effect.forkScoped)
const release = yield* Queue.take(fixture.probes)
if (interrupt === "source") yield* Deferred.interrupt(release)
if (interrupt === "caller") yield* Fiber.interrupt(moving)
const exit = yield* Fiber.await(moving)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect((yield* fixture.session.get(created.id)).location.directory).toBe(fixture.source)
expect(yield* fixture.session.inbox(created.id)).toEqual([pending])
}).pipe(Effect.timeout("5 seconds")),
)
}
itWithInstance.live("does not recover if execution starts during the source probe", () =>
Effect.gen(function* () {
const fixture = yield* sourceProbe({ execution: true })
const created = yield* fixture.session.create({ location: Location.Ref.make({ directory: fixture.source }) })
const moving = yield* fixture.session
.move({ sessionID: created.id, directory: fixture.destination })
.pipe(Effect.forkScoped)
const release = yield* Queue.take(fixture.probes)
yield* fixture.execution.wake(created.id)
// The real coordinator now owns execution; its separate instance acquisition stays suspended.
yield* Queue.take(fixture.probes)
expect(yield* fixture.execution.isActive(created.id)).toBe(true)
yield* Deferred.die(release, new Error("source unavailable"))
yield* Fiber.join(moving)
expect((yield* fixture.session.get(created.id)).location.directory).toBe(fixture.source)
expect(yield* fixture.session.inbox(created.id)).toMatchObject([{ type: "move", delivery: "steer" }])
expect(yield* fixture.execution.isActive(created.id)).toBe(true)
yield* fixture.execution.interrupt(created.id)
yield* fixture.execution.awaitIdle(created.id)
}).pipe(Effect.timeout("5 seconds")),
)
itWithInstance.live(
"recovers a missing source without initializing its instance and retains destination workspace identity",
() =>
Effect.gen(function* () {
const fixture = yield* sourceProbe()
const created = yield* fixture.session.create({
location: Location.Ref.make({ directory: fixture.source, workspaceID: Workspace.ID.create() }),
})
yield* Effect.promise(() => rm(fixture.source, { recursive: true }))
const workspaceID = Workspace.ID.create()
yield* fixture.session.move({ sessionID: created.id, directory: fixture.destination, workspaceID })
expect((yield* fixture.session.get(created.id)).location).toEqual(
Location.Ref.make({ directory: fixture.destination, workspaceID }),
)
expect(yield* fixture.session.inbox(created.id)).toEqual([])
expect(yield* Queue.size(fixture.probes)).toBe(0)
}).pipe(Effect.timeout("5 seconds")),
)
for (const changed of ["directory", "workspace"] as const) {
itWithInstance.live(`allows inbox cancellation during a source probe and rejects stale ${changed} recovery`, () =>
Effect.gen(function* () {
const fixture = yield* sourceProbe()
const created = yield* fixture.session.create({ location: Location.Ref.make({ directory: fixture.source }) })
const pending = yield* fixture.session.synthetic({
sessionID: created.id,
text: "Cancel pending",
resume: false,
})
const moving = yield* fixture.session
.move({ sessionID: created.id, directory: fixture.destination })
.pipe(Effect.exit, Effect.forkScoped)
const release = yield* Queue.take(fixture.probes)
yield* fixture.session.cancelInbox({ sessionID: created.id, inboxID: pending.id }).pipe(
Effect.timeout("2 seconds"),
Effect.onError(() => Deferred.interrupt(release)),
)
expect(yield* fixture.session.inbox(created.id)).toEqual([])
expect(moving.pollUnsafe()).toBeUndefined()
const location = Location.Ref.make({
directory: changed === "directory" ? fixture.destination : fixture.source,
workspaceID: changed === "workspace" ? Workspace.ID.create() : undefined,
})
yield* fixture.bus.publish(SessionEvent.Moved, {
sessionID: created.id,
location,
projectID: created.projectID,
})
yield* Deferred.die(release, new Error("source unavailable"))
expect(Exit.isSuccess(yield* Fiber.join(moving))).toBe(true)
expect((yield* fixture.session.get(created.id)).location).toEqual(location)
expect(yield* fixture.session.inbox(created.id)).toMatchObject([
{ type: "move", payload: { location: { directory: fixture.destination } } },
])
}).pipe(Effect.timeout("5 seconds")),
)
}
itWithUnavailableDestination.effect("rejects an unavailable destination before admitting the move", () =>
tmpdirScoped().pipe(
Effect.flatMap((tmp) =>
@@ -482,23 +111,15 @@ describe("Session.move", () => {
yield* session.move({ sessionID: created.id, directory: destination })
expect((yield* session.get(created.id)).location.directory).toBe(AbsolutePath.make(source))
expect(yield* session.inbox(created.id)).toHaveLength(1)
const pending = yield* session.synthetic({
sessionID: created.id,
text: "Keep queued",
delivery: "queue",
resume: false,
})
yield* session.move({ sessionID: created.id, directory: destination, delivery: "queue" })
expect(yield* session.inbox(created.id)).toHaveLength(3)
yield* Effect.promise(() => rm(source, { recursive: true }))
yield* session.move({ sessionID: created.id, directory: destination })
expect((yield* session.get(created.id)).location.directory).toBe(destination)
expect(yield* session.inbox(created.id)).toEqual([pending])
expect(yield* session.inbox(created.id)).toEqual([])
yield* session.move({ sessionID: created.id, directory: destination })
expect(yield* session.inbox(created.id)).toHaveLength(2)
expect(yield* session.inbox(created.id)).toHaveLength(1)
yield* Effect.promise(() => mkdir(path.join(tmp.path, "other")))
const steered = yield* session.create({
+3 -20
View File
@@ -2141,13 +2141,7 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(
TestLLM.tool("call-prefix", "echo", { text: "x".repeat(4_000) }),
TestLLM.textWithUsage("Earlier answer", "prefix-answer", 185_000),
TestLLM.complete(
{
reason: { normalized: "stop" },
providerMetadata: { [s.currentModel.provider]: { responseId: "summary" } },
},
LLMEvent.textDelta({ id: "prefix-summary", text: "## Objective\n- Checkpoint summary" }),
),
TestLLM.text("## Objective\n- Checkpoint summary", "prefix-summary"),
)
yield* s.runPrompt("Review these changes")
if (reason === "manual") {
@@ -2183,10 +2177,6 @@ describe("SessionRunnerLLM", () => {
expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.")
expect(requestAgents[2]).toBe(Agent.ID.make("compaction"))
expect(s.executions).toEqual(["x".repeat(4_000)])
expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({
model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant },
providerState: { responseId: "summary" },
})
// Compare wire content without the cache breakpoints that move to the new final message.
const before = yield* compileRequest(LLMRequest.update(normal, { cache: "none" }))
@@ -2219,14 +2209,8 @@ describe("SessionRunnerLLM", () => {
)
: TestLLM.text("Let me search the codebase. I will fill in ## Objective later.", "invalid-summary")
yield* s.llm.push(
invalid.map((event) =>
LLMEvent.is.stepFinish(event)
? { ...event, providerMetadata: { openai: { responseId: "rejected-summary-state" } } }
: event,
),
summary
? [LLMEvent.textDelta({ id: "summary-recovered", text: "### Active\n- Recovered summary" })]
: invalid,
invalid,
summary ? TestLLM.text("### Active\n- Recovered summary", "summary-recovered") : invalid,
)
const compact = yield* s.session.compact({ sessionID })
yield* s.resume
@@ -2236,7 +2220,6 @@ describe("SessionRunnerLLM", () => {
expect(userTexts(s.requests[1]).at(-1)).toContain("did not fill in the required summary template")
expect(s.requests.every((request) => request.toolChoice === undefined)).toBe(true)
expect(s.executions).toEqual([])
expect(JSON.stringify(yield* s.messages)).not.toContain("rejected-summary-state")
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject(
summary
? { status: "completed", summary: "### Active\n- Recovered summary" }
-2
View File
@@ -148,9 +148,7 @@ for (const fixture of [
.all()
const types = events.map((event) => event.type)
const terminal = fixture.finish === "stop" ? "session.step.ended.1" : "session.step.failed.1"
expect(types.filter((type) => type === "session.step.streamed.1")).toHaveLength(1)
expect(types.filter((type) => type === terminal)).toHaveLength(1)
expect(types.indexOf("session.step.streamed.1")).toBeLessThan(types.indexOf(terminal))
expect(
types.indexOf(fixture.toolChoice === "none" ? "session.tool.failed.2" : "session.tool.success.2"),
).toBeLessThan(types.indexOf(terminal))
@@ -1,132 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { ShellParse } from "../../src/shell/parse.js"
import { ShellScan } from "../../src/shell/scan.js"
const contexts = [
(source: string) => source,
(source: string) => `( ${source} )`,
(source: string) => `{ ${source}; }`,
(source: string) => `if true; then ${source}; fi`,
(source: string) => `outer() { ${source}; }; outer`,
]
const bodies = [
"for value in one two; do scan_probe; done",
"while true; do scan_probe; break; done",
"until false; do scan_probe; break; done",
"case value in value) scan_probe;; *) scan_other;; esac",
]
describe("compound function acceptance", () => {
for (const shell of ["bash", "zsh"]) {
for (const head of ["probe()", "function probe", "function probe()", "probe-name()"])
for (const body of bodies)
for (const context of contexts) {
const name = head.includes("probe-name") ? "probe-name" : "probe"
const source = context(`${head} ${body}; ${name}`)
test(`${shell}: ${source}`, async () => {
// Braces preserve the function's behavior, but avoid Tree-sitter's recovery artifacts.
const legacy = await Effect.runPromise(
ShellParse.scan(context(`${head} { ${body}; }; ${name}`), shell, "/workspace"),
)
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
})
}
}
test.each(bodies)("keeps compound function bodies inside command substitutions: %s", (body) => {
const source = `printf '%s' "$( probe() ${body}; probe )"`
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands[0]?.resource).toBe(source)
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
expect(result.commands.at(-1)?.words).toEqual(["probe"])
})
})
const values = [
"one two",
"'two words' one",
"'cd' '/outside'",
"'do' 'done'",
"'(literal)' '$(scan_ignored)'",
"one\\\ntwo",
"$(printf one)",
'"$(printf one)"',
"<(printf one)",
"",
]
const loops = values.flatMap((value) =>
[
`for value (${value}) scan_probe "$value"`,
`for value (${value}) { scan_probe "$value"; }`,
...(value
? [
`for value (${value}) do scan_probe "$value"; done`,
`for value (${value}); do scan_probe "$value"; done`,
`for value (${value})\ndo scan_probe "$value"; done`,
`for value (${value}) # ignored\ndo scan_probe "$value"; done`,
`for value (${value}) \\\ndo scan_probe "$value"; done`,
]
: []),
].map((source) => ({ source, equivalent: `for value in ${value}; do scan_probe "$value"; done` })),
)
describe("Zsh parenthesized loop acceptance", () => {
for (const fixture of loops)
for (const context of contexts) {
const source = context(fixture.source)
test(source, async () => {
const legacy = await Effect.runPromise(ShellParse.scan(context(fixture.equivalent), "zsh", "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
})
}
test.each([
"for x (one two) for y (a b) scan_probe",
"for x (one two) scan_probe && scan_other",
"for x (one two) scan_probe | scan_other",
"printf '%s' \"$(for x (one two) scan_probe)\"",
"for x (one two) { for y (a b); do scan_probe; done; }",
"for x (one two) [[ $(scan_probe) == ok ]]",
"for x (one two) (( 1 + $(scan_probe) ))",
])("retains commands in nested shorthand loops: %s", (source) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
if (source.includes("scan_other"))
expect(result.commands.map((command) => command.words[0])).toContain("scan_other")
})
})
describe("real-shell compound syntax", () => {
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
test
.skipIf(!executable)
.each([
...bodies.map((body) => `probe() ${body}; probe`),
...bodies.map((body) => `printf '%s' "$(probe() ${body}; probe)"`),
...(shell === "zsh" ? loops.map((fixture) => fixture.source) : []),
])(`${shell}: %s`, (source) => {
if (!executable) throw new Error(`${shell} is unavailable`)
const execution = Bun.spawnSync(
[
executable,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-c",
`scan_probe() { printf 'executed\\n' >&2; }; ${source}; wait`,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" }, timeout: 2_000 },
)
expect(execution.exitCode).toBe(0)
expect(execution.stderr.toString()).toEqual(
source.includes("value ()") ? "" : expect.stringContaining("executed\n"),
)
expect(ShellScan.scan(source).kind).toBe("scanned")
})
}
})
@@ -1,158 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { ShellParse } from "../../src/shell/parse.js"
import { ShellScan } from "../../src/shell/scan.js"
const conditions = ["[[ -n <(scan_probe) ]]", "[[ -n >(scan_probe) ]]"]
const contexts = [
(source: string) => source,
(source: string) => `( ${source} )`,
(source: string) => `{ ${source}; }`,
(source: string) => `if ${source}; then printf visible; fi`,
(source: string) => `check() { ${source}; }; check`,
(source: string) => `printf '%s' "$( ${source}; printf visible)"`,
]
const functions = ["probe", "probe-name", "probe.name", "probe:name"].flatMap((name) =>
[`${name}()`, `function ${name}`, `function ${name}()`].flatMap((head) =>
[
"{ scan_probe; }",
"(scan_probe)",
"if true; then scan_probe; fi",
"[[ $(scan_probe) == ok ]]",
"(( 1 + $(scan_probe) ))",
].map((body) => `${head} ${body}; ${name}`),
),
)
describe("legacy-accepted shell syntax regressions", () => {
test.each(["() { scan_probe; }", "probe() { scan_probe; }; probe"])(
"preserves deeply indented function definitions: %s",
async (source) => {
const command = `( ${" ".repeat(32_000)}${source} )`
const legacy = await Effect.runPromise(ShellParse.scan(command, "zsh", "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(command, "zsh", "/workspace"))).toEqual(legacy)
},
)
test.each(conditions.flatMap((source) => contexts.map((context) => context(source))))(
"retains conditional process substitutions and permission resources: %s",
async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, "bash", "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, "bash", "/workspace"))).toEqual(legacy)
},
)
for (const shell of ["bash", "zsh"]) {
test.each(
["probe()", "probe \\\n()", "function \\\nprobe()", "function probe \\\n()"].flatMap((head) =>
[" \\\n", " \\\n # ignored ) }\n", "# ignored \\\n"].flatMap((gap) =>
contexts.map((context) => context(`${head}${gap}{ scan_probe; }; probe`)),
),
),
)(`${shell} preserves line continuations at function boundaries: %s`, async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
})
test.each(
["probe()", "function probe", "function probe()"].flatMap((head) =>
[" # ignored ) }\n", "\n# ignored ) }\n\n", " # first\n# second\n"].flatMap((gap) =>
contexts.map((context) => context(`${head}${gap}{ scan_probe; }; probe`)),
),
),
)(`${shell} preserves comments between a function head and its body: %s`, async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
})
test.each(functions)(
`${shell} preserves function resources, saved prefixes, and directories: %s`,
async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
},
)
const executable = Bun.which(shell)
test
.skipIf(!executable)
.each([
"probe-name() { scan_probe; }; probe-name",
"function probe.name { scan_probe; }; probe.name",
"probe:name() if true; then scan_probe; fi; probe:name",
"probe()# ignored ) }\n{ scan_probe; }; probe",
"probe \\\n() \\\n{ scan_probe; }; probe",
"function \\\nprobe() # ignored \\\n{ scan_probe; }; probe",
...(shell === "bash" ? conditions : ["() { scan_probe; }"]),
])(`${shell} really executes the extracted command: %s`, (source) => {
if (!executable) throw new Error(`${shell} is unavailable`)
const execution = Bun.spawnSync(
[
executable,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-c",
`scan_probe() { printf 'executed\\n' >&2; }; ${source}; wait`,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.exitCode).toBe(0)
expect(execution.stderr.toString()).toBe("executed\n")
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
})
}
test.each([
"() { scan_probe; }",
"( () { scan_probe; } )",
"{ () { scan_probe; }; }",
"while() { scan_probe; break; }",
"until() { scan_probe; break; }",
])("preserves Zsh anonymous functions and parenthesized loop permissions: %s", async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, "zsh", "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
})
// Tree-sitter recovers these valid Zsh forms with synthetic commands or truncated outer resources.
// Pin both results rather than treating recovery artifacts as executable shell syntax.
test.each([
{
source: "if () { scan_probe; }; then printf visible; fi",
legacy: ["scan_probe", "then printf visible", "fi"],
portable: ["scan_probe", "printf visible"],
},
{
source: "check() { () { scan_probe; }; }; check",
legacy: ["scan_probe", "}", "check"],
portable: ["scan_probe", "check"],
},
{
source: "printf '%s' \"$( () { scan_probe; }; printf visible)\"",
legacy: ["printf '%s'", "scan_probe", "printf visible"],
portable: ["printf '%s' \"$( () { scan_probe; }; printf visible)\"", "scan_probe", "printf visible"],
},
])("accepts anonymous-function compositions despite legacy recovery artifacts: $source", async (fixture) => {
const legacy = await Effect.runPromise(ShellParse.scan(fixture.source, "zsh", "/workspace"))
const portable = await Effect.runPromise(ShellParse.scanPortable(fixture.source, "zsh", "/workspace"))
expect(legacy.commands.map((command) => command.resource)).toEqual([...fixture.legacy])
expect(portable.commands.map((command) => command.resource)).toEqual([...fixture.portable])
})
test.each([
"[[ -n '<(scan_ignored)' ]]",
'[[ -n "<(scan_ignored)" ]]',
"[[ -n '>(scan_ignored)' ]]",
'[[ -n ">(scan_ignored)" ]]',
"[[ -n $'<(scan_ignored)' ]]",
])("does not turn quoted process-substitution text into commands: %s", (source) => {
expect(ShellScan.scan(source)).toEqual({ kind: "scanned", commands: [] })
})
})
@@ -3,30 +3,7 @@ import { ShellScan } from "../../src/shell/scan.js"
const pwsh = process.env.SHELL_SCAN_PWSH ?? Bun.which("pwsh")
// These ordinary forms must stay accepted, not disappear behind the oracle's opaque-result filter.
const supported = [
"Invoke-ProbeA; Invoke-ProbeB",
"$result = Invoke-ProbeA; Invoke-ProbeB",
"if (Invoke-ProbeA) { Invoke-ProbeB } else { Invoke-ProbeC }",
"foreach ($item in (Invoke-ProbeA)) { Invoke-ProbeB }",
"function Get-Probe { param($x); Invoke-ProbeB }; Invoke-ProbeA",
"$x = @{ first = Invoke-ProbeA; second = @(Invoke-ProbeB; Invoke-ProbeC) }",
'Invoke-ProbeA "$(Invoke-ProbeB "$(Invoke-ProbeC)")"',
"Invoke-ProbeA | ForEach-Object { Invoke-ProbeB }",
"Invoke-ProbeA @'\nliteral ; }\n'@; Invoke-ProbeB",
'Invoke-ProbeA @"\n$(Invoke-ProbeB)\n"@; Invoke-ProbeC',
"Invoke-ProbeA `\n argument; Invoke-ProbeB",
"Invoke-ProbeA 2>&1; Invoke-ProbeB",
"& 'Invoke-ProbeA' argument; Invoke-ProbeB",
"Invoke-ProbeA --% literal; ignored\nInvoke-ProbeB",
]
test.each(supported)("accepts supported PowerShell syntax without an opaque escape hatch: %s", (source) => {
expect(ShellScan.scanPowerShell(source).kind).toBe("scanned")
})
const fixtures = [
...supported,
...[
"$result = Invoke-ProbeA; Invoke-ProbeB",
"$result = (Invoke-ProbeA); Invoke-ProbeB",
@@ -366,10 +343,6 @@ test.skipIf(!pwsh)(
let executed = 0
for (const result of results) {
const scan = ShellScan.scanPowerShell(result.source)
if (supported.includes(result.source)) {
expect(result.errors, result.source).toEqual([])
expect(scan.kind, result.source).toBe("scanned")
}
if (scan.kind === "opaque" || result.errors.length > 0) continue
scanned++
executed += result.executed.length
-138
View File
@@ -1,138 +0,0 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { State } from "@opencode-ai/core/state"
import { it } from "./lib/effect"
it.effect("detaches every registration of a failed group and refreshes every affected domain", () =>
Effect.gen(function* () {
const notices: string[] = []
const failures: State.Failure[] = []
let refresh = Effect.void
let fail = false
let calls = 0
const grouped = State.group((failure, changed) => {
failures.push(failure)
refresh = changed
})
const first = State.create({
name: "first",
initial: () => ({ values: [] as string[] }),
editor: (value) => value,
notify: () => Effect.sync(() => void notices.push("first")),
})
const second = State.create({
name: "second",
initial: () => ({ values: [] as string[] }),
editor: (value) => value,
notify: () => Effect.sync(() => void notices.push("second")),
})
yield* first.transform((editor) => editor.values.push("healthy"))
const registration = yield* first.transform((editor) => editor.values.push("grouped")).pipe(grouped)
yield* first.transform((editor) => editor.values.push("also grouped")).pipe(grouped)
yield* second
.transform((editor) => {
calls++
editor.values.push("partial")
if (fail) throw new Error("broken")
})
.pipe(grouped)
const before = first.get()
notices.length = 0
fail = true
yield* second.reload()
expect(first.get().values).toEqual(["healthy"])
expect(second.get().values).toEqual([])
expect(before.values).toEqual(["healthy", "grouped", "also grouped"])
expect(failures).toHaveLength(1)
expect(failures[0]?.state).toBe("second")
expect(calls).toBe(2)
notices.length = 0
// The group deduplicates its domain notifications without relying on an outer batch.
yield* refresh
expect(notices.toSorted()).toEqual(["first", "second"])
yield* registration.dispose
expect(notices).toHaveLength(2)
yield* first.transform((editor) => editor.values.push("resurrected")).pipe(grouped)
expect(first.get().values).toEqual(["healthy"])
expect(failures).toHaveLength(1)
}),
)
it.effect("restarts an outer candidate when a nested read disables one of its contributors", () =>
Effect.gen(function* () {
let fail = false
const grouped = State.group(() => {})
const inner = State.create({ initial: () => ({ value: 0 }), editor: (value) => value })
const outer = State.create({ initial: () => ({ value: 0 }), editor: (value) => value })
yield* outer.transform((editor) => (editor.value += 10)).pipe(grouped)
yield* inner
.transform((editor) => {
editor.value = 5
if (fail) throw new Error("inner failed")
})
.pipe(grouped)
yield* outer.transform((editor) => (editor.value += inner.get().value + 1))
expect(outer.get().value).toBe(16)
fail = true
yield* State.batch(
Effect.gen(function* () {
yield* inner.reload()
yield* outer.reload()
expect(outer.get().value).toBe(1)
expect(inner.get().value).toBe(0)
}),
)
}),
)
it.effect("disables multiple failing groups once each before publishing a complete fold", () =>
Effect.gen(function* () {
const reported: string[] = []
const first = State.group(() => reported.push("first"))
const second = State.group(() => reported.push("second"))
const state = State.create({ initial: () => ({ values: [] as string[] }), editor: (value) => value })
yield* State.batch(
Effect.gen(function* () {
yield* state
.transform((editor) => {
editor.values.push("first")
throw "first failed"
})
.pipe(first)
yield* state
.transform((editor) => {
editor.values.push("second")
throw { message: "second failed" }
})
.pipe(second)
yield* state.transform((editor) => editor.values.push("healthy"))
}),
)
expect(state.get().values).toEqual(["healthy"])
expect(reported).toEqual(["first", "second"])
yield* state.reload()
expect(reported).toEqual(["first", "second"])
}),
)
it.effect("does not disable a group for a notification failure after successful replay", () =>
Effect.gen(function* () {
let reported = 0
let fail = true
const grouped = State.group(() => reported++)
const state = State.create({
initial: () => ({ value: 0 }),
editor: (value) => value,
notify: () => (fail ? Effect.die("observer failed") : Effect.void),
})
yield* state.transform((editor) => editor.value++).pipe(grouped, Effect.exit)
expect(reported).toBe(0)
expect(state.get().value).toBe(1)
fail = false
yield* state.reload()
expect(state.get().value).toBe(1)
}),
)
-144
View File
@@ -526,150 +526,6 @@ describe("ShellTool scanner permissions", () => {
}
})
describe("ShellTool conditional process substitution", () => {
const test = isWindows || !Bun.which("bash") ? permissionIt.live.skip : permissionIt.live
for (const portable of [false, true]) {
test(`${portable ? "native" : "legacy"}: a nested deny prevents the substitution from running`, () =>
withScanner(
portable,
(registry, directory) =>
Effect.gen(function* () {
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(toolIdentity.agent, (agent) => {
agent.permissions = [
{ action: "shell", resource: "*", effect: "allow" },
{ action: "shell", resource: "printf *", effect: "deny" },
]
}),
)
const marker = path.join(directory.active, "marker")
const result = yield* runPermissionCommand(
registry,
'[[ -n <(printf reached > marker) ]]; wait "$!"',
marker,
[],
)
expect(result.exit).toMatchObject({
_tag: "Success",
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
})
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
}),
"bash",
))
for (const reply of ["reject", "once", "always"] as const) {
test(`${portable ? "native" : "legacy"}: conditional substitutions respect ${reply}`, () =>
withScanner(
portable,
(registry, directory) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
const location = yield* Location.Service
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["wait *"] })
const marker = path.join(directory.active, "marker")
const command = '[[ -n <(printf reached > marker) ]]; wait "$!"'
const result = yield* runPermissionCommand(registry, command, marker, [reply])
expect(result.requests).toMatchObject([
{ action: "shell", resources: ["printf reached > marker", 'wait "$!"'], save: ["printf *", "wait *"] },
])
if (reply === "reject") {
expect(Exit.isFailure(result.exit)).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
return
}
expect(result.exit).toMatchObject({
_tag: "Success",
value: { status: "completed", metadata: { exit: 0 } },
})
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("reached")
yield* Effect.promise(() => fs.unlink(marker))
const repeat = yield* runPermissionCommand(
registry,
command,
marker,
reply === "always" ? [] : ["reject"],
)
expect(repeat.requests).toHaveLength(reply === "always" ? 0 : 1)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(reply === "always")
}),
"bash",
))
}
}
})
describe("ShellTool compound syntax approval compatibility", () => {
for (const fixture of [
{
shell: "zsh",
command: 'for value (a b) printf %s "$value"',
equivalent: 'for value in a b; do printf %s "$value"; done',
output: "ab",
saved: ["printf *"],
},
{
shell: "zsh",
command: 'for value (a b) { printf %s "$value"; }',
equivalent: 'for value in a b; do printf %s "$value"; done',
output: "ab",
saved: ["printf *"],
},
{
shell: "zsh",
command: 'for value ($(printf a)) do printf %s "$value"; done',
equivalent: 'for value in $(printf a); do printf %s "$value"; done',
output: "a",
saved: ["printf *"],
},
{
shell: "bash",
command: 'probe() for value in a b; do printf %s "$value"; done; probe',
equivalent: 'probe() { for value in a b; do printf %s "$value"; done; }; probe',
output: "ab",
saved: ["printf *", "probe *"],
},
{
shell: "bash",
command: 'printf %s "$(probe() case value in value) printf hello;; esac; probe)"',
equivalent: 'printf %s "$(probe() { case value in value) printf hello;; esac; }; probe)"',
output: "hello",
saved: ["printf *", "probe *"],
},
]) {
const test = isWindows || !Bun.which(fixture.shell) ? permissionIt.live.skip : permissionIt.live
for (const portable of [false, true]) {
test(`${fixture.shell} ${portable ? "native" : "legacy equivalent"}: ${fixture.command}`, () =>
withScanner(
portable,
(registry, directory) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
const location = yield* Location.Service
yield* saved.add({ projectID: location.project.id, action: "shell", resources: fixture.saved })
const result = yield* runPermissionCommand(
registry,
portable ? fixture.command : fixture.equivalent,
path.join(directory.active, "marker"),
[],
)
expect(result.requests).toEqual([])
expect(result.exit).toMatchObject({
_tag: "Success",
value: {
status: "completed",
metadata: { exit: 0 },
content: [{ type: "text", text: fixture.output }, { type: "text" }],
},
})
}),
fixture.shell,
))
}
}
})
describe("ShellTool ordinary shell syntax", () => {
for (const shell of ["bash", "zsh"]) {
const test = isWindows || !Bun.which(shell) ? permissionIt.live.skip : permissionIt.live
+37 -73
View File
@@ -162,20 +162,6 @@ type PromptFooterInput = {
readonly showDetails: boolean
}
export type PanelPresentation = "panel" | "fullscreen"
/** Client-local state of the selected session panel. The host owns its layout and input scope. */
export interface PanelInput {
readonly sessionID: string
readonly width: number
readonly presentation: PanelPresentation
readonly focused: boolean
readonly canSplit: boolean
readonly focus: () => void
readonly close: () => void
readonly toggleFullscreen: () => void
}
/**
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
* may render around, inside, or take over. Paths are absolute and
@@ -194,7 +180,6 @@ export interface SlotMap {
readonly "prompt.footer.status": PromptFooterInput
readonly "prompt.footer.file": PromptFooterInput
readonly "session.composer.top": { readonly sessionID: string }
readonly "session.panel": PanelInput
readonly "sidebar.content": { readonly sessionID: string }
readonly "sidebar.footer": { readonly sessionID: string }
}
@@ -218,58 +203,45 @@ export type SlotPath = keyof SlotMap
* `render` receives the target slot's input, reactively. The `?: never`
* fields make the variants mutually exclusive: a claim with two placement
* keys is a type error, not a silent priority pick.
*
* `session.panel` is an exclusive named replacement selected by ui.panel.open,
* not by plugin enable order. Its instance survives presentation changes.
*/
export type SlotClaim<Path extends SlotPath = SlotPath> = Path extends SlotPath
? { readonly render: (input: SlotMap[Path]) => JSX.Element } & (Path extends "session.panel"
? { readonly name: string }
: { readonly name?: string }) &
(Path extends "session.panel"
? {
readonly replace: Path
readonly prepend?: never
readonly append?: never
readonly before?: never
readonly after?: never
}
:
| {
readonly prepend: Path
readonly append?: never
readonly before?: never
readonly after?: never
readonly replace?: never
}
| {
readonly append: Path
readonly prepend?: never
readonly before?: never
readonly after?: never
readonly replace?: never
}
| {
readonly before: Path
readonly prepend?: never
readonly append?: never
readonly after?: never
readonly replace?: never
}
| {
readonly after: Path
readonly prepend?: never
readonly append?: never
readonly before?: never
readonly replace?: never
}
| {
readonly replace: Path
readonly prepend?: never
readonly append?: never
readonly before?: never
readonly after?: never
})
? { readonly render: (input: SlotMap[Path]) => JSX.Element } & (
| {
readonly prepend: Path
readonly append?: never
readonly before?: never
readonly after?: never
readonly replace?: never
}
| {
readonly append: Path
readonly prepend?: never
readonly before?: never
readonly after?: never
readonly replace?: never
}
| {
readonly before: Path
readonly prepend?: never
readonly append?: never
readonly after?: never
readonly replace?: never
}
| {
readonly after: Path
readonly prepend?: never
readonly append?: never
readonly before?: never
readonly replace?: never
}
| {
readonly replace: Path
readonly prepend?: never
readonly append?: never
readonly before?: never
readonly after?: never
}
)
: never
export interface App {
@@ -478,14 +450,6 @@ export interface UI {
navigate(destination: Destination): void
current(): Route
}
readonly panel: {
/** Opens a named session.panel contribution owned by this plugin in the current session. */
open(name: string, options?: { readonly presentation?: PanelPresentation }): boolean
/** Closes this plugin's active panel. Other plugins' panels are unaffected. */
close(): void
/** This plugin's active panel, if any. Reactive when read in a Solid computation. */
current(): { readonly name: string; readonly sessionID: string } | undefined
}
readonly tabs: {
/** Returns whether session tabs are enabled for this TUI. */
enabled(): boolean
+4 -9
View File
@@ -8890,8 +8890,7 @@
{
"type": "null"
}
],
"description": "An absolute path or a path relative to the requested location. Defaults to the location directory."
]
},
"required": false
}
@@ -8942,7 +8941,7 @@
}
}
},
"description": "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
"description": "List direct children of one directory relative to the requested location.",
"summary": "List directory"
}
},
@@ -13778,12 +13777,8 @@
}
]
},
"subagent": {
"type": "boolean"
},
"subtask": {
"type": "boolean",
"description": "Deprecated alias for subagent."
"type": "boolean"
}
},
"required": ["template"],
@@ -13904,7 +13899,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify"]
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",
+2 -2
View File
@@ -34,8 +34,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
default_agent: Schema.String.pipe(optional).annotate({
description: "Default primary agent to use when no session agent is selected",
}),
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
description: "Disable updates or notify when one is available",
update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({
description: "Disable updates, notify when one is available, or install automatically",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
+1 -2
View File
@@ -9,6 +9,5 @@ export class Info extends Schema.Class<Info>("Config.Command")({
description: Schema.String.pipe(optional),
agent: Schema.String.pipe(optional),
model: ConfigModel.Selection.pipe(optional),
subagent: Schema.Boolean.pipe(optional),
subtask: Schema.Boolean.annotate({ description: "Deprecated alias for subagent." }).pipe(optional),
subtask: Schema.Boolean.pipe(optional),
}) {}
-2
View File
@@ -585,8 +585,6 @@ export namespace Compaction {
schema: {
...Base,
reason: Started.data.fields.reason,
model: SessionMessage.CompactionCompleted.fields.model,
providerState: SessionMessage.CompactionCompleted.fields.providerState,
text: Schema.String,
recent: Schema.String,
},
-2
View File
@@ -250,8 +250,6 @@ export const CompactionCompleted = Schema.Struct({
...CompactionBase,
status: Schema.tag("completed"),
reason: Schema.Literals(["auto", "manual"]),
model: Model.Ref.pipe(optional),
providerState: ProviderState.pipe(optional),
summary: Schema.String,
recent: Schema.String,
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
+3 -6
View File
@@ -4,7 +4,7 @@ import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/effect"
import type { Workspace } from "@opencode-ai/core/workspace"
import { Context, Effect, Layer } from "effect"
import type { Config, Scope } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { FetchHttpClient } from "effect/unstable/http"
import { EmbeddedHost } from "../internal/host"
import type { SdkInstances } from "../internal/instances"
@@ -35,12 +35,9 @@ export const create: <R = never>(
R = never,
>(options: CreateOptions<R> = {}, embed: EmbedOptions = {}) {
const host = yield* Effect.acquireRelease(EmbeddedHost.create(options, embed), (host) => Effect.promise(host.close))
const httpClient = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
Effect.provideService(
HttpClient.HttpClient,
// FetchHttpClient reads Fetch at request time; callers must not replace this host's in-process transport.
HttpClient.transformResponse(httpClient, Effect.provideService(FetchHttpClient.Fetch, host.fetch)),
Effect.provide(
FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, host.fetch)), Layer.fresh),
),
)
-65
View File
@@ -1,65 +0,0 @@
import { expect } from "bun:test"
import { Context, Effect, Exit, Layer, Scope, Stream } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { testEffect } from "../../core/test/lib/effect"
import { AbsolutePath, Location, OpenCode, Session } from "../src/effect"
const it = testEffect(Layer.empty)
for (const entrypoint of ["create", "layer"] as const) {
it.live(`${entrypoint} keeps requests and streams on its own transport despite an ambient Fetch`, () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const calls: string[] = []
const ambient = Object.assign(
(input: RequestInfo | URL) => {
calls.push(input instanceof Request ? input.url : String(input))
return Promise.reject(new Error("The caller's Fetch must not receive embedded SDK requests"))
},
{ preconnect: () => undefined },
)
const parent = yield* Effect.scope
const scope = yield* Scope.fork(parent)
const options: OpenCode.CreateOptions = {
app: { version: "transport-test" },
config: { directory: directory.path, project: false, content: "{}" },
events: { persist: true },
models: { fetch: false },
fs: { filewatcher: false },
}
const client = yield* (
entrypoint === "create"
? OpenCode.create(options).pipe(Scope.provide(scope))
: Layer.buildWithScope(OpenCode.layer(options), scope).pipe(Effect.map(Context.get(OpenCode.Service)))
).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient))
yield* Effect.gen(function* () {
expect(yield* client.health.get()).toMatchObject({ healthy: true, version: "transport-test" })
const session = yield* client.sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }),
})
expect((yield* client.sessions.get({ sessionID: session.id })).id).toBe(session.id)
const events = yield* client.sessions.log({ sessionID: session.id }).pipe(Stream.runCollect)
expect(events.some((event) => event.type === "session.created")).toBe(true)
expect(yield* client.events.subscribe().pipe(Stream.take(1), Stream.runCollect)).toMatchObject([
{ type: "server.connected" },
])
expect(yield* client.sessions.get({ sessionID: Session.ID.create() }).pipe(Effect.flip)).toMatchObject({
_tag: "SessionNotFoundError",
})
// Binding the SDK's transport must not change the caller's surrounding context.
expect(yield* FetchHttpClient.Fetch).toBe(ambient)
}).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient))
expect(calls).toEqual([])
yield* Scope.close(scope, Exit.void)
expect(
Exit.isFailure(
yield* client.health.get().pipe(Effect.provideService(FetchHttpClient.Fetch, ambient), Effect.exit),
),
).toBe(true)
expect(calls).toEqual([])
}),
)
}
+7 -5
View File
@@ -14,7 +14,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
return output === undefined ? {} : { output }
}).pipe(
Effect.mapError((error) =>
error.type === "rpc.invalid_output" || error.type === "rpc.internal"
error.type === "rpc.invalid_output"
? new RpcInternalError({ type: error.type, message: error.message })
: new RpcError({
type: error.type,
@@ -22,10 +22,12 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
...(error.data === undefined ? {} : { data: error.data }),
}),
),
// Defects outside handler execution are still logged, never echoed to the client.
Effect.catchDefect((defect) =>
Effect.logError("rpc call failed", { rpc: params.rpcID, method: params.method, defect }).pipe(
Effect.andThen(Effect.fail(new RpcInternalError({ type: "rpc.internal", message: "RPC call failed" }))),
Effect.catchDefect((error) =>
Effect.fail(
new RpcInternalError({
type: "rpc.internal",
message: error instanceof Error ? error.message : "RPC call failed",
}),
),
),
),
+8 -1
View File
@@ -1,9 +1,11 @@
export * as ServerProcess from "./process"
import { NodeHttpServer } from "@effect/platform-node"
import { Bus } from "@opencode-ai/core/bus"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
import {
HttpMiddleware,
@@ -114,7 +116,12 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
)
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
yield* status.ready
return { address: bound.http.address, shutdown: shutdown.await }
return {
address: bound.http.address,
shutdown: shutdown.await,
updateAvailable: (version: string) =>
Context.get(context, Bus.Service).publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
}
}).pipe(
Effect.catchCause((cause) => {
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
+15 -3
View File
@@ -1,4 +1,5 @@
import { expect } from "bun:test"
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
import { it } from "../../core/test/lib/effect"
@@ -99,9 +100,12 @@ it.live("allows browser preflight requests without credentials", () =>
)
expect(event.status).toBe(200)
expect(event.headers.get("content-encoding")).toBeNull()
const body = event.body
if (!body) return yield* Effect.die(new Error("Event response has no body"))
yield* Effect.promise(() => body.cancel())
if (!event.body) return yield* Effect.die(new Error("Event response has no body"))
const reader = event.body.getReader()
yield* Effect.promise(() => readUntil(reader, "server.connected"))
yield* server.updateAvailable("2.0.0")
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
yield* Effect.promise(() => reader.cancel())
const missing = yield* Effect.promise(() =>
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
@@ -126,3 +130,11 @@ it.live("allows browser preflight requests without credentials", () =>
)
}),
)
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
while (true) {
const next = await reader.read()
if (next.done) throw new Error(`Event stream ended before ${expected}`)
if (new TextDecoder().decode(next.value).includes(expected)) return
}
}
@@ -1,74 +0,0 @@
import { expect } from "bun:test"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Context, Effect, Layer, Schema } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createEmbeddedRoutes } from "../src/routes"
const Broken = Rpc.define({
id: "broken",
methods: {
handler: { input: Schema.String, output: Schema.String },
schema: {
input: Schema.String.check(
Schema.makeFilter(() => {
throw new Error("private schema detail")
}),
),
output: Schema.String,
},
},
events: {},
})
for (const method of ["handler", "schema"] as const) {
it.live(`returns HTTP 500 without exposing the ${method} defect`, () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const context = yield* Layer.build(
createEmbeddedRoutes({
database: { path: ":memory:" },
models: { fetch: false },
config: { directory: directory.path, project: false, content: "{}" },
fs: { filewatcher: false },
}).pipe(Layer.provide(HttpServer.layerServices)),
)
const sdk = Context.get(context, SdkPlugins.Service)
yield* sdk.register(
define({
id: "broken-rpc",
effect: (ctx) =>
ctx.rpc
.register(Broken, {
handler: () => Effect.die(new Error("private handler detail")),
schema: Effect.succeed,
})
.pipe(Effect.asVoid, Effect.orDie),
}),
)
const handler = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(HttpEffect.toWebHandlerWith(context))
const url = new URL(`/api/rpc/broken/${method}`, "http://opencode.local")
url.searchParams.set("location[directory]", directory.path)
const response = yield* Effect.promise(() =>
handler(
new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ input: "hello" }),
}),
),
)
expect(response.status).toBe(500)
expect(yield* Effect.promise(() => response.json())).toEqual({
_tag: "RpcInternalError",
type: "rpc.internal",
message: "RPC call failed",
})
}),
)
}
+33 -65
View File
@@ -100,7 +100,6 @@ import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider, useStorage } from "./context/storage"
import { SessionTerminalsProvider } from "./context/session-terminals"
import { PanelProvider, usePanel } from "./context/panel"
import { SessionFrame } from "./component/session-frame"
import { createTuiClipboard } from "./clipboard"
@@ -187,7 +186,6 @@ export type TuiInput = {
args: Args
config: Config.Interface
updater?: {
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
apply: (version: string) => Promise<void>
}
packages: PackageSource
@@ -222,6 +220,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const service = managed
? {
reconnect: async (signal: AbortSignal) => {
// Give the server a chance to respawn itself before starting client-side recovery.
await new Promise((resolve) => setTimeout(resolve, 50))
if (signal.aborted) throw signal.reason ?? new Error("Server reconnect cancelled")
const endpoint = await managed.reconnect(signal)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return { api: OpenCode.make(next), url: endpoint.url }
@@ -398,24 +399,22 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PanelProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</PanelProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
@@ -477,7 +476,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
const dialog = useDialog()
const local = useLocal()
const sessionTabs = useSessionTabs()
const panels = usePanel()
const keymap = Keymap.use()
const event = useEvent()
const client = useClient()
@@ -509,36 +507,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
"update-notifications",
{ initial: { versions: [] } },
)
const showUpdate = (version: string) => {
const updater = props.updater
if (!updater || updateNotifications.versions.includes(version)) return
void markUpdateNotification((draft) => {
draft.versions = [...draft.versions, version].slice(-100)
}).catch((error) => log.error("failed to persist update notification", { error }))
const key = `update:${version}`
dialog.replace(
() => (
<DialogUpdate
dialogKey={key}
version={version}
install={() => updater.apply(version)}
restart={client.restart}
/>
),
undefined,
{ key },
)
dialog.setCentered(true)
}
onMount(() => {
const updater = props.updater
if (!updater) return
const controller = new AbortController()
onCleanup(() => controller.abort())
void updater.monitor(showUpdate, controller.signal).catch((error) => {
if (!controller.signal.aborted) log.error("update monitor failed", { error })
})
})
const tabsResize = createPaneResize({
value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH,
defaultValue: () => SESSION_SIDEBAR_WIDTH,
@@ -612,22 +580,9 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () =>
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
const fullscreenPanel = () =>
route.data.type === "session" &&
panels.current()?.sessionID === route.data.sessionID &&
panels.presentation() === "fullscreen"
const tabsVisible = () => tabsAvailable() && !fullscreenPanel()
const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
const verticalTabsVisible = () => tabsVisible() && tabsVertical()
// Measure the prospective split layout, even while full-screen hides the tabs.
createEffect(() => panels.setWidth(dimensions().width - (tabsAvailable() && tabsVertical() ? tabsResize.size() : 0)))
createEffect(() => {
const current = panels.current()
if (!current || (route.data.type === "session" && route.data.sessionID === current.sessionID)) return
panels.close()
})
createEffect(() => {
renderer.useMouse = config.data.mouse
})
@@ -1260,6 +1215,19 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
})
})
event.on("installation.update-available", (evt) => {
const updater = props.updater
const restart = client.restart
if (!updater || !restart) return
const version = evt.data.version
if (updateNotifications.versions.includes(version)) return
void markUpdateNotification((draft) => {
draft.versions = [...draft.versions, version].slice(-100)
}).catch((error) => log.error("failed to persist update notification", { error }))
dialog.replace(() => <DialogUpdate version={version} install={() => updater.apply(version)} restart={restart} />)
dialog.setCentered(true)
})
event.on("tui.session.select", (evt, { workspace }) => {
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
route.navigate({
+18 -31
View File
@@ -8,32 +8,22 @@ import { useDialog } from "../ui/dialog"
import { Spinner } from "./spinner"
type State =
| { type: "ready"; active: "update" | "skip" }
| { type: "ready"; active: "update" | "ignore" }
| { type: "installing" }
| { type: "restarting" }
| { type: "failed"; message: string }
export function DialogUpdate(props: {
dialogKey: string
version: string
install: () => Promise<void>
restart?: () => Promise<void>
}) {
export function DialogUpdate(props: { version: string; install: () => Promise<void>; restart: () => Promise<void> }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const [state, setState] = createSignal<State>({ type: "ready", active: "update" })
const close = () => {
if (dialog.key === props.dialogKey) dialog.clear()
}
const install = async () => {
setState({ type: "installing" })
await props.install()
if (props.restart) {
setState({ type: "restarting" })
await props.restart()
}
close()
setState({ type: "restarting" })
await props.restart()
dialog.clear()
}
const beginInstall = () => {
@@ -44,16 +34,16 @@ export function DialogUpdate(props: {
const run = () => {
const current = state()
if (current.type !== "ready") return
if (current.active === "skip") return close()
if (current.active === "ignore") return dialog.clear()
beginInstall()
}
const toggle = () =>
setState((current) =>
current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current,
current.type === "ready" ? { ...current, active: current.active === "update" ? "ignore" : "update" } : current,
)
const selected = (action: "update" | "skip") => {
const selected = (action: "update" | "ignore") => {
const current = state()
return current.type === "ready" && current.active === action
}
@@ -70,7 +60,7 @@ export function DialogUpdate(props: {
bind: "return",
title: "Confirm update action",
group: "Dialog",
run: () => (state().type === "failed" ? close() : run()),
run: () => (state().type === "failed" ? dialog.clear() : run()),
},
{
bind: "left",
@@ -91,9 +81,9 @@ export function DialogUpdate(props: {
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Update available
Update
</text>
<text fg={theme.text.subdued} onMouseUp={close}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -101,17 +91,14 @@ export function DialogUpdate(props: {
<Switch>
<Match when={state().type === "ready"}>
<text fg={theme.text.subdued}>
An update is available. Applying will
{props.restart
? " restart the server and active sessions will be resumed."
: " install the update but you will need to manually restart."}
Update to v{props.version}? It will be applied in the background and active sessions will be restarted.
</text>
</Match>
<Match when={state().type === "installing"}>
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}</Spinner>
<Spinner>Installing OpenCode {props.version}</Spinner>
</Match>
<Match when={state().type === "restarting"}>
<Spinner shimmer={theme.text.default}>Restarting the background service</Spinner>
<Spinner>Restarting the background service</Spinner>
</Match>
<Match when={state().type === "failed"}>
<text fg={theme.text.feedback.error.default}>{failure()}</text>
@@ -127,7 +114,7 @@ export function DialogUpdate(props: {
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={close}
onMouseUp={() => dialog.clear()}
>
<text fg={theme.text.action.primary.focused}>close</text>
</box>
@@ -136,19 +123,19 @@ export function DialogUpdate(props: {
}
>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["skip", "update"] as const}>
<For each={["ignore", "update"] as const}>
{(action) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
if (action === "skip") return close()
if (action === "ignore") return dialog.clear()
beginInstall()
}}
>
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
{action === "update" ? "Update" : "Skip"}
{action === "update" ? "Update" : "Ignore"}
</text>
</box>
)}

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