Compare commits

..
Author SHA1 Message Date
James Long dea9125004 feat(tui): add split diff viewer 2026-09-03 20:15:29 +00:00
James Long 43bd2a516b refactor(cli): move update checks to TUI clients (#46963) 2026-09-03 14:55:26 -04:00
Kit Langton dad7688739 refactor(core): reuse job scope as generation identity (#47121) 2026-09-03 14:47:44 -04:00
Kit Langton 961b8ccb86 refactor(core): remove unobservable websocket states (#47117) 2026-09-03 14:24:45 -04:00
Kit Langton 97303c39dd fix(core): expand portable shell scanner compatibility (#47110) 2026-09-03 14:11:58 -04:00
Kit Langton 282c84d79f refactor(core): remove redundant step closeout flags (#46937) 2026-09-03 13:35:42 -04:00
Kit Langton 2dcfc89fab feat(tui): announce server plugin failures (#47103)
Show grouped server plugin failure notices with an Open plugins action, retain the home failure count, and reveal failed built-ins in the plugin dialog. Keep unchanged failures quiet across inventory refreshes and reconnects.
2026-09-03 13:17:10 -04:00
Kit Langton a222401f19 fix(core): normalize RPC handler failures (#46946) 2026-09-03 13:15:24 -04:00
Kit Langton 36da0d5c77 fix(core): retry failed location initialization (#46957) 2026-09-03 13:10:59 -04:00
Shoubhit Dash 7819e7f503 fix(core): run command subagents in the background (#47081) 2026-09-03 22:37:58 +05:30
Aiden Cline 6e63b970f3 feat(core): persist compaction model and provider state (#46962) 2026-09-03 12:06:50 -05:00
Kit Langton 610d7e952a refactor(client): remove redundant location spreads (#47086) 2026-09-03 12:51:12 -04:00
Kit Langton ac874a6e90 fix(core): recover idle moves through the selected instance (#46955) 2026-09-03 16:45:16 +00:00
Kit Langton f40ecefdef feat(core): disable plugins after transform failures (#47083)
Disable failed registration groups, rebuild healthy state, and report plugin failures with safe diagnostic references. Keep cleanup outside activation locks and preserve disabled revisions across unrelated reloads. Cover deferred hook and RPC cleanup with real-service regression tests.
2026-09-03 16:38:11 +00:00
Kit Langton c370a1bdd0 refactor(app): remove redundant project setter wrappers (#47085) 2026-09-03 16:33:41 +00:00
Kit Langton 309f4534fa test(app): align timeline assertions with activity controls (#47089) 2026-09-03 12:20:08 -04:00
Kit Langton 0ae3bf743f fix(tui): show execution failures in the viewed session (#46968) 2026-09-03 12:00:48 -04:00
Kit Langton f94eefaa50 fix(tui): preserve markdown blocks on plugin toggles (#47084) 2026-09-03 11:37:41 -04:00
Kit Langton a04d72bb39 fix(sdk): bind embedded transport at request time (#46971) 2026-09-03 11:16:22 -04:00
112 changed files with 4498 additions and 2251 deletions
+1 -38
View File
@@ -247,8 +247,6 @@ 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`.
@@ -261,7 +259,7 @@ This is different from prompt caching, server-side history storage, or truncatio
### Explicit compaction
`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.
`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.
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
@@ -281,41 +279,6 @@ 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,7 +42,6 @@ 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(",")}}`
@@ -150,12 +149,6 @@ 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 {
+3 -7
View File
@@ -920,7 +920,7 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
const outputItemID = (state: Pick<ParserState, "outputItems">, event: Event) =>
const outputItemID = (state: ParserState, 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,11 +932,7 @@ 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: Pick<ParserState, "outputItems">,
item: StreamItem,
index: number | undefined,
): OutputItem => ({
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
...item,
id:
item.id ??
@@ -946,7 +942,7 @@ const resolveItem = (
// 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: Pick<ParserState, "outputItems">, input: Event): NormalizedEvent => ({
export const normalize = (state: ParserState, 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,
+2 -44
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 { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import type { LLMRequest, JsonSchema, 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,7 +13,6 @@ 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"
@@ -104,18 +103,6 @@ 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,
@@ -175,35 +162,6 @@ 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) {
@@ -281,7 +239,7 @@ export const transport = channelTransport({
})
export const route = Route.make({
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
compact: ResponsesCompaction.make(adapter),
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
@@ -1,122 +0,0 @@
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 { CompactionOperations } from "./route/client.js"
import type { CompactOperation } 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 CompactionOperations | undefined = CompactionOperations | undefined,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
}
+3 -4
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, CompactionOperations } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactOperation } 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,7 +39,6 @@ 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,
@@ -103,7 +102,7 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
modelID: string | ModelID,
@@ -169,7 +168,7 @@ const config = (settings: Settings): Config => {
export const responsesModel: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
typeof responsesRoute.compact
CompactOperation
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
+6 -7
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactOperation } 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 CompactionOperations | undefined>(
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
) =>
@@ -132,11 +132,10 @@ const config = (settings: Settings): Config => {
}
}
export const model: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
typeof OpenAIResponses.route.compact
>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) => {
return configure(config(settings)).responses(modelID)
}
+6 -7
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Route, type RouteDefaultsInput, type CompactOperation } 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: { endpoint: XAIResponses.compact },
compact: XAIResponses.compact,
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
@@ -103,11 +103,10 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<
Settings,
XAIProviderOptionsInput,
typeof responsesRoute.compact
>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
+38 -113
View File
@@ -14,7 +14,6 @@ import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
AIError,
CompactionResponse,
CompactionCheckpointResponse,
AIErrorReason,
GenerationOptions,
HttpOptions,
@@ -39,7 +38,7 @@ export interface RouteBody<Body> {
export interface Route<
Body,
Prepared = unknown,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
readonly compact: Compact
readonly id: string
@@ -54,15 +53,7 @@ export interface Route<
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
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 with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
readonly model: <Options extends ProviderOptions = ProviderOptions>(
input: RouteMappedLanguageModelInput,
) => LanguageModel<Options, Compact>
@@ -83,7 +74,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 CompactionOperations | undefined = CompactionOperations | undefined> = Route<
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
any,
any,
Compact
@@ -110,7 +101,6 @@ export interface RouteDefaultsInput {
}
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly compact?: CompactionOperations
readonly id?: string
readonly provider?: string | ProviderID
readonly providerMetadataKey?: string
@@ -121,7 +111,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
route: AnyRoute<Compact>,
mapped: RouteMappedLanguageModelInput,
) => {
@@ -172,7 +162,10 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
}
export interface Interface {
readonly compact: CompactMethod
readonly compact: (
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
readonly stream: StreamMethod
readonly generate: GenerateMethod
}
@@ -196,64 +189,12 @@ export type CompactOperation = (
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
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 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 }>
readonly model: LanguageModel<ProviderOptions, CompactOperation>
}
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 const canCompact = (request: LLMRequest): request is CompactionRequest =>
request.model.route.compact !== undefined
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -275,7 +216,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
}
export interface MakeInput<Body, Frame, Event, State> {
readonly compact?: CompactionOperations
readonly compact?: CompactOperation
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -297,7 +238,7 @@ export interface MakeInput<Body, Frame, Event, State> {
}
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
readonly compact?: CompactionOperations
readonly compact?: CompactOperation
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -385,10 +326,9 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: routeInput.defaults ?? {},
body: protocol.body,
with: (patch: RoutePatch<Body, Prepared>) => {
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
const { 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:
@@ -403,7 +343,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
})
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
makeRouteLanguageModel<Options, CompactionOperations | undefined>(route, input),
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
@@ -500,12 +440,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
}
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> & { 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>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared>
@@ -617,23 +557,14 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
})
}
export function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError, Service>
export function compact(
export const compact = (
request: CompactionRequest,
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError, Service>
export function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
return Effect.gen(function* () {
options?: Pick<StreamOptions, "http">,
): Effect.Effect<CompactionResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
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)
return yield* client.compact(request, options)
})
}
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
@@ -647,27 +578,21 @@ 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,
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)
}),
})
}),
)
-6
View File
@@ -9,12 +9,6 @@ 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,21 +97,6 @@ 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, CompactionOperations } from "../route/client.js"
import type { AnyRoute, CompactOperation } 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 CompactionOperations | undefined = CompactionOperations | undefined,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
@@ -194,7 +194,7 @@ export class LanguageModel<
static make<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
Compact extends CompactOperation | undefined = CompactOperation | 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 CompactionOperations | undefined>(
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options, Compact>,
): LanguageModel.ConstructorInput<Compact> {
return {
@@ -218,11 +218,11 @@ export class LanguageModel<
}
}
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
static update<Options extends ProviderOptions, Compact extends CompactOperation | 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 CompactionOperations | undefined = CompactionOperations | undefined> = {
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | 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 CompactionOperations | undefined = CompactionOperations | undefined> = Omit<
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
ConstructorInput<Compact>,
"id" | "provider" | "defaults" | "compatibility"
> & {
+11 -39
View File
@@ -1,17 +1,10 @@
export * as TestLLM from "./testing.js"
import {
LLMClient,
type CompactionRequest,
type CheckpointRequest,
type EndpointCompactOptions,
type TriggerCompactOptions,
} from "./route/client.js"
import { LLMClient } from "./route/client.js"
import {
LLMEvent,
LLMResponse,
CompactionResponse,
CompactionCheckpointResponse,
type FinishReasonDetails,
type AIError,
type LLMRequest,
@@ -20,11 +13,7 @@ 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
| CompactionCheckpointResponse
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
@@ -143,38 +132,21 @@ const make = (options: LayerOptions) =>
Stream.unwrap(
take(request).pipe(
Effect.map((response) => {
if (response instanceof CompactionResponse || response instanceof CompactionCheckpointResponse)
if (response instanceof CompactionResponse)
return Stream.die("TestLLM generation requires an event response")
return Stream.isStream(response) ? response : Stream.fromIterable(response)
}),
),
)
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 trigger compaction requires a CompactionCheckpointResponse")
return response instanceof CompactionResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse")
}),
)
}
const test = Test.of({
compact,
compact: (request) =>
take(request).pipe(
Effect.flatMap((response) =>
response instanceof CompactionResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse"),
),
),
stream,
generate: (request) =>
stream(request).pipe(
@@ -1,146 +0,0 @@
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" })
@@ -1,374 +0,0 @@
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,7 +3,6 @@ import {
AIError,
CompactionPart,
CompactionResponse,
CompactionCheckpointResponse,
LanguageModel,
LLM,
LLMClient,
@@ -80,47 +79,6 @@ 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", "test/provider/checkpoint.test.ts"]
"include": ["test/**/*.types.ts", "test/testing.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)).toContainText("Agent · explore")
await expect(notices.nth(0)).toHaveText(/^Agent changed\s*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,20 +182,15 @@ 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.locator('[data-component="session-background-hint"]')
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
const hint = page.getByRole("button", { name: /move running work to the background/i })
await expect(hint).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect
.poll(async () => {
const [cardBox, hintBox, prefixBox] = await Promise.all([
card.boundingBox(),
hint.boundingBox(),
hintPrefix.boundingBox(),
])
if (!cardBox || !hintBox || !prefixBox) return undefined
const [cardBox, hintBox] = await Promise.all([card.boundingBox(), hint.boundingBox()])
if (!cardBox || !hintBox) return undefined
return {
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
aligned: Math.abs(cardBox.x - hintBox.x) < 2,
ordered: cardBox.y < hintBox.y,
}
})
@@ -220,10 +215,10 @@ test("navigates from a running subagent card and hides background controls in th
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await expect(page.getByRole("button", { name: /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.getByText(/move running work to the background/i)).toHaveCount(0)
await expect(page.getByRole("button", { name: /move running work to the background/i })).toHaveCount(0)
})
for (const name of ["shell", "subagent"] as const) {
@@ -267,7 +262,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.locator('[data-component="session-background-hint"]')).toBeVisible()
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
@@ -286,9 +281,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 item running in background" })
const summary = page.getByRole("button", { name: "1 background task running", exact: true })
await expect(summary).toContainText("1")
await expect(summary).toContainText("Running work in background")
await expect(summary).toContainText("1 background task running")
await summary.click()
await expect(
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
@@ -387,7 +382,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.getByText(/move running work to the background/i)).toBeVisible()
await expect(page.getByRole("button", { name: /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"]')
@@ -396,7 +391,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 items running in background" })
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
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-circle-exclamation",
"#opencode-v2-icon-outline-hexagonal-warning",
)
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.locator('[data-component="session-background-hint"]')
const hint = page.getByRole("button", { name: /move running work to the background/i })
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,12 +1,60 @@
import { describe, expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query"
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { OpenCode } from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store"
import { bootstrapGlobal, 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"]
+3 -23
View File
@@ -79,25 +79,13 @@ 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: setBootStore,
setGlobalStore,
queryClient,
})
return Date.now()
@@ -105,14 +93,6 @@ 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({
@@ -216,7 +196,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
}
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
setProjects((projects) =>
setGlobalStore("project", (projects) =>
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
)
}
@@ -275,7 +255,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
return {
data: globalStore,
set,
set: setGlobalStore,
child: children.child,
disableMcp: children.disableMcp,
// bootstrap,
@@ -47,7 +47,6 @@ 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
@@ -83,11 +82,14 @@ export default Runtime.handler(Commands, (input) =>
get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)),
},
updater: service
? {
apply: (version) => runPromise(updater.apply(version)),
}
: undefined,
updater: {
monitor: (notify, signal) =>
runPromise(
updater.monitor((version) => Effect.sync(() => notify(version))),
{ signal },
),
apply: (version) => runPromise(updater.apply(version)),
},
packages: {
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
},
+6 -55
View File
@@ -7,14 +7,12 @@ 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 { spawn } from "node:child_process"
import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect"
import { 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"
@@ -29,7 +27,6 @@ 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: [
@@ -54,8 +51,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
const replacement = yield* Deferred.make<PersistentPty.Handoff | null>()
const next = yield* Effect.scoped(
return yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default"
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
@@ -66,7 +62,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 Option.none<PersistentPty.Handoff | null>()
if (incumbent !== undefined) return
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.
@@ -163,62 +159,17 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
}),
)
if (server === undefined) return Option.none<PersistentPty.Handoff | null>()
if (server === undefined) return
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"
? Effect.raceFirst(
server.shutdown.pipe(Effect.as(Option.none<PersistentPty.Handoff | null>())),
Deferred.await(replacement).pipe(Effect.map(Option.some)),
)
? server.shutdown
: options.mode === "stdio"
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
? waitForStdinClose()
: 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) {
+3 -6
View File
@@ -1,5 +1,5 @@
export type Policy = "disable" | "notify" | "auto"
export type Action = "none" | "notify" | "upgrade"
export type Policy = "disable" | "notify"
export type Action = "none" | "notify"
const maximumComponent = "9007199254740991"
const versionPattern =
@@ -10,10 +10,7 @@ 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"
if (policy === "notify") return "notify"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "notify"
return "upgrade"
return "notify"
}
export function parseReleaseVersion(input: string) {
+19 -28
View File
@@ -6,22 +6,17 @@ 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("auto")
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
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("auto")
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
})
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", () => {
test("reports every available release", () => {
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")
@@ -32,25 +27,21 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
})
test("reports majors instead of automatically installing them", () => {
expect(action("1.2.3", "2.0.0", "auto")).toBe("notify")
})
test("reports up-to-date only when versions match", () => {
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
})
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade")
test("reports when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", "notify")).toBe("notify")
})
test("accepts strict release version variants", () => {
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")
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")
})
test("preserves strict validity", () => {
@@ -71,21 +62,21 @@ describe("updater", () => {
"0.9007199254740992.0",
"0.0.9007199254740992",
]
invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none"))
invalid.forEach((version) => expect(action("1.2.3", version, "notify"), version).toBe("none"))
})
test("handles numeric limits without losing precision", () => {
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify")
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "notify")).toBe("notify")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "notify")).toBe("notify")
})
test("preserves equality for oversized numeric prerelease identifiers", () => {
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")
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")
})
test("rejects versions longer than semver's limit before trimming", () => {
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")
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")
})
})
+28 -165
View File
@@ -1,154 +1,36 @@
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, Ref, Schedule, Semaphore, Stream } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
import { action, parseReleaseVersion, 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 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 monitor: (notify: (version: string) => 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 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
readonly notificationThreshold?: Duration.Input
export const monitorUpdates = Effect.fnUntraced(function* (input: {
readonly inspect: () => Effect.Effect<string | undefined, Error>
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,
},
)
readonly initialDelay?: Duration.Input
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))
})
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -161,13 +43,14 @@ 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" || value === "auto") return value
if (value === "disable" || value === "notify") return value
if (value === "auto") return "notify"
return
}
if (!("autoupdate" in input)) return
if (input.autoupdate === false) return "disable"
if (input.autoupdate === "notify") return "notify"
if (input.autoupdate === true) return "auto"
if (input.autoupdate === true) return "notify"
}
const make = Effect.gen(function* () {
@@ -192,7 +75,7 @@ const make = Effect.gen(function* () {
Effect.orElseSucceed(() => undefined),
),
)
return values.findLast((value) => value !== undefined) ?? "auto"
return values.findLast((value) => value !== undefined) ?? "notify"
})
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
@@ -302,19 +185,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* (): Effect.fn.Return<Inspection, Error> {
const inspect = Effect.fnUntraced(function* () {
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 { action: "none" }
return undefined
}
const policy = yield* readPolicy()
if (policy === "disable") {
yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
return { action: "none" }
return undefined
}
const version = yield* latest()
@@ -325,19 +208,16 @@ 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 { action: "none" }
return undefined
}
if (next === "notify") {
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return { action: next, version }
}
return { action: next, version }
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return version
})
const install = Effect.fnUntraced(function* (version: string) {
const detected = yield* method()
if (!detected) {
yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* Effect.logWarning("update skipped: installation method not found")
return false
}
yield* upgrade(detected, version)
@@ -349,26 +229,9 @@ const make = Effect.gen(function* () {
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
})
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 })),
)
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
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 })
return Service.of({ monitor, apply, method, latest, upgrade })
})
export const layer = Layer.effect(Service, make)
+1 -2
View File
@@ -12,9 +12,8 @@ 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 automatic updates"),
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
method: () =>
Effect.sync(() => {
record("method")
+27 -94
View File
@@ -1,107 +1,40 @@
import { expect } from "bun:test"
import { Deferred, Effect, Layer, Option } from "effect"
import { Effect, Layer, Queue } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.live("installs and restarts after the final Session settles", () =>
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
Effect.gen(function* () {
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,
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed("2.0.0"),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
}).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)
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")))),
)
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")
}),
)
const wait = (promise: Promise<unknown>, message: () => string) =>
Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))]))
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)
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)
},
}
}
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
}),
)
+2
View File
@@ -965,6 +965,8 @@ 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
}
+38 -22
View File
@@ -138,17 +138,6 @@ 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"
@@ -521,6 +510,19 @@ 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 }
@@ -809,16 +811,6 @@ 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
@@ -1351,6 +1343,23 @@ 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 = {
@@ -1883,7 +1892,7 @@ export type ConfigEntry =
shell?: string
model?: string | { providerID: string; model: string; variant?: string }
default_agent?: string
update?: "disable" | "notify" | "auto"
update?: "disable" | "notify"
share?: "manual" | "auto" | "disabled"
enterprise?: { url?: string }
username?: string
@@ -1971,6 +1980,7 @@ export type ConfigEntry =
description?: string
agent?: string
model?: string | { providerID: string; model: string; variant?: string }
subagent?: boolean
subtask?: boolean
}
}
@@ -3063,6 +3073,8 @@ 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
}
@@ -3340,6 +3352,8 @@ 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
}
@@ -3617,6 +3631,8 @@ 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
}
+6 -9
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, { ...store.location[key], info: location })
setStore("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,6 +1038,8 @@ 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,
})
@@ -1048,6 +1050,8 @@ 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 },
@@ -1105,7 +1109,6 @@ 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(
@@ -1147,7 +1150,6 @@ export function createData(config: CreateDataInput) {
break
case "vcs.branch.updated":
setStore("location", locationKey(location), (data) => ({
...data,
vcs: {
branch: {
...data?.vcs?.branch,
@@ -1165,7 +1167,6 @@ 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 },
@@ -1175,7 +1176,6 @@ 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,10 +1801,7 @@ 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, {
...store.location[key],
websearch: providers.data,
})
setStore("location", key, { websearch: providers.data })
},
},
skill: locationResource("skill", (location) => api().skill.list({ location })),
@@ -98,13 +98,15 @@ 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", text: "Summary", recent: "Recent" },
data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" },
})
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ type: "compaction", status: "completed", summary: "Summary" },
{ type: "compaction", status: "completed", summary: "Summary", model, providerState },
])
}
},
+98
View File
@@ -355,8 +355,10 @@ 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 = {
@@ -402,6 +404,9 @@ 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 {
@@ -469,6 +474,99 @@ 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({
+9 -2
View File
@@ -73,6 +73,11 @@ 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"
@@ -86,7 +91,10 @@ export function normalize(input: unknown): Result {
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
}
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
if (legacyUpdate !== undefined) encoded.update = ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
const migratedUpdate =
legacyUpdate === undefined ? undefined : ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
const update = prefer(migratedUpdate, nativeUpdate, ["update"], diagnostics)
if (update !== undefined) encoded.update = update
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
@@ -196,7 +204,6 @@ 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,
+46 -14
View File
@@ -1,7 +1,6 @@
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"
@@ -10,8 +9,11 @@ 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"
@@ -32,6 +34,9 @@ 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()))
})
@@ -67,18 +72,14 @@ 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 = 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 commandAgent = agent === undefined ? undefined : (yield* ctx.agent.get({ agentID: agent })).data
const model =
command.model === undefined
? commandAgent?.model
@@ -89,15 +90,46 @@ 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: yield* evaluateTemplate(command.template, input.prompt.text, {
location,
processes,
shell,
}),
text,
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
@@ -196,8 +228,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}`),
),
)
},
+5 -8
View File
@@ -55,7 +55,6 @@ type Active = {
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
scope: Scope.Closeable
token: object
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
recovery?: Recovery
@@ -77,7 +76,7 @@ type BackgroundResult = {
backgrounded?: Deferred.Deferred<Info>
}
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
type BlockWait = {
done: Deferred.Deferred<Info>
@@ -184,14 +183,14 @@ export const make = Effect.gen(function* () {
})
})
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const settle = Effect.fnUntraced(function* (id: string, scope: Scope.Closeable, 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.token !== token) return [{}, jobs]
if (job.scope !== scope) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed"
@@ -241,7 +240,6 @@ 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,
@@ -255,18 +253,17 @@ 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, token }, new Map(jobs).set(id, job)]
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
}),
)
if ("scope" in result)
yield* restore(input.run).pipe(
Effect.exit,
Effect.flatMap((exit) => settle(id, result.token, exit)),
Effect.flatMap((exit) => settle(id, result.scope, exit)),
Effect.asVoid,
Effect.forkIn(result.scope, { startImmediately: true }),
)
+38 -11
View File
@@ -1,5 +1,4 @@
import { Duration, Effect, Layer, LayerMap } from "effect"
import { existsSync } from "fs"
import { Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Option } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Instance } from "./instance.js"
import { Location } from "./location.js"
@@ -16,20 +15,48 @@ export function buildLocationServiceMap(
return Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
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 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 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) => inner.invalidate(LocationServiceMap.canonical(ref)),
invalidate: (ref: Location.Ref) =>
Effect.suspend(() => {
const key = LocationServiceMap.canonical(ref)
MutableHashMap.remove(booting, key)
return inner.invalidate(key)
}),
}
// Cached instances borrow their owner instead of retaining its Layer scope.
const bindings: LayerNode.Replacements = [
+137 -30
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, References, Scope, Semaphore } from "effect"
import { Cause, Context, Effect, Exit, Latch, Layer, Logger, Queue, References, Scope, Semaphore } from "effect"
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
import { PluginHost } from "./plugin/host.js"
@@ -26,43 +26,64 @@ const layer = Layer.effect(
const lock = Semaphore.makeUnsafe(1)
const ready = yield* Latch.make(true)
const pending = new Set<object>()
const hold = () =>
Effect.sync(() => {
const token = {}
pending.add(token)
ready.closeUnsafe()
return Effect.sync(() => {
if (pending.delete(token) && pending.size === 0) ready.openUnsafe()
})
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(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 child = yield* Scope.fork(scope)
const activation: Activation = { plugin, scope: yield* Scope.fork(scope) }
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() =>
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(() =>
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
).pipe(
grouped,
inherit,
Effect.updateContext((context: Context.Context<never>) =>
Context.make(Scope.Scope, child).pipe(
Context.make(Scope.Scope, activation.scope).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) ? Scope.close(child, exit) : Effect.void)),
Effect.onExit((exit) =>
Exit.isFailure(exit) && !activation.failure ? Scope.close(activation.scope, exit) : Effect.void,
),
Effect.exit,
)
if (Exit.isSuccess(loaded)) return { scope: child } as const
if (activation.failure || Exit.isSuccess(exit)) return { activation } as const
yield* Effect.logWarning("failed to load plugin", {
"plugin.id": plugin.id,
cause: loaded.cause,
cause: exit.cause,
})
return { error: Cause.pretty(loaded.cause) } as const
return { error: Cause.pretty(exit.cause) } as const
})
const activate = Effect.fn("Plugin.activate")(function* (
@@ -81,6 +102,8 @@ 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]
@@ -108,29 +131,36 @@ const layer = Layer.effect(
([id, slot]) =>
Effect.gen(function* () {
active.delete(id)
if (slot.loaded) yield* Scope.close(slot.loaded.scope, Exit.void)
if (slot.activation && !slot.activation.failure)
yield* Scope.close(slot.activation.scope, Exit.void)
}),
{ discard: true },
)
for (const definition of definitions.slice(prefix)) {
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
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) {
active.set(definition.id, {
plugin: definition,
loaded: { plugin: definition, scope: loaded.scope },
activation: result.activation,
})
continue
}
active.set(definition.id, { plugin: definition, error: loaded.error })
active.set(definition.id, { plugin: definition, error: result.error })
const fallback = previous.get(definition.id)?.loaded
if (!fallback) continue
const fallback = slot?.activation
if (!fallback || fallback.failure) continue
const restored = yield* load(fallback.plugin)
if (restored.scope !== undefined) {
if (restored.activation !== undefined) {
active.set(definition.id, {
plugin: definition,
loaded: { plugin: fallback.plugin, scope: restored.scope },
error: loaded.error,
activation: restored.activation,
error: result.error,
})
continue
}
@@ -149,9 +179,68 @@ 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))
}),
@@ -168,19 +257,37 @@ const layer = Layer.effect(
}),
)
// `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.
// `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.
type Slot = {
readonly plugin: Generation
readonly loaded?: { readonly plugin: Generation; readonly scope: Scope.Closeable }
readonly activation?: Activation
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: slot.error === undefined ? { status: "active" } : { status: "failed", error: slot.error },
state: failure === undefined ? { status: "active" } : { status: "failed", ...failure },
features: { server: true, ...slot.plugin.features },
}
}
+9 -1
View File
@@ -121,7 +121,15 @@ 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)))
}).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"))),
),
),
)
return yield* encode(method.output, result).pipe(
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
)
+5 -57
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 { AbsolutePath, RelativePath } from "./schema.js"
import { RelativePath } from "./schema.js"
import { Agent } from "@opencode-ai/schema/agent"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
@@ -42,7 +42,6 @@ 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"
@@ -62,7 +61,6 @@ 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"
@@ -168,15 +166,7 @@ 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: (input: {
sessionID: SessionSchema.ID
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<
void,
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
>
readonly move: SessionMove.Interface["move"]
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["prompt"]>
@@ -232,18 +222,15 @@ 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 locations = yield* LocationServiceMap.Service
const fs = yield* FSUtil.Service
const moves = yield* SessionMove.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({
@@ -410,45 +397,7 @@ 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: 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)
}),
move: moves.move,
compact: (input) => sessions.forSession(input.sessionID).compact(input),
wait: (sessionID) => sessions.forSession(sessionID).wait(),
active: execution.active,
@@ -499,10 +448,9 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionStore.node,
Instance.node,
SessionInbox.node,
LocationServiceMap.node,
SessionMove.node,
SessionProjector.node,
FSUtil.node,
Global.node,
App.node,
],
})
+8
View File
@@ -367,6 +367,7 @@ 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, {
@@ -407,6 +408,7 @@ 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
@@ -436,6 +438,10 @@ 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
}
@@ -482,6 +488,8 @@ 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,13 +176,7 @@ export const layer = (options?: Options) =>
(message) =>
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
)
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."
)
return SubagentCompletion.text(assistant)
}),
),
})
@@ -410,6 +410,8 @@ 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,
})
@@ -422,6 +424,8 @@ 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 },
+15 -24
View File
@@ -28,11 +28,9 @@ 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>
readonly lifecycle: { delivery: Delivery }
delivery: "send-attempted" | "provider-observed" | "terminal"
}
interface Channel {
@@ -130,14 +128,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
code: "close",
phase: "close",
delivery:
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",
channel.active.delivery === "provider-observed" || channel.active.delivery === "terminal"
? "accepted"
: "ambiguous",
}),
),
)
@@ -198,7 +191,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
code: "idle-data",
phase: "receive",
})
active.lifecycle.delivery = "provider-observed"
active.delivery = "provider-observed"
if (typeof message !== "string")
return yield* transportError("Unsupported binary WebSocket frame", {
url: exchange.connect.url,
@@ -226,8 +219,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
phase:
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
delivery:
channel.active?.lifecycle.delivery === "provider-observed" ||
channel.active?.lifecycle.delivery === "terminal" ||
channel.active?.delivery === "provider-observed" ||
channel.active?.delivery === "terminal" ||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
? "accepted"
: error.reason._tag === "Transport" && error.reason.code === "1009"
@@ -256,7 +249,6 @@ 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", {
@@ -288,7 +280,6 @@ 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",
@@ -314,7 +305,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
),
)
if (!channel) return fallback(exchange)
lifecycle.delivery = "ready"
if (channel.pending) {
channel.pending = undefined
@@ -326,9 +316,11 @@ 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), lifecycle }
const active: Active = {
queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY),
delivery: "send-attempted",
}
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)),
@@ -366,7 +358,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
operation: "read",
code: "idle-timeout",
phase: "receive",
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
}),
),
}),
@@ -375,7 +367,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
Effect.sync(() => {
if (!observationTerminal(observation)) return
terminal = observation
lifecycle.delivery = "terminal"
active.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
@@ -411,7 +403,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
operation: "read",
code: "incomplete",
phase: "receive",
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
})
yield* poison(owner, channel, error)
}),
@@ -448,7 +440,6 @@ 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() {
@@ -456,7 +447,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, lifecycle)),
Effect.andThen(start(owner, exchange)),
Effect.tap((started) =>
Effect.sync(() => {
execution = started
+146 -33
View File
@@ -1,15 +1,26 @@
export * as SessionMove from "./move.js"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Cause, Effect, Schema } from "effect"
import { Cause, Context, Effect, Layer, 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",
@@ -26,36 +37,138 @@ export class DestinationUnavailableError extends Schema.TaggedError<DestinationU
{ directory: AbsolutePath },
) {}
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 }))),
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 }))),
)
}),
)
}),
)
return payload
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,
],
})
@@ -90,10 +90,8 @@ 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"]
@@ -112,8 +110,6 @@ 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(),
@@ -367,9 +363,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
readonly snapshot?: Snapshot.ID
readonly files?: readonly RelativePath[]
}) {
if (stepFailed || stepFailure === undefined) return
if (stepFailure === undefined) return
const assistantMessageID = yield* startAssistant()
stepFailed = true
yield* bus.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
assistantMessageID,
@@ -3,6 +3,19 @@ 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">,
@@ -16,7 +29,7 @@ export const deliver = Effect.fnUntraced(function* (
const recovery = input.recovery
const text =
input.status === "completed"
? (input.output ?? "Subagent completed without a text response.")
? (input.output ?? NO_TEXT)
: input.status === "error"
? (input.error ?? "Subagent failed")
: "Subagent cancelled"
+60
View File
@@ -0,0 +1,60 @@
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,6 +297,9 @@ 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 }
+35 -6
View File
@@ -149,6 +149,7 @@ 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") {
@@ -166,13 +167,24 @@ 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 =
/^(?: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),
)
const definition = bashFunctionHead(input, index)
if (definition && !header()) {
index += definition[0].length - 1
index += definition.length - 1
segment = index + 1
continue
}
@@ -536,6 +548,14 @@ 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] === "{" ? "}" : ")"
@@ -544,6 +564,7 @@ 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++
@@ -566,6 +587,11 @@ 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
@@ -725,7 +751,10 @@ function bashExpansion(
index = nested.end
continue
}
if (kind === "array" && "<>=".includes(char) && input[index + 1] === "(") {
if (
((kind === "array" && "<>=".includes(char)) || (kind === "test" && "<>".includes(char))) &&
input[index + 1] === "("
) {
const nested = bashDelimited(input, index + 1, depth + 1)
if (!nested) return
substitutions.push(nested.source)
+86 -19
View File
@@ -31,6 +31,49 @@ 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
@@ -112,19 +155,38 @@ 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: { run: TransformCallback<Editor> }[] = []
const transforms = new Set<{ run: TransformCallback<Editor>; group: RegistrationGroup | undefined }>()
let dirty = false
let closed = false
let version = 0
const invalidate = () => {
dirty = true
version++
}
const get = () => {
if (closed || !dirty) 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
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
}
}
// One stable value per State, so a batch's notification Set holds it at most once.
@@ -137,7 +199,7 @@ export function create<State, Editor>(options: Options<State, Editor>): Interfac
const changed = Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (closed) return
dirty = true
invalidate()
const batch = yield* CurrentBatch
if (batch?.active) {
if (batch.shutdown) {
@@ -156,18 +218,23 @@ 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 }
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)
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)
yield* Scope.addFinalizer(scope, dispose)
yield* changed
return { dispose }
+11 -50
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, Scope } from "effect"
import { Effect, Schema } 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,42 +60,7 @@ export const Plugin = {
const agents = yield* Agent.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
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 }),
)
})
const subagents = yield* SubagentJob.make
yield* ctx.tool
.transform((editor) =>
@@ -225,18 +190,10 @@ export const Plugin = {
agent: agent.name,
description: input.description,
}
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))),
})
yield* subagents.start(recovery)
if (background) {
yield* jobs.background(info.id)
yield* notifyWhenDone(recovery, info.started_at)
yield* subagents.background(recovery)
return backgroundResult(child.id)
}
@@ -248,7 +205,7 @@ export const Plugin = {
),
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(recovery, result.info.started_at)
yield* subagents.notify(recovery, result.info.started_at)
return backgroundResult(child.id)
}
// Failure surfaces keep the sessionID visible so the model can continue the child.
@@ -258,7 +215,11 @@ 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 ?? NO_TEXT }
return {
sessionID: child.id,
status: "completed" as const,
output: result?.info.output ?? SubagentCompletion.NO_TEXT,
}
}).pipe(
Effect.map((output) => ({
output,
+3 -5
View File
@@ -29,11 +29,9 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
update:
info.autoupdate === false
? "disable"
: info.autoupdate === "notify"
: info.autoupdate === "notify" || info.autoupdate === true
? "notify"
: info.autoupdate === true
? "auto"
: undefined,
: undefined,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
@@ -172,7 +170,7 @@ export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>)
description: command.description,
agent: command.agent,
model: modelSelection(command.model, command.variant),
subtask: command.subtask,
subagent: command.subtask,
},
]),
)
@@ -0,0 +1,164 @@
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,
})
})
}
+20 -2
View File
@@ -4,7 +4,10 @@ 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/schema/session"
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 { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Command } from "@opencode-ai/core/command"
@@ -27,6 +30,8 @@ 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"
@@ -41,12 +46,25 @@ const shellLayer = Layer.succeed(
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
LayerNode.group([
Command.node,
Bus.node,
FSUtil.node,
AppProcess.node,
Location.node,
ShellSelect.node,
Session.node,
Job.node,
Agent.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,
],
),
)
+33 -22
View File
@@ -13,6 +13,7 @@ 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"
@@ -665,10 +666,18 @@ 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("auto")
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
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({
@@ -828,30 +837,32 @@ describe("Config", () => {
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
})
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,
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,
},
},
}).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(
+2 -1
View File
@@ -21,6 +21,7 @@ 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"
@@ -37,7 +38,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]))),
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node, Job.node]))),
)
const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
+65
View File
@@ -64,6 +64,71 @@ 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,7 +19,6 @@ 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
@@ -35,7 +34,6 @@ 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", {
+193 -4
View File
@@ -3,7 +3,24 @@ 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 { DateTime, Duration, Effect, Equal, Hash, Layer, LayerMap, Option, RcMap, Schema, Stream } from "effect"
import {
Cause,
DateTime,
Deferred,
Duration,
Effect,
Equal,
Exit,
Fiber,
Hash,
Layer,
LayerMap,
Option,
RcMap,
Schema,
Scope,
Stream,
} from "effect"
import { TestClock } from "effect/testing"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
@@ -13,6 +30,7 @@ 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"
@@ -22,7 +40,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 } from "./fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { testEffect } from "./lib/effect"
@@ -61,6 +79,177 @@ 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
@@ -150,8 +339,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 keeps the existing
// behavior: dropped as soon as it goes idle so a retry can rebuild it.
// A local ref with the same missing directory is dropped after its
// boot failure 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])
}),
+305
View File
@@ -0,0 +1,305 @@
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)
}),
)
+252 -1
View File
@@ -1,12 +1,14 @@
import { expect } from "bun:test"
import path from "path"
import { Clock, Effect } from "effect"
import { Clock, Deferred, 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"
@@ -154,6 +156,57 @@ 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
@@ -196,6 +249,204 @@ 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
@@ -0,0 +1,62 @@
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)
}),
)
}
@@ -1,228 +0,0 @@
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,6 +1243,7 @@ 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: {
@@ -1297,6 +1298,8 @@ describe("SessionTransfer", () => {
type: "compaction",
status: "completed",
reason: "manual",
model,
providerState,
summary: "summary",
recent: "recent",
time: { created: DateTime.makeUnsafe(9) },
@@ -1313,6 +1316,11 @@ 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,14 +445,17 @@ describe("SessionModelTransport", () => {
)
})
test("closes an active exchange without waiting for its Session permit", async () => {
test.each([false, true])("classifies active and queued close (observed: %s)", async (observed) => {
const started = Deferred.makeUnsafe<void>()
const messages = queue<string | Uint8Array, AIError>()
let closed = 0
const connector: WebSocketConnector = {
open: () =>
Effect.succeed({
sendText: () => Deferred.succeed(started, undefined),
sendText: () =>
observed
? Queue.offer(messages, "frame").pipe(Effect.asVoid)
: Deferred.succeed(started, undefined).pipe(Effect.asVoid),
messages: Stream.fromQueue(messages),
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
}),
@@ -462,17 +465,30 @@ describe("SessionModelTransport", () => {
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const running = yield* collect(transport.bind(session), exchange("active")).pipe(
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,
Effect.forkChild({ startImmediately: true }),
)
yield* Deferred.await(started)
yield* transport.close(session)
const result = yield* Effect.result(Fiber.join(running))
expect(result).toMatchObject({
expect(yield* Fiber.join(running)).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", code: "close", delivery: "ambiguous" } },
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" } },
})
expect(closed).toBe(1)
}),
@@ -545,6 +561,43 @@ 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")) }
+383 -4
View File
@@ -1,11 +1,14 @@
import { describe, expect } from "bun:test"
import path from "path"
import { mkdir, rm } from "fs/promises"
import { Effect, Layer, LayerMap } from "effect"
import { chmod, mkdir, readdir, rm } from "fs/promises"
import { Cause, Context, Deferred, Duration, Effect, Exit, Fiber, Layer, LayerMap, Queue } 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"
@@ -14,10 +17,14 @@ 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"
@@ -74,8 +81,372 @@ 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) =>
@@ -111,15 +482,23 @@ 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([])
expect(yield* session.inbox(created.id)).toEqual([pending])
yield* session.move({ sessionID: created.id, directory: destination })
expect(yield* session.inbox(created.id)).toHaveLength(1)
expect(yield* session.inbox(created.id)).toHaveLength(2)
yield* Effect.promise(() => mkdir(path.join(tmp.path, "other")))
const steered = yield* session.create({
+20 -3
View File
@@ -2141,7 +2141,13 @@ 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.text("## Objective\n- Checkpoint summary", "prefix-summary"),
TestLLM.complete(
{
reason: { normalized: "stop" },
providerMetadata: { [s.currentModel.provider]: { responseId: "summary" } },
},
LLMEvent.textDelta({ id: "prefix-summary", text: "## Objective\n- Checkpoint summary" }),
),
)
yield* s.runPrompt("Review these changes")
if (reason === "manual") {
@@ -2177,6 +2183,10 @@ 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" }))
@@ -2209,8 +2219,14 @@ describe("SessionRunnerLLM", () => {
)
: TestLLM.text("Let me search the codebase. I will fill in ## Objective later.", "invalid-summary")
yield* s.llm.push(
invalid,
summary ? TestLLM.text("### Active\n- Recovered summary", "summary-recovered") : invalid,
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,
)
const compact = yield* s.session.compact({ sessionID })
yield* s.resume
@@ -2220,6 +2236,7 @@ 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,7 +148,9 @@ 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))
@@ -0,0 +1,132 @@
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")
})
}
})
@@ -0,0 +1,158 @@
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,7 +3,30 @@ 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",
@@ -343,6 +366,10 @@ 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
@@ -0,0 +1,138 @@
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,6 +526,150 @@ 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
+9 -4
View File
@@ -8890,7 +8890,8 @@
{
"type": "null"
}
]
],
"description": "An absolute path or a path relative to the requested location. Defaults to the location directory."
},
"required": false
}
@@ -8941,7 +8942,7 @@
}
}
},
"description": "List direct children of one directory relative to the requested location.",
"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.",
"summary": "List directory"
}
},
@@ -13777,8 +13778,12 @@
}
]
},
"subtask": {
"subagent": {
"type": "boolean"
},
"subtask": {
"type": "boolean",
"description": "Deprecated alias for subagent."
}
},
"required": ["template"],
@@ -13899,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify", "auto"]
"enum": ["disable", "notify"]
},
"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", "auto"]).pipe(optional).annotate({
description: "Disable updates, notify when one is available, or install automatically",
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
description: "Disable updates or notify when one is available",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
+2 -1
View File
@@ -9,5 +9,6 @@ export class Info extends Schema.Class<Info>("Config.Command")({
description: Schema.String.pipe(optional),
agent: Schema.String.pipe(optional),
model: ConfigModel.Selection.pipe(optional),
subtask: Schema.Boolean.pipe(optional),
subagent: Schema.Boolean.pipe(optional),
subtask: Schema.Boolean.annotate({ description: "Deprecated alias for subagent." }).pipe(optional),
}) {}
+2
View File
@@ -585,6 +585,8 @@ 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,6 +250,8 @@ 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" })
+6 -3
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 } from "effect/unstable/http"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { EmbeddedHost } from "../internal/host"
import type { SdkInstances } from "../internal/instances"
@@ -35,9 +35,12 @@ 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.provide(
FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, host.fetch)), Layer.fresh),
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)),
),
)
+65
View File
@@ -0,0 +1,65 @@
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([])
}),
)
}
+5 -7
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.invalid_output" || error.type === "rpc.internal"
? new RpcInternalError({ type: error.type, message: error.message })
: new RpcError({
type: error.type,
@@ -22,12 +22,10 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
...(error.data === undefined ? {} : { data: error.data }),
}),
),
Effect.catchDefect((error) =>
Effect.fail(
new RpcInternalError({
type: "rpc.internal",
message: error instanceof Error ? error.message : "RPC call failed",
}),
// 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" }))),
),
),
),
+1 -8
View File
@@ -1,11 +1,9 @@
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,
@@ -116,12 +114,7 @@ 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,
updateAvailable: (version: string) =>
Context.get(context, Bus.Service).publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
}
return { address: bound.http.address, shutdown: shutdown.await }
}).pipe(
Effect.catchCause((cause) => {
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
+3 -15
View File
@@ -1,5 +1,4 @@
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"
@@ -100,12 +99,9 @@ it.live("allows browser preflight requests without credentials", () =>
)
expect(event.status).toBe(200)
expect(event.headers.get("content-encoding")).toBeNull()
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 body = event.body
if (!body) return yield* Effect.die(new Error("Event response has no body"))
yield* Effect.promise(() => body.cancel())
const missing = yield* Effect.promise(() =>
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
@@ -130,11 +126,3 @@ 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
}
}
@@ -0,0 +1,74 @@
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",
})
}),
)
}
+50 -32
View File
@@ -100,6 +100,7 @@ import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider, useStorage } from "./context/storage"
import { SessionTerminalsProvider } from "./context/session-terminals"
import { SessionPanelProvider } from "./context/session-panel"
import { SessionFrame } from "./component/session-frame"
import { createTuiClipboard } from "./clipboard"
@@ -186,6 +187,7 @@ 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
@@ -220,9 +222,6 @@ 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 }
@@ -399,22 +398,24 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
<SessionPanelProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</SessionPanelProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
@@ -507,6 +508,36 @@ 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,
@@ -1215,19 +1246,6 @@ 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({
+31 -18
View File
@@ -8,22 +8,32 @@ import { useDialog } from "../ui/dialog"
import { Spinner } from "./spinner"
type State =
| { type: "ready"; active: "update" | "ignore" }
| { type: "ready"; active: "update" | "skip" }
| { type: "installing" }
| { type: "restarting" }
| { type: "failed"; message: string }
export function DialogUpdate(props: { version: string; install: () => Promise<void>; restart: () => Promise<void> }) {
export function DialogUpdate(props: {
dialogKey: string
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()
setState({ type: "restarting" })
await props.restart()
dialog.clear()
if (props.restart) {
setState({ type: "restarting" })
await props.restart()
}
close()
}
const beginInstall = () => {
@@ -34,16 +44,16 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
const run = () => {
const current = state()
if (current.type !== "ready") return
if (current.active === "ignore") return dialog.clear()
if (current.active === "skip") return close()
beginInstall()
}
const toggle = () =>
setState((current) =>
current.type === "ready" ? { ...current, active: current.active === "update" ? "ignore" : "update" } : current,
current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current,
)
const selected = (action: "update" | "ignore") => {
const selected = (action: "update" | "skip") => {
const current = state()
return current.type === "ready" && current.active === action
}
@@ -60,7 +70,7 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
bind: "return",
title: "Confirm update action",
group: "Dialog",
run: () => (state().type === "failed" ? dialog.clear() : run()),
run: () => (state().type === "failed" ? close() : run()),
},
{
bind: "left",
@@ -81,9 +91,9 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Update
Update available
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={close}>
esc
</text>
</box>
@@ -91,14 +101,17 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
<Switch>
<Match when={state().type === "ready"}>
<text fg={theme.text.subdued}>
Update to v{props.version}? It will be applied in the background and active sessions will be restarted.
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."}
</text>
</Match>
<Match when={state().type === "installing"}>
<Spinner>Installing OpenCode {props.version}</Spinner>
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}</Spinner>
</Match>
<Match when={state().type === "restarting"}>
<Spinner>Restarting the background service</Spinner>
<Spinner shimmer={theme.text.default}>Restarting the background service</Spinner>
</Match>
<Match when={state().type === "failed"}>
<text fg={theme.text.feedback.error.default}>{failure()}</text>
@@ -114,7 +127,7 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={() => dialog.clear()}
onMouseUp={close}
>
<text fg={theme.text.action.primary.focused}>close</text>
</box>
@@ -123,19 +136,19 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
}
>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["ignore", "update"] as const}>
<For each={["skip", "update"] as const}>
{(action) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
if (action === "ignore") return dialog.clear()
if (action === "skip") return close()
beginInstall()
}}
>
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
{action === "update" ? "Update" : "Ignore"}
{action === "update" ? "Update" : "Skip"}
</text>
</box>
)}
+128 -48
View File
@@ -1,15 +1,27 @@
import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
import {
batch,
createComponent,
createEffect,
createMemo,
createResource,
createSignal,
on,
onCleanup,
Show,
} from "solid-js"
import { useConfig } from "../config"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useSessionTerminals } from "../context/session-terminals"
import { usePromptRef } from "../context/prompt"
import { useSessionPanel } from "../context/session-panel"
import { useStorage } from "../context/storage"
import { useTheme } from "../context/theme"
import { Session } from "../routes/session"
import { Sidebar } from "../routes/session/sidebar"
import { clampTerminalPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { clampSessionPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { createPaneResize } from "../ui/pane-resize"
import { PaneResizeHandle } from "../ui/pane-resize-handle"
import { useToast } from "../ui/toast"
@@ -23,38 +35,44 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const toast = useToast()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const panel = useSessionPanel()
const elevated = useTheme("elevated")
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
const defaultTerminalWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
const [layout, updateLayout] = useStorage().store<{ terminalWidth?: number }>("layout", { initial: {} })
const terminalResize = createPaneResize({
value: () => layout.terminalWidth ?? defaultTerminalWidth(),
defaultValue: defaultTerminalWidth,
clamp: (width) => clampTerminalPaneWidth(width, availableWidth()),
const defaultPaneWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
const [layout, updateLayout] = useStorage().store<{ paneWidth?: number; terminalWidth?: number }>("layout", {
initial: {},
})
const paneResize = createPaneResize({
value: () => layout.paneWidth ?? layout.terminalWidth ?? defaultPaneWidth(),
defaultValue: defaultPaneWidth,
clamp: (width) => clampSessionPaneWidth(width, availableWidth()),
fromMouse: (event) => dimensions().width - event.x - 1,
contains: (event, width) => event.x >= dimensions().width - width - 1 && event.x <= dimensions().width - width,
onCommit: (width) => {
void updateLayout((draft) => {
draft.terminalWidth = width
draft.paneWidth = width
}).catch((error) => console.error("Failed to persist TUI layout", error))
},
})
let resizeRelease = false
const finishTerminalResize = (event: MouseEvent) => {
if (terminalResize.resizing()) {
const finishPaneResize = (event: MouseEvent) => {
if (paneResize.resizing()) {
// A captured drag-end can be followed by mouse-up on the focus overlay.
resizeRelease = true
queueMicrotask(() => {
resizeRelease = false
})
}
terminalResize.onMouseUp(event)
paneResize.onMouseUp(event)
}
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [sessionWidth, setSessionWidth] = createSignal<number>()
const [terminalFocused, setTerminalFocused] = createSignal(false)
const [panelFocused, setPanelFocused] = createSignal(false)
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
let focusTerminal: (() => void) | undefined
let sessionScroll: ScrollBoxRenderable | undefined
let focusPanel: (() => void) | undefined
createResource(
() => (config.data.session.terminal ? props.sessionID : undefined),
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
@@ -65,22 +83,43 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const value = session()
return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID)
}
const activePanel = createMemo(() => {
const current = panel.current()
if (current?.sessionID === props.sessionID) return current
})
createEffect(
on(
() => selectedTerminal()?.id,
(id) => {
if (id) setSidebarOpen(false)
if (!id) return
setSidebarOpen(false)
if (activePanel()) panel.close()
},
{ defer: true },
),
)
const splitAvailable = createMemo(() => dimensions().width > 80)
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
createEffect(() => panel.setAvailable(props.sessionID, splitAvailable()))
onCleanup(() => panel.setAvailable(props.sessionID, false))
createEffect(() => {
const current = activePanel()
if (!current || splitAvailable()) return
panel.close()
current.onUnavailable?.()
})
createEffect(() => {
if (!activePanel()) return
setSidebarOpen(false)
if (selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
})
const sidebarVisible = createMemo(() => {
if (data.session.get(props.sessionID)?.parentID) return false
if (sidebarOpen()) return true
return (config.data.session?.sidebar ?? "auto") === "auto" && wide()
})
const rightPane = createMemo(() => {
if (activePanel()) return "panel"
if (sidebarOpen() && sidebarVisible()) return "sidebar"
if (selectedTerminal()) return "terminal"
if (sidebarVisible()) return "sidebar"
@@ -94,21 +133,44 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
})
.catch(toast.error)
setSidebarOpen(!visible)
if (!visible && activePanel()) panel.close()
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
})
}
const focusSession = () => {
// Permission prompts replace the input, so returning focus must not depend on it.
if (terminalFocused()) renderer.currentFocusedRenderable?.blur()
if (terminalFocused() || panelFocused()) renderer.currentFocusedRenderable?.blur()
prompt.current?.focus()
}
const focusRightPane = () => {
if (activePanel()) {
focusPanel?.()
return
}
focusTerminal?.()
}
createEffect(
on(
() => activePanel()?.id,
(id) => {
if (!id) {
setPanelFocused(false)
return
}
requestAnimationFrame(() => {
if (activePanel()?.id !== id) return
focusPanel?.()
})
},
),
)
createEffect(() => {
if (!restoreTerminalFocus() || selectedTerminal()) return
setRestoreTerminalFocus(false)
focusSession()
})
Keymap.createLayer(() => ({
enabled: () => config.data.session.terminal === true,
enabled: () => config.data.session.terminal === true || activePanel() !== undefined,
commands: [
{
id: "pane.focus.left",
@@ -117,10 +179,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
},
{
id: "pane.focus.right",
title: "Focus terminal pane",
run: () => {
focusTerminal?.()
},
title: "Focus right pane",
run: focusRightPane,
},
],
}))
@@ -132,9 +192,9 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
minHeight={0}
flexDirection="row"
position="relative"
onMouseDrag={terminalResize.onMouseDrag}
onMouseDragEnd={finishTerminalResize}
onMouseUp={finishTerminalResize}
onMouseDrag={paneResize.onMouseDrag}
onMouseDragEnd={finishPaneResize}
onMouseUp={finishPaneResize}
>
<box
flexGrow={1}
@@ -149,13 +209,13 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
<Session
scrollRef={(value) => (sessionScroll = value)}
verticalTabsWidth={props.verticalTabsWidth}
promptMuted={terminalFocused()}
promptMuted={terminalFocused() || panelFocused()}
sidebarVisible={rightPane() === "sidebar"}
onToggleSidebar={toggleSidebar}
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
width={sessionWidth()}
/>
<Show when={terminalFocused()}>
<Show when={terminalFocused() || panelFocused()}>
<box
position="absolute"
left={0}
@@ -174,37 +234,61 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
}}
// Consume the release before revealing permission buttons underneath.
onMouseUp={() => {
if (terminalResize.resizing() || resizeRelease) return
if (paneResize.resizing() || resizeRelease) return
focusSession()
}}
/>
</Show>
</box>
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
<Show when={rightPane() === "terminal" || rightPane() === "panel" || (rightPane() === "sidebar" && wide())}>
<box
flexShrink={0}
width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH}
width={rightPane() === "terminal" || rightPane() === "panel" ? paneResize.size() : SESSION_SIDEBAR_WIDTH}
minWidth={0}
minHeight={0}
backgroundColor={rightPane() === "panel" ? elevated.background.default : undefined}
>
<Show
when={rightPane() === "sidebar"}
fallback={
<Show keyed when={selectedTerminal()?.id}>
{(ptyID) => (
<TerminalPane
ptyID={ptyID}
resizing={terminalResize.resizing()}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
setRestoreTerminalFocus(false)
}}
onFocusChange={setTerminalFocused}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
)}
<Show
keyed
when={activePanel()}
fallback={
<Show keyed when={selectedTerminal()?.id}>
{(ptyID) => (
<TerminalPane
ptyID={ptyID}
resizing={paneResize.resizing()}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
setRestoreTerminalFocus(false)
}}
onFocusChange={setTerminalFocused}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
)}
</Show>
}
>
{(item) =>
createComponent(item.render, {
get width() {
return paneResize.size()
},
get resizing() {
return paneResize.resizing()
},
get focused() {
return panelFocused()
},
onFocusChange: setPanelFocused,
onFocusRequest: (value) => (focusPanel = value),
close: panel.close,
})
}
</Show>
}
>
@@ -212,12 +296,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</Show>
</box>
</Show>
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
<PaneResizeHandle
resize={terminalResize}
left={availableWidth() - terminalResize.size() - 1}
highlight="right"
/>
<Show when={(rightPane() === "terminal" || rightPane() === "panel") && availableWidth() >= 3}>
<PaneResizeHandle resize={paneResize} left={availableWidth() - paneResize.size() - 1} highlight="right" />
</Show>
<Show when={rightPane() === "sidebar" && !wide()}>
<box
+104
View File
@@ -0,0 +1,104 @@
import {
OptimizedBuffer,
RGBA,
TargetChannel,
TextRenderable,
type RenderContext,
type TextOptions,
} from "@opentui/core"
import { extend, type JSX } from "@opentui/solid"
import { splitProps } from "solid-js"
import { coast, intensityAt } from "./tab-pulse"
type ShimmerTextOptions = TextOptions & {
shimmer: RGBA
}
const DURATION = 1200
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
const CONTINUATION = 0xc0000000 | 0
class ShimmerTextRenderable extends TextRenderable {
private _shimmer = RGBA.defaultForeground()
private elapsed = 0
private scratch: OptimizedBuffer | undefined
private mask = new Float32Array(0)
private matrix = new Float32Array(16)
constructor(ctx: RenderContext, options: ShimmerTextOptions) {
super(ctx, options)
this.matrix[3] = this._shimmer.r
this.matrix[7] = this._shimmer.g
this.matrix[11] = this._shimmer.b
this.matrix[15] = 1
if (options.shimmer) this.shimmer = options.shimmer
this.live = true
}
set shimmer(value: RGBA) {
if (value.equals(this._shimmer)) return
this._shimmer = value
this.matrix[3] = value.r
this.matrix[7] = value.g
this.matrix[11] = value.b
this.requestRender()
}
override render(buffer: OptimizedBuffer, deltaTime: number) {
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
this.elapsed = (this.elapsed + deltaTime) % DURATION
if (!this.scratch)
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
this.scratch.resize(this.width, this.height)
this.scratch.clear(TRANSPARENT)
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
const characters = this.scratch.buffers.char
let end = 0
for (let row = 0; row < this.height; row++) {
let column = this.width
while (
column > 0 &&
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
)
column--
end = Math.max(end, column)
}
const front = -4 + coast(this.elapsed / DURATION) * (end + 22)
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
let strength = 0
for (let cell = 0; cell < characters.length; cell++) {
const column = cell % this.width
if ((characters[cell] & CONTINUATION) !== CONTINUATION) strength = intensityAt(column, front, 4, 18)
this.mask[cell * 3] = column
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
this.mask[cell * 3 + 2] = strength
}
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
this.markClean()
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
}
override destroy() {
this.scratch?.destroy()
this.scratch = undefined
super.destroy()
}
}
extend({ shimmer_text: ShimmerTextRenderable })
declare module "@opentui/solid" {
interface OpenTUIComponents {
shimmer_text: typeof ShimmerTextRenderable
}
}
type Props = Omit<JSX.IntrinsicElements["text"], "ref"> & { shimmer: RGBA }
export function ShimmerText(props: Props) {
const [local, text] = splitProps(props, ["shimmer"])
return <shimmer_text {...text} shimmer={local.shimmer} />
}
+26 -8
View File
@@ -1,30 +1,48 @@
import { Show } from "solid-js"
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
import { useTheme } from "../context/theme"
import { useConfig } from "../config"
import type { JSX } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { registerOpencodeSpinner } from "./register-spinner"
import { SPINNER_FRAMES } from "./spinner-frames"
import { ShimmerText } from "./shimmer-text"
export { SPINNER_FRAMES } from "./spinner-frames"
registerOpencodeSpinner()
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
export function Spinner(props: { children?: JSX.Element; color?: RGBA; shimmer?: RGBA }) {
const theme = useTheme()
const config = useConfig().data
const color = () => props.color ?? theme.text.subdued
const [frame, setFrame] = createSignal(0)
createEffect(() => {
if (!(config.animations ?? true) || !props.shimmer) return
const timer = setInterval(() => setFrame((value) => (value + 1) % SPINNER_FRAMES.length), 80)
onCleanup(() => clearInterval(timer))
})
return (
<Show
when={config.animations ?? true}
fallback={<text fg={color()}>{props.children ? <> {props.children}</> : "⋯"}</text>}
>
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
<Show when={props.children}>
<text fg={color()}>{props.children}</text>
</Show>
</box>
<Show
when={props.shimmer}
fallback={
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
<Show when={props.children}>
<text fg={color()}>{props.children}</text>
</Show>
</box>
}
>
{(shimmer) => (
<ShimmerText fg={color()} shimmer={shimmer()}>
{SPINNER_FRAMES[frame()]} {props.children}
</ShimmerText>
)}
</Show>
</Show>
)
}
+2 -1
View File
@@ -84,6 +84,7 @@ export const Definitions = {
"diff.single_patch": keybind("s", "Toggle single patch view"),
"diff.switch_source": keybind("d", "Switch diff viewer source"),
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
"diff.toggle_fullscreen": keybind("f", "Toggle diff viewer full screen"),
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
@@ -93,7 +94,7 @@ export const Definitions = {
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
"pane.focus.left": keybind("<leader>left", "Focus session pane"),
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
"pane.focus.right": keybind("<leader>right", "Focus right pane"),
"terminal.select": keybind("<leader>down", "Select terminal"),
"terminal.toggle": keybind("<leader>t", "Toggle terminal pane"),
"terminal.close": keybind("<leader>up", "Close terminal pane"),
@@ -0,0 +1,54 @@
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
export type SessionPanelRenderProps = {
readonly width: number
readonly resizing: boolean
readonly focused: boolean
readonly onFocusChange: (focused: boolean) => void
readonly onFocusRequest: (focus: (() => void) | undefined) => void
readonly close: () => void
}
type Panel = {
readonly id: string
readonly sessionID: string
readonly render: (props: SessionPanelRenderProps) => JSX.Element
readonly onUnavailable?: () => void
}
const Context = createContext<{
readonly current: () => Panel | undefined
readonly open: (panel: Panel) => void
readonly close: () => void
readonly available: (sessionID: string) => boolean
readonly setAvailable: (sessionID: string, available: boolean) => void
}>()
export function SessionPanelProvider(props: ParentProps) {
const [current, setCurrent] = createSignal<Panel>()
const [availableSessionID, setAvailableSessionID] = createSignal<string>()
return (
<Context.Provider
value={{
current,
open: setCurrent,
close: () => setCurrent(),
available: (sessionID) => availableSessionID() === sessionID,
setAvailable: (sessionID, available) =>
setAvailableSessionID((current) => (available ? sessionID : current === sessionID ? undefined : current)),
}}
>
{props.children}
</Context.Provider>
)
}
export function useSessionPanel() {
const value = useContext(Context)
if (!value) throw new Error("useSessionPanel must be used within a SessionPanelProvider")
return value
}
export function useOptionalSessionPanel() {
return useContext(Context)
}
@@ -52,7 +52,11 @@ function Plugins(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
const plugins = usePlugin()
const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length)
const failed = createMemo(
() =>
plugins.list().filter((item) => item.status === "failed").length +
plugins.server().filter((item) => item.state.status === "failed").length,
)
return (
<Show when={failed()}>
@@ -4,6 +4,7 @@ import type { Vcs } from "@opencode-ai/schema/vcs"
import { Plugin } from "@opencode-ai/plugin/tui"
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
import {
CliRenderEvents,
MouseButton,
TextAttributes,
type BoxRenderable,
@@ -22,7 +23,8 @@ import { getScrollAcceleration } from "../../util/scroll"
import { createDebouncedSignal } from "../../util/signal"
import { useConfig } from "../../config"
import { locationKey } from "../../context/data"
import { useThemes } from "../../context/theme"
import { useOptionalSessionPanel } from "../../context/session-panel"
import { useTheme, useThemes } from "../../context/theme"
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
import {
allExpandedFileTreeDirectories,
@@ -75,9 +77,55 @@ function diffSourceLabel(mode: DiffMode) {
return "Uncommitted"
}
function DiffViewer(props: { context: Plugin.Context }) {
type PanelController = NonNullable<ReturnType<typeof useOptionalSessionPanel>>
function openDiffPanel(context: Plugin.Context, panel: PanelController, sessionID: string) {
panel.open({
id: ROUTE,
sessionID,
onUnavailable: () => openDiffFullscreen(context, panel, sessionID, false),
render: (input) => (
<DiffViewer
context={context}
sessionID={sessionID}
width={input.width}
embedded
focused={input.focused}
onFocusChange={input.onFocusChange}
onFocusRequest={input.onFocusRequest}
onClose={input.close}
/>
),
})
}
function openDiffFullscreen(context: Plugin.Context, panel: PanelController, sessionID: string, split: boolean) {
panel.close()
context.ui.router.navigate({
type: "plugin",
name: ROUTE,
data: {
sessionID,
returnRoute: { type: "session", sessionID },
...(split ? { split: true } : {}),
},
})
}
function DiffViewer(props: {
context: Plugin.Context
sessionID?: string
width?: number
embedded?: boolean
focused?: boolean
onFocusChange?: (focused: boolean) => void
onFocusRequest?: (focus: (() => void) | undefined) => void
onClose?: () => void
}) {
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const config = useConfig()
const panel = useOptionalSessionPanel()
const [memory, updateMemory] = props.context.storage.memory<{
source?: DiffMode
bases: Record<string, string>
@@ -89,13 +137,14 @@ function DiffViewer(props: { context: Plugin.Context }) {
mode?: DiffMode
sessionID?: string
returnRoute?: Route
split?: boolean
}
| undefined
}
const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch")
const location = createMemo(
() => {
const sessionID = params()?.sessionID
const sessionID = props.sessionID ?? params()?.sessionID
return sessionID
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
: props.context.data.location.default()
@@ -157,52 +206,103 @@ function DiffViewer(props: { context: Plugin.Context }) {
if (!base) return "Base not reported"
return `vs ${base.name}`
}
const sessionID = () => props.sessionID ?? params()?.sessionID
const canToggleFullscreen = () =>
panel !== undefined &&
sessionID() !== undefined &&
(props.embedded === true || (params()?.split === true && dimensions().width > 80))
const toggleFullscreen = () => {
const id = sessionID()
if (!panel || !id) return
if (props.embedded) {
openDiffFullscreen(props.context, panel, id, true)
return
}
openDiffPanel(props.context, panel, id)
props.context.ui.router.navigate({ type: "session", sessionID: id })
}
let panelNode: BoxRenderable | undefined
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === panelNode)
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
onCleanup(() => {
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
props.onFocusChange?.(false)
props.onFocusRequest?.(undefined)
})
const content = () => (
<DiffViewerContent
context={props.context}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={props.embedded ? { ...config.data.diffs, tree: false } : config.data.diffs}
width={props.width}
fileTree={!props.embedded}
elevated={props.embedded}
focused={props.embedded ? props.focused === true : true}
onToggleFullscreen={canToggleFullscreen() ? toggleFullscreen : undefined}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, ...value }
})
.catch(() => {})
}}
onClose={() => {
if (props.onClose) return props.onClose()
props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })
}}
onSwitchSource={(mode) => {
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
/>
)
if (props.embedded)
return (
<box
ref={(node: BoxRenderable) => {
panelNode = node
props.onFocusRequest?.(() => node.focus())
}}
flexGrow={1}
minWidth={0}
minHeight={0}
focusable
onMouseDown={() => panelNode?.focus()}
>
{content()}
</box>
)
return (
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
<DiffViewerContent
context={props.context}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={config.data.diffs}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, ...value }
})
.catch(() => {})
}}
onClose={() => props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })}
onSwitchSource={(mode) => {
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
/>
{content()}
</box>
)
}
@@ -266,28 +366,36 @@ export function DiffViewerContent(props: {
navigation?: "tree" | "list"
loadImage?: (file: string, signal: AbortSignal) => Promise<Uint8Array>
preferences?: DiffPreferences
width?: number
fileTree?: boolean
elevated?: boolean
focused?: boolean
onPreferencesChange?: (value: DiffPreferences) => void
onClose: () => void
onSwitchSource: (mode: DiffMode) => void
onChooseBase?: () => void
onToggleFullscreen?: () => void
}) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig()
const dialog = props.context.ui.dialog
const theme = useThemes().current
const currentSyntax = useThemes().currentSyntax
const themes = useThemes()
const elevated = useTheme("elevated")
const theme = props.elevated ? elevated : themes.current
const currentSyntax = themes.currentSyntax
const files = () => props.files
const width = () => props.width ?? dimensions().width
const mode = () => props.mode
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.preferences?.tree ?? true)
const showFileTree = createMemo(
() => dimensions().width >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
() => props.fileTree !== false && width() >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
)
const [singlePatch, setSinglePatch] = createSignal(props.preferences?.single ?? false)
const fileTreeWidth = createMemo(() =>
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(dimensions().width / 4))),
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(width() / 4))),
)
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? fileTreeWidth() : 0) - 4)
const patchPaneWidth = createMemo(() => width() - (showFileTree() ? fileTreeWidth() : 0) - (props.elevated ? 2 : 4))
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.preferences?.view))
const view = createMemo(() =>
@@ -301,6 +409,7 @@ export function DiffViewerContent(props: {
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(config.data))
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
const helpShortcut = () => props.context.keymap.shortcuts("diff.help")[0]
const firstShortcut = (id: string, fallback: string) => props.context.keymap.shortcuts(id)[0] ?? fallback
let scroll: ScrollBoxRenderable | undefined
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
const patchDiffByFileIndex = new Map<number, PatchDiffRef>()
@@ -683,6 +792,13 @@ export function DiffViewerContent(props: {
props.onPreferencesChange?.({ view: next })
},
},
{
id: "diff.toggle_fullscreen",
title: "Toggle diff viewer full screen",
group: "VCS",
enabled: () => props.onToggleFullscreen !== undefined,
run: () => props.onToggleFullscreen?.(),
},
{
id: "diff.help",
title: "Show more diff viewer shortcuts",
@@ -748,7 +864,13 @@ export function DiffViewerContent(props: {
}
const openHelpDialog = () => {
dialog.show(() => <DiffViewerHelpDialog context={props.context} single={singlePatch()} />)
dialog.show(() => (
<DiffViewerHelpDialog
context={props.context}
single={singlePatch()}
fullscreen={props.onToggleFullscreen !== undefined}
/>
))
dialog.set({ size: "medium", centered: true })
}
@@ -777,6 +899,7 @@ export function DiffViewerContent(props: {
)
props.context.keymap.layer(() => ({
enabled: () => props.focused !== false,
commands,
}))
@@ -874,7 +997,13 @@ export function DiffViewerContent(props: {
/>
</Show>
<box flexGrow={1} minWidth={0} minHeight={0} paddingLeft={2} paddingRight={2}>
<box
flexGrow={1}
minWidth={0}
minHeight={0}
paddingLeft={props.elevated ? 1 : 2}
paddingRight={props.elevated ? 1 : 2}
>
<box
id="diff-patch-top-edge"
ref={(edge: BoxRenderable) => {
@@ -953,7 +1082,7 @@ export function DiffViewerContent(props: {
zIndex={1}
backgroundColor={background()}
paddingLeft={1}
paddingRight={1}
paddingRight={props.elevated ? 0 : 1}
paddingBottom={1}
>
<box flexGrow={1} minWidth={0}>
@@ -1056,7 +1185,45 @@ export function DiffViewerContent(props: {
</Match>
</Switch>
</box>
<Show when={!showFileTree()}>
<Show when={props.elevated}>
<box height={1} flexShrink={0} />
<box height={1} flexShrink={0} paddingLeft={2} paddingRight={2}>
<Show
when={props.focused}
fallback={
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.right", "ctrl+x →")}</span>
{" focus diff"}
</text>
}
>
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.left", "ctrl+x ←")}</span>
{" focus session "}
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.toggle_fullscreen", "f")}</span>
{" full screen "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.down", "j")}/{firstShortcut("diff.up", "k")}
</span>
{" scroll "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.next_file", "n")}/{firstShortcut("diff.previous_file", "p")}
</span>
{" files "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.next_hunk", "]")}/{firstShortcut("diff.previous_hunk", "[")}
</span>
{" hunks "}
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.close", "q")}</span>
{" close "}
<span style={{ fg: theme.text.default }}>{helpShortcut() ?? "?"}</span>
{" see all"}
</text>
</Show>
</box>
<box height={1} flexShrink={0} />
</Show>
<Show when={!showFileTree() && !props.elevated}>
<box position="absolute" top={0} right={0} width={1} height={1}>
<HelpShortcut compact />
</box>
@@ -1146,7 +1313,7 @@ function DiffFileMenu(props: {
)
}
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean; fullscreen: boolean }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
const shortcut =
@@ -1186,6 +1353,9 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
{ shortcut: shortcut("diff.single_patch"), label: "All files / single file" },
{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" },
{ shortcut: shortcut("diff.switch_source"), label: "Switch diff source" },
...(props.fullscreen
? [{ shortcut: shortcut("diff.toggle_fullscreen"), label: "Full screen / split view" }]
: []),
{ shortcut: () => props.context.keymap.shortcuts("diff.close").join(" / "), label: "Close diff viewer" },
],
},
@@ -1243,6 +1413,7 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
}
function Commands(props: { context: Plugin.Context }) {
const panel = useOptionalSessionPanel()
props.context.keymap.layer(() => ({
mode: "global",
commands: [
@@ -1254,6 +1425,15 @@ function Commands(props: { context: Plugin.Context }) {
palette: true,
run() {
const route = props.context.ui.router.current()
if (route.type === "session" && panel?.available(route.sessionID)) {
if (panel.current()?.id === ROUTE && panel.current()?.sessionID === route.sessionID) {
panel.close()
} else {
openDiffPanel(props.context, panel, route.sessionID)
}
props.context.ui.dialog.clear()
return
}
const returnRoute: Route =
route.type === "home"
? { type: "home" }
@@ -60,12 +60,16 @@ export default Plugin.define({
context.data.on("session.execution.interrupted", (event) => ended(event.data.sessionID)),
context.data.on("session.execution.failed", (event) => {
const sessionID = event.data.sessionID
if (terminal.has(sessionID)) return
if (errored.has(sessionID)) {
ended(sessionID)
return
}
errored.add(sessionID)
notify(context, sessionID, event.data.error.message, "error")
const route = context.ui.router.current()
if (route.type === "session" && route.sessionID === sessionID)
context.ui.toast.show({ title: "Session failed", message: event.data.error.message, variant: "error" })
ended(sessionID)
}),
]
@@ -79,7 +79,9 @@ export function PluginsDialog(props: {
...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
]
})
const visibleEntries = createMemo(() => entries().filter((entry) => showInternal() || !entry.internal))
const visibleEntries = createMemo(() =>
entries().filter((entry) => showInternal() || !entry.internal || status(entry) === "failed"),
)
createEffect(() => {
if (visibleEntries().some((entry) => entry.key === focused())) return
const first = visibleEntries().find((entry) => entry.runtime === "tui") ?? visibleEntries()[0]
@@ -286,9 +288,7 @@ function source(plugin: PluginInfo, context: Plugin.Context) {
function isLocal(entry: Entry) {
if (entry.runtime === "server") return entry.plugin.source.type === "local"
const target = entry.target
return (
target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target)
)
return target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target)
}
function status(entry: Entry) {
+48 -41
View File
@@ -1,6 +1,6 @@
import type { PluginInfo } from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
import type { MarkdownCodeBlockRenderer, MarkdownOptions } from "@opentui/core"
import {
batch,
createContext,
@@ -33,6 +33,7 @@ import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot,
import { createSourceWatcher } from "./watch"
import { discoverPluginTargets, freshSpecifier, localSource } from "./discovery"
import { isMissingPath } from "../util/config-directories"
import { createMarkdownRenderer } from "./markdown"
export interface PackageSource {
readonly prepare: (spec: string, install?: boolean) => Promise<Host.Target>
@@ -52,6 +53,7 @@ type RegisteredPlugin = {
type Value = {
readonly ready: () => boolean
readonly list: () => ReadonlyArray<State>
readonly server: () => readonly PluginInfo[]
readonly registered: () => ReadonlyArray<RegisteredPlugin>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slots: {
@@ -83,30 +85,21 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
const PluginContext = createContext<Value>()
let sourceVersion = Date.now()
export function combineMarkdownRenderers(
sources: ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
): MarkdownOptions["renderNode"] {
const renderers = new Map<string, MarkdownCodeBlockRenderer>()
for (const source of sources) {
for (const [language, render] of Object.entries(source)) renderers.set(language, render)
}
if (renderers.size === 0) return undefined
return createMarkdownCodeBlockRenderer(renderers)
}
export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) {
const host = usePluginHost()
const config = useConfig()
const lifecycle = useTuiLifecycle()
const client = useClient()
const data = useData()
const [serverPlugins, setServerPlugins] = createSignal<
ReadonlyArray<
PluginInfo & { readonly state: { readonly status: "active" } } & {
readonly source: { readonly type: "package" } | { readonly type: "local" }
}
>
>([])
const [serverPlugins, setServerPlugins] = createSignal<readonly PluginInfo[]>([])
const serverTuiPlugins = createMemo(() =>
serverPlugins().filter(
(plugin): plugin is PluginInfo & { readonly source: { readonly type: "package" } | { readonly type: "local" } } =>
plugin.state.status === "active" &&
plugin.features.tui === true &&
(plugin.source.type === "package" || plugin.source.type === "local"),
),
)
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@@ -125,12 +118,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
sourceVersions.set(entrypoint, { digest, generation })
return generation
}
const markdown = createMemo(() =>
combineMarkdownRenderers(
Object.values(store.registrations).flatMap((registration) =>
registration.active ? [registration.markdown] : [],
),
),
const markdown = createMarkdownRenderer(() =>
Object.values(store.registrations).flatMap((registration) => (registration.active ? [registration.markdown] : [])),
)
const clearContributions = (id: string) => {
setStore("registrations", id, "routes", reconcileStore({}))
@@ -271,7 +260,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
install: true,
optional: true,
})),
...serverPlugins().map((plugin) => ({
...serverTuiPlugins().map((plugin) => ({
entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path),
install: false,
optional: true,
@@ -487,7 +476,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
createEffect(
on(
() => JSON.stringify([serverPlugins(), config.data.plugins ?? []]),
() => JSON.stringify([serverTuiPlugins(), config.data.plugins ?? []]),
() => {
npmFailures.clear()
void enqueue(reconcile).then(
@@ -500,20 +489,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
const syncServerPlugins = () =>
client.api.plugin
.list({ location: data.location.default() })
.then((response) =>
setServerPlugins(
response.data.filter(
(
plugin,
): plugin is PluginInfo & { readonly state: { readonly status: "active" } } & {
readonly source: { readonly type: "package" } | { readonly type: "local" }
} =>
plugin.state.status === "active" &&
plugin.features.tui === true &&
(plugin.source.type === "package" || plugin.source.type === "local"),
),
),
)
.then((response) => {
const failed = response.data.filter(
(plugin) =>
plugin.state.status === "failed" &&
!serverPlugins().some(
(previous) =>
serverPluginName(previous) === serverPluginName(plugin) && isDeepEqual(previous.state, plugin.state),
),
)
setServerPlugins(response.data)
const first = failed[0]
if (!first) return
host.toast.show({
variant: "error",
title: failed.length === 1 ? `Plugin failed: ${serverPluginName(first)}` : `${failed.length} plugins failed`,
message:
(failed.length > 1 ? `${failed.map(serverPluginName).join(", ")}\n` : "") + "Run /plugins to view details.",
action: { label: "Open plugins", run: () => host.keymap.dispatch("plugins.list") },
})
})
.catch(() => undefined)
createEffect(
on(
@@ -552,6 +547,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
value={{
ready: () => store.ready,
list: () => store.states,
server: serverPlugins,
registered: () =>
Object.entries(store.registrations).map(([id, plugin]) => ({
id,
@@ -572,6 +568,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
)
}
function serverPluginName(plugin: PluginInfo) {
return (
plugin.id ??
(plugin.source.type === "package"
? plugin.source.target
: plugin.source.type === "local"
? plugin.source.path
: plugin.source.type)
)
}
async function disposeAll(cleanups: Dispose[]) {
const failures: unknown[] = []
for (const cleanup of cleanups.splice(0).reverse()) await cleanup().catch((error) => failures.push(error))
+18
View File
@@ -0,0 +1,18 @@
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer } from "@opentui/core"
import { isShallowEqual } from "remeda"
import { createMemo } from "solid-js"
export function createMarkdownRenderer(
sources: () => ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
) {
// Changing renderNode makes OpenTUI destroy and rebuild every Markdown block.
// Only invalidate it when the effective last-wins language handlers change.
const renderers = createMemo(
() => Object.fromEntries(sources().flatMap((source) => Object.entries(source))),
undefined,
{ equals: isShallowEqual },
)
return createMemo(() =>
Object.keys(renderers()).length === 0 ? undefined : createMarkdownCodeBlockRenderer(renderers()),
)
}

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