mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-04 07:56:23 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2ac0d5505 | ||
|
|
06e2699efe | ||
|
|
815436683d |
+38
-1
@@ -247,6 +247,8 @@ it does not repair or truncate them.
|
||||
|
||||
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. Its `replacement` contains the next context window, including retained user messages. The client returns that result and usage directly, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
|
||||
|
||||
For `compact(request, { mechanism: "trigger" })`, script a `CompactionCheckpointResponse` instead. It carries `checkpoint`, `responseID`, and optional `usage`. Endpoint and trigger calls reject each other's fixtures; both share the same queue, gates, lazy recording, and fallback controls.
|
||||
|
||||
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
|
||||
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
|
||||
`testLayer`.
|
||||
@@ -259,7 +261,7 @@ This is different from prompt caching, server-side history storage, or truncatio
|
||||
|
||||
### Explicit compaction
|
||||
|
||||
`LLMClient.compact(request)` is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response.
|
||||
`LLMClient.compact(request)` (equivalently, `{ mechanism: "endpoint" }`) is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response. This mechanism does not accept a WebSocket executor.
|
||||
|
||||
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
|
||||
|
||||
@@ -279,6 +281,41 @@ Generation-only body overlays such as `stream` and `store` are not sent to the c
|
||||
|
||||
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. Anthropic does not expose this operation in this package; its in-band compaction remains available below. Compatible routes do not inherit an explicit compact endpoint simply because they use a Responses protocol.
|
||||
|
||||
### Streamed checkpoint compaction
|
||||
|
||||
OpenAI Responses also exposes a separate, explicitly selected mechanism:
|
||||
|
||||
```ts
|
||||
const result =
|
||||
yield *
|
||||
LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket, // Optional: without it, the request uses HTTP/SSE.
|
||||
})
|
||||
|
||||
result.checkpoint // Successful encrypted CompactionPart.
|
||||
result.responseID
|
||||
result.usage
|
||||
```
|
||||
|
||||
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request. It follows the [Codex V2 request shape](https://github.com/openai/codex/blob/728cb12/codex-rs/core/src/compact_remote_v2_attempt.rs), with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Session/cache identifiers, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
|
||||
|
||||
Only a successful `response.completed` with a response ID and exactly one logical encrypted checkpoint succeeds. Repeated item events are correlated by ID/output slot, including ID-less checkpoints. Other output is ignored, not returned as assistant text or dispatched as tools. Failed, incomplete, malformed, and interrupted responses return errors rather than partial checkpoints.
|
||||
|
||||
The result is **not a replacement window**. The caller selects retained history, combines it with `result.checkpoint`, and durably installs it before continuing. The operation does not choose a retention budget, prune messages, or modify the original request.
|
||||
|
||||
The supplied WebSocket executor can reuse a compatible append baseline for the compaction request. On completion the protocol supplies no continuation checkpoint, clearing the old baseline so the next generation sends the newly installed window in full. Validation occurs before transport completion is acknowledged. There is no operation-level retry or fallback to `/responses/compact`; existing safe transport fallback may use SSE, with full history and no connection-local response ID.
|
||||
|
||||
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
|
||||
|
||||
```ts
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
|
||||
}
|
||||
```
|
||||
|
||||
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
|
||||
|
||||
### Advanced: in-band compaction
|
||||
|
||||
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
|
||||
|
||||
@@ -42,6 +42,7 @@ const canonical = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
|
||||
if (!ProviderShared.isRecord(value)) return ProviderShared.encodeJson(value)
|
||||
return `{${Object.keys(value)
|
||||
.filter((key) => value[key] !== undefined)
|
||||
.sort()
|
||||
.map((key) => `${ProviderShared.encodeJson(key)}:${canonical(value[key])}`)
|
||||
.join(",")}}`
|
||||
@@ -149,6 +150,12 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
|
||||
}
|
||||
if (observation.type !== "completed") return observation
|
||||
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
|
||||
if (
|
||||
Array.isArray(request.input) &&
|
||||
request.input.some((item) => ProviderShared.isRecord(item) && item.type === "compaction_trigger")
|
||||
)
|
||||
return observation
|
||||
const responseID = event.response?.id
|
||||
if (!responseID || responseID.trim().length === 0) return observation
|
||||
return {
|
||||
|
||||
@@ -920,7 +920,7 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
|
||||
return parts.filter((part) => part !== undefined).join("\n\n")
|
||||
}
|
||||
|
||||
const outputItemID = (state: ParserState, event: Event) =>
|
||||
const outputItemID = (state: Pick<ParserState, "outputItems">, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
|
||||
@@ -932,7 +932,11 @@ const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
|
||||
|
||||
// An item without an id adopts the id already open in its output slot,
|
||||
// otherwise it gets a locally minted one.
|
||||
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
|
||||
const resolveItem = (
|
||||
state: Pick<ParserState, "outputItems">,
|
||||
item: StreamItem,
|
||||
index: number | undefined,
|
||||
): OutputItem => ({
|
||||
...item,
|
||||
id:
|
||||
item.id ??
|
||||
@@ -942,7 +946,7 @@ const resolveItem = (state: ParserState, item: StreamItem, index: number | undef
|
||||
|
||||
// Registered output slots are authoritative for `item_id` routing, and items
|
||||
// are resolved here so everything downstream can rely on `item.id`.
|
||||
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
|
||||
export const normalize = (state: Pick<ParserState, "outputItems">, input: Event): NormalizedEvent => ({
|
||||
...input,
|
||||
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
|
||||
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import type { LLMRequest, JsonSchema, ToolDefinition } from "../schema/index.js"
|
||||
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
@@ -13,6 +13,7 @@ import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { ResponsesCompaction } from "./utils/responses-compaction.js"
|
||||
import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
@@ -103,6 +104,18 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
/** Request control, never conversation content. */
|
||||
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
|
||||
const CheckpointBody = Schema.Struct({
|
||||
...OpenAIResponsesBody.fields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
|
||||
store: Schema.Literal(false),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
@@ -162,6 +175,35 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
})
|
||||
})
|
||||
|
||||
const checkpointBody = {
|
||||
schema: CheckpointBody,
|
||||
from: Effect.fn("OpenAIResponses.checkpointBody")(function* (request: LLMRequest) {
|
||||
const native = yield* fromRequest(LLMRequest.update(request, { toolChoice: undefined }))
|
||||
const overlay = request.http?.body
|
||||
// Complete history is required for stateless replay and SSE recovery. Raw input overrides bypass that contract.
|
||||
if (
|
||||
overlay?.input !== undefined ||
|
||||
overlay?.previous_response_id !== undefined ||
|
||||
overlay?.conversation !== undefined
|
||||
)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Trigger compaction requires complete canonical history, not an input or continuation override",
|
||||
)
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(CheckpointBody))({
|
||||
...mergeJsonRecords(native, overlay),
|
||||
input: [...native.input, { type: "compaction_trigger" }],
|
||||
stream: true,
|
||||
store: false,
|
||||
parallel_tool_calls: true,
|
||||
tool_choice: undefined,
|
||||
context_management: undefined,
|
||||
text: undefined,
|
||||
max_output_tokens: undefined,
|
||||
max_tool_calls: undefined,
|
||||
})
|
||||
}),
|
||||
}
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
@@ -239,7 +281,7 @@ export const transport = channelTransport({
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
compact: ResponsesCompaction.make(adapter),
|
||||
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Route, type RouteBody, type TriggerCompactOperation } from "../../route/client.js"
|
||||
import { Protocol } from "../../route/protocol.js"
|
||||
import { CompactionCheckpointResponse, HttpOptions, LLMEvent, LLMRequest } from "../../schema/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
interface State {
|
||||
readonly parser: Pick<OpenResponses.ParserState, "id" | "provider" | "outputItems">
|
||||
readonly checkpoints: Readonly<Record<string, CompactionCheckpointResponse["checkpoint"]>>
|
||||
readonly responseID?: string
|
||||
}
|
||||
|
||||
const onOutputItem = Effect.fn("ResponsesCheckpoint.onOutputItem")(function* (
|
||||
state: State,
|
||||
input: OpenResponses.Event,
|
||||
) {
|
||||
const event = OpenResponses.normalize(state.parser, input)
|
||||
const item = event.item
|
||||
if (!item) return state
|
||||
const parser =
|
||||
event.output_index === undefined || state.parser.outputItems[event.output_index] === item.id
|
||||
? state.parser
|
||||
: { ...state.parser, outputItems: { ...state.parser.outputItems, [event.output_index]: item.id } }
|
||||
const next = parser === state.parser ? state : { ...state, parser }
|
||||
if (event.type === "response.output_item.added" || item.type !== "compaction") return next
|
||||
if (
|
||||
event.output_index !== undefined &&
|
||||
Object.entries(state.parser.outputItems).some(
|
||||
([index, id]) => id === item.id && Number(index) !== event.output_index,
|
||||
)
|
||||
)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction checkpoint appeared in multiple output slots")
|
||||
if (!item.encrypted_content)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction output is missing its encrypted content")
|
||||
const previous = state.checkpoints[item.id]
|
||||
if (previous && previous.encrypted !== item.encrypted_content)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction output changed after completion")
|
||||
if (previous) return next
|
||||
return {
|
||||
...next,
|
||||
checkpoints: {
|
||||
...state.checkpoints,
|
||||
[item.id]: { type: "compaction", provider: parser.provider, id: item.id, encrypted: item.encrypted_content },
|
||||
},
|
||||
} satisfies State
|
||||
})
|
||||
|
||||
/** Collect a trigger response before acknowledging transport completion. No generation output escapes. */
|
||||
export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
|
||||
Effect.fn("ResponsesCheckpoint.execute")(function* (request, executor, options) {
|
||||
const source = request.model.route
|
||||
let result: CompactionCheckpointResponse | undefined
|
||||
// Route registries erase the frame type. The codec validates that boundary before parsing.
|
||||
const event: Schema.Codec<OpenResponses.Event, unknown> = OpenResponses.protocol.stream.event
|
||||
const protocol = Protocol.make({
|
||||
id: source.protocol,
|
||||
body,
|
||||
stream: {
|
||||
event,
|
||||
initial: (request: LLMRequest): State => ({
|
||||
parser: { id: source.id, provider: request.model.provider, outputItems: {} },
|
||||
checkpoints: {},
|
||||
}),
|
||||
terminal: OpenResponses.terminal,
|
||||
step: Effect.fn("ResponsesCheckpoint.step")(function* (state: State, event: OpenResponses.Event) {
|
||||
if (event.response?.id && state.responseID && event.response.id !== state.responseID)
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response ID changed during execution")
|
||||
if (event.type === "response.created") return [{ ...state, responseID: event.response?.id }, []] as const
|
||||
if (event.type === "error" || event.type === "response.failed")
|
||||
return yield* OpenResponses.providerFailure(event, "Compaction request failed")
|
||||
if (event.type === "response.incomplete")
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response was incomplete")
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done")
|
||||
return [yield* onOutputItem(state, event), []] as const
|
||||
if (event.type !== "response.completed") return [state, []] as const
|
||||
const responseID = event.response?.id
|
||||
if (!responseID?.trim())
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response is missing its response ID")
|
||||
if (event.response?.status !== undefined && event.response.status !== "completed")
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response did not complete successfully")
|
||||
let next = state
|
||||
for (const [index, item] of (event.response?.output ?? []).entries()) {
|
||||
next = yield* onOutputItem(next, { type: "response.output_item.done", output_index: index, item })
|
||||
}
|
||||
const checkpoints = Object.values(next.checkpoints)
|
||||
const checkpoint = checkpoints[0]
|
||||
if (checkpoints.length !== 1 || !checkpoint)
|
||||
return yield* ProviderShared.eventError(
|
||||
source.id,
|
||||
"Compaction response must contain exactly one checkpoint",
|
||||
)
|
||||
result = new CompactionCheckpointResponse({
|
||||
checkpoint,
|
||||
responseID,
|
||||
usage: OpenResponses.mapUsage(event.response?.usage, OpenResponses.metadataKey(request.model)),
|
||||
})
|
||||
return [next, [LLMEvent.finish({ reason: { normalized: "stop" } })]] as const
|
||||
}),
|
||||
},
|
||||
})
|
||||
const route = Route.make({
|
||||
id: source.id,
|
||||
provider: source.provider,
|
||||
providerMetadataKey: source.providerMetadataKey,
|
||||
protocol,
|
||||
endpoint: source.endpoint,
|
||||
auth: source.auth,
|
||||
transport: source.transport,
|
||||
})
|
||||
const native = yield* body.from(request)
|
||||
// The body builder already applied and validated overlays. Do not let transport reapply them.
|
||||
const preparedRequest = LLMRequest.update(request, {
|
||||
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
|
||||
})
|
||||
const prepared = yield* route.prepareTransport(native, preparedRequest, options)
|
||||
yield* route.streamPrepared(prepared, preparedRequest, { http: executor }, options).pipe(Stream.runDrain)
|
||||
if (!result) return yield* ProviderShared.eventError(source.id, "Compaction response ended without a checkpoint")
|
||||
return result
|
||||
})
|
||||
|
||||
export * as ResponsesCheckpoint from "./responses-checkpoint.js"
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||
import type { CompactOperation } from "./route/client.js"
|
||||
import type { CompactionOperations } from "./route/client.js"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
@@ -10,7 +10,7 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
export interface Definition<
|
||||
ProviderSettings extends Settings = Settings,
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
> {
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -39,6 +39,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
|
||||
|
||||
const responsesRoute = OpenAIResponses.route.with({
|
||||
compact: { endpoint: OpenAIResponses.route.compact.endpoint },
|
||||
id: "azure-openai-responses",
|
||||
provider: id,
|
||||
auth: routeAuth,
|
||||
@@ -102,7 +103,7 @@ const auth = (input: Config) => {
|
||||
)
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
modelID: string | ModelID,
|
||||
@@ -168,7 +169,7 @@ const config = (settings: Settings): Config => {
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
CompactOperation
|
||||
typeof responsesRoute.compact
|
||||
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -73,7 +73,7 @@ const defaults = (input: Config) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
) =>
|
||||
@@ -132,10 +132,11 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
export const model: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
typeof OpenAIResponses.route.compact
|
||||
>["model"] = (modelID, settings) => {
|
||||
return configure(config(settings)).responses(modelID)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
@@ -32,7 +32,7 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
compact: XAIResponses.compact,
|
||||
compact: { endpoint: XAIResponses.compact },
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
@@ -103,10 +103,11 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
export const model: ProviderPackage.Definition<
|
||||
Settings,
|
||||
XAIProviderOptionsInput,
|
||||
typeof responsesRoute.compact
|
||||
>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
+114
-39
@@ -14,6 +14,7 @@ import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
AIErrorReason,
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -38,7 +39,7 @@ export interface RouteBody<Body> {
|
||||
export interface Route<
|
||||
Body,
|
||||
Prepared = unknown,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
> {
|
||||
readonly compact: Compact
|
||||
readonly id: string
|
||||
@@ -53,7 +54,15 @@ export interface Route<
|
||||
readonly transport: Transport<Body, Prepared, unknown>
|
||||
readonly defaults: RouteDefaults
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
|
||||
readonly with: {
|
||||
<Next extends CompactionOperations | undefined>(
|
||||
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
|
||||
): Route<Body, Prepared, Next>
|
||||
(
|
||||
patch: Omit<RoutePatch<Body, Prepared>, "compact"> & { readonly compact?: undefined },
|
||||
): Route<Body, Prepared, Compact>
|
||||
(patch: RoutePatch<Body, Prepared>): Route<Body, Prepared>
|
||||
}
|
||||
readonly model: <Options extends ProviderOptions = ProviderOptions>(
|
||||
input: RouteMappedLanguageModelInput,
|
||||
) => LanguageModel<Options, Compact>
|
||||
@@ -74,7 +83,7 @@ export interface Route<
|
||||
// Normal call sites use `OpenAIChat.route`; callers only need body types
|
||||
// when preparing a request with a protocol-specific type assertion.
|
||||
// oxlint-disable-next-line typescript-eslint/no-explicit-any
|
||||
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
|
||||
export type AnyRoute<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Route<
|
||||
any,
|
||||
any,
|
||||
Compact
|
||||
@@ -101,6 +110,7 @@ export interface RouteDefaultsInput {
|
||||
}
|
||||
|
||||
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
readonly compact?: CompactionOperations
|
||||
readonly id?: string
|
||||
readonly provider?: string | ProviderID
|
||||
readonly providerMetadataKey?: string
|
||||
@@ -111,7 +121,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
|
||||
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
|
||||
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
route: AnyRoute<Compact>,
|
||||
mapped: RouteMappedLanguageModelInput,
|
||||
) => {
|
||||
@@ -162,10 +172,7 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly compact: (
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
readonly compact: CompactMethod
|
||||
readonly stream: StreamMethod
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
@@ -189,12 +196,64 @@ export type CompactOperation = (
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
|
||||
export type CompactionRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, CompactOperation>
|
||||
export type TriggerCompactOperation = (
|
||||
request: LLMRequest,
|
||||
executor: RequestExecutor.Interface,
|
||||
options: TriggerCompactOptions,
|
||||
) => Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
|
||||
/** Protocol capabilities, not deployment/model eligibility. */
|
||||
export interface CompactionOperations {
|
||||
readonly endpoint?: CompactOperation
|
||||
readonly trigger?: TriggerCompactOperation
|
||||
}
|
||||
|
||||
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
|
||||
request.model.route.compact !== undefined
|
||||
export interface EndpointCompactOptions extends Pick<StreamOptions, "http"> {
|
||||
readonly mechanism?: "endpoint"
|
||||
readonly webSocket?: never
|
||||
}
|
||||
|
||||
export interface TriggerCompactOptions extends StreamOptions {
|
||||
readonly mechanism: "trigger"
|
||||
}
|
||||
|
||||
// Keep the required route shape explicit: the schema class's self type erases its model parameter in assignability.
|
||||
export type CompactionRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, { readonly endpoint: CompactOperation }>
|
||||
}
|
||||
export type CheckpointRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, { readonly trigger: TriggerCompactOperation }>
|
||||
}
|
||||
|
||||
export interface CompactMethod<R = never> {
|
||||
(request: CheckpointRequest, options: TriggerCompactOptions): Effect.Effect<CompactionCheckpointResponse, AIError, R>
|
||||
(request: CompactionRequest, options?: EndpointCompactOptions): Effect.Effect<CompactionResponse, AIError, R>
|
||||
}
|
||||
|
||||
export function canCompact(
|
||||
request: LLMRequest,
|
||||
options?: { readonly mechanism?: "endpoint" },
|
||||
): request is CompactionRequest
|
||||
export function canCompact(
|
||||
request: LLMRequest,
|
||||
options: { readonly mechanism: "trigger" },
|
||||
): request is CheckpointRequest
|
||||
export function canCompact(request: LLMRequest, options?: { readonly mechanism?: string }) {
|
||||
if (options?.mechanism === "trigger") return request.model.route.compact?.trigger !== undefined
|
||||
if (options?.mechanism !== undefined && options.mechanism !== "endpoint") return false
|
||||
return request.model.route.compact?.endpoint !== undefined
|
||||
}
|
||||
|
||||
const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefined) => {
|
||||
if (mechanism !== undefined && mechanism !== "endpoint" && mechanism !== "trigger")
|
||||
return ProviderShared.invalidRequest(`Unknown compaction mechanism: ${mechanism}`)
|
||||
return ProviderShared.unsupportedOperation({
|
||||
operation: mechanism === "trigger" ? "compact.trigger" : "compact",
|
||||
provider: request.model.provider,
|
||||
route: request.model.route.id,
|
||||
message: `${request.model.provider}/${request.model.route.id} does not support ${mechanism === "trigger" ? "trigger" : "explicit"} compaction`,
|
||||
})
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
|
||||
@@ -216,7 +275,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
|
||||
}
|
||||
|
||||
export interface MakeInput<Body, Frame, Event, State> {
|
||||
readonly compact?: CompactOperation
|
||||
readonly compact?: CompactionOperations
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -238,7 +297,7 @@ export interface MakeInput<Body, Frame, Event, State> {
|
||||
}
|
||||
|
||||
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
readonly compact?: CompactOperation
|
||||
readonly compact?: CompactionOperations
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -326,9 +385,10 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
with: (patch: RoutePatch<Body, Prepared>) => {
|
||||
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
return build({
|
||||
...routeInput,
|
||||
compact: "compact" in patch ? compact : routeInput.compact,
|
||||
id: id ?? routeInput.id,
|
||||
provider: provider ?? routeInput.provider,
|
||||
providerMetadataKey:
|
||||
@@ -343,7 +403,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
},
|
||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
|
||||
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
|
||||
makeRouteLanguageModel<Options, CompactionOperations | undefined>(route, input),
|
||||
prepareTransport: (body, request, options) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
@@ -440,12 +500,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
|
||||
}
|
||||
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, Prepared, CompactOperation>
|
||||
export function make<Body, Frame, Event, State>(
|
||||
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
|
||||
export function make<Body, Prepared, Frame, Event, State, Compact extends CompactionOperations>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: Compact },
|
||||
): Route<Body, Prepared, Compact>
|
||||
export function make<Body, Frame, Event, State, Compact extends CompactionOperations>(
|
||||
input: MakeInput<Body, Frame, Event, State> & { readonly compact: Compact },
|
||||
): Route<Body, HttpTransport.HttpPrepared<Frame>, Compact>
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||
): Route<Body, Prepared>
|
||||
@@ -557,14 +617,23 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
|
||||
})
|
||||
}
|
||||
|
||||
export const compact = (
|
||||
export function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError, Service>
|
||||
export function compact(
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service>
|
||||
export function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
|
||||
return Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.compact(request, options)
|
||||
if (options?.mechanism === "trigger" && canCompact(request, options)) return yield* client.compact(request, options)
|
||||
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
|
||||
return yield* client.compact(request, options)
|
||||
return yield* unsupportedCompaction(request, options?.mechanism)
|
||||
})
|
||||
}
|
||||
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
Stream.unwrap(
|
||||
@@ -578,21 +647,27 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const stream = streamRequestWith({ http: executor })
|
||||
function compact(
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError>
|
||||
function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
|
||||
return Effect.suspend((): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> => {
|
||||
if (options?.mechanism === "trigger" && canCompact(request, options))
|
||||
return request.model.route.compact.trigger(prepareRequest(request), executor, options)
|
||||
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
|
||||
return request.model.route.compact.endpoint(prepareRequest(request), executor, options)
|
||||
return unsupportedCompaction(request, options?.mechanism)
|
||||
})
|
||||
}
|
||||
return Service.of({
|
||||
stream,
|
||||
generate: generateWith(stream),
|
||||
compact: (request, options) =>
|
||||
Effect.suspend(() => {
|
||||
const operation = request.model.route.compact
|
||||
if (!operation)
|
||||
return ProviderShared.unsupportedOperation({
|
||||
operation: "compact",
|
||||
provider: request.model.provider,
|
||||
route: request.model.route.id,
|
||||
message: `${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
|
||||
})
|
||||
return operation(prepareRequest(request), executor, options)
|
||||
}),
|
||||
compact,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,12 @@ export type {
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
StreamOptions,
|
||||
CompactMethod,
|
||||
CompactionOperations,
|
||||
CompactionRequest,
|
||||
CheckpointRequest,
|
||||
EndpointCompactOptions,
|
||||
TriggerCompactOptions,
|
||||
} from "./client.js"
|
||||
export * from "./executor.js"
|
||||
export { Auth } from "./auth.js"
|
||||
|
||||
@@ -97,6 +97,21 @@ export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.Co
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
/** A checkpoint only; retained history and replacement-window construction belong to the caller. */
|
||||
export class CompactionCheckpointResponse extends Schema.Class<CompactionCheckpointResponse>(
|
||||
"LLM.CompactionCheckpointResponse",
|
||||
)({
|
||||
checkpoint: CompactionPart.pipe(
|
||||
Schema.refine(
|
||||
(part): part is CompactionPart & { readonly encrypted: string; readonly text?: never } =>
|
||||
part.encrypted !== undefined && part.encrypted.length > 0,
|
||||
{ message: "A checkpoint response requires encrypted compaction content" },
|
||||
),
|
||||
),
|
||||
responseID: Schema.String.check(Schema.isPattern(/\S/)),
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
export const StepStart = Schema.Struct({
|
||||
type: Schema.tag("step-start"),
|
||||
index: Schema.Number,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { ModelID, ProviderID } from "./ids.js"
|
||||
import type { AnyRoute, CompactOperation } from "../route/client.js"
|
||||
import type { AnyRoute, CompactionOperations } from "../route/client.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
|
||||
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
|
||||
@@ -175,7 +175,7 @@ export namespace LanguageModelCompatibility {
|
||||
|
||||
export class LanguageModel<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
> {
|
||||
declare protected readonly _ProviderOptions: Options
|
||||
readonly id: ModelID
|
||||
@@ -194,7 +194,7 @@ export class LanguageModel<
|
||||
|
||||
static make<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
>(input: LanguageModel.Input<Compact>) {
|
||||
return new LanguageModel<Options, Compact>({
|
||||
id: ModelID.make(input.id),
|
||||
@@ -206,7 +206,7 @@ export class LanguageModel<
|
||||
})
|
||||
}
|
||||
|
||||
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
static input<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
): LanguageModel.ConstructorInput<Compact> {
|
||||
return {
|
||||
@@ -218,11 +218,11 @@ export class LanguageModel<
|
||||
}
|
||||
}
|
||||
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
model: LanguageModel<Options>,
|
||||
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
|
||||
): LanguageModel<Options, Compact>
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
|
||||
): LanguageModel<Options, Compact>
|
||||
@@ -241,7 +241,7 @@ export class LanguageModel<
|
||||
}
|
||||
|
||||
export namespace LanguageModel {
|
||||
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
|
||||
export type ConstructorInput<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute<Compact>
|
||||
@@ -249,7 +249,7 @@ export namespace LanguageModel {
|
||||
readonly compatibility?: LanguageModelCompatibility
|
||||
}
|
||||
|
||||
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
|
||||
export type Input<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Omit<
|
||||
ConstructorInput<Compact>,
|
||||
"id" | "provider" | "defaults" | "compatibility"
|
||||
> & {
|
||||
|
||||
+39
-11
@@ -1,10 +1,17 @@
|
||||
export * as TestLLM from "./testing.js"
|
||||
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import {
|
||||
LLMClient,
|
||||
type CompactionRequest,
|
||||
type CheckpointRequest,
|
||||
type EndpointCompactOptions,
|
||||
type TriggerCompactOptions,
|
||||
} from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
type FinishReasonDetails,
|
||||
type AIError,
|
||||
type LLMRequest,
|
||||
@@ -13,7 +20,11 @@ import {
|
||||
} from "./schema/index.js"
|
||||
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
|
||||
|
||||
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
|
||||
export type Response =
|
||||
| readonly LLMEvent[]
|
||||
| Stream.Stream<LLMEvent, AIError>
|
||||
| CompactionResponse
|
||||
| CompactionCheckpointResponse
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
@@ -132,21 +143,38 @@ const make = (options: LayerOptions) =>
|
||||
Stream.unwrap(
|
||||
take(request).pipe(
|
||||
Effect.map((response) => {
|
||||
if (response instanceof CompactionResponse)
|
||||
if (response instanceof CompactionResponse || response instanceof CompactionCheckpointResponse)
|
||||
return Stream.die("TestLLM generation requires an event response")
|
||||
return Stream.isStream(response) ? response : Stream.fromIterable(response)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const test = Test.of({
|
||||
compact: (request) =>
|
||||
take(request).pipe(
|
||||
Effect.flatMap((response) =>
|
||||
response instanceof CompactionResponse
|
||||
function compact(
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError>
|
||||
function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
function compact(
|
||||
request: LLMRequest,
|
||||
options?: EndpointCompactOptions | TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> {
|
||||
return take(request).pipe(
|
||||
Effect.flatMap((response): Effect.Effect<CompactionResponse | CompactionCheckpointResponse> => {
|
||||
if (options?.mechanism === "trigger")
|
||||
return response instanceof CompactionCheckpointResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM compaction requires a CompactionResponse"),
|
||||
),
|
||||
),
|
||||
: Effect.die("TestLLM trigger compaction requires a CompactionCheckpointResponse")
|
||||
return response instanceof CompactionResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM compaction requires a CompactionResponse")
|
||||
}),
|
||||
)
|
||||
}
|
||||
const test = Test.of({
|
||||
compact,
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
CompactionCheckpointResponse,
|
||||
CompactionResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMRequest,
|
||||
} from "../../src/index.js"
|
||||
import {
|
||||
Anthropic,
|
||||
Azure,
|
||||
AmazonBedrock,
|
||||
AmazonBedrockMantle,
|
||||
OpenAI,
|
||||
OpenAICompatibleResponses,
|
||||
XAI,
|
||||
} from "../../src/providers.js"
|
||||
import type { WebSocketChannelExecutor } from "../../src/route.js"
|
||||
import type { RoutePatch } from "../../src/route/client.js"
|
||||
import type { OpenAIResponsesBody } from "../../src/protocols/openai-responses.js"
|
||||
import type { Prepared } from "../../src/protocols/open-responses-channel.js"
|
||||
|
||||
declare const webSocket: WebSocketChannelExecutor
|
||||
const model = OpenAI.configure().responses("fixture")
|
||||
const request = LLM.request({ model, prompt: "hello" })
|
||||
LLMClient.compact(request).pipe(Effect.map((result) => result satisfies CompactionResponse))
|
||||
LLMClient.compact(request, { mechanism: "endpoint" }).pipe(Effect.map((result) => result satisfies CompactionResponse))
|
||||
LLMClient.compact(request, { mechanism: "trigger", webSocket }).pipe(
|
||||
Effect.map((result) => {
|
||||
result satisfies CompactionCheckpointResponse
|
||||
result.checkpoint.encrypted satisfies string
|
||||
result.responseID satisfies string
|
||||
// @ts-expect-error A trigger does not return replacement history.
|
||||
result.replacement
|
||||
}),
|
||||
)
|
||||
// @ts-expect-error Endpoint compaction does not accept a WebSocket executor.
|
||||
LLMClient.compact(request, { mechanism: "endpoint", webSocket })
|
||||
// @ts-expect-error Omitting mechanism selects the HTTP endpoint.
|
||||
LLMClient.compact(request, { webSocket })
|
||||
// @ts-expect-error Unknown mechanisms do not have a permissive fallback overload.
|
||||
LLMClient.compact(request, { mechanism: "other" })
|
||||
|
||||
for (const selected of [
|
||||
model,
|
||||
OpenAI.model("fixture", {}),
|
||||
model.route.with({ headers: { fixture: "test" } }).model({ id: "fixture" }),
|
||||
LanguageModel.make(LanguageModel.input(model)),
|
||||
LanguageModel.update(model, { defaults: { generation: { maxTokens: 100 } } }),
|
||||
]) {
|
||||
LLMClient.compact(LLM.request({ model: selected }), { mechanism: "trigger" })
|
||||
}
|
||||
LLMClient.compact(new LLMRequest(LLMRequest.input(request)), { mechanism: "trigger" })
|
||||
LLMClient.compact(LLMRequest.update(request, { messages: [] }), { mechanism: "trigger" })
|
||||
|
||||
const azure = Azure.configure({ resourceName: "fixture" }).responses("fixture")
|
||||
const xai = XAI.configure().responses("fixture")
|
||||
LLMClient.compact(LLM.request({ model: azure }))
|
||||
LLMClient.compact(LLM.request({ model: xai }))
|
||||
// @ts-expect-error Azure must not inherit OpenAI's trigger operation.
|
||||
LLMClient.compact(LLM.request({ model: azure }), { mechanism: "trigger" })
|
||||
LLMClient.compact(LLM.request({ model: Azure.responsesModel("fixture", { resourceName: "fixture" }) }), {
|
||||
// @ts-expect-error Azure's package entrypoint must preserve its narrower capability.
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error xAI endpoint support does not imply trigger support.
|
||||
LLMClient.compact(LLM.request({ model: xai }), { mechanism: "trigger" })
|
||||
// @ts-expect-error xAI's package entrypoint must preserve its narrower capability.
|
||||
LLMClient.compact(LLM.request({ model: XAI.model("fixture", {}) }), { mechanism: "trigger" })
|
||||
|
||||
const unsupported = {
|
||||
bedrock: LLM.request({ model: AmazonBedrock.configure().model("fixture") }),
|
||||
mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }),
|
||||
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
|
||||
openai: LLM.request({ model: OpenAI.configure().chat("fixture") }),
|
||||
azure: LLM.request({ model: Azure.configure({ resourceName: "fixture" }).chat("fixture") }),
|
||||
xai: LLM.request({ model: XAI.configure().chat("fixture") }),
|
||||
compatible: LLM.request({
|
||||
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
|
||||
}),
|
||||
}
|
||||
// @ts-expect-error Bedrock does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.bedrock)
|
||||
// @ts-expect-error Bedrock does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.bedrock, { mechanism: "trigger" })
|
||||
// @ts-expect-error Mantle does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.mantle)
|
||||
// @ts-expect-error Mantle must not inherit trigger support from OpenAI's protocol.
|
||||
LLMClient.compact(unsupported.mantle, { mechanism: "trigger" })
|
||||
// @ts-expect-error Anthropic does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.anthropic)
|
||||
// @ts-expect-error Anthropic does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.anthropic, { mechanism: "trigger" })
|
||||
// @ts-expect-error OpenAI Chat does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.openai)
|
||||
// @ts-expect-error OpenAI Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.openai, { mechanism: "trigger" })
|
||||
// @ts-expect-error Azure Chat does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.azure)
|
||||
// @ts-expect-error Azure Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.azure, { mechanism: "trigger" })
|
||||
// @ts-expect-error xAI Chat does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.xai)
|
||||
// @ts-expect-error xAI Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.xai, { mechanism: "trigger" })
|
||||
// @ts-expect-error Generic protocol compatibility does not grant endpoint support.
|
||||
LLMClient.compact(unsupported.compatible)
|
||||
// @ts-expect-error Generic protocol compatibility does not grant trigger support.
|
||||
LLMClient.compact(unsupported.compatible, { mechanism: "trigger" })
|
||||
// @ts-expect-error Changing the model replaces its capability.
|
||||
LLMClient.compact(LLMRequest.update(request, { model: azure }), { mechanism: "trigger" })
|
||||
// @ts-expect-error Changing the route replaces its capability.
|
||||
LLMClient.compact(LLM.request({ model: LanguageModel.update(model, { route: azure.route }) }), { mechanism: "trigger" })
|
||||
LLMClient.compact(
|
||||
LLM.request({
|
||||
model: model.route.with({ compact: { endpoint: model.route.compact.endpoint } }).model({ id: "fixture" }),
|
||||
}),
|
||||
// @ts-expect-error Replacing route operations does not retain the old trigger capability.
|
||||
{ mechanism: "trigger" },
|
||||
)
|
||||
|
||||
declare const dynamic: LLMRequest
|
||||
declare const patch: Partial<LLMRequest.Input>
|
||||
declare const routePatch: RoutePatch<OpenAIResponsesBody, Prepared>
|
||||
// @ts-expect-error A dynamic operation override cannot preserve trigger support.
|
||||
LLMClient.compact(LLM.request({ model: model.route.with(routePatch).model({ id: "fixture" }) }), {
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error Explicitly removing operations removes trigger support.
|
||||
LLMClient.compact(LLM.request({ model: model.route.with({ compact: undefined }).model({ id: "fixture" }) }), {
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error Dynamic models must be narrowed.
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
if (LLMClient.canCompact(dynamic, { mechanism: "trigger" })) {
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
LLMClient.Service.use((client) => client.compact(dynamic, { mechanism: "trigger" }))
|
||||
}
|
||||
if (LLMClient.canCompact(dynamic)) {
|
||||
LLMClient.compact(dynamic)
|
||||
// @ts-expect-error Endpoint narrowing does not grant trigger support.
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
}
|
||||
// @ts-expect-error A dynamic model override cannot preserve trigger support.
|
||||
LLMClient.compact(LLMRequest.update(request, patch), { mechanism: "trigger" })
|
||||
@@ -0,0 +1,374 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { CompactionCheckpointResponse, LLM, LLMClient, LLMRequest, LanguageModel, SystemPart } from "../../src/index.js"
|
||||
import { Anthropic, Azure, OpenAI, XAI } from "../../src/providers.js"
|
||||
import { Route } from "../../src/route/client.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse, scriptedResponses, truncatedStream } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("fixture"), prompt: "hello" })
|
||||
const trigger = { mechanism: "trigger" } as const
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(new URL(request.url).pathname).toBe("/v1/responses")
|
||||
expect(new URL(request.url).searchParams.get("deployment")).toBe("fixture")
|
||||
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
|
||||
expect(request.headers.authorization).toBe("Bearer fixture")
|
||||
expect(request.headers["chatgpt-account-id"]).toBe("fixture-account")
|
||||
expect(request.headers["x-codex-beta-features"]).toBe("remote_compaction_v2")
|
||||
expect(request.headers["x-deployment"]).toBe("resolved")
|
||||
const body = JSON.parse(text)
|
||||
expect(body).toMatchObject({
|
||||
model: "fixture",
|
||||
stream: true,
|
||||
store: false,
|
||||
instructions: "system\noperator",
|
||||
parallel_tool_calls: true,
|
||||
prompt_cache_key: "session-key",
|
||||
service_tier: "priority",
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "session", ttl: "1h" },
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }, { type: "compaction_trigger" }],
|
||||
})
|
||||
expect(body.tools).toHaveLength(1)
|
||||
expect(body.tools[0].name).toBe("lookup")
|
||||
expect(body.tool_choice).toBeUndefined()
|
||||
expect(body.context_management).toBeUndefined()
|
||||
expect(body.text).toBeUndefined()
|
||||
expect(body.max_output_tokens).toBeUndefined()
|
||||
expect(body.previous_response_id).toBeUndefined()
|
||||
return respond(
|
||||
sseEvents({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [checkpoint],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
input_tokens_details: { cached_tokens: 40 },
|
||||
output_tokens: 5,
|
||||
total_tokens: 105,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
).effect("trigger uses normal request preparation, configured deployment, and supplied subscription headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: string[] = []
|
||||
const route = Route.make({
|
||||
id: "fixture-responses",
|
||||
provider: "openai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
compact: OpenAIResponses.route.compact,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: request.model.route.auth,
|
||||
transport: OpenAIResponses.transport,
|
||||
headers: () => {
|
||||
calls.push("headers")
|
||||
return { "x-deployment": "resolved" }
|
||||
},
|
||||
}).with({ endpoint: { query: { deployment: "fixture" } } })
|
||||
const input = LLM.request({
|
||||
model: route.model({ id: "fixture" }),
|
||||
system: [SystemPart.make("system"), SystemPart.make("operator")],
|
||||
prompt: "hello",
|
||||
promptCacheKey: "session-key",
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
toolChoice: { type: "tool", name: "lookup" },
|
||||
generation: { maxTokens: 1 },
|
||||
providerOptions: {
|
||||
store: true,
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
contextManagement: [{ type: "compaction" }],
|
||||
},
|
||||
http: {
|
||||
headers: { "chatgpt-account-id": "fixture-account", "x-codex-beta-features": "remote_compaction_v2" },
|
||||
query: { trace: "request" },
|
||||
body: {
|
||||
service_tier: "priority",
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "session", ttl: "1h" },
|
||||
store: true,
|
||||
stream: false,
|
||||
text: { format: { type: "json_object" } },
|
||||
tool_choice: "required",
|
||||
},
|
||||
},
|
||||
})
|
||||
const original = LLMRequest.input(input)
|
||||
const result = yield* LLMClient.compact(input, {
|
||||
...trigger,
|
||||
http: (request, next) => {
|
||||
calls.push("http")
|
||||
return next(request)
|
||||
},
|
||||
})
|
||||
expect(result).toBeInstanceOf(CompactionCheckpointResponse)
|
||||
expect(result.checkpoint).toMatchObject({
|
||||
type: "compaction",
|
||||
provider: "openai",
|
||||
id: "cmp_1",
|
||||
encrypted: "opaque",
|
||||
})
|
||||
expect(result.responseID).toBe("resp_1")
|
||||
expect(result.usage).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 5,
|
||||
totalTokens: 105,
|
||||
cacheReadInputTokens: 40,
|
||||
})
|
||||
expect(LLMRequest.input(input)).toEqual(original)
|
||||
expect(calls).toEqual(["headers", "http"])
|
||||
const codec = Schema.fromJsonString(CompactionCheckpointResponse)
|
||||
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(result))).toEqual(result)
|
||||
expect("replacement" in result).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const id of ["cmp_1", undefined]) {
|
||||
for (const added of [true, false]) {
|
||||
const item = { type: "compaction", id, encrypted_content: "opaque" }
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.created", response: { id: "resp_1" } },
|
||||
...(added ? [{ type: "response.output_item.added", output_index: 0, item: { type: "compaction", id } }] : []),
|
||||
{ type: "response.output_item.done", output_index: 0, item },
|
||||
{ type: "response.output_item.done", output_index: 0, item },
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [item] } },
|
||||
),
|
||||
),
|
||||
).effect(`correlates repeated checkpoint events: id=${id}, added=${added}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request, trigger)
|
||||
expect(result.checkpoint.encrypted).toBe("opaque")
|
||||
expect(result.checkpoint.id).toBeString()
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" },
|
||||
},
|
||||
{ type: "response.output_text.delta", delta: "do not expose this" },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" }, checkpoint],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
).effect("ignores other output rather than generating an answer or dispatching tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request, trigger)
|
||||
expect(result.checkpoint.encrypted).toBe("opaque")
|
||||
expect("message" in result).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [name, events] of Object.entries({
|
||||
missing: [{ type: "response.completed", response: { id: "resp_1", output: [] } }],
|
||||
multiple: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, { ...checkpoint, id: "cmp_2" }] } },
|
||||
],
|
||||
duplicateSlots: [{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, checkpoint] } }],
|
||||
malformed: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ type: "compaction", id: "cmp_1" }] } },
|
||||
],
|
||||
empty: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ ...checkpoint, encrypted_content: "" }] } },
|
||||
],
|
||||
wrongType: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ ...checkpoint, encrypted_content: 42 }] } },
|
||||
],
|
||||
noResponseID: [{ type: "response.completed", response: { output: [checkpoint] } }],
|
||||
changedID: [
|
||||
{ type: "response.created", response: { id: "resp_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_2", output: [checkpoint] } },
|
||||
],
|
||||
changedCheckpoint: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [{ ...checkpoint, encrypted_content: "changed" }] },
|
||||
},
|
||||
],
|
||||
incomplete: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.incomplete", response: { id: "resp_1", incomplete_details: { reason: "max_output_tokens" } } },
|
||||
],
|
||||
failed: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.failed", response: { id: "resp_1", error: { code: "server_error", message: "failed" } } },
|
||||
],
|
||||
wrongStatus: [{ type: "response.completed", response: { id: "resp_1", status: "incomplete", output: [checkpoint] } }],
|
||||
})) {
|
||||
const wire = events.map((event) => ({ ...event, fixture_extra: "preserved" }))
|
||||
testEffect(
|
||||
fixedResponse(sseEvents(...wire), { headers: { "content-type": "text/event-stream", "x-fixture": "preserved" } }),
|
||||
).effect(`rejects ${name} checkpoint response and preserves original error context`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason.body).toBe(JSON.stringify(wire.at(-1)))
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-fixture": "preserved" } })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: checkpoint }))).effect(
|
||||
"rejects clean EOF without response.completed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
}),
|
||||
)
|
||||
testEffect(truncatedStream([sseEvents({ type: "response.output_item.done", item: checkpoint })])).effect(
|
||||
"does not return a checkpoint from an interrupted stream",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("Transport")
|
||||
}),
|
||||
)
|
||||
|
||||
for (const body of [{ input: [] }, { previous_response_id: "stale" }, { conversation: "stored" }]) {
|
||||
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
|
||||
`rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(LLMRequest.update(request, { http: { body } }), trigger).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("trigger capability follows selected routes, independently of endpoint support", () => {
|
||||
expect(LLMClient.canCompact(request, trigger)).toBe(true)
|
||||
for (const model of [
|
||||
Azure.configure({ resourceName: "fixture" }).responses("fixture"),
|
||||
XAI.configure().responses("fixture"),
|
||||
]) {
|
||||
expect(LLMClient.canCompact(LLM.request({ model }))).toBe(true)
|
||||
expect(LLMClient.canCompact(LLM.request({ model }), trigger)).toBe(false)
|
||||
expect(LLMClient.canCompact(LLMRequest.update(request, { model }), trigger)).toBe(false)
|
||||
expect(
|
||||
LLMClient.canCompact(
|
||||
LLM.request({ model: LanguageModel.update(request.model, { route: model.route }) }),
|
||||
trigger,
|
||||
),
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
|
||||
"untyped unsupported mechanisms and routes fail locally in both client surfaces",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* LLMClient.Service
|
||||
for (const model of [
|
||||
Anthropic.configure().model("fixture"),
|
||||
Azure.configure({ resourceName: "fixture" }).responses("fixture"),
|
||||
]) {
|
||||
const unsupported = LLM.request({ model })
|
||||
// @ts-expect-error Exercise untyped consumers; runtime must still reject unsupported routes.
|
||||
const error = yield* LLMClient.compact(unsupported, trigger).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("UnsupportedOperation")
|
||||
// @ts-expect-error The service has the same runtime guard.
|
||||
const serviceError = yield* client.compact(unsupported, trigger).pipe(Effect.flip)
|
||||
expect(serviceError.reason._tag).toBe("UnsupportedOperation")
|
||||
}
|
||||
// @ts-expect-error Exercise an unknown mechanism supplied by JavaScript.
|
||||
const error = yield* LLMClient.compact(request, { mechanism: "other" }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
// @ts-expect-error An empty string is not the default mechanism.
|
||||
const empty = yield* client.compact(request, { mechanism: "" }).pipe(Effect.flip)
|
||||
expect(empty.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
|
||||
for (const valid of [true, false]) {
|
||||
testEffect(fixedResponse("must not use HTTP")).effect(
|
||||
`acknowledges channel completion only after validation: valid=${valid}`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
let completed = 0
|
||||
const operation = LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket: {
|
||||
execute: () =>
|
||||
Effect.succeed({
|
||||
frames: Stream.make(
|
||||
JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: valid ? [checkpoint] : [] },
|
||||
}),
|
||||
),
|
||||
complete: Effect.sync(() => {
|
||||
completed++
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
const result = yield* Effect.result(operation)
|
||||
expect(result._tag).toBe(valid ? "Success" : "Failure")
|
||||
expect(completed).toBe(valid ? 1 : 0)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("checkpoint result schema rejects failed or unencrypted representations", () => {
|
||||
const decode = Schema.decodeUnknownSync(CompactionCheckpointResponse)
|
||||
for (const checkpoint of [
|
||||
{ type: "compaction", provider: "anthropic", text: null },
|
||||
{ type: "compaction", provider: "anthropic", text: "summary" },
|
||||
{ type: "compaction", provider: "openai", encrypted: "" },
|
||||
])
|
||||
expect(() => decode({ checkpoint, responseID: "resp_1" })).toThrow()
|
||||
expect(() =>
|
||||
decode({ checkpoint: { type: "compaction", provider: "openai", encrypted: "opaque" }, responseID: " " }),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
testEffect(
|
||||
scriptedResponses([
|
||||
sseEvents(
|
||||
{ type: "response.created", response: { id: "resp_discarded" } },
|
||||
{ type: "response.output_item.done", item: { ...checkpoint, encrypted_content: "discarded" } },
|
||||
{ type: "response.incomplete", response: { id: "resp_discarded", usage: { input_tokens: 999 } } },
|
||||
),
|
||||
sseEvents({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_success", output: [checkpoint], usage: { input_tokens: 12 } },
|
||||
}),
|
||||
]),
|
||||
).effect("an explicitly retried effect does not reuse failed-attempt checkpoint or metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const operation = LLMClient.compact(request, trigger)
|
||||
yield* operation.pipe(Effect.flip)
|
||||
const result = yield* operation
|
||||
expect(result.checkpoint.encrypted).toBe("opaque")
|
||||
expect(result.responseID).toBe("resp_success")
|
||||
expect(result.usage?.inputTokens).toBe(12)
|
||||
}),
|
||||
)
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AIError,
|
||||
CompactionPart,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
@@ -79,6 +80,47 @@ describe("TestLLM legacy client", () => {
|
||||
})
|
||||
|
||||
describe("TestLLM first-class client", () => {
|
||||
it.effect("scripts trigger checkpoints lazily with gates, queue order, and fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
const request = LLM.request({ model: OpenAI.configure().responses("fixture"), prompt: "hello" })
|
||||
const checkpoint = new CompactionCheckpointResponse({
|
||||
checkpoint: { type: "compaction", provider: ProviderID.make("openai"), encrypted: "opaque" },
|
||||
responseID: "resp_fixture",
|
||||
})
|
||||
const endpoint = new CompactionResponse({ replacement: [] })
|
||||
yield* client.push(checkpoint, endpoint)
|
||||
const operation = LLMClient.compact(request, { mechanism: "trigger" })
|
||||
expect(yield* client.requests()).toEqual([])
|
||||
const gate = yield* client.gate()
|
||||
const fiber = yield* operation.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* client.wait(1)
|
||||
expect(fiber.pollUnsafe()).toBeUndefined()
|
||||
yield* gate.release
|
||||
expect(yield* Fiber.join(fiber)).toBe(checkpoint)
|
||||
expect(yield* client.compact(request)).toBe(endpoint)
|
||||
yield* client.serve((observed) => {
|
||||
expect(observed).toBe(request)
|
||||
return checkpoint
|
||||
})
|
||||
expect(yield* LLMClient.compact(request, { mechanism: "trigger" })).toBe(checkpoint)
|
||||
yield* client.always(checkpoint)
|
||||
expect(yield* LLMClient.compact(request, { mechanism: "trigger" })).toBe(checkpoint)
|
||||
yield* client.push(endpoint, checkpoint, checkpoint)
|
||||
expect(yield* client.compact(request, { mechanism: "trigger" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM trigger compaction requires a CompactionCheckpointResponse",
|
||||
)
|
||||
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM compaction requires a CompactionResponse",
|
||||
)
|
||||
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM generation requires an event response",
|
||||
)
|
||||
expect(yield* client.requests()).toHaveLength(7)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects response fixtures for the wrong operation", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts", "test/provider/checkpoint.test.ts"]
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
|
||||
const notices = page.locator('[data-slot="session-timeline-notice"]')
|
||||
await expect(notices).toHaveCount(4)
|
||||
await expect(notices.nth(0)).toHaveText(/^Agent changed\s*Explore$/)
|
||||
await expect(notices.nth(0)).toContainText("Agent · explore")
|
||||
await expect(notices.nth(1)).toContainText("explore finished · Search code")
|
||||
await expect(notices.nth(2)).toContainText("Continuing after restart")
|
||||
await expect(notices.nth(3)).toContainText("Skill · Review")
|
||||
@@ -182,15 +182,20 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await expect(card).not.toContainText("(background)")
|
||||
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
|
||||
const hint = page.getByRole("button", { name: /move running work to the background/i })
|
||||
const hint = page.locator('[data-component="session-background-hint"]')
|
||||
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
|
||||
await expect(hint).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [cardBox, hintBox] = await Promise.all([card.boundingBox(), hint.boundingBox()])
|
||||
if (!cardBox || !hintBox) return undefined
|
||||
const [cardBox, hintBox, prefixBox] = await Promise.all([
|
||||
card.boundingBox(),
|
||||
hint.boundingBox(),
|
||||
hintPrefix.boundingBox(),
|
||||
])
|
||||
if (!cardBox || !hintBox || !prefixBox) return undefined
|
||||
return {
|
||||
aligned: Math.abs(cardBox.x - hintBox.x) < 2,
|
||||
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
|
||||
ordered: cardBox.y < hintBox.y,
|
||||
}
|
||||
})
|
||||
@@ -215,10 +220,10 @@ test("navigates from a running subagent card and hides background controls in th
|
||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
|
||||
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
|
||||
await page.locator('[data-component="task-tool-card"]').click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
|
||||
await expect(page.getByRole("button", { name: /move running work to the background/i })).toHaveCount(0)
|
||||
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const name of ["shell", "subagent"] as const) {
|
||||
@@ -262,7 +267,7 @@ for (const name of ["shell", "subagent"] as const) {
|
||||
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
|
||||
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
|
||||
const request = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
|
||||
@@ -281,9 +286,9 @@ test("shows a badge for active background work", async ({ page }) => {
|
||||
})
|
||||
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
const summary = page.getByRole("button", { name: "1 background task running", exact: true })
|
||||
const summary = page.getByRole("button", { name: "1 item running in background" })
|
||||
await expect(summary).toContainText("1")
|
||||
await expect(summary).toContainText("1 background task running")
|
||||
await expect(summary).toContainText("Running work in background")
|
||||
await summary.click()
|
||||
await expect(
|
||||
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
|
||||
@@ -382,7 +387,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
},
|
||||
})
|
||||
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
|
||||
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
|
||||
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
|
||||
const used = page
|
||||
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
|
||||
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
@@ -391,7 +396,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
|
||||
const summary = page.getByRole("button", { name: "2 items running in background" })
|
||||
await expect(summary).toContainText("2")
|
||||
await summary.click()
|
||||
const list = page.locator('[data-component="session-background-list"]')
|
||||
|
||||
@@ -173,7 +173,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
|
||||
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
|
||||
"href",
|
||||
"#opencode-v2-icon-outline-hexagonal-warning",
|
||||
"#opencode-v2-icon-circle-exclamation",
|
||||
)
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -134,7 +134,7 @@ for (const name of ["read", "shell", "subagent"] as const) {
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeInViewport()
|
||||
if (name !== "read") {
|
||||
const hint = page.getByRole("button", { name: /move running work to the background/i })
|
||||
const hint = page.locator('[data-component="session-background-hint"]')
|
||||
await expect(hint).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
|
||||
await page.screenshot({ path: testInfo.outputPath(`working-grouped-${name}.png`) })
|
||||
|
||||
@@ -1,60 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerSync } from "@/runtime/server/sync"
|
||||
|
||||
type ProjectApi = ServerApi["project"]
|
||||
type WorktreeApi = ServerApi["worktree"]
|
||||
|
||||
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(new Request(input, init).url)
|
||||
if (url.pathname === "/api/location")
|
||||
return Response.json({
|
||||
directory: "/repo",
|
||||
project: { id: "project", directory: "/repo", canonical: "/repo" },
|
||||
})
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
|
||||
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
|
||||
throw new Error(`Unexpected request: ${url.pathname}`)
|
||||
},
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
const [store, setStore] = createStore<ServerSync["data"]>({
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
project: [],
|
||||
provider_auth: {},
|
||||
config: {},
|
||||
reload: undefined,
|
||||
})
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
try {
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
|
||||
|
||||
setStore("project", (projects) => projects.map((project) => ({ ...project, name: "Renamed" })))
|
||||
expect(store.project[0]?.name).toBe("Renamed")
|
||||
setStore("project", [])
|
||||
expect(store.project).toEqual([])
|
||||
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
|
||||
expect(store.config).toEqual({})
|
||||
} finally {
|
||||
queryClient.clear()
|
||||
}
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const location = {} as ServerApi["location"]
|
||||
|
||||
@@ -79,13 +79,25 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
||||
setGlobalStore("project", next)
|
||||
}
|
||||
|
||||
const setBootStore = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && Array.isArray(input[1])) {
|
||||
setProjects(input[1] as Project[])
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
}) as typeof setGlobalStore
|
||||
|
||||
const bootstrap = useQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "bootstrap"],
|
||||
queryFn: async () => {
|
||||
await bootstrapGlobal({
|
||||
serverAPI: serverSDK.api,
|
||||
scope: serverSDK.scope,
|
||||
setGlobalStore,
|
||||
setGlobalStore: setBootStore,
|
||||
queryClient,
|
||||
})
|
||||
return Date.now()
|
||||
@@ -93,6 +105,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
enabled: connected(),
|
||||
}))
|
||||
|
||||
const set = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
||||
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
}) as typeof setGlobalStore
|
||||
|
||||
const paused = () => untrack(() => globalStore.reload) !== undefined
|
||||
|
||||
const queue = createRefreshQueue({
|
||||
@@ -196,7 +216,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
}
|
||||
|
||||
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
|
||||
setGlobalStore("project", (projects) =>
|
||||
setProjects((projects) =>
|
||||
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
|
||||
)
|
||||
}
|
||||
@@ -255,7 +275,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set: setGlobalStore,
|
||||
set,
|
||||
child: children.child,
|
||||
disableMcp: children.disableMcp,
|
||||
// bootstrap,
|
||||
|
||||
@@ -47,6 +47,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
if (!server.service) yield* updater.check().pipe(Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -82,14 +83,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||
get: () => runPromise(config.get()),
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
updater: {
|
||||
monitor: (notify, signal) =>
|
||||
runPromise(
|
||||
updater.monitor((version) => Effect.sync(() => notify(version))),
|
||||
{ signal },
|
||||
),
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
updater: service
|
||||
? {
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
}
|
||||
: undefined,
|
||||
packages: {
|
||||
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
|
||||
},
|
||||
|
||||
@@ -7,12 +7,14 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { spawn } from "node:child_process"
|
||||
import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
@@ -27,6 +29,7 @@ export type Options = {
|
||||
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
|
||||
export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
return yield* processEffect(options).pipe(
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
|
||||
replacements: [
|
||||
@@ -51,7 +54,8 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
return yield* Effect.scoped(
|
||||
const replacement = yield* Deferred.make<PersistentPty.Handoff | null>()
|
||||
const next = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const foreground = options.mode === "default"
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
@@ -62,7 +66,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
serviceOptions !== undefined && port !== undefined
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
: undefined
|
||||
if (incumbent !== undefined) return
|
||||
if (incumbent !== undefined) return Option.none<PersistentPty.Handoff | null>()
|
||||
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
|
||||
const environmentPassword = yield* Env.password
|
||||
// Keep the lease credential out of the environment inherited by tools.
|
||||
@@ -159,17 +163,62 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (server === undefined) return
|
||||
if (server === undefined) return Option.none<PersistentPty.Handoff | null>()
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater
|
||||
.monitor({
|
||||
url,
|
||||
password,
|
||||
managed: options.mode === "service",
|
||||
notify: server.updateAvailable,
|
||||
restart: (handoff) => Deferred.succeed(replacement, handoff).pipe(Effect.asVoid),
|
||||
})
|
||||
.pipe(Effect.forkScoped)
|
||||
return yield* options.mode === "service"
|
||||
? server.shutdown
|
||||
? Effect.raceFirst(
|
||||
server.shutdown.pipe(Effect.as(Option.none<PersistentPty.Handoff | null>())),
|
||||
Deferred.await(replacement).pipe(Effect.map(Option.some)),
|
||||
)
|
||||
: options.mode === "stdio"
|
||||
? waitForStdinClose()
|
||||
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
|
||||
: Effect.never
|
||||
}).pipe(Effect.annotateLogs({ role: "server" })),
|
||||
)
|
||||
if (Option.isNone(next)) return
|
||||
yield* spawnReplacement(next.value)
|
||||
})
|
||||
|
||||
const spawnReplacement = Effect.fnUntraced(function* (handoff: PersistentPty.Handoff | null) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const [command, ...args] = options.command
|
||||
if (!command) return yield* Effect.fail(new Error("Failed to resolve CLI command for restart"))
|
||||
// We do not monitor the replacement after spawn. A managed TUI
|
||||
// recovers with Service.ensure if startup fails; a future client
|
||||
// restart signal could coordinate that recovery instead.
|
||||
yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...options.env,
|
||||
OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined,
|
||||
},
|
||||
})
|
||||
child.once("spawn", () => {
|
||||
child.unref()
|
||||
resolve()
|
||||
})
|
||||
child.once("error", reject)
|
||||
}),
|
||||
catch: (cause) => new Error("Failed to start replacement server", { cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type Policy = "disable" | "notify"
|
||||
export type Action = "none" | "notify"
|
||||
export type Policy = "disable" | "notify" | "auto"
|
||||
export type Action = "none" | "notify" | "upgrade"
|
||||
|
||||
const maximumComponent = "9007199254740991"
|
||||
const versionPattern =
|
||||
@@ -10,7 +10,10 @@ export function action(current: string, latest: string, policy: Policy): Action
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
return "notify"
|
||||
if (policy === "notify") return "notify"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (currentVersion.major !== latestVersion.major) return "notify"
|
||||
return "upgrade"
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(input: string) {
|
||||
|
||||
@@ -6,17 +6,22 @@ describe("updater", () => {
|
||||
test("reads update policy from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
|
||||
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps the v1 update policy", () => {
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
|
||||
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
|
||||
})
|
||||
|
||||
test("reports every available release", () => {
|
||||
test("automatically updates patches and minors", () => {
|
||||
expect(action("1.2.3", "1.2.4", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", "auto")).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("reports patches and minors without automatically installing them", () => {
|
||||
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
|
||||
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
|
||||
expect(action("1.2.3", "2.0.0", "notify")).toBe("notify")
|
||||
@@ -27,21 +32,25 @@ describe("updater", () => {
|
||||
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
|
||||
})
|
||||
|
||||
test("reports up-to-date only when versions match", () => {
|
||||
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
|
||||
test("reports majors instead of automatically installing them", () => {
|
||||
expect(action("1.2.3", "2.0.0", "auto")).toBe("notify")
|
||||
})
|
||||
|
||||
test("reports when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", "notify")).toBe("notify")
|
||||
test("reports up-to-date only when versions match", () => {
|
||||
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
|
||||
})
|
||||
|
||||
test("upgrades when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("accepts strict release version variants", () => {
|
||||
expect(action("v1.2.3", " 1.2.4\n", "notify")).toBe("notify")
|
||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "notify")).toBe("notify")
|
||||
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "notify")).toBe("notify")
|
||||
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "notify")).toBe("notify")
|
||||
expect(action("1.2.3+old", "1.2.3+new", "notify")).toBe("none")
|
||||
expect(action("v1.2.3+old", "1.2.3", "notify")).toBe("none")
|
||||
expect(action("v1.2.3", " 1.2.4\n", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "auto")).toBe("upgrade")
|
||||
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "auto")).toBe("upgrade")
|
||||
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3+old", "1.2.3+new", "auto")).toBe("none")
|
||||
expect(action("v1.2.3+old", "1.2.3", "auto")).toBe("none")
|
||||
})
|
||||
|
||||
test("preserves strict validity", () => {
|
||||
@@ -62,21 +71,21 @@ describe("updater", () => {
|
||||
"0.9007199254740992.0",
|
||||
"0.0.9007199254740992",
|
||||
]
|
||||
invalid.forEach((version) => expect(action("1.2.3", version, "notify"), version).toBe("none"))
|
||||
invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none"))
|
||||
})
|
||||
|
||||
test("handles numeric limits without losing precision", () => {
|
||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "notify")).toBe("notify")
|
||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "notify")).toBe("notify")
|
||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade")
|
||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify")
|
||||
})
|
||||
|
||||
test("preserves equality for oversized numeric prerelease identifiers", () => {
|
||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "notify")).toBe("none")
|
||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "notify")).toBe("notify")
|
||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "auto")).toBe("none")
|
||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "auto")).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("rejects versions longer than semver's limit before trimming", () => {
|
||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "notify")).toBe("none")
|
||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "notify")).toBe("notify")
|
||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "auto")).toBe("none")
|
||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "auto")).toBe("upgrade")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,36 +1,154 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
|
||||
export interface Interface {
|
||||
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
|
||||
readonly check: () => Effect.Effect<void>
|
||||
readonly monitor: (input: {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
}) => Effect.Effect<void>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export const monitorUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly inspect: () => Effect.Effect<string | undefined, Error>
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly initialDelay?: Duration.Input
|
||||
export type Inspection =
|
||||
| { readonly action: "none" }
|
||||
| { readonly action: Exclude<Action, "none">; readonly version: string }
|
||||
|
||||
type State =
|
||||
| { readonly type: "current" }
|
||||
| { readonly type: "available"; readonly version: string; readonly availableSince: number }
|
||||
| { readonly type: "ready-to-restart"; readonly version: string }
|
||||
|
||||
export interface MonitorInput {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly inspect: () => Effect.Effect<Inspection, Error>
|
||||
readonly install: (version: string) => Effect.Effect<boolean, Error>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
readonly interval?: Duration.Input
|
||||
}) {
|
||||
const interval = input.interval ?? "10 minutes"
|
||||
const initialDelay = input.initialDelay ?? "90 seconds"
|
||||
const check = Effect.gen(function* () {
|
||||
const version = yield* input.inspect()
|
||||
if (version !== undefined) yield* input.notify(version)
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
|
||||
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
|
||||
readonly notificationThreshold?: Duration.Input
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) {
|
||||
const state = yield* Ref.make<State>({ type: "current" })
|
||||
const applyLock = yield* Semaphore.make(1)
|
||||
const client = OpenCode.make({
|
||||
baseUrl: input.url,
|
||||
headers: { authorization: `Basic ${btoa(`opencode:${input.password}`)}` },
|
||||
})
|
||||
|
||||
const applyIfIdle = () =>
|
||||
applyLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* Ref.get(state)
|
||||
if (pending.type !== "available") return
|
||||
const active = yield* Effect.tryPromise({
|
||||
try: () => client.session.active(),
|
||||
catch: (cause) => new Error("Failed to read active sessions", { cause }),
|
||||
})
|
||||
if (Object.keys(active).length > 0) return
|
||||
const latest = yield* input.inspect()
|
||||
if (latest.action !== "upgrade") {
|
||||
yield* Ref.set(state, { type: "current" })
|
||||
return
|
||||
}
|
||||
const installed = yield* input
|
||||
.install(latest.version)
|
||||
.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("automatic update failed", { cause: error }).pipe(Effect.as(false)),
|
||||
),
|
||||
)
|
||||
if (!installed) return
|
||||
const handoff = input.managed
|
||||
? yield* Effect.tryPromise({
|
||||
try: () => client.experimental.persistentPty.handoff(),
|
||||
catch: (cause) => new Error("Failed to prepare persistent terminals for restart", { cause }),
|
||||
})
|
||||
: undefined
|
||||
yield* Ref.set(state, { type: "ready-to-restart", version: latest.version })
|
||||
if (handoff) yield* input.restart(handoff.handoff)
|
||||
}),
|
||||
)
|
||||
|
||||
const checkServer = Effect.gen(function* () {
|
||||
const result = yield* input.inspect()
|
||||
if (result.action === "notify") {
|
||||
yield* input.notify(result.version)
|
||||
return
|
||||
}
|
||||
if (result.action !== "upgrade") {
|
||||
yield* Ref.update(
|
||||
state,
|
||||
(current): State => (current.type === "ready-to-restart" ? current : { type: "current" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* Ref.update(state, (current): State => {
|
||||
if (current.type === "ready-to-restart" && current.version === result.version) return current
|
||||
return {
|
||||
type: "available",
|
||||
version: result.version,
|
||||
availableSince: current.type === "available" ? current.availableSince : Date.now(),
|
||||
}
|
||||
})
|
||||
yield* applyIfIdle()
|
||||
const pending = yield* Ref.get(state)
|
||||
if (
|
||||
pending.type === "available" &&
|
||||
Date.now() - pending.availableSince >= Duration.toMillis(input.notificationThreshold ?? "3 days")
|
||||
)
|
||||
yield* input.notify(pending.version)
|
||||
}).pipe(Effect.catch((cause) => Effect.logWarning("automatic update check failed", { cause })))
|
||||
|
||||
const subscribe = Effect.suspend(() =>
|
||||
Stream.fromAsyncIterable(
|
||||
client.event.subscribe(),
|
||||
(cause) => new Error("Update event stream failed", { cause }),
|
||||
).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (event.type === "server.connected") return applyIfIdle()
|
||||
if (
|
||||
event.type !== "session.execution.succeeded" &&
|
||||
event.type !== "session.execution.failed" &&
|
||||
event.type !== "session.execution.interrupted"
|
||||
)
|
||||
return Effect.void
|
||||
return Effect.tryPromise({
|
||||
try: () => client.session.wait({ sessionID: event.data.sessionID }),
|
||||
catch: (cause) => new Error(`Failed to wait for Session ${event.data.sessionID}`, { cause }),
|
||||
}).pipe(Effect.andThen(applyIfIdle()))
|
||||
}),
|
||||
Effect.catch((cause) => Effect.logWarning("update event stream disconnected", { cause })),
|
||||
),
|
||||
).pipe(Effect.repeat(Schedule.spaced("1 second")))
|
||||
|
||||
return yield* Effect.all(
|
||||
[checkServer.pipe(Effect.repeat(Schedule.spaced(input.interval ?? "10 minutes"))), subscribe],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
@@ -43,14 +161,13 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||
if (errors.length || typeof input !== "object" || input === null) return
|
||||
if ("update" in input) {
|
||||
const value = input.update
|
||||
if (value === "disable" || value === "notify") return value
|
||||
if (value === "auto") return "notify"
|
||||
if (value === "disable" || value === "notify" || value === "auto") return value
|
||||
return
|
||||
}
|
||||
if (!("autoupdate" in input)) return
|
||||
if (input.autoupdate === false) return "disable"
|
||||
if (input.autoupdate === "notify") return "notify"
|
||||
if (input.autoupdate === true) return "notify"
|
||||
if (input.autoupdate === true) return "auto"
|
||||
}
|
||||
|
||||
const make = Effect.gen(function* () {
|
||||
@@ -75,7 +192,7 @@ const make = Effect.gen(function* () {
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? "notify"
|
||||
return values.findLast((value) => value !== undefined) ?? "auto"
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
@@ -185,19 +302,19 @@ const make = Effect.gen(function* () {
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
|
||||
const inspect = Effect.fnUntraced(function* () {
|
||||
const inspect = Effect.fnUntraced(function* (): Effect.fn.Return<Inspection, Error> {
|
||||
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) {
|
||||
yield* Effect.logInfo("update check skipped", {
|
||||
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
})
|
||||
return undefined
|
||||
return { action: "none" }
|
||||
}
|
||||
const policy = yield* readPolicy()
|
||||
if (policy === "disable") {
|
||||
yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||
return undefined
|
||||
return { action: "none" }
|
||||
}
|
||||
|
||||
const version = yield* latest()
|
||||
@@ -208,16 +325,19 @@ const make = Effect.gen(function* () {
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
if (next === "none") {
|
||||
yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
return undefined
|
||||
return { action: "none" }
|
||||
}
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return version
|
||||
if (next === "notify") {
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return { action: next, version }
|
||||
}
|
||||
return { action: next, version }
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (version: string) {
|
||||
const detected = yield* method()
|
||||
if (!detected) {
|
||||
yield* Effect.logWarning("update skipped: installation method not found")
|
||||
yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
return false
|
||||
}
|
||||
yield* upgrade(detected, version)
|
||||
@@ -229,9 +349,26 @@ const make = Effect.gen(function* () {
|
||||
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
})
|
||||
|
||||
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
|
||||
const check = Effect.fn("cli.updater.check")(
|
||||
function* () {
|
||||
const result = yield* inspect()
|
||||
if (result.action !== "upgrade") return
|
||||
yield* install(result.version)
|
||||
},
|
||||
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
|
||||
)
|
||||
|
||||
return Service.of({ monitor, apply, method, latest, upgrade })
|
||||
const monitor = Effect.fn("cli.updater.monitor")(function* (input: {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
}) {
|
||||
return yield* monitorServer({ ...input, inspect, install })
|
||||
})
|
||||
|
||||
return Service.of({ check, monitor, apply, method, latest, upgrade })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
@@ -12,8 +12,9 @@ await Effect.runPromise(
|
||||
process.argv.slice(2),
|
||||
).pipe(
|
||||
Effect.provideService(Updater.Service, {
|
||||
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
|
||||
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply automatic updates"),
|
||||
method: () =>
|
||||
Effect.sync(() => {
|
||||
record("method")
|
||||
|
||||
@@ -1,40 +1,107 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Deferred, Effect, Layer, Option } from "effect"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
|
||||
it.live("installs and restarts after the final Session settles", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed("2.0.0"),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
const fixture = yield* Effect.acquireRelease(Effect.sync(makeServer), (server) => Effect.sync(() => server.stop()))
|
||||
const installed = yield* Deferred.make<string>()
|
||||
const restarted = yield* Deferred.make<void>()
|
||||
yield* Updater.monitorServer({
|
||||
url: fixture.url,
|
||||
password: "test",
|
||||
managed: true,
|
||||
inspect: () => Effect.succeed({ action: "upgrade", version: "1.1.0" }),
|
||||
install: (version) => Deferred.succeed(installed, version).pipe(Effect.as(true)),
|
||||
restart: () => Deferred.succeed(restarted, undefined).pipe(Effect.asVoid),
|
||||
notify: () => Effect.void,
|
||||
}).pipe(Effect.forkScoped)
|
||||
yield* wait(fixture.activeRead, () => "Updater did not check active Sessions")
|
||||
yield* wait(fixture.eventOpened, () => "Updater did not open the server event stream")
|
||||
expect(Option.isNone(yield* Deferred.poll(installed))).toBe(true)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("89 seconds")
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
fixture.settle()
|
||||
yield* wait(fixture.waited, () => "Updater did not receive the settlement event")
|
||||
expect(
|
||||
yield* Effect.raceFirst(
|
||||
Deferred.await(installed),
|
||||
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not install the update")))),
|
||||
),
|
||||
).toBe("1.1.0")
|
||||
yield* Effect.raceFirst(
|
||||
Deferred.await(restarted),
|
||||
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not restart the server")))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not notify when no update is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed(undefined),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
const wait = (promise: Promise<unknown>, message: () => string) =>
|
||||
Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))]))
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
}),
|
||||
)
|
||||
function makeServer() {
|
||||
const encoder = new TextEncoder()
|
||||
const activeRead = Promise.withResolvers<void>()
|
||||
const eventOpened = Promise.withResolvers<void>()
|
||||
const waited = Promise.withResolvers<void>()
|
||||
let active = true
|
||||
let events: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/session/active") {
|
||||
activeRead.resolve()
|
||||
return Response.json({ data: active ? { ses_test: { type: "running" } } : {} })
|
||||
}
|
||||
if (url.pathname === "/api/session/ses_test/wait" && request.method === "POST") {
|
||||
waited.resolve()
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/experimental/persistent-pty/handoff" && request.method === "POST") {
|
||||
return Response.json({ handoff: null })
|
||||
}
|
||||
if (url.pathname === "/api/event") {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
events = controller
|
||||
eventOpened.resolve()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
url: server.url.origin,
|
||||
activeRead: activeRead.promise,
|
||||
eventOpened: eventOpened.promise,
|
||||
waited: waited.promise,
|
||||
settle() {
|
||||
active = false
|
||||
events?.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_settled",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "ses_test", seq: 0, version: 1 },
|
||||
data: { sessionID: "ses_test" },
|
||||
})}\n\n`,
|
||||
),
|
||||
)
|
||||
events?.close()
|
||||
events = undefined
|
||||
},
|
||||
stop() {
|
||||
server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -965,8 +965,6 @@ export type SessionLogOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly text: string
|
||||
readonly recent: string
|
||||
}
|
||||
|
||||
@@ -138,6 +138,17 @@ export type SessionMessageCompactionRunning = {
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type SessionMessageCompactionCompleted = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
summary: string
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -510,19 +521,6 @@ export type SessionMessageAssistantReasoning = {
|
||||
time?: { created: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageCompactionCompleted = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState
|
||||
summary: string
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type ToolContent = ToolTextContent | ToolFileContent
|
||||
|
||||
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
|
||||
@@ -811,6 +809,16 @@ export type SessionCompactionStarted = {
|
||||
data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string }
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
|
||||
}
|
||||
|
||||
export type SessionCompactionFailed = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1343,23 +1351,6 @@ export type SessionToolCalled = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState1
|
||||
text: string
|
||||
recent: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
|
||||
|
||||
export type SessionMessageAssistantReasoning1 = {
|
||||
@@ -1892,7 +1883,7 @@ export type ConfigEntry =
|
||||
shell?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
default_agent?: string
|
||||
update?: "disable" | "notify"
|
||||
update?: "disable" | "notify" | "auto"
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
enterprise?: { url?: string }
|
||||
username?: string
|
||||
@@ -1980,7 +1971,6 @@ export type ConfigEntry =
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
subagent?: boolean
|
||||
subtask?: boolean
|
||||
}
|
||||
}
|
||||
@@ -3073,8 +3063,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "completed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
}
|
||||
@@ -3352,8 +3340,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "completed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
}
|
||||
@@ -3631,8 +3617,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "completed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
}
|
||||
|
||||
@@ -574,7 +574,7 @@ export function createData(config: CreateDataInput) {
|
||||
.location.get({ location: locationQuery(defaultLocation()) })
|
||||
.then((location) => {
|
||||
const key = locationKey(location)
|
||||
setStore("location", key, { info: location })
|
||||
setStore("location", key, { ...store.location[key], info: location })
|
||||
})
|
||||
.catch((error) => console.error("Failed to preload location", error))
|
||||
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
|
||||
@@ -1038,8 +1038,6 @@ export function createData(config: CreateDataInput) {
|
||||
Object.assign(current, {
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
})
|
||||
@@ -1050,8 +1048,6 @@ export function createData(config: CreateDataInput) {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
time: { created: event.created },
|
||||
@@ -1109,6 +1105,7 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
}
|
||||
setStore("location", key, (data) => ({
|
||||
...data,
|
||||
integration: data?.integration?.map((integration) => {
|
||||
if (integration.id !== event.data.integrationID) return integration
|
||||
const active = integration.connections.find(
|
||||
@@ -1150,6 +1147,7 @@ export function createData(config: CreateDataInput) {
|
||||
break
|
||||
case "vcs.branch.updated":
|
||||
setStore("location", locationKey(location), (data) => ({
|
||||
...data,
|
||||
vcs: {
|
||||
branch: {
|
||||
...data?.vcs?.branch,
|
||||
@@ -1167,6 +1165,7 @@ export function createData(config: CreateDataInput) {
|
||||
break
|
||||
case "shell.created":
|
||||
setStore("location", locationKey(location), (data) => ({
|
||||
...data,
|
||||
shell: {
|
||||
...data?.shell,
|
||||
[event.data.info.id]: { ...event.data.info, location },
|
||||
@@ -1176,6 +1175,7 @@ export function createData(config: CreateDataInput) {
|
||||
case "shell.exited":
|
||||
case "shell.deleted":
|
||||
setStore("location", locationKey(location), (data) => ({
|
||||
...data,
|
||||
shell: Object.fromEntries(Object.entries(data?.shell ?? {}).filter(([id]) => id !== event.data.id)),
|
||||
}))
|
||||
break
|
||||
@@ -1801,7 +1801,10 @@ export function createData(config: CreateDataInput) {
|
||||
const input = { location: locationQuery(ref ?? defaultLocation()) }
|
||||
const providers = await api().websearch.providers(input)
|
||||
const key = locationKey(providers.location)
|
||||
setStore("location", key, { websearch: providers.data })
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
websearch: providers.data,
|
||||
})
|
||||
},
|
||||
},
|
||||
skill: locationResource("skill", (location) => api().skill.list({ location })),
|
||||
|
||||
@@ -98,15 +98,13 @@ test.each(["started", "cancelled", "failed"])(
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
if (kind === "started") {
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
|
||||
const model = { providerID: "demo", id: "model" }
|
||||
const providerState = { responseId: "summary-response" }
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.ended",
|
||||
data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" },
|
||||
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
|
||||
})
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
|
||||
{ type: "compaction", status: "completed", summary: "Summary", model, providerState },
|
||||
{ type: "compaction", status: "completed", summary: "Summary" },
|
||||
])
|
||||
}
|
||||
},
|
||||
|
||||
@@ -355,10 +355,8 @@ test("refreshes global credential events across every loaded location and worksp
|
||||
setup.data.location.integration.sync(location),
|
||||
setup.data.location.model.sync(location),
|
||||
setup.data.location.provider.sync(location),
|
||||
setup.data.location.reference.sync(location),
|
||||
]),
|
||||
)
|
||||
const references = locations.map((location) => setup.data.location.reference.list(location))
|
||||
requests.length = 0
|
||||
|
||||
const updated: OpenCodeEvent = {
|
||||
@@ -404,9 +402,6 @@ test("refreshes global credential events across every loaded location and worksp
|
||||
["/api/provider", "/other", "workspace-other"],
|
||||
]),
|
||||
)
|
||||
locations.forEach((location, index) =>
|
||||
expect(setup.data.location.reference.list(location)).toBe(references[index]),
|
||||
)
|
||||
requests.length = 0
|
||||
}
|
||||
} finally {
|
||||
@@ -474,99 +469,6 @@ test("refreshes references for the location an update names", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves sibling catalogs through location preload, branch, shell, and websearch updates", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const location = { directory: "/project", project: { id: "project", directory: "/project", canonical: "/project" } }
|
||||
const requests: string[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const pathname = new URL((input instanceof Request ? input : new Request(input, init)).url).pathname
|
||||
requests.push(pathname)
|
||||
if (pathname === "/api/session/active") return Response.json({})
|
||||
if (pathname === "/api/project") return Response.json([])
|
||||
if (pathname === "/api/location") return Response.json(location)
|
||||
if (pathname === "/api/vcs")
|
||||
return Response.json({ location, data: { branch: { current: "main", default: "main" } } })
|
||||
if (pathname === "/api/reference")
|
||||
return Response.json({
|
||||
location,
|
||||
data: [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
|
||||
})
|
||||
if (pathname === "/api/websearch/provider")
|
||||
return Response.json({ location, data: [{ id: "search", name: "Search" }] })
|
||||
throw new Error(`Unexpected request: ${pathname}`)
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: location.directory,
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const emit = (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details }))
|
||||
const shell = {
|
||||
id: "sh_first",
|
||||
status: "running" as const,
|
||||
command: "echo hello",
|
||||
cwd: location.directory,
|
||||
shell: "/bin/sh",
|
||||
file: "/shell-output",
|
||||
metadata: {},
|
||||
time: { started: 1 },
|
||||
}
|
||||
|
||||
try {
|
||||
// A live event may arrive before any location reads have populated this key.
|
||||
emit({ type: "shell.created", location, data: { info: shell } })
|
||||
expect(setup.data.shell.get(shell.id)).toMatchObject(shell)
|
||||
const first = setup.data.shell.get(shell.id)
|
||||
await Promise.all([setup.data.location.reference.sync(), setup.data.location.vcs.sync()])
|
||||
const references = setup.data.location.reference.list()
|
||||
expect(references?.map((reference) => [reference.name, reference.path])).toEqual([["docs", "/docs"]])
|
||||
expect(setup.data.shell.get(shell.id)).toBe(first)
|
||||
|
||||
emit({ type: "vcs.branch.updated", location, data: { branch: "feature" } })
|
||||
expect(setup.data.location.vcs.info()?.branch).toEqual({ current: "feature", default: "main" })
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
emit({ type: "shell.created", location, data: { info: { ...shell, id: "sh_second" } } })
|
||||
expect(setup.data.shell.list().map((shell) => shell.id)).toEqual(["sh_first", "sh_second"])
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
emit({ type: "shell.deleted", location, data: { id: "sh_second" } })
|
||||
expect(setup.data.shell.list().map((shell) => shell.id)).toEqual(["sh_first"])
|
||||
expect(setup.data.shell.get(shell.id)).toBe(first)
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
|
||||
await setup.data.location.websearch.refresh()
|
||||
expect(setup.data.location.websearch.list()).toEqual([{ id: "search", name: "Search" }])
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
emit({ type: "server.connected", data: {} })
|
||||
await wait(() => setup.data.location.info() !== undefined)
|
||||
expect(setup.data.location.info()).toMatchObject(location)
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
expect(setup.data.shell.get(shell.id)).toBe(first)
|
||||
expect(setup.data.location.vcs.info()?.branch).toEqual({ current: "feature", default: "main" })
|
||||
expect(requests.toSorted()).toEqual([
|
||||
"/api/location",
|
||||
"/api/project",
|
||||
"/api/reference",
|
||||
"/api/session/active",
|
||||
"/api/vcs",
|
||||
"/api/websearch/provider",
|
||||
])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("reports optimistic sessions as creating until the request settles", async () => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const api = OpenCode.make({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CodeModeCatalog } from "./catalog.js"
|
||||
// prettier-ignore
|
||||
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
|
||||
|
||||
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools callable inside \`execute\`. It does not affect tools exposed directly outside Code Mode.${hasMoreTools ? `
|
||||
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
|
||||
|
||||
## Search
|
||||
|
||||
|
||||
@@ -73,11 +73,6 @@ export function normalize(input: unknown): Result {
|
||||
const legacyUpdate = own(input, "autoupdate")
|
||||
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
: undefined
|
||||
const nativeUpdate = own(input, "update")
|
||||
? input.update === "auto"
|
||||
? "notify"
|
||||
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
? "auto"
|
||||
@@ -91,10 +86,7 @@ export function normalize(input: unknown): Result {
|
||||
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
|
||||
}
|
||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||
const migratedUpdate =
|
||||
legacyUpdate === undefined ? undefined : ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
|
||||
const update = prefer(migratedUpdate, nativeUpdate, ["update"], diagnostics)
|
||||
if (update !== undefined) encoded.update = update
|
||||
if (legacyUpdate !== undefined) encoded.update = ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
|
||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||
|
||||
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
|
||||
@@ -204,6 +196,7 @@ export function normalize(input: unknown): Result {
|
||||
shell: Info.fields.shell,
|
||||
model: Info.fields.model,
|
||||
default_agent: Info.fields.default_agent,
|
||||
update: Info.fields.update,
|
||||
share: Info.fields.share,
|
||||
enterprise: Info.fields.enterprise,
|
||||
username: Info.fields.username,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as ConfigCommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
@@ -9,11 +10,8 @@ import { AppProcess } from "@opencode-ai/util/process"
|
||||
import path from "path"
|
||||
import { Effect, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SubagentJob } from "../../session/subagent-job.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
@@ -34,9 +32,6 @@ export const Plugin = define({
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const sessions = yield* Session.Service
|
||||
const agents = yield* Agent.Service
|
||||
const subagents = yield* SubagentJob.make
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
@@ -72,14 +67,18 @@ export const Plugin = define({
|
||||
yield* ctx.command.transform((editor) => {
|
||||
for (const document of loaded.documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
const subagent = command.subagent ?? command.subtask
|
||||
editor.add({
|
||||
name,
|
||||
description: command.description,
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||
const commandAgent = agent === undefined ? undefined : (yield* ctx.agent.get({ agentID: agent })).data
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||
})
|
||||
const model =
|
||||
command.model === undefined
|
||||
? commandAgent?.model
|
||||
@@ -90,46 +89,15 @@ export const Plugin = define({
|
||||
? {}
|
||||
: { variant: Model.VariantID.make(command.model.variant) }),
|
||||
}
|
||||
const text = yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
})
|
||||
if (subagent ?? commandAgent?.mode === "subagent") {
|
||||
const parent = yield* sessions.get(input.sessionID)
|
||||
const selected = yield* agents.select(agent ?? parent.agent)
|
||||
const child = yield* sessions.create({
|
||||
parentID: parent.id,
|
||||
title: command.description ?? name,
|
||||
agent: selected.id,
|
||||
model: model ?? selected.info?.model ?? parent.model,
|
||||
})
|
||||
yield* sessions.prompt({
|
||||
...input.prompt,
|
||||
sessionID: child.id,
|
||||
text: ["You are a subagent spawned by another session.", text].join("\n"),
|
||||
resume: false,
|
||||
})
|
||||
const recovery = {
|
||||
kind: "subagent" as const,
|
||||
parentSessionID: parent.id,
|
||||
childSessionID: child.id,
|
||||
agent: selected.id,
|
||||
description: command.description ?? name,
|
||||
}
|
||||
yield* subagents.start(recovery)
|
||||
yield* subagents.background(recovery)
|
||||
return
|
||||
}
|
||||
if (agent !== undefined) {
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
}),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
@@ -228,8 +196,8 @@ function evaluateTemplate(
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError(
|
||||
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
Effect.mapError((error) =>
|
||||
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -18,9 +18,7 @@ const RemoteModel = Schema.Struct({
|
||||
Schema.Struct({
|
||||
batch_size: Schema.Number,
|
||||
default: Schema.Struct({
|
||||
// API version 2026-08-01 renamed cache_price to cache_read_price.
|
||||
cache_price: Schema.optional(Schema.Number),
|
||||
cache_read_price: Schema.optional(Schema.Number),
|
||||
cache_price: Schema.Number,
|
||||
input_price: Schema.Number,
|
||||
output_price: Schema.Number,
|
||||
}),
|
||||
@@ -168,9 +166,7 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
|
||||
input: Money.USDPerMillionTokens.make((prices?.default.input_price ?? 0) * usdPerMillion),
|
||||
output: Money.USDPerMillionTokens.make((prices?.default.output_price ?? 0) * usdPerMillion),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(
|
||||
(prices?.default.cache_read_price ?? prices?.default.cache_price ?? 0) * usdPerMillion,
|
||||
),
|
||||
read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion),
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -55,6 +55,7 @@ type Active = {
|
||||
done: Deferred.Deferred<Info>
|
||||
backgrounded: Deferred.Deferred<Info>
|
||||
scope: Scope.Closeable
|
||||
token: object
|
||||
blockingSessions: Map<SessionSchema.ID, number>
|
||||
isBackgrounded: boolean
|
||||
recovery?: Recovery
|
||||
@@ -76,7 +77,7 @@ type BackgroundResult = {
|
||||
backgrounded?: Deferred.Deferred<Info>
|
||||
}
|
||||
|
||||
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
|
||||
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
|
||||
|
||||
type BlockWait = {
|
||||
done: Deferred.Deferred<Info>
|
||||
@@ -183,14 +184,14 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
})
|
||||
|
||||
const settle = Effect.fnUntraced(function* (id: string, scope: Scope.Closeable, exit: Exit.Exit<string, unknown>) {
|
||||
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.scope !== scope) return [{}, jobs]
|
||||
if (job.token !== token) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||
? "completed"
|
||||
@@ -240,6 +241,7 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(existing) }, jobs]
|
||||
}
|
||||
const scope = yield* Scope.fork(state.scope, "parallel")
|
||||
const token = {}
|
||||
const job = {
|
||||
info: {
|
||||
id,
|
||||
@@ -253,17 +255,18 @@ export const make = Effect.gen(function* () {
|
||||
done,
|
||||
backgrounded,
|
||||
scope,
|
||||
token,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
isBackgrounded: false,
|
||||
recovery: input.recovery,
|
||||
}
|
||||
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
|
||||
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
|
||||
}),
|
||||
)
|
||||
if ("scope" in result)
|
||||
yield* restore(input.run).pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => settle(id, result.scope, exit)),
|
||||
Effect.flatMap((exit) => settle(id, result.token, exit)),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(result.scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Option } from "effect"
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { existsSync } from "fs"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Instance } from "./instance.js"
|
||||
import { Location } from "./location.js"
|
||||
@@ -15,48 +16,20 @@ export function buildLocationServiceMap(
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const owner = yield* Effect.scope
|
||||
const booting = MutableHashMap.empty<Location.Ref, object>()
|
||||
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = yield* LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const build = {}
|
||||
MutableHashMap.set(booting, ref, build)
|
||||
return Layer.fromBuild((memoMap, scope) =>
|
||||
Effect.suspend(() =>
|
||||
Layer.buildWithMemoMap(Instance.layer(ref, { replacements: bindings }), memoMap, scope),
|
||||
).pipe(
|
||||
Effect.onExit((exit) => {
|
||||
const finish = Effect.suspend(() => {
|
||||
// An explicitly invalidated build must not evict its replacement.
|
||||
if (Option.getOrUndefined(MutableHashMap.get(booting, ref)) !== build) return Effect.void
|
||||
MutableHashMap.remove(booting, ref)
|
||||
// Evict once per failed build, before its result reaches borrowers.
|
||||
return Exit.isFailure(exit) ? inner.invalidate(ref) : Effect.void
|
||||
})
|
||||
// With no borrowers, invalidation closes the entry's scope and
|
||||
// joins this lookup fiber. Let the owner finish that cleanup.
|
||||
return Exit.isFailure(exit)
|
||||
? finish.pipe(Effect.forkIn(owner, { startImmediately: true }), Effect.asVoid)
|
||||
: finish
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
// Retain healthy graphs. Boot failures, not local filesystem probes,
|
||||
// decide whether a location (including workspace placement) can retry.
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
const inner = yield* LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements: bindings }), {
|
||||
// Workspace-placed directories exist only inside the workspace, so a
|
||||
// local stat consults the wrong filesystem. Workspace liveness is
|
||||
// owned by placement; do not probe the sandbox here, which would
|
||||
// provision lazily-idle workspaces.
|
||||
idleTimeToLive: (ref) =>
|
||||
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
|
||||
})
|
||||
const map = {
|
||||
...inner,
|
||||
get: (ref: Location.Ref) => inner.get(LocationServiceMap.canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => inner.contextEffect(LocationServiceMap.canonical(ref)),
|
||||
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(LocationServiceMap.canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) =>
|
||||
Effect.suspend(() => {
|
||||
const key = LocationServiceMap.canonical(ref)
|
||||
MutableHashMap.remove(booting, key)
|
||||
return inner.invalidate(key)
|
||||
}),
|
||||
invalidate: (ref: Location.Ref) => inner.invalidate(LocationServiceMap.canonical(ref)),
|
||||
}
|
||||
// Cached instances borrow their owner instead of retaining its Layer scope.
|
||||
const bindings: LayerNode.Replacements = [
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
export * as ModalModels from "./models.js"
|
||||
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Option, Schema } from "effect"
|
||||
import { Model } from "../model.js"
|
||||
import { Provider } from "../provider.js"
|
||||
|
||||
const providerID = Provider.ID.make("modal")
|
||||
|
||||
const ReasoningOption = Schema.Struct({
|
||||
type: Schema.Literal("effort"),
|
||||
values: Schema.Array(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
id: Schema.String,
|
||||
base_model_id: Schema.optional(Schema.String),
|
||||
hugging_face_id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
input_modalities: Schema.optional(Schema.Array(Schema.String)),
|
||||
output_modalities: Schema.optional(Schema.Array(Schema.String)),
|
||||
context_length: Schema.optional(Schema.Number),
|
||||
max_output_length: Schema.optional(Schema.Number),
|
||||
pricing: Schema.optional(
|
||||
Schema.Struct({
|
||||
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
}),
|
||||
),
|
||||
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
|
||||
supported_features: Schema.optional(Schema.Array(Schema.String)),
|
||||
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
|
||||
interleaved: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Boolean,
|
||||
Schema.Struct({
|
||||
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
})
|
||||
|
||||
const Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
|
||||
const decodeResponse = Schema.decodeUnknownSync(Response)
|
||||
const decodeModel = Schema.decodeUnknownOption(RemoteModel)
|
||||
|
||||
type RemoteModel = typeof RemoteModel.Type
|
||||
|
||||
export async function get(baseURL: string, apiKey: string, existing: readonly Model.Info[]) {
|
||||
const response = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch Modal models: ${response.status}`)
|
||||
|
||||
// Decode each item tolerantly so one malformed entry cannot discard the
|
||||
// whole inventory. A malformed envelope still fails the fetch.
|
||||
const remote = decodeResponse(await response.json()).data.flatMap((raw) => {
|
||||
const model = Option.getOrUndefined(decodeModel(raw))
|
||||
return model ? [model] : []
|
||||
})
|
||||
const templates = new Map(existing.map((model) => [model.id, model]))
|
||||
const result = new Map<Model.ID, Model.Info>()
|
||||
for (const item of remote) {
|
||||
const template = templates.get(Model.ID.make(item.base_model_id ?? item.hugging_face_id ?? item.id))
|
||||
const id = Model.ID.make(item.id)
|
||||
result.set(id, build(id, item, baseURL, template))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function price(value: string | number | undefined, fallback: Money.USDPerMillionTokens) {
|
||||
if (value === undefined) return fallback
|
||||
const parsed = Number(value) * 1_000_000
|
||||
return Number.isFinite(parsed) ? Money.USDPerMillionTokens.make(parsed) : fallback
|
||||
}
|
||||
|
||||
function limit(value: number | undefined, fallback: number) {
|
||||
const parsed = value === undefined ? fallback : Math.trunc(value)
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function build(id: Model.ID, remote: RemoteModel, baseURL: string, previous?: Model.Info) {
|
||||
const cost = previous?.cost[0]
|
||||
const input = previous?.limit.input
|
||||
return Model.Info.make({
|
||||
...Model.Info.default(providerID, id),
|
||||
id,
|
||||
modelID: Model.ID.make(remote.id),
|
||||
providerID,
|
||||
name: remote.name ?? previous?.name ?? remote.id,
|
||||
family: previous?.family,
|
||||
compatibility:
|
||||
remote.interleaved === undefined
|
||||
? previous?.compatibility
|
||||
: (Model.compatibility(remote.interleaved) ?? previous?.compatibility),
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: Provider.mergeOverlay(previous?.settings, { baseURL }),
|
||||
headers: previous?.headers,
|
||||
body: previous?.body,
|
||||
capabilities: {
|
||||
tools: remote.supported_features?.includes("tools") ?? previous?.capabilities.tools ?? true,
|
||||
input: remote.input_modalities ?? previous?.capabilities.input ?? ["text"],
|
||||
output: remote.output_modalities ?? previous?.capabilities.output ?? ["text"],
|
||||
},
|
||||
variants: remote.reasoning_options === undefined ? (previous?.variants ?? []) : variants(remote),
|
||||
time: previous?.time ?? { released: 0 },
|
||||
cost: [
|
||||
{
|
||||
input: price(remote.pricing?.prompt, cost?.input ?? Money.USDPerMillionTokens.zero),
|
||||
output: price(remote.pricing?.completion, cost?.output ?? Money.USDPerMillionTokens.zero),
|
||||
cache: {
|
||||
read: price(remote.pricing?.input_cache_read, cost?.cache.read ?? Money.USDPerMillionTokens.zero),
|
||||
write: cost?.cache.write ?? Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
],
|
||||
status: previous?.status ?? "active",
|
||||
enabled: previous?.enabled ?? true,
|
||||
limit: {
|
||||
context: limit(remote.context_length, previous?.limit.context ?? 0),
|
||||
...(input === undefined ? {} : { input }),
|
||||
output: limit(remote.max_output_length, previous?.limit.output ?? 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function variants(remote: RemoteModel): Model.Info["variants"] {
|
||||
const seen = new Map<string, Model.Info["variants"][number]>()
|
||||
for (const option of remote.reasoning_options ?? []) {
|
||||
for (const value of option.values) {
|
||||
const effort = value ?? "none"
|
||||
if (!seen.has(effort))
|
||||
seen.set(effort, { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } })
|
||||
}
|
||||
}
|
||||
return [...seen.values()]
|
||||
}
|
||||
+30
-137
@@ -5,7 +5,7 @@ import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import type { PersistentPty } from "./persistent-pty.js"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Logger, Queue, References, Scope, Semaphore } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { Bus } from "./bus.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { PluginHost } from "./plugin/host.js"
|
||||
@@ -26,64 +26,43 @@ const layer = Layer.effect(
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const ready = yield* Latch.make(true)
|
||||
const pending = new Set<object>()
|
||||
let closed = false
|
||||
const holdUnsafe = () => {
|
||||
if (closed) return Effect.void
|
||||
const token = {}
|
||||
pending.add(token)
|
||||
ready.closeUnsafe()
|
||||
return Effect.sync(() => {
|
||||
if (pending.delete(token) && pending.size === 0) ready.openUnsafe()
|
||||
const hold = () =>
|
||||
Effect.sync(() => {
|
||||
const token = {}
|
||||
pending.add(token)
|
||||
ready.closeUnsafe()
|
||||
return Effect.sync(() => {
|
||||
if (pending.delete(token) && pending.size === 0) ready.openUnsafe()
|
||||
})
|
||||
})
|
||||
}
|
||||
const hold = () => Effect.sync(holdUnsafe)
|
||||
const pendingFailures = yield* Queue.unbounded<PendingFailure>()
|
||||
let discovered: readonly Failure[] = []
|
||||
let inventory: Plugin.Info[] = []
|
||||
const list = Effect.fn("Plugin.list")(function* () {
|
||||
return inventory
|
||||
})
|
||||
const host = yield* PluginHost.make({ list })
|
||||
const load = Effect.fnUntraced(function* (plugin: Generation) {
|
||||
const activation: Activation = { plugin, scope: yield* Scope.fork(scope) }
|
||||
const child = yield* Scope.fork(scope)
|
||||
const inherit = yield* State.inherit()
|
||||
const grouped = State.group((failure, refresh) => {
|
||||
activation.failure = {
|
||||
error: `Plugin disabled after ${failure.state}.transform failed. Check server logs for details.`,
|
||||
ref: `err_${crypto.randomUUID().slice(0, 8)}`,
|
||||
}
|
||||
Queue.offerUnsafe(pendingFailures, {
|
||||
plugin,
|
||||
scope: activation.scope,
|
||||
failure,
|
||||
refresh,
|
||||
ref: activation.failure.ref,
|
||||
release: holdUnsafe(),
|
||||
})
|
||||
})
|
||||
const exit = yield* Effect.suspend(() =>
|
||||
const loaded = yield* Effect.suspend(() =>
|
||||
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
|
||||
).pipe(
|
||||
grouped,
|
||||
inherit,
|
||||
Effect.updateContext((context: Context.Context<never>) =>
|
||||
Context.make(Scope.Scope, activation.scope).pipe(
|
||||
Context.make(Scope.Scope, child).pipe(
|
||||
Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)),
|
||||
Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)),
|
||||
),
|
||||
),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
|
||||
Effect.onExit((exit) =>
|
||||
Exit.isFailure(exit) && !activation.failure ? Scope.close(activation.scope, exit) : Effect.void,
|
||||
),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
Effect.exit,
|
||||
)
|
||||
if (activation.failure || Exit.isSuccess(exit)) return { activation } as const
|
||||
if (Exit.isSuccess(loaded)) return { scope: child } as const
|
||||
yield* Effect.logWarning("failed to load plugin", {
|
||||
"plugin.id": plugin.id,
|
||||
cause: exit.cause,
|
||||
cause: loaded.cause,
|
||||
})
|
||||
return { error: Cause.pretty(exit.cause) } as const
|
||||
return { error: Cause.pretty(loaded.cause) } as const
|
||||
})
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
@@ -102,8 +81,6 @@ const layer = Layer.effect(
|
||||
() =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
discovered = failures
|
||||
const current = Array.from(active.values())
|
||||
const changed = definitions.findIndex((definition, index) => {
|
||||
const entry = current[index]
|
||||
@@ -131,36 +108,29 @@ const layer = Layer.effect(
|
||||
([id, slot]) =>
|
||||
Effect.gen(function* () {
|
||||
active.delete(id)
|
||||
if (slot.activation && !slot.activation.failure)
|
||||
yield* Scope.close(slot.activation.scope, Exit.void)
|
||||
if (slot.loaded) yield* Scope.close(slot.loaded.scope, Exit.void)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
for (const definition of definitions.slice(prefix)) {
|
||||
const slot = previous.get(definition.id)
|
||||
// Reordering healthy registrations does not authorize retrying a failed revision.
|
||||
if (slot?.activation?.failure && slot.plugin.revision === definition.revision) {
|
||||
active.set(definition.id, { ...slot, plugin: definition })
|
||||
continue
|
||||
}
|
||||
const result = yield* load(definition)
|
||||
if (result.activation !== undefined) {
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
active.set(definition.id, {
|
||||
plugin: definition,
|
||||
activation: result.activation,
|
||||
loaded: { plugin: definition, scope: loaded.scope },
|
||||
})
|
||||
continue
|
||||
}
|
||||
active.set(definition.id, { plugin: definition, error: result.error })
|
||||
active.set(definition.id, { plugin: definition, error: loaded.error })
|
||||
|
||||
const fallback = slot?.activation
|
||||
if (!fallback || fallback.failure) continue
|
||||
const fallback = previous.get(definition.id)?.loaded
|
||||
if (!fallback) continue
|
||||
const restored = yield* load(fallback.plugin)
|
||||
if (restored.activation !== undefined) {
|
||||
if (restored.scope !== undefined) {
|
||||
active.set(definition.id, {
|
||||
plugin: definition,
|
||||
activation: restored.activation,
|
||||
error: result.error,
|
||||
loaded: { plugin: fallback.plugin, scope: restored.scope },
|
||||
error: loaded.error,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -179,68 +149,9 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
yield* Queue.take(pendingFailures).pipe(
|
||||
Effect.flatMap((item) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logWarning("disabled plugin after transform failure", {
|
||||
"plugin.id": item.plugin.id,
|
||||
state: item.failure.state,
|
||||
ref: item.ref,
|
||||
cause: Cause.die(item.failure.cause),
|
||||
})
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
// Failure is already recorded on its exact activation, so an old queued item
|
||||
// cannot disable a replacement and teardown need not wait for this worker.
|
||||
inventory = [...Array.from(active.values()).map(slotInfo), ...discovered]
|
||||
const refreshed = yield* State.batch(item.refresh).pipe(Effect.exit)
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
if (Exit.isFailure(refreshed))
|
||||
yield* Effect.logWarning("failed to refresh state after disabling plugin", {
|
||||
"plugin.id": item.plugin.id,
|
||||
ref: item.ref,
|
||||
cause: refreshed.cause,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
// Cleanup must also be scheduled if an inventory observer fails. User finalizers
|
||||
// may await readiness, so never join them under the activation lock or readiness hold.
|
||||
Effect.ensuring(
|
||||
Scope.close(item.scope, Exit.void).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to clean up disabled plugin", {
|
||||
"plugin.id": item.plugin.id,
|
||||
ref: item.ref,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
),
|
||||
),
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) =>
|
||||
Effect.logError("failed to report disabled plugin", {
|
||||
"plugin.id": item.plugin.id,
|
||||
ref: item.ref,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(item.release),
|
||||
),
|
||||
),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
const close = (exit: Exit.Exit<unknown, unknown>) =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
closed = true
|
||||
pending.clear()
|
||||
ready.openUnsafe()
|
||||
active.clear()
|
||||
yield* State.shutdown(Scope.close(scope, exit))
|
||||
}),
|
||||
@@ -257,37 +168,19 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
// `plugin` is the requested definition; `activation` is its last activation, which may have
|
||||
// failed or be an older fallback while the requested revision keeps failing setup.
|
||||
// `plugin` is the definition the slot was last asked to run; `loaded` is the generation actually
|
||||
// running, which stays an older fallback while the requested revision keeps failing setup.
|
||||
type Slot = {
|
||||
readonly plugin: Generation
|
||||
readonly activation?: Activation
|
||||
readonly loaded?: { readonly plugin: Generation; readonly scope: Scope.Closeable }
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// Share the activation across slot snapshots so teardown sees failures synchronously,
|
||||
// including failures discovered after activate() has captured its previous slots.
|
||||
type Activation = {
|
||||
readonly plugin: Generation
|
||||
readonly scope: Scope.Closeable
|
||||
failure?: { readonly error: string; readonly ref: string }
|
||||
}
|
||||
|
||||
type PendingFailure = {
|
||||
readonly plugin: Generation
|
||||
readonly scope: Scope.Closeable
|
||||
readonly failure: State.Failure
|
||||
readonly refresh: Effect.Effect<void>
|
||||
readonly ref: string
|
||||
readonly release: Effect.Effect<void>
|
||||
}
|
||||
|
||||
function slotInfo(slot: Slot): Plugin.Info {
|
||||
const failure = slot.activation?.failure ?? (slot.error === undefined ? undefined : { error: slot.error })
|
||||
return {
|
||||
id: Plugin.ID.make(slot.plugin.id),
|
||||
source: slot.plugin.source ?? { type: "builtin" },
|
||||
state: failure === undefined ? { status: "active" } : { status: "failed", ...failure },
|
||||
state: slot.error === undefined ? { status: "active" } : { status: "failed", error: slot.error },
|
||||
features: { server: true, ...slot.plugin.features },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { KiloPlugin } from "./provider/kilo.js"
|
||||
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
|
||||
import { LMStudioPlugin } from "./provider/lmstudio.js"
|
||||
import { MistralPlugin } from "./provider/mistral.js"
|
||||
import { ModalPlugin } from "./provider/modal.js"
|
||||
import { NvidiaPlugin } from "./provider/nvidia.js"
|
||||
import { OllamaPlugin } from "./provider/ollama.js"
|
||||
import { OpenAIPlugin } from "./provider/openai.js"
|
||||
@@ -49,7 +48,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
LLMGatewayPlugin,
|
||||
LMStudioPlugin,
|
||||
MistralPlugin,
|
||||
ModalPlugin,
|
||||
NvidiaPlugin,
|
||||
OllamaPlugin,
|
||||
OpencodePlugin,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const clientID = "Ov23li8tweQw6odWQebz"
|
||||
const apiVersion = "2026-08-01"
|
||||
const apiVersion = "2026-06-01"
|
||||
const userApiVersion = "2025-04-01"
|
||||
const pollingSafetyMargin = 3000
|
||||
const methodID = Integration.MethodID.make("device")
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Effect, Semaphore, Stream } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Catalog } from "../../catalog.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { ModalModels } from "../../modal/models.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const providerID = Provider.ID.make("modal")
|
||||
|
||||
export const ModalPlugin = define({
|
||||
id: "opencode.provider.modal",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const catalog = yield* Catalog.Service
|
||||
const bus = yield* Bus.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
const loaded: {
|
||||
baseURL?: string
|
||||
models?: Map<Model.ID, Model.Info>
|
||||
} = {}
|
||||
|
||||
const load = Effect.fn("ModalPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("modal")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
const apiKey = credential?.type === "key" ? credential.key : process.env.MODAL_PROXY_TOKEN
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const baseURL = typeof provider?.settings?.baseURL === "string" ? provider.settings.baseURL : undefined
|
||||
if (!apiKey || !baseURL) {
|
||||
loaded.baseURL = undefined
|
||||
loaded.models = undefined
|
||||
return
|
||||
}
|
||||
loaded.baseURL = baseURL
|
||||
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === providerID)
|
||||
loaded.models = yield* Effect.tryPromise({
|
||||
try: () => ModalModels.get(baseURL, apiKey, existing),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
Effect.catch((cause) => Effect.logWarning("failed to sync Modal models", { cause }).pipe(Effect.as(undefined))),
|
||||
)
|
||||
})
|
||||
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
if (!loaded.models) return
|
||||
for (const id of item.models.keys()) {
|
||||
if (!loaded.models.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
|
||||
}
|
||||
for (const [id, model] of loaded.models) {
|
||||
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
|
||||
}
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("modal")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
@@ -121,15 +121,7 @@ const layer = Layer.effect(
|
||||
// The heterogeneous registry erases handlers after their selected schema validates input.
|
||||
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
|
||||
return execution
|
||||
}).pipe(
|
||||
Effect.catch((error) => encodeError(method, error)),
|
||||
// Normalize handler bugs here so direct callers can recover just like HTTP callers.
|
||||
Effect.catchDefect((defect) =>
|
||||
Effect.logError("rpc handler failed", { rpc: rpcID, method: name, defect }).pipe(
|
||||
Effect.andThen(Effect.fail(failure("rpc.internal", "RPC call failed"))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.catch((error) => encodeError(method, error)))
|
||||
return yield* encode(method.output, result).pipe(
|
||||
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Database } from "./database/database.js"
|
||||
import { SessionProjector } from "./session/projector.js"
|
||||
import { SessionMessageTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { RelativePath } from "./schema.js"
|
||||
import { AbsolutePath, RelativePath } from "./schema.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "./session/error.js"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { SessionInbox } from "./session/inbox.js"
|
||||
import { InstructionState } from "./session/instruction-state.js"
|
||||
@@ -61,6 +62,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { Job } from "./job.js"
|
||||
import type { Command } from "./command.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
|
||||
@@ -166,7 +168,15 @@ export interface Interface {
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly move: SessionMove.Interface["move"]
|
||||
readonly move: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
directory: AbsolutePath
|
||||
workspaceID?: Location.Ref["workspaceID"]
|
||||
delivery?: SessionInbox.Delivery
|
||||
}) => Effect.Effect<
|
||||
void,
|
||||
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
|
||||
>
|
||||
readonly prompt: (
|
||||
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
|
||||
) => ReturnType<Session.Handle["prompt"]>
|
||||
@@ -222,15 +232,18 @@ const layer = Layer.effect(
|
||||
const db = database.db
|
||||
const bus = yield* Bus.Service
|
||||
const projects = yield* Project.Service
|
||||
const global = yield* Global.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const instances = yield* Instance.Service
|
||||
const moves = yield* SessionMove.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const sessions = yield* Session.make()
|
||||
const admission = yield* SessionInbox.Service
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
const result = Service.of({
|
||||
@@ -397,7 +410,45 @@ const layer = Layer.effect(
|
||||
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
|
||||
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
|
||||
rename: (input) => sessions.forSession(input.sessionID).rename(input),
|
||||
move: moves.move,
|
||||
move: Effect.fn("Session.move")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const payload = yield* SessionMove.prepare({ ...input, session }).pipe(
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(Project.Service, projects),
|
||||
Effect.provideService(LocationServiceMap.Service, locations),
|
||||
)
|
||||
const item = SessionInbox.Item.make({
|
||||
type: "move",
|
||||
payload,
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
yield* SessionInbox.serialized(
|
||||
input.sessionID,
|
||||
Effect.gen(function* () {
|
||||
const latest = yield* result.get(input.sessionID)
|
||||
const source = yield* fs.stat(latest.location.directory).pipe(Effect.orElseSucceed(() => undefined))
|
||||
// Active runners must hand off at a step boundary to retain their continuation.
|
||||
if ((!source || source.type !== "Directory") && !(yield* execution.isActive(input.sessionID))) {
|
||||
const cancellations = (yield* SessionInbox.moveIDs(db, input.sessionID)).map(
|
||||
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
|
||||
)
|
||||
const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...payload }] as const
|
||||
const first = cancellations[0]
|
||||
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid)
|
||||
return yield* bus.publishAll([first, ...cancellations.slice(1), moved])
|
||||
}
|
||||
yield* admission
|
||||
.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID: input.sessionID,
|
||||
item,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
compact: (input) => sessions.forSession(input.sessionID).compact(input),
|
||||
wait: (sessionID) => sessions.forSession(sessionID).wait(),
|
||||
active: execution.active,
|
||||
@@ -448,9 +499,10 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
|
||||
SessionStore.node,
|
||||
Instance.node,
|
||||
SessionInbox.node,
|
||||
SessionMove.node,
|
||||
LocationServiceMap.node,
|
||||
SessionProjector.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
App.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -367,7 +367,6 @@ export const layer = Layer.effect(
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
let usage: SessionUsage.Recorded | undefined
|
||||
let providerState: SessionMessage.ProviderState | undefined
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? bus.publish(SessionEvent.UsageRecorded, {
|
||||
@@ -408,7 +407,6 @@ export const layer = Layer.effect(
|
||||
// Ignored tool calls never enter the follow-up history or need fabricated results.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
chunks.length = 0
|
||||
providerState = undefined
|
||||
yield* llm
|
||||
.stream(
|
||||
attempt === 0
|
||||
@@ -438,10 +436,6 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
providerState =
|
||||
event.providerMetadata?.[
|
||||
context.model.model.route.providerMetadataKey ?? context.model.model.provider
|
||||
]
|
||||
const step = SessionUsage.record(event.usage, context.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
@@ -488,8 +482,6 @@ export const layer = Layer.effect(
|
||||
yield* bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
model: context.model.ref,
|
||||
providerState,
|
||||
text: summary,
|
||||
recent: history.recent,
|
||||
})
|
||||
|
||||
@@ -176,7 +176,13 @@ export const layer = (options?: Options) =>
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
return SubagentCompletion.text(assistant)
|
||||
if (assistant?.type !== "assistant") return "Subagent completed without a text response."
|
||||
return (
|
||||
assistant.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("") || "Subagent completed without a text response."
|
||||
)
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -410,8 +410,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
})
|
||||
@@ -424,8 +422,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
status: "completed",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
time: { created },
|
||||
|
||||
@@ -28,9 +28,11 @@ const events = Metric.counter("opencode_session_websocket_events_total", {
|
||||
const metric = (event: string, attributes: Record<string, string> = {}) =>
|
||||
Metric.update(events.pipe(Metric.withAttributes({ event, ...attributes })), 1)
|
||||
|
||||
type Delivery = "queued" | "connecting" | "ready" | "send-attempted" | "provider-observed" | "terminal"
|
||||
|
||||
interface Active {
|
||||
readonly queue: Queue.Queue<string, AIError>
|
||||
delivery: "send-attempted" | "provider-observed" | "terminal"
|
||||
readonly lifecycle: { delivery: Delivery }
|
||||
}
|
||||
|
||||
interface Channel {
|
||||
@@ -128,9 +130,14 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
code: "close",
|
||||
phase: "close",
|
||||
delivery:
|
||||
channel.active.delivery === "provider-observed" || channel.active.delivery === "terminal"
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
channel.active.lifecycle.delivery === "queued" ||
|
||||
channel.active.lifecycle.delivery === "connecting" ||
|
||||
channel.active.lifecycle.delivery === "ready"
|
||||
? "not-sent"
|
||||
: channel.active.lifecycle.delivery === "provider-observed" ||
|
||||
channel.active.lifecycle.delivery === "terminal"
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -191,7 +198,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
code: "idle-data",
|
||||
phase: "receive",
|
||||
})
|
||||
active.delivery = "provider-observed"
|
||||
active.lifecycle.delivery = "provider-observed"
|
||||
if (typeof message !== "string")
|
||||
return yield* transportError("Unsupported binary WebSocket frame", {
|
||||
url: exchange.connect.url,
|
||||
@@ -219,8 +226,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
phase:
|
||||
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
||||
delivery:
|
||||
channel.active?.delivery === "provider-observed" ||
|
||||
channel.active?.delivery === "terminal" ||
|
||||
channel.active?.lifecycle.delivery === "provider-observed" ||
|
||||
channel.active?.lifecycle.delivery === "terminal" ||
|
||||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
|
||||
? "accepted"
|
||||
: error.reason._tag === "Transport" && error.reason.code === "1009"
|
||||
@@ -249,6 +256,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const start = Effect.fn("SessionModelTransport.start")(function* (
|
||||
owner: State,
|
||||
exchange: WebSocketChannelExchange,
|
||||
lifecycle: { delivery: Delivery },
|
||||
) {
|
||||
if (owner.closed)
|
||||
return yield* transportError("Session WebSocket owner is closed", {
|
||||
@@ -280,6 +288,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
yield* closeChannel(owner, current)
|
||||
}
|
||||
|
||||
lifecycle.delivery = owner.channel ? "ready" : "connecting"
|
||||
if (owner.channel)
|
||||
yield* Effect.logDebug("session websocket reused", {
|
||||
sessionTransport: "websocket",
|
||||
@@ -305,6 +314,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
),
|
||||
)
|
||||
if (!channel) return fallback(exchange)
|
||||
lifecycle.delivery = "ready"
|
||||
|
||||
if (channel.pending) {
|
||||
channel.pending = undefined
|
||||
@@ -316,11 +326,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
)
|
||||
if (create.mode === "full") channel.checkpoint = undefined
|
||||
const active: Active = {
|
||||
queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY),
|
||||
delivery: "send-attempted",
|
||||
}
|
||||
const active: Active = { queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY), lifecycle }
|
||||
channel.active = active
|
||||
lifecycle.delivery = "send-attempted"
|
||||
const sent = yield* channel.connection.sendText(create.message).pipe(
|
||||
Effect.withSpan("SessionModelTransport.send"),
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
@@ -358,7 +366,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
operation: "read",
|
||||
code: "idle-timeout",
|
||||
phase: "receive",
|
||||
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -367,7 +375,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.sync(() => {
|
||||
if (!observationTerminal(observation)) return
|
||||
terminal = observation
|
||||
active.delivery = "terminal"
|
||||
lifecycle.delivery = "terminal"
|
||||
const staged = observation.type === "completed" ? observation.checkpoint : undefined
|
||||
if (staged) channel.pending = { token, checkpoint: staged }
|
||||
if (observation.type !== "completed" || !staged) channel.checkpoint = undefined
|
||||
@@ -403,7 +411,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
operation: "read",
|
||||
code: "incomplete",
|
||||
phase: "receive",
|
||||
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
})
|
||||
yield* poison(owner, channel, error)
|
||||
}),
|
||||
@@ -440,6 +448,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) => {
|
||||
const owner = state(sessionID)
|
||||
const lifecycle = { delivery: "queued" as Delivery }
|
||||
let execution: WebSocketChannelExecution | undefined
|
||||
return Effect.succeed({
|
||||
get http() {
|
||||
@@ -447,7 +456,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
},
|
||||
frames: Stream.unwrap(
|
||||
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
|
||||
Effect.andThen(start(owner, exchange)),
|
||||
Effect.andThen(start(owner, exchange, lifecycle)),
|
||||
Effect.tap((started) =>
|
||||
Effect.sync(() => {
|
||||
execution = started
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
export * as SessionMove from "./move.js"
|
||||
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Instance } from "../instance/service.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Project } from "../project.js"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import { NotFoundError } from "./error.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionExecution } from "./execution.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionProjector } from "./projector.js"
|
||||
import { SessionRunner } from "./runner/index.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
|
||||
export class DestinationNotFoundError extends Schema.TaggedError<DestinationNotFoundError>()(
|
||||
"Session.DestinationNotFoundError",
|
||||
@@ -37,138 +26,36 @@ export class DestinationUnavailableError extends Schema.TaggedError<DestinationU
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly move: (input: {
|
||||
sessionID: Session.ID
|
||||
directory: AbsolutePath
|
||||
workspaceID?: Location.Ref["workspaceID"]
|
||||
delivery?: SessionInbox.Delivery
|
||||
}) => Effect.Effect<
|
||||
void,
|
||||
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
|
||||
>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionMove") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const projects = yield* Project.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const instances = yield* Instance.Service
|
||||
const admission = yield* SessionInbox.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const get = Effect.fn("SessionMove.get")(function* (sessionID: Session.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* new NotFoundError({ sessionID })
|
||||
return session
|
||||
})
|
||||
|
||||
const resolveDestination = Effect.fn("SessionMove.resolveDestination")(function* (
|
||||
session: Session.Info,
|
||||
input: Parameters<Interface["move"]>[0],
|
||||
) {
|
||||
const value = input.directory.trim()
|
||||
const expanded =
|
||||
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
||||
const directory = AbsolutePath.make(path.resolve(session.location.directory, expanded))
|
||||
const info = yield* fs.stat(directory).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!info) return yield* new DestinationNotFoundError({ directory })
|
||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
const project = yield* projects.resolve(directory)
|
||||
const destination: SessionInbox.MovePayload = {
|
||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
}
|
||||
yield* locations.contextEffect(destination.location).pipe(
|
||||
Effect.scoped,
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
|
||||
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
|
||||
)
|
||||
}),
|
||||
export const prepare = Effect.fn("SessionMove.prepare")(function* (input: {
|
||||
session: Session.Info
|
||||
directory: AbsolutePath
|
||||
workspaceID?: Location.Ref["workspaceID"]
|
||||
}) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const projects = yield* Project.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const value = input.directory.trim()
|
||||
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
||||
const directory = AbsolutePath.make(path.resolve(input.session.location.directory, expanded))
|
||||
const info = yield* fs.stat(directory).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!info) return yield* new DestinationNotFoundError({ directory })
|
||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
const project = yield* projects.resolve(directory)
|
||||
const payload: SessionInbox.MovePayload = {
|
||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
}
|
||||
yield* Location.Service.pipe(
|
||||
Effect.provide(locations.get(payload.location)),
|
||||
Effect.scoped,
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
|
||||
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
|
||||
)
|
||||
return destination
|
||||
})
|
||||
|
||||
const sourceUnavailable = Effect.fn("SessionMove.sourceUnavailable")(function* (session: Session.Info) {
|
||||
if (yield* execution.isActive(session.id)) return false
|
||||
if (!(yield* fs.isDir(session.location.directory))) return true
|
||||
return yield* SessionRunner.Service.pipe(
|
||||
instances.provide(session),
|
||||
Effect.as(false),
|
||||
Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.succeed(true))),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
move: Effect.fn("SessionMove.move")(function* (input) {
|
||||
const session = yield* get(input.sessionID)
|
||||
const destination = yield* resolveDestination(session, input)
|
||||
// Probe outside the inbox lock so cancellation remains available during initialization.
|
||||
const unavailable = yield* sourceUnavailable(session)
|
||||
const item = SessionInbox.Item.make({
|
||||
type: "move",
|
||||
payload: destination,
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
yield* SessionInbox.serialized(
|
||||
input.sessionID,
|
||||
Effect.gen(function* () {
|
||||
const latest = yield* get(input.sessionID)
|
||||
// Only recover the placement we probed; active runners retain their step-boundary handoff.
|
||||
if (
|
||||
unavailable &&
|
||||
latest.location.directory === session.location.directory &&
|
||||
latest.location.workspaceID === session.location.workspaceID &&
|
||||
!(yield* execution.isActive(input.sessionID))
|
||||
) {
|
||||
const cancellations = (yield* SessionInbox.moveIDs(database.db, input.sessionID)).map(
|
||||
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
|
||||
)
|
||||
const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...destination }] as const
|
||||
const first = cancellations[0]
|
||||
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid)
|
||||
return yield* bus.publishAll([first, ...cancellations.slice(1), moved])
|
||||
}
|
||||
yield* admission
|
||||
.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID: input.sessionID,
|
||||
item,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
Project.node,
|
||||
LocationServiceMap.node,
|
||||
SessionStore.node,
|
||||
SessionExecution.node,
|
||||
Instance.node,
|
||||
SessionInbox.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
],
|
||||
}),
|
||||
)
|
||||
return payload
|
||||
})
|
||||
|
||||
@@ -90,8 +90,10 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
}
|
||||
const assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
let providerFailed = false
|
||||
let outputStarted = false
|
||||
let stepStreamed = false
|
||||
let stepFailure: SessionError.Error | undefined
|
||||
let stepSettlement: StepRecord["finish"]
|
||||
|
||||
@@ -110,6 +112,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
const currentAssistantMessageID = () =>
|
||||
stepStarted ? Effect.succeed(assistantMessageID) : Effect.die(new Error("Tool event before assistant step start"))
|
||||
const streamed = Effect.fnUntraced(function* () {
|
||||
if (stepStreamed) return
|
||||
stepStreamed = true
|
||||
yield* bus.publish(SessionEvent.Step.Streamed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
@@ -363,8 +367,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
readonly snapshot?: Snapshot.ID
|
||||
readonly files?: readonly RelativePath[]
|
||||
}) {
|
||||
if (stepFailure === undefined) return
|
||||
if (stepFailed || stepFailure === undefined) return
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
stepFailed = true
|
||||
yield* bus.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
|
||||
@@ -3,19 +3,6 @@ export * as SubagentCompletion from "./subagent-completion.js"
|
||||
import { Effect } from "effect"
|
||||
import type { Job } from "../job.js"
|
||||
import type { Session } from "../session.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
|
||||
export const NO_TEXT = "Subagent completed without a text response."
|
||||
|
||||
export function text(message: SessionMessage.Info | undefined) {
|
||||
if (message?.type !== "assistant") return NO_TEXT
|
||||
return (
|
||||
message.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("") || NO_TEXT
|
||||
)
|
||||
}
|
||||
|
||||
export const deliver = Effect.fnUntraced(function* (
|
||||
sessions: Pick<Session.Interface, "synthetic">,
|
||||
@@ -29,7 +16,7 @@ export const deliver = Effect.fnUntraced(function* (
|
||||
const recovery = input.recovery
|
||||
const text =
|
||||
input.status === "completed"
|
||||
? (input.output ?? NO_TEXT)
|
||||
? (input.output ?? "Subagent completed without a text response.")
|
||||
: input.status === "error"
|
||||
? (input.error ?? "Subagent failed")
|
||||
: "Subagent cancelled"
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
export * as SubagentJob from "./subagent-job.js"
|
||||
|
||||
import { Effect, Scope } from "effect"
|
||||
import { Job } from "../job.js"
|
||||
import { Session } from "../session.js"
|
||||
import { SubagentCompletion } from "./subagent-completion.js"
|
||||
|
||||
type Recovery = Extract<Job.Recovery, { kind: "subagent" }>
|
||||
|
||||
interface Runner {
|
||||
start: (recovery: Recovery) => Effect.Effect<Job.Info>
|
||||
background: (recovery: Recovery) => Effect.Effect<void>
|
||||
notify: (recovery: Recovery, startedAt: number) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const make: Effect.Effect<Runner, never, Session.Service | Job.Service | Scope.Scope> = Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const jobs = yield* Job.Service
|
||||
const scope = yield* Scope.Scope
|
||||
// One observer per job generation, including continuations of the same child.
|
||||
const notifications = new Set<string>()
|
||||
|
||||
const notify = Effect.fn("SubagentJob.notify")(function* (recovery: Recovery, startedAt: number) {
|
||||
const key = `${recovery.childSessionID}:${startedAt}`
|
||||
if (notifications.has(key)) return
|
||||
notifications.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
const info = (yield* jobs.wait({ id: recovery.childSessionID })).info
|
||||
if (info) yield* SubagentCompletion.deliver(sessions, jobs, { ...info, recovery })
|
||||
}).pipe(
|
||||
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
start: (recovery: Recovery) =>
|
||||
jobs.start({
|
||||
id: recovery.childSessionID,
|
||||
type: "subagent",
|
||||
title: recovery.description,
|
||||
metadata: {},
|
||||
recovery,
|
||||
run: Effect.gen(function* () {
|
||||
yield* sessions.resume(recovery.childSessionID)
|
||||
const messages = yield* sessions.messages({ sessionID: recovery.childSessionID, order: "desc", limit: 20 })
|
||||
const assistant = messages.find(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
return SubagentCompletion.text(assistant)
|
||||
}),
|
||||
}),
|
||||
background: Effect.fn("SubagentJob.background")(function* (recovery: Recovery) {
|
||||
const info = yield* jobs.background(recovery.childSessionID)
|
||||
if (info) yield* notify(recovery, info.started_at)
|
||||
}),
|
||||
notify,
|
||||
}
|
||||
})
|
||||
@@ -297,9 +297,6 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
|
||||
metadata: meta,
|
||||
summary: redact("compaction-summary", message.id, message.summary),
|
||||
recent: redact("compaction-recent", message.id, message.recent),
|
||||
...(message.status === "completed"
|
||||
? { providerState: metadata("compaction-provider-state", message.id, message.providerState) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
return { ...message, metadata: meta }
|
||||
|
||||
@@ -149,7 +149,6 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
|
||||
const char = input[index]
|
||||
if (!wordStarted) wordStart = index
|
||||
if (!quote && !wordStarted) {
|
||||
if (char === " " || char === "\t") continue
|
||||
const structure = structures.at(-1)
|
||||
const token = /^[A-Za-z_][A-Za-z0-9_]*(?=[ \t\n;()<>]|$)/.exec(input.slice(index))?.[0]
|
||||
if (structure?.kind === "case" && structure.phase === "header" && token === "in") {
|
||||
@@ -167,24 +166,13 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
if (structure?.kind === "for" && structure.phase === "header" && char === "(" && input[index + 1] !== "(") {
|
||||
const values = bashExpansion(input, index, depth, "array")
|
||||
if (!values) return { kind: "opaque", reason: "compound-command" }
|
||||
finishCommand()
|
||||
const failure = addSubstitutions(values)
|
||||
if (failure) return failure
|
||||
commands.push(...nestedCommands.splice(0))
|
||||
// Zsh permits a sublist or brace group directly after the value list, without do/done.
|
||||
structure.phase = "do"
|
||||
if (!/^(?:[ \t\n;]|\\\n|#[^\n]*(?:\n|$))*do(?=[ \t\n;]|$)/.test(input.slice(values.end + 1))) structures.pop()
|
||||
index = values.end
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
if (!words.length && !hasRedirect && !compoundEnd) {
|
||||
const definition = bashFunctionHead(input, index)
|
||||
const definition =
|
||||
/^(?:function[ \t]+[A-Za-z_][A-Za-z0-9_]*(?:[ \t]*\([ \t]*\))?|[A-Za-z_][A-Za-z0-9_]*[ \t]*\([ \t]*\))[ \t\n]*(?=[{(])/.exec(
|
||||
input.slice(index),
|
||||
)
|
||||
if (definition && !header()) {
|
||||
index += definition.length - 1
|
||||
index += definition[0].length - 1
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
@@ -548,14 +536,6 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
|
||||
|
||||
type BashExpansion = { source: string; end: number; substitutions?: string[] }
|
||||
|
||||
function bashFunctionHead(input: string, start: number) {
|
||||
// Share recognition with delimiter scanning so case patterns in function bodies do not close the outer group.
|
||||
// Names need not be variable identifiers. Zsh permits anonymous functions, including in an if condition.
|
||||
return /^(?!if(?:[ \t]|\\\n)*\()(?:function[ \t]+(?:\\\n[ \t]*)*[A-Za-z_][A-Za-z0-9_.:-]*(?:(?:[ \t]|\\\n)*\([ \t]*\))?|(?:[A-Za-z_][A-Za-z0-9_.:-]*(?:[ \t]|\\\n)*)?\([ \t]*\))(?:[ \t\n]|\\\n|#[^\n]*(?:\n|$))*(?=[{(]|\[\[(?=[ \t\n])|(?:if|while|until|for|select|case)[ \t\n])/.exec(
|
||||
input.slice(start),
|
||||
)?.[0]
|
||||
}
|
||||
|
||||
function bashDelimited(input: string, start: number, depth: number): BashExpansion | undefined {
|
||||
if (depth >= MAX_SUBSTITUTION_DEPTH) return
|
||||
const close = input[start] === "{" ? "}" : ")"
|
||||
@@ -564,7 +544,6 @@ function bashDelimited(input: string, start: number, depth: number): BashExpansi
|
||||
let commandStart = true
|
||||
for (let index = start + 1; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (char === " " || char === "\t") continue
|
||||
if (char === "\\") {
|
||||
if (input[index + 1] !== "\n") commandStart = false
|
||||
index++
|
||||
@@ -587,11 +566,6 @@ function bashDelimited(input: string, start: number, depth: number): BashExpansi
|
||||
continue
|
||||
}
|
||||
const boundary = index === start + 1 || /[ \t\n;|&(){}]/.test(input[index - 1])
|
||||
const definition = commandStart && boundary ? bashFunctionHead(input, index) : undefined
|
||||
if (definition) {
|
||||
index += definition.length - 1
|
||||
continue
|
||||
}
|
||||
if (char === "#" && boundary) {
|
||||
const newline = input.indexOf("\n", index)
|
||||
if (newline < 0) return
|
||||
@@ -751,10 +725,7 @@ function bashExpansion(
|
||||
index = nested.end
|
||||
continue
|
||||
}
|
||||
if (
|
||||
((kind === "array" && "<>=".includes(char)) || (kind === "test" && "<>".includes(char))) &&
|
||||
input[index + 1] === "("
|
||||
) {
|
||||
if (kind === "array" && "<>=".includes(char) && input[index + 1] === "(") {
|
||||
const nested = bashDelimited(input, index + 1, depth + 1)
|
||||
if (!nested) return
|
||||
substitutions.push(nested.source)
|
||||
|
||||
+19
-86
@@ -31,49 +31,6 @@ export interface Transformable<Editor> {
|
||||
readonly reload: Reload
|
||||
}
|
||||
|
||||
export interface Failure {
|
||||
readonly state: string
|
||||
readonly cause: unknown
|
||||
}
|
||||
|
||||
type GroupedRegistration = {
|
||||
readonly remove: () => boolean
|
||||
readonly notify: Effect.Effect<void>
|
||||
}
|
||||
|
||||
type RegistrationGroup = {
|
||||
failed: boolean
|
||||
readonly registrations: Set<GroupedRegistration>
|
||||
readonly report: (failure: Failure, refresh: Effect.Effect<void>) => void
|
||||
}
|
||||
|
||||
const CurrentGroup = Context.Reference<RegistrationGroup | undefined>("@opencode/State/CurrentGroup", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
/**
|
||||
* Groups registrations without coupling State to plugin identity or asynchronous cleanup.
|
||||
* A failed group is detached synchronously; its supervisor must run refresh and close its scope.
|
||||
*/
|
||||
export function group(report: RegistrationGroup["report"]) {
|
||||
const group: RegistrationGroup = { failed: false, registrations: new Set(), report }
|
||||
return <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, CurrentGroup, group)
|
||||
}
|
||||
|
||||
function disable(group: RegistrationGroup, failure: Failure) {
|
||||
if (group.failed) return
|
||||
group.failed = true
|
||||
const notifications = new Set<Effect.Effect<void>>()
|
||||
for (const registration of group.registrations) {
|
||||
registration.remove()
|
||||
notifications.add(registration.notify)
|
||||
}
|
||||
group.report(
|
||||
failure,
|
||||
Effect.forEach(notifications, (notify) => notify, { discard: true }),
|
||||
)
|
||||
}
|
||||
|
||||
type Batch = {
|
||||
active: boolean
|
||||
readonly shutdown: boolean
|
||||
@@ -155,38 +112,19 @@ export interface Interface<State, Editor> extends Transformable<Editor> {
|
||||
|
||||
export function create<State, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
|
||||
let state = options.initial()
|
||||
const transforms = new Set<{ run: TransformCallback<Editor>; group: RegistrationGroup | undefined }>()
|
||||
const transforms: { run: TransformCallback<Editor> }[] = []
|
||||
let dirty = false
|
||||
let closed = false
|
||||
let version = 0
|
||||
|
||||
const invalidate = () => {
|
||||
dirty = true
|
||||
version++
|
||||
}
|
||||
|
||||
const get = () => {
|
||||
if (closed || !dirty) return state
|
||||
while (true) {
|
||||
const started = version
|
||||
const next = options.initial()
|
||||
const editor = options.editor(next)
|
||||
for (const transform of transforms) {
|
||||
try {
|
||||
transform.run(editor)
|
||||
} catch (cause) {
|
||||
if (!transform.group) throw cause
|
||||
disable(transform.group, { state: options.name ?? "anonymous", cause })
|
||||
}
|
||||
// A nested read can disable a group that already contributed to this candidate.
|
||||
if (version !== started) break
|
||||
}
|
||||
if (version !== started) continue
|
||||
// Ungrouped failures still propagate; grouped failures restart from a fresh candidate.
|
||||
state = next
|
||||
dirty = false
|
||||
return state
|
||||
}
|
||||
const next = options.initial()
|
||||
const editor = options.editor(next)
|
||||
for (const transform of transforms) transform.run(editor)
|
||||
// Only a complete fold becomes visible; a throwing callback leaves the previous value and stays dirty.
|
||||
state = next
|
||||
dirty = false
|
||||
return state
|
||||
}
|
||||
|
||||
// One stable value per State, so a batch's notification Set holds it at most once.
|
||||
@@ -199,7 +137,7 @@ export function create<State, Editor>(options: Options<State, Editor>): Interfac
|
||||
const changed = Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
invalidate()
|
||||
dirty = true
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
if (batch.shutdown) {
|
||||
@@ -218,23 +156,18 @@ export function create<State, Editor>(options: Options<State, Editor>): Interfac
|
||||
transform: Effect.fn("State.transform")(function* (update) {
|
||||
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
|
||||
const scope = yield* Scope.Scope
|
||||
const group = yield* CurrentGroup
|
||||
if (group?.failed) return { dispose: Effect.void }
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const transform = { run: update, group }
|
||||
const registration: GroupedRegistration = {
|
||||
remove: () => {
|
||||
if (!transforms.delete(transform)) return false
|
||||
group?.registrations.delete(registration)
|
||||
invalidate()
|
||||
return true
|
||||
},
|
||||
notify: changed,
|
||||
}
|
||||
const dispose = Effect.uninterruptible(Effect.suspend(() => (registration.remove() ? changed : Effect.void)))
|
||||
transforms.add(transform)
|
||||
group?.registrations.add(registration)
|
||||
const transform = { run: update }
|
||||
const dispose = Effect.uninterruptible(
|
||||
Effect.suspend(() => {
|
||||
const index = transforms.indexOf(transform)
|
||||
if (index < 0) return Effect.void
|
||||
transforms.splice(index, 1)
|
||||
return changed
|
||||
}),
|
||||
)
|
||||
transforms.push(transform)
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
yield* changed
|
||||
return { dispose }
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as SubagentTool from "./subagent.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Job } from "../../job.js"
|
||||
@@ -10,10 +10,10 @@ import { Permission } from "../../permission.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
import { SubagentCompletion } from "../../session/subagent-completion.js"
|
||||
import { SubagentJob } from "../../session/subagent-job.js"
|
||||
|
||||
export const name = "subagent"
|
||||
|
||||
const NO_TEXT = "Subagent completed without a text response."
|
||||
const backgroundResult = (sessionID: SessionSchema.ID) => ({
|
||||
sessionID,
|
||||
status: "running" as const,
|
||||
@@ -60,7 +60,42 @@ export const Plugin = {
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* Permission.Service
|
||||
const subagents = yield* SubagentJob.make
|
||||
const scope = yield* Scope.Scope
|
||||
// One completion observer per job generation. Keyed by child plus start time so a fresh
|
||||
// continuation job is observable even while a settled generation's observer is finalizing.
|
||||
const notifications = new Set<string>()
|
||||
|
||||
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
|
||||
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
|
||||
const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) {
|
||||
const messages = yield* sessions.messages({ sessionID, order: "desc", limit: 20 })
|
||||
const assistant = messages.find(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT
|
||||
const text = assistant.content
|
||||
.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
return text.length > 0 ? text : NO_TEXT
|
||||
})
|
||||
|
||||
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
|
||||
recovery: Extract<Job.Recovery, { kind: "subagent" }>,
|
||||
startedAt: number,
|
||||
) {
|
||||
const key = `${recovery.childSessionID}:${startedAt}`
|
||||
if (notifications.has(key)) return
|
||||
notifications.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
const info = (yield* jobs.wait({ id: recovery.childSessionID })).info
|
||||
if (info) yield* SubagentCompletion.deliver(sessions, jobs, { ...info, recovery })
|
||||
}).pipe(
|
||||
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((editor) =>
|
||||
@@ -190,10 +225,18 @@ export const Plugin = {
|
||||
agent: agent.name,
|
||||
description: input.description,
|
||||
}
|
||||
yield* subagents.start(recovery)
|
||||
const info = yield* jobs.start({
|
||||
id: child.id,
|
||||
type: name,
|
||||
title: input.description,
|
||||
metadata: {},
|
||||
recovery,
|
||||
run: sessions.resume(child.id).pipe(Effect.andThen(latestAssistantText(child.id))),
|
||||
})
|
||||
|
||||
if (background) {
|
||||
yield* subagents.background(recovery)
|
||||
yield* jobs.background(info.id)
|
||||
yield* notifyWhenDone(recovery, info.started_at)
|
||||
return backgroundResult(child.id)
|
||||
}
|
||||
|
||||
@@ -205,7 +248,7 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* subagents.notify(recovery, result.info.started_at)
|
||||
yield* notifyWhenDone(recovery, result.info.started_at)
|
||||
return backgroundResult(child.id)
|
||||
}
|
||||
// Failure surfaces keep the sessionID visible so the model can continue the child.
|
||||
@@ -215,11 +258,7 @@ export const Plugin = {
|
||||
})
|
||||
if (result?.info.status === "cancelled")
|
||||
return yield* new ToolFailure({ message: `Subagent cancelled (sessionID: ${child.id})` })
|
||||
return {
|
||||
sessionID: child.id,
|
||||
status: "completed" as const,
|
||||
output: result?.info.output ?? SubagentCompletion.NO_TEXT,
|
||||
}
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
|
||||
@@ -29,9 +29,11 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
update:
|
||||
info.autoupdate === false
|
||||
? "disable"
|
||||
: info.autoupdate === "notify" || info.autoupdate === true
|
||||
: info.autoupdate === "notify"
|
||||
? "notify"
|
||||
: undefined,
|
||||
: info.autoupdate === true
|
||||
? "auto"
|
||||
: undefined,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
@@ -170,7 +172,7 @@ export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>)
|
||||
description: command.description,
|
||||
agent: command.agent,
|
||||
model: modelSelection(command.model, command.variant),
|
||||
subagent: command.subtask,
|
||||
subtask: command.subtask,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("CodeModeInstructions", () => {
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [echo] }))
|
||||
expect(initialized.text).toContain(
|
||||
"This catalog is the complete set of tools callable inside `execute`. It does not affect tools exposed directly outside Code Mode.",
|
||||
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
|
||||
)
|
||||
expect(initialized.text).toContain("## Available tools")
|
||||
expect(initialized.text).not.toContain("## Search")
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { offlineModels } from "../fixture/models"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const llmLayer = TestLLM.testLayer({ fallback: TestLLM.text("Review complete", "review") })
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
llmLayer,
|
||||
AppNodeBuilder.build(LayerNode.group([Session.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
LayerNodePlatform.llmClient.replace(llmLayer),
|
||||
SessionRunnerModel.node.replace(
|
||||
Layer.succeed(SessionRunnerModel.Service, {
|
||||
resolve: (session) =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: session.model?.id ?? "parent", provider: "test", route: OpenAIChat.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
},
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
|
||||
|
||||
describe("command subagents", () => {
|
||||
for (const fixture of [
|
||||
{
|
||||
name: "native JSON",
|
||||
format: "json",
|
||||
command: { subagent: true, agent: "build", model: "test/override" },
|
||||
agent: "build",
|
||||
model: "override",
|
||||
},
|
||||
{
|
||||
name: "legacy Markdown",
|
||||
format: "markdown",
|
||||
command: { subtask: true, agent: "build" },
|
||||
agent: "build",
|
||||
model: "parent",
|
||||
},
|
||||
{
|
||||
name: "subagent mode by default",
|
||||
format: "json",
|
||||
command: { agent: "reviewer" },
|
||||
agent: "reviewer",
|
||||
model: "child",
|
||||
},
|
||||
] as const) {
|
||||
it.live(`runs ${fixture.name} in the background without switching the parent`, () =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* project(fixture.command, fixture.format)
|
||||
const sessions = yield* Session.Service
|
||||
const llm = yield* TestLLM.Test
|
||||
const gate = yield* llm.gate()
|
||||
|
||||
// This must return while the child's model is still blocked.
|
||||
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
|
||||
yield* gate.started
|
||||
const children = (yield* sessions.list({ parentID: parent.id })).data
|
||||
expect(children).toHaveLength(1)
|
||||
const child = children[0]
|
||||
if (!child) return yield* Effect.die("Expected a child session")
|
||||
expect(child).toMatchObject({ agent: fixture.agent, model: { id: fixture.model }, title: "Review code" })
|
||||
expect(yield* sessions.get(parent.id)).toMatchObject({ agent: "build", model: parentModel })
|
||||
expect(yield* sessions.context(parent.id)).toEqual([])
|
||||
expect(yield* llm.requests()).toHaveLength(1)
|
||||
expect((yield* sessions.context(child.id)).filter((message) => message.type === "user")).toMatchObject([
|
||||
{ text: "You are a subagent spawned by another session.\nReview changes: ready" },
|
||||
])
|
||||
yield* gate.release
|
||||
yield* llm.wait(2)
|
||||
yield* sessions.wait(parent.id)
|
||||
const notices = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
||||
expect(notices).toMatchObject([{ metadata: { source: "subagent", childID: child.id, state: "completed" } }])
|
||||
expect(notices[0]?.text).toContain("Review complete")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("subagent: false overrides subagent mode and the legacy alias", () =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* project({ subagent: false, subtask: true, agent: "reviewer" }, "json")
|
||||
const sessions = yield* Session.Service
|
||||
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
|
||||
yield* sessions.wait(parent.id)
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data).toEqual([])
|
||||
expect(yield* sessions.get(parent.id)).toMatchObject({
|
||||
agent: "reviewer",
|
||||
model: { id: "child" },
|
||||
})
|
||||
expect((yield* sessions.context(parent.id)).filter((message) => message.type === "user")).toMatchObject([
|
||||
{ text: "Review changes: ready" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function project(
|
||||
command: { agent?: string; model?: string; subagent?: boolean; subtask?: boolean },
|
||||
format: "json" | "markdown",
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const definition = { description: "Review code", template: "Review $ARGUMENTS: !`printf ready`", ...command }
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
agents: { reviewer: { mode: "subagent", model: "test/child" } },
|
||||
...(format === "markdown" ? {} : { commands: { review: definition } }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (format === "markdown")
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, ".opencode/commands/review.md"),
|
||||
[
|
||||
"---",
|
||||
"description: Review code",
|
||||
...Object.entries(command).map(([key, value]) => `${key}: ${value}`),
|
||||
"---",
|
||||
definition.template,
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const sessions = yield* Session.Service
|
||||
return yield* sessions.create({
|
||||
location: { directory: AbsolutePath.make(tmp.path) },
|
||||
title: "Parent session",
|
||||
agent: Agent.ID.make("build"),
|
||||
model: parentModel,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -4,10 +4,7 @@ import { describe, expect } from "bun:test"
|
||||
import { DateTime, Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { advance, drain } from "../lib/clock"
|
||||
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
@@ -30,8 +27,6 @@ import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes
|
||||
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { offlineModels } from "../fixture/models"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
@@ -46,25 +41,12 @@ const shellLayer = Layer.succeed(
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Command.node,
|
||||
Bus.node,
|
||||
FSUtil.node,
|
||||
AppProcess.node,
|
||||
Location.node,
|
||||
ShellSelect.node,
|
||||
Session.node,
|
||||
Job.node,
|
||||
Agent.node,
|
||||
]),
|
||||
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
|
||||
[
|
||||
Mcp.node.replace(emptyMcpLayer),
|
||||
Config.node.replace(emptyConfigLayer),
|
||||
Location.node.replace(testLocationLayer),
|
||||
ShellSelect.node.replace(shellLayer),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -666,18 +665,10 @@ describe("Config", () => {
|
||||
test("migrates the v1 update policy", () => {
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
|
||||
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes the previous native auto update policy", () => {
|
||||
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
|
||||
type: "normalized",
|
||||
encoded: { update: "notify" },
|
||||
diagnostics: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates v1 provider lists to policies", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
@@ -837,32 +828,30 @@ describe("Config", () => {
|
||||
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
|
||||
})
|
||||
|
||||
for (const subtask of [true, false]) {
|
||||
test(`migrates v1 command configuration with subtask: ${subtask}`, () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask,
|
||||
},
|
||||
test("migrates v1 command configuration", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
},
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subagent: subtask,
|
||||
},
|
||||
})
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subtask: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test("normalizes renamed permission actions when migrating v1 permissions", () => {
|
||||
expect(
|
||||
|
||||
@@ -21,7 +21,6 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
@@ -38,7 +37,7 @@ import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node, Job.node]))),
|
||||
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
@@ -118,40 +118,3 @@ test("defensively syncs advertised Copilot models", async () => {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("prices cache reads from either token price spelling", async () => {
|
||||
// API version 2026-08-01 renamed cache_price to cache_read_price; older payloads still use cache_price.
|
||||
const item = (id: string, prices: Record<string, number>) => ({
|
||||
model_picker_enabled: true,
|
||||
id,
|
||||
name: id,
|
||||
version: `${id}-2026-08-01`,
|
||||
supported_endpoints: ["/chat/completions"],
|
||||
billing: { token_prices: { batch_size: 1_000_000, default: { input_price: 250, output_price: 1500, ...prices } } },
|
||||
capabilities: {
|
||||
family: "gpt",
|
||||
limits: { max_output_tokens: 1000, max_prompt_tokens: 8000 },
|
||||
supports: { tool_calls: true },
|
||||
},
|
||||
})
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
Response.json({
|
||||
data: [
|
||||
item("renamed", { cache_read_price: 25, cache_write_price: 0 }),
|
||||
item("legacy", { cache_price: 25 }),
|
||||
item("unpriced", {}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
try {
|
||||
const models = await CopilotModels.get(server.url.origin, {}, [])
|
||||
expect(models.get(Model.ID.make("renamed"))?.cost[0]).toMatchObject({ input: 2.5, output: 15, cache: { read: 0.25 } })
|
||||
expect(models.get(Model.ID.make("legacy"))?.cost[0]).toMatchObject({ input: 2.5, output: 15, cache: { read: 0.25 } })
|
||||
expect(models.get(Model.ID.make("unpriced"))?.cost[0]).toMatchObject({ cache: { read: 0 } })
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,71 +64,6 @@ describe("Job", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reuses running work when started again with the same ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const output = yield* Deferred.make<string>()
|
||||
const job = yield* jobs.start({ id: "job_reused", type: "test", run: Deferred.await(output) })
|
||||
|
||||
expect(
|
||||
yield* jobs.start({ id: job.id, type: "duplicate", run: Effect.die("Duplicate work must not run") }),
|
||||
).toEqual(job)
|
||||
|
||||
yield* Deferred.succeed(output, "original output")
|
||||
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
|
||||
type: "test",
|
||||
status: "completed",
|
||||
output: "original output",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores an obsolete callback after a cancellation waiter starts a same-ID replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const callback = yield* Deferred.make<() => void>()
|
||||
const output = yield* Deferred.make<string>()
|
||||
const finalized = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
id: "job_replaced",
|
||||
type: "test",
|
||||
run: Effect.callback<string>((resume) => {
|
||||
Deferred.doneUnsafe(
|
||||
callback,
|
||||
Effect.succeed(() => resume(Effect.succeed("obsolete output"))),
|
||||
)
|
||||
}),
|
||||
})
|
||||
const complete = yield* Deferred.await(callback)
|
||||
// Cancellation wakes waiters before closing the old scope, allowing the old callback to race replacement.
|
||||
const replacement = yield* jobs.wait({ id: job.id }).pipe(
|
||||
Effect.tap((result) => Effect.sync(() => expect(result.info?.status).toBe("cancelled"))),
|
||||
Effect.andThen(
|
||||
jobs.start({
|
||||
id: job.id,
|
||||
type: "replacement",
|
||||
run: Deferred.await(output).pipe(Effect.ensuring(Deferred.succeed(finalized, undefined))),
|
||||
}),
|
||||
),
|
||||
Effect.andThen(Effect.sync(complete)),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
|
||||
yield* jobs.cancel(job.id)
|
||||
yield* Fiber.join(replacement)
|
||||
expect(yield* jobs.get(job.id)).toMatchObject({ type: "replacement", status: "running" })
|
||||
expect(yield* Deferred.isDone(finalized)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(output, "replacement output")
|
||||
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
|
||||
type: "replacement",
|
||||
status: "completed",
|
||||
output: "replacement output",
|
||||
})
|
||||
expect(yield* Deferred.isDone(finalized)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns finished from a blocking wait when completion wins", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface WebSocketServerFixture {
|
||||
}
|
||||
|
||||
export interface WebSocketServerOptions {
|
||||
readonly http?: (request: Request) => Response | Promise<Response>
|
||||
readonly upgrade?: (request: Request) => boolean
|
||||
readonly open?: (socket: Bun.ServerWebSocket<ConnectionData>) => void
|
||||
readonly message?: (socket: Bun.ServerWebSocket<ConnectionData>, message: string | Buffer) => void
|
||||
@@ -34,6 +35,7 @@ export const makeWebSocketServer = (options: WebSocketServerOptions = {}) =>
|
||||
port: 0,
|
||||
fetch(request, server) {
|
||||
state.headers.push(Object.fromEntries(request.headers.entries()))
|
||||
if (request.headers.get("upgrade") !== "websocket" && options.http) return options.http(request)
|
||||
if ((options.upgrade?.(request) ?? true) && server.upgrade(request, { data: { id: connection++ } }))
|
||||
return undefined
|
||||
return new Response("WebSocket upgrade required", {
|
||||
|
||||
@@ -3,24 +3,7 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import {
|
||||
Cause,
|
||||
DateTime,
|
||||
Deferred,
|
||||
Duration,
|
||||
Effect,
|
||||
Equal,
|
||||
Exit,
|
||||
Fiber,
|
||||
Hash,
|
||||
Layer,
|
||||
LayerMap,
|
||||
Option,
|
||||
RcMap,
|
||||
Schema,
|
||||
Scope,
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { DateTime, Duration, Effect, Equal, Hash, Layer, LayerMap, Option, RcMap, Schema, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
@@ -30,7 +13,6 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -40,7 +22,7 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -79,177 +61,6 @@ const itWithActivity = testEffect(
|
||||
)
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
for (const failure of ["file", "permissions", "config reference"] as const) {
|
||||
for (const invalidate of [false, true]) {
|
||||
// The file-path fixture boots on Windows rather than failing during
|
||||
// discovery. The config-reference case covers repair on every OS.
|
||||
// Windows does not enforce POSIX directory modes, and root bypasses them.
|
||||
const test =
|
||||
(failure === "file" && process.platform === "win32") ||
|
||||
(failure === "permissions" && (process.platform === "win32" || process.getuid?.() === 0))
|
||||
? it.live.skip
|
||||
: it.live
|
||||
test(`retries after repairing ${failure}${invalidate ? " with explicit invalidation" : ""}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const directory = path.join(dir.path, "repaired")
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const load = Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
|
||||
|
||||
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
|
||||
if (failure === "permissions") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { mode: 0o000 }))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(directory, 0o755)))
|
||||
}
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
|
||||
)
|
||||
}
|
||||
const first = yield* Effect.exit(load)
|
||||
expect(Exit.isFailure(first)).toBe(true)
|
||||
if (failure === "config reference" && Exit.isFailure(first)) {
|
||||
expect(Cause.squash(first.cause)).toMatchObject({
|
||||
name: "ConfigInvalidError",
|
||||
data: { message: expect.stringContaining('bad file reference: "{file:username.txt}"') },
|
||||
})
|
||||
}
|
||||
if (!invalidate) expect(yield* locations.contextEffectOption(ref).pipe(Effect.scoped)).toEqual(Option.none())
|
||||
|
||||
if (failure === "file") {
|
||||
yield* Effect.promise(() => fs.rm(directory))
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
}
|
||||
if (failure === "permissions") yield* Effect.promise(() => fs.chmod(directory, 0o755))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
|
||||
}
|
||||
expect((yield* Effect.promise(() => fs.stat(directory))).isDirectory()).toBe(true)
|
||||
if (invalidate) yield* locations.invalidate(ref)
|
||||
const repaired = yield* Effect.exit(load)
|
||||
expect(Exit.isSuccess(repaired)).toBe(true)
|
||||
// A successful graph remains cached after its last borrower releases.
|
||||
expect(yield* load).toBe(yield* repaired)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
for (const failure of ["file", "missing", "config reference"] as const) {
|
||||
// A file-path Location boots on Windows; use the missing config reference there.
|
||||
const test = failure === "file" && process.platform === "win32" ? it.live.skip : it.live
|
||||
test(`keeps the repaired graph after concurrent ${failure} failures release`, () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const directory = path.join(dir.path, "concurrent")
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
|
||||
)
|
||||
}
|
||||
|
||||
const scopes = yield* Effect.forEach(Array.from({ length: 8 }), () =>
|
||||
Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void)),
|
||||
)
|
||||
const failures = yield* Effect.forEach(
|
||||
scopes,
|
||||
(scope) => locations.contextEffect(ref).pipe(Scope.provide(scope), Effect.exit),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(failures.every(Exit.isFailure)).toBe(true)
|
||||
if (failure === "file") yield* Effect.promise(() => fs.rm(directory))
|
||||
if (failure !== "config reference") yield* Effect.promise(() => fs.mkdir(directory))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
|
||||
}
|
||||
const repaired = yield* locations.contextEffect(ref)
|
||||
|
||||
yield* Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void))
|
||||
expect(yield* locations.contextEffect(ref)).toBe(repaired)
|
||||
expect(Option.getOrThrow(yield* locations.contextEffectOption(ref))).toBe(repaired)
|
||||
}))
|
||||
}
|
||||
|
||||
for (const disposition of ["retry", "invalidate", "interrupt"] as const) {
|
||||
testEffect(Layer.empty).live(
|
||||
disposition === "invalidate"
|
||||
? "does not let an invalidated boot failure evict its replacement"
|
||||
: disposition === "interrupt"
|
||||
? "finishes a failed boot after its acquisition scopes close"
|
||||
: "shares a failed boot across acquisition APIs and retries",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const builds = { started: 0 }
|
||||
const finalized: number[] = []
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
LocationWatcher.node.replace(
|
||||
LocationWatcher.node.mapLayer((layer) =>
|
||||
layer.pipe(
|
||||
Layer.tap(() =>
|
||||
Effect.gen(function* () {
|
||||
const build = ++builds.started
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => finalized.push(build)))
|
||||
if (build !== 1) return
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
yield* Effect.die("first boot failed")
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
])
|
||||
yield* Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const first = yield* locations.contextEffect(ref).pipe(Effect.scoped, Effect.exit, Effect.forkScoped)
|
||||
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
|
||||
yield* Deferred.await(entered)
|
||||
const scope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void))
|
||||
const second = yield* Location.Service.pipe(
|
||||
Effect.provide(locations.get(ref)),
|
||||
Effect.exit,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const third = yield* locations
|
||||
.contextEffectOption(ref)
|
||||
.pipe(Scope.provide(scope), Effect.exit, Effect.forkScoped({ startImmediately: true }))
|
||||
if (disposition === "invalidate") yield* locations.invalidate(ref)
|
||||
if (disposition === "interrupt") {
|
||||
yield* Fiber.interrupt(first)
|
||||
yield* Fiber.interrupt(second)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
}
|
||||
const replacement = disposition === "invalidate" ? yield* locations.contextEffect(ref) : undefined
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
if (disposition !== "interrupt") {
|
||||
expect(Exit.isFailure(yield* Fiber.join(first))).toBe(true)
|
||||
expect(Exit.isFailure(yield* Fiber.join(second))).toBe(true)
|
||||
}
|
||||
expect(Exit.isFailure(yield* Fiber.join(third).pipe(Effect.timeout("2 seconds")))).toBe(true)
|
||||
expect(finalized).toEqual([1])
|
||||
expect(builds.started).toBe(disposition === "invalidate" ? 2 : 1)
|
||||
const recovered = yield* locations.contextEffect(ref)
|
||||
if (replacement) expect(recovered).toBe(replacement)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* locations.contextEffect(ref)).toBe(recovered)
|
||||
expect(builds.started).toBe(2)
|
||||
}).pipe(Effect.provide(layer))
|
||||
expect(finalized).toEqual([1, 2])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
itWithActivity.effect("does not refresh lifetime from inferred Session routing", () =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
@@ -339,8 +150,8 @@ describe("LocationServiceMap", () => {
|
||||
expect(location.directory).toBe(directory)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
|
||||
|
||||
// A local ref with the same missing directory is dropped after its
|
||||
// boot failure so a retry can rebuild it.
|
||||
// A local ref with the same missing directory keeps the existing
|
||||
// behavior: dropped as soon as it goes idle so a retry can rebuild it.
|
||||
yield* Location.Service.pipe(Effect.provide(locations.get(localRef)), Effect.scoped, Effect.exit)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
|
||||
}),
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { ModalModels } from "@opencode-ai/core/modal/models"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
|
||||
const providerID = Provider.ID.make("modal")
|
||||
|
||||
test("modal plugin is registered", () => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.modal")
|
||||
})
|
||||
|
||||
function template(id: string, overrides: Partial<Model.Info> = {}) {
|
||||
return Model.Info.make({
|
||||
...Model.Info.default(providerID, Model.ID.make(id)),
|
||||
name: `${id} catalog`,
|
||||
family: Model.Family.make("catalog-family"),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
test("maps live Modal models onto catalog templates", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer test-key")
|
||||
expect(new URL(request.url).pathname).toBe("/v1/models")
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "live-model",
|
||||
base_model_id: "base-model",
|
||||
name: "Live Model",
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
context_length: 128000,
|
||||
max_output_length: 8192,
|
||||
pricing: { prompt: "0.000001", completion: 0.000002, input_cache_read: "0.0000002" },
|
||||
supported_sampling_parameters: ["temperature"],
|
||||
supported_features: ["tools", "reasoning"],
|
||||
reasoning_options: [{ type: "effort", values: ["low", "high", null] }],
|
||||
interleaved: { field: "reasoning_content" },
|
||||
},
|
||||
{
|
||||
id: "standalone",
|
||||
context_length: 64000,
|
||||
},
|
||||
{ id: "malformed", context_length: "huge" },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const base = template("base-model")
|
||||
const stale = template("stale")
|
||||
const models = await ModalModels.get(`${server.url.origin}/v1`, "test-key", [base, stale])
|
||||
|
||||
expect(models.has(Model.ID.make("stale"))).toBe(false)
|
||||
expect(models.has(Model.ID.make("malformed"))).toBe(false)
|
||||
|
||||
const model = models.get(Model.ID.make("live-model"))
|
||||
expect(model?.name).toBe("Live Model")
|
||||
expect(model?.family).toBe(Model.Family.make("catalog-family"))
|
||||
expect(model?.providerID).toBe(providerID)
|
||||
expect(model?.modelID).toBe(Model.ID.make("live-model"))
|
||||
expect(model?.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible"))
|
||||
expect(model?.settings).toMatchObject({ baseURL: `${server.url.origin}/v1` })
|
||||
expect(model?.compatibility).toMatchObject({ reasoningField: "reasoning_content" })
|
||||
expect(model?.capabilities).toMatchObject({ tools: true, input: ["text", "image"], output: ["text"] })
|
||||
expect(model?.cost[0]?.input).toBe(Money.USDPerMillionTokens.make(1))
|
||||
expect(model?.cost[0]?.output).toBe(Money.USDPerMillionTokens.make(2))
|
||||
expect(Number(model?.cost[0]?.cache.read)).toBeCloseTo(0.2, 10)
|
||||
expect(model?.cost[0]?.cache.write).toBe(Money.USDPerMillionTokens.zero)
|
||||
expect(model?.limit).toMatchObject({ context: 128000, output: 8192 })
|
||||
expect(model?.variants.map((variant) => variant.id)).toEqual([
|
||||
Model.VariantID.make("low"),
|
||||
Model.VariantID.make("high"),
|
||||
Model.VariantID.make("none"),
|
||||
])
|
||||
expect(model?.variants[0]?.settings).toMatchObject({ reasoningEffort: "low" })
|
||||
expect(model?.status).toBe("active")
|
||||
|
||||
const fresh = models.get(Model.ID.make("standalone"))
|
||||
expect(fresh?.name).toBe("standalone")
|
||||
expect(fresh?.family).toBeUndefined()
|
||||
expect(fresh?.capabilities).toMatchObject({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(fresh?.variants).toEqual([])
|
||||
expect(fresh?.limit).toMatchObject({ context: 64000, output: 0 })
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps template cost and limits when the proxy omits them", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
Response.json({
|
||||
data: [{ id: "sparse", hugging_face_id: "hf-base" }],
|
||||
}),
|
||||
})
|
||||
|
||||
try {
|
||||
const base = template("hf-base", {
|
||||
cost: [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(5),
|
||||
output: Money.USDPerMillionTokens.make(10),
|
||||
cache: { read: Money.USDPerMillionTokens.make(1), write: Money.USDPerMillionTokens.make(2) },
|
||||
},
|
||||
],
|
||||
limit: { context: 1000, input: 500, output: 250 },
|
||||
})
|
||||
const models = await ModalModels.get(server.url.origin, "test-key", [base])
|
||||
const model = models.get(Model.ID.make("sparse"))
|
||||
expect(model?.name).toBe("hf-base catalog")
|
||||
expect(model?.cost[0]).toMatchObject({ input: 5, output: 10, cache: { read: 1, write: 2 } })
|
||||
expect(model?.limit).toMatchObject({ context: 1000, input: 500, output: 250 })
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("throws on proxy failure so the plugin can fail soft", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => new Response("nope", { status: 500 }),
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(ModalModels.get(server.url.origin, "test-key", [])).rejects.toThrow()
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
@@ -1,305 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Fiber, Schema } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
it.live("removes a failed plugin's hooks and RPC handlers without affecting healthy plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
const cleaned = yield* Deferred.make<void>()
|
||||
const invoked: string[] = []
|
||||
let fail = false
|
||||
yield* plugins.activate(
|
||||
["broken", "healthy"].map((id) => ({
|
||||
id,
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
// Finalizers run in reverse order, so this signals after registration cleanup.
|
||||
if (id === "broken") yield* Effect.addFinalizer(() => Deferred.succeed(cleaned, undefined))
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: id, execute: () => Effect.void })
|
||||
if (id === "broken" && fail) throw new Error("transform failed")
|
||||
})
|
||||
yield* ctx.shell.hook("create.before", () => Effect.sync(() => void invoked.push(id)))
|
||||
yield* ctx.rpc
|
||||
.register(
|
||||
Rpc.define({ id, methods: { check: { input: Schema.Struct({}), output: Schema.String } }, events: {} }),
|
||||
{ check: () => Effect.succeed(id) },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (id === "broken") yield* Effect.addFinalizer(() => plugins.awaitActivation)
|
||||
}),
|
||||
})),
|
||||
)
|
||||
const trigger = hooks.trigger("shell", "create.before", {
|
||||
command: "echo fixture",
|
||||
cwd: ".",
|
||||
timeout: 1_000,
|
||||
shell: "sh",
|
||||
env: {},
|
||||
})
|
||||
yield* trigger
|
||||
expect(invoked).toEqual(["broken", "healthy"])
|
||||
expect(yield* rpc.call("broken", "check", {})).toBe("broken")
|
||||
expect(yield* rpc.call("healthy", "check", {})).toBe("healthy")
|
||||
expect((yield* commands.list()).map((command) => command.name)).toEqual(["broken", "healthy"])
|
||||
|
||||
fail = true
|
||||
yield* commands.reload()
|
||||
yield* Deferred.await(cleaned).pipe(Effect.timeout("1 second"))
|
||||
invoked.length = 0
|
||||
yield* trigger
|
||||
expect(invoked).toEqual(["healthy"])
|
||||
expect(yield* rpc.call("broken", "check", {}).pipe(Effect.flip)).toMatchObject({ type: "rpc.unavailable" })
|
||||
expect(yield* rpc.call("healthy", "check", {})).toBe("healthy")
|
||||
expect((yield* commands.list()).map((command) => command.name)).toEqual(["healthy"])
|
||||
expect((yield* plugins.list()).map((plugin) => plugin.state.status)).toEqual(["failed", "active"])
|
||||
}),
|
||||
)
|
||||
|
||||
Array.of("reload", "teardown").forEach((boundary) =>
|
||||
it.live(`does not join queued failed-plugin cleanup during ${boundary}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const escape = yield* Deferred.make<void>()
|
||||
const cleaned = yield* Deferred.make<void>()
|
||||
let fail = false
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "changing",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "changing", description: "old", execute: () => Effect.void })
|
||||
if (fail) throw new Error("changing failed")
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(entered, undefined).pipe(
|
||||
Effect.andThen(plugins.awaitActivation.pipe(Effect.raceFirst(Deferred.await(escape)))),
|
||||
Effect.andThen(Deferred.succeed(cleaned, undefined)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "trigger",
|
||||
revision: "1",
|
||||
effect: () =>
|
||||
Effect.addFinalizer(() =>
|
||||
boundary === "teardown"
|
||||
? commands.reload().pipe(Effect.andThen(commands.list()), Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
},
|
||||
])
|
||||
fail = true
|
||||
const activation = yield* (boundary === "reload" ? commands.reload() : Effect.void).pipe(
|
||||
Effect.andThen(
|
||||
plugins.activate([
|
||||
{
|
||||
id: "changing",
|
||||
revision: "2",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) =>
|
||||
editor.add({ name: "changing", description: "new", execute: () => Effect.void }),
|
||||
)
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
]),
|
||||
),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(entered)
|
||||
const result = yield* Fiber.join(activation).pipe(Effect.timeout("250 millis"), Effect.exit)
|
||||
// Allow teardown to finish even if activation incorrectly joins the old finalizer.
|
||||
yield* Deferred.succeed(escape, undefined)
|
||||
yield* Fiber.join(activation)
|
||||
yield* plugins.awaitActivation
|
||||
yield* Deferred.await(cleaned)
|
||||
expect(Exit.isSuccess(result)).toBe(true)
|
||||
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
|
||||
expect(yield* commands.get("changing")).toMatchObject({ description: "new" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not restore a disabled generation with a pending failure when its replacement fails setup", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const loads: string[] = []
|
||||
let fail = false
|
||||
const generation = (revision: string): Plugin.Generation => ({
|
||||
id: "replacement",
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
loads.push(revision)
|
||||
if (revision === "2") yield* Effect.die("setup failed")
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "replacement", execute: () => Effect.void })
|
||||
if (fail) throw new Error("replay failed")
|
||||
})
|
||||
}),
|
||||
})
|
||||
yield* plugins.activate([generation("1")])
|
||||
fail = true
|
||||
yield* commands.reload()
|
||||
yield* plugins.activate([generation("2")])
|
||||
yield* plugins.awaitActivation
|
||||
expect(loads).toEqual(["1", "2"])
|
||||
expect((yield* plugins.list())[0]?.state.status).toBe("failed")
|
||||
expect(yield* commands.get("replacement")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
Array.of("pending", "reported").forEach((status) =>
|
||||
it.live(`preserves ${status} failures when an earlier plugin changes`, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const loads: string[] = []
|
||||
let fail = true
|
||||
const generation = (id: string, revision: string): Plugin.Generation => ({
|
||||
id,
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
loads.push(`${id}@${revision}`)
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: id, execute: () => Effect.void })
|
||||
if (id === "broken" && fail) throw new Error("broken failed")
|
||||
})
|
||||
}),
|
||||
})
|
||||
const broken = generation("broken", "1")
|
||||
const later = generation("later", "1")
|
||||
yield* plugins.activate([generation("earlier", "1"), broken, later])
|
||||
if (status === "reported") yield* plugins.awaitActivation
|
||||
fail = false
|
||||
yield* plugins.activate([generation("earlier", "2"), broken, later])
|
||||
yield* plugins.awaitActivation
|
||||
expect(loads).toEqual(["earlier@1", "broken@1", "later@1", "earlier@2", "later@1"])
|
||||
const failed = (yield* plugins.list())[1]?.state
|
||||
expect(failed).toMatchObject({ status: "failed", ref: expect.stringMatching(/^err_/) })
|
||||
expect((yield* commands.list()).map((entry) => entry.name)).toEqual(["earlier", "later"])
|
||||
|
||||
// Reordering and removing other plugins must preserve the same failure too.
|
||||
yield* plugins.activate([later, broken, generation("earlier", "2")])
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list())[1]?.state).toEqual(failed)
|
||||
expect((yield* commands.list()).map((entry) => entry.name)).toEqual(["later", "earlier"])
|
||||
yield* plugins.activate([broken, later])
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list())[0]?.state).toEqual(failed)
|
||||
expect(loads.filter((entry) => entry === "broken@1")).toHaveLength(1)
|
||||
|
||||
yield* plugins.activate([generation("broken", "2"), later])
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
|
||||
expect(yield* commands.get("broken")).toBeDefined()
|
||||
expect(loads.filter((entry) => entry === "broken@2")).toHaveLength(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("continues failure reporting and cleanup after a plugin update observer fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const cleaned: string[] = []
|
||||
let fail = false
|
||||
let failPublication = true
|
||||
yield* plugins.activate(
|
||||
["first", "second"].map((id) => ({
|
||||
id,
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void cleaned.push(id)))
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: id, execute: () => Effect.void })
|
||||
if (fail) throw new Error(`${id} failed`)
|
||||
})
|
||||
}),
|
||||
})),
|
||||
)
|
||||
yield* Effect.acquireRelease(
|
||||
bus.listen((event) => {
|
||||
if (event.type !== Plugin.Event.Updated.type || !failPublication) return Effect.void
|
||||
failPublication = false
|
||||
return Effect.die("observer failed")
|
||||
}),
|
||||
(unsubscribe) => unsubscribe,
|
||||
)
|
||||
fail = true
|
||||
yield* commands.reload()
|
||||
const ready = yield* plugins.awaitActivation.pipe(Effect.timeout("250 millis"), Effect.exit)
|
||||
expect(Exit.isSuccess(ready)).toBe(true)
|
||||
expect((yield* plugins.list()).map((entry) => entry.state.status)).toEqual(["failed", "failed"])
|
||||
expect(cleaned.toSorted()).toEqual(["first", "second"])
|
||||
expect(yield* commands.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("settles readiness before shutdown joins a disabled plugin's finalizers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const escape = yield* Deferred.make<void>()
|
||||
let fail = false
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "closing",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "closing", execute: () => Effect.void })
|
||||
if (fail) throw new Error("closing failed")
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(entered, undefined).pipe(
|
||||
Effect.andThen(plugins.awaitActivation.pipe(Effect.raceFirst(Deferred.await(escape)))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
])
|
||||
fail = true
|
||||
const shutdown = yield* commands
|
||||
.reload()
|
||||
.pipe(Effect.andThen(plugins.close(Exit.void)), Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(entered)
|
||||
const result = yield* Fiber.join(shutdown).pipe(Effect.timeout("250 millis"), Effect.exit)
|
||||
// Release the fixture even on the old implementation, rather than hanging test teardown.
|
||||
yield* Deferred.succeed(escape, undefined)
|
||||
yield* Fiber.join(shutdown)
|
||||
expect(Exit.isSuccess(result)).toBe(true)
|
||||
const release = yield* plugins.hold()
|
||||
yield* plugins.awaitActivation
|
||||
let restarted = false
|
||||
yield* plugins.activate([
|
||||
{ id: "after-close", revision: "1", effect: () => Effect.sync(() => void (restarted = true)) },
|
||||
])
|
||||
yield* release
|
||||
expect(restarted).toBe(false)
|
||||
}),
|
||||
)
|
||||
@@ -1,14 +1,12 @@
|
||||
import { expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Clock, Deferred, Effect } from "effect"
|
||||
import { Clock, Effect } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { fromPromise } from "@opencode-ai/plugin/promise/adapter"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
@@ -156,57 +154,6 @@ it.effect("reports a failed plugin without blocking a healthy plugin", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables a plugin whose transform fails after setup without publishing its partial edits", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const integrations = yield* Integration.Service
|
||||
let cleaned = false
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "before",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) => editor.add({ name: "shared", description: "original", execute: () => Effect.void }))
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
{
|
||||
id: "broken",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void (cleaned = true)))
|
||||
yield* ctx.integration.transform((editor) => editor.update("broken", (entry) => (entry.name = "Broken")))
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "shared", description: "partial", execute: () => Effect.void })
|
||||
throw new Error("replay failed")
|
||||
})
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "after",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) => editor.add({ name: "healthy", execute: () => Effect.void }))
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
])
|
||||
|
||||
yield* plugins.awaitActivation
|
||||
expect(cleaned).toBe(true)
|
||||
expect((yield* plugins.list()).find((plugin) => plugin.id === "broken")?.state).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining("command.transform failed"),
|
||||
ref: expect.stringMatching(/^err_/),
|
||||
})
|
||||
expect(yield* commands.get("shared")).toMatchObject({ description: "original" })
|
||||
expect(yield* commands.get("healthy")).toBeDefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("broken"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the suffix after a failed plugin alive across identical activations", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -249,204 +196,6 @@ it.effect("keeps the suffix after a failed plugin alive across identical activat
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("attributes replay failure to the broken plugin rather than a later plugin reading the registry", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "broken-plugin",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform(() => {
|
||||
throw new Error("plugin failed")
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
{
|
||||
id: "reader",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.command.list().pipe(Effect.orDie)
|
||||
yield* ctx.command.transform((editor) => editor.add({ name: "reader", execute: () => Effect.void }))
|
||||
}),
|
||||
},
|
||||
])
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list()).map((entry) => `${entry.id}:${entry.state.status}`)).toEqual([
|
||||
"broken-plugin:failed",
|
||||
"reader:active",
|
||||
])
|
||||
expect(yield* commands.get("reader")).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables plugins after runtime reload failures without retrying an unchanged generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const reported: string[] = []
|
||||
yield* Effect.acquireRelease(
|
||||
bus.listen((event) =>
|
||||
event.type === Plugin.Event.Updated.type
|
||||
? plugins.list().pipe(
|
||||
Effect.tap((items) => Effect.sync(() => void reported.push(items[0]?.state.status ?? "empty"))),
|
||||
Effect.asVoid,
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
(unsubscribe) => unsubscribe,
|
||||
)
|
||||
let fail = false
|
||||
let loads = 0
|
||||
let reload = () => Effect.void
|
||||
const generation = (revision: string): Plugin.Generation => ({
|
||||
id: "runtime",
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
loads++
|
||||
reload = ctx.command.reload
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "runtime", execute: () => Effect.void })
|
||||
if (fail) throw new Error("private failure detail")
|
||||
})
|
||||
}),
|
||||
})
|
||||
const discovery = {
|
||||
source: { type: "local" as const, path: "/missing" },
|
||||
state: { status: "failed" as const, error: "Import failed" },
|
||||
features: { server: true },
|
||||
} satisfies Plugin.Info
|
||||
yield* plugins.activate([generation("1")], [discovery])
|
||||
expect(yield* commands.get("runtime")).toBeDefined()
|
||||
fail = true
|
||||
yield* reload()
|
||||
yield* plugins.awaitActivation
|
||||
const inventory = yield* plugins.list()
|
||||
expect(inventory[0]?.state).toMatchObject({ status: "failed", ref: expect.stringMatching(/^err_/) })
|
||||
expect(JSON.stringify(inventory[0]?.state)).not.toContain("private failure detail")
|
||||
expect(reported.at(-1)).toBe("failed")
|
||||
expect(inventory[1]).toEqual(discovery)
|
||||
expect(yield* commands.get("runtime")).toBeUndefined()
|
||||
|
||||
fail = false
|
||||
yield* plugins.activate([generation("1")], [discovery])
|
||||
expect(loads).toBe(1)
|
||||
expect(yield* commands.get("runtime")).toBeUndefined()
|
||||
yield* plugins.activate([generation("2")], [discovery])
|
||||
expect(loads).toBe(2)
|
||||
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
|
||||
expect(yield* commands.get("runtime")).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables plugins after replay failures discovered during setup without restoring the old generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const cleaned = yield* Deferred.make<void>()
|
||||
const loads: string[] = []
|
||||
const generation = (revision: string): Plugin.Generation => ({
|
||||
id: "replacement",
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
loads.push(revision)
|
||||
if (revision === "2")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
plugins.awaitActivation.pipe(Effect.andThen(Deferred.succeed(cleaned, undefined))),
|
||||
)
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "replacement", execute: () => Effect.void })
|
||||
if (revision === "2") throw new Error("replay failure")
|
||||
})
|
||||
if (revision === "2") {
|
||||
yield* ctx.command.list().pipe(Effect.orDie)
|
||||
yield* Effect.die("subsequent setup failure")
|
||||
}
|
||||
}),
|
||||
})
|
||||
yield* plugins.activate([generation("1")])
|
||||
yield* plugins.activate([generation("2")])
|
||||
yield* plugins.awaitActivation
|
||||
yield* Deferred.await(cleaned)
|
||||
expect(loads).toEqual(["1", "2"])
|
||||
expect((yield* plugins.list())[0]?.state).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining("command.transform"),
|
||||
})
|
||||
expect(yield* commands.get("replacement")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not let asynchronous plugin cleanup block recovered registry readiness", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const cleaned = yield* Deferred.make<void>()
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "async-cleanup",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.command.transform(() => {
|
||||
throw new Error("failed")
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
plugins.awaitActivation.pipe(Effect.andThen(Deferred.succeed(cleaned, undefined))),
|
||||
)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "healthy",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) => editor.add({ name: "healthy", execute: () => Effect.void }))
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
])
|
||||
yield* plugins.awaitActivation
|
||||
yield* Deferred.await(cleaned)
|
||||
expect(yield* commands.get("healthy")).toBeDefined()
|
||||
expect((yield* plugins.list())[0]?.state.status).toBe("failed")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retains Promise plugin groups for later registrations and ignores a disabled group's attempts", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
let register = async () => {}
|
||||
const definition = fromPromise({
|
||||
id: "promise-plugin",
|
||||
setup(ctx) {
|
||||
register = async () => {
|
||||
await ctx.command.transform((editor) => {
|
||||
editor.add({ name: "late", execute: async () => {} })
|
||||
throw new Error("late Promise failure")
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
yield* plugins.activate([{ ...definition, revision: "1" }])
|
||||
yield* Effect.promise(register)
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list())[0]?.state).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining("command.transform"),
|
||||
})
|
||||
expect(yield* commands.get("late")).toBeUndefined()
|
||||
yield* Effect.promise(register)
|
||||
expect(yield* commands.get("late")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reloading a plugin replaces its command implementation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
expect(requests[0]?.has("x-api-key")).toBe(false)
|
||||
expect(requests[0]?.get("x-initiator")).toBe("user")
|
||||
expect(requests[0]?.get("copilot-vision-request")).toBe("true")
|
||||
expect(requests[0]?.get("x-github-api-version")).toBe("2026-08-01")
|
||||
expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
|
||||
expect(requests[0]?.get("user-agent")).toBe("opencode/beta/1.2.3/test")
|
||||
}),
|
||||
)
|
||||
@@ -145,7 +145,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
expect(event.request.headers.has("x-api-key")).toBe(false)
|
||||
expect(event.request.headers.get("x-initiator")).toBe("user")
|
||||
expect(event.request.headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14")
|
||||
expect(event.request.headers.get("x-github-api-version")).toBe("2026-08-01")
|
||||
expect(event.request.headers.get("x-github-api-version")).toBe("2026-06-01")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Layer, Logger, Schema } from "effect"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(Rpc.node, [
|
||||
Location.node.replace(Layer.succeed(Location.Service, location({ directory: AbsolutePath.make("/rpc-project") }))),
|
||||
]),
|
||||
)
|
||||
const Broken = Rpc.define({
|
||||
id: "broken",
|
||||
methods: {
|
||||
dies: { input: Schema.Undefined, output: Schema.String },
|
||||
throws: { input: Schema.Undefined, output: Schema.String },
|
||||
raw: { input: Schema.Undefined, output: Schema.String },
|
||||
undeclared: { input: Schema.Undefined, output: Schema.String },
|
||||
invalidError: {
|
||||
input: Schema.Undefined,
|
||||
output: Schema.String,
|
||||
errors: { known: Schema.Struct({ count: Schema.Int }) },
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
for (const method of ["dies", "throws", "raw", "undeclared", "invalidError"] as const) {
|
||||
it.effect(`recovers from ${method} through the typed rpc.internal failure`, () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
yield* rpc.register(Broken, {
|
||||
dies: () => Effect.die(new Error("handler defect")),
|
||||
throws: () => {
|
||||
throw new Error("handler threw")
|
||||
},
|
||||
// Raw Promise rejections reach this boundary as failed Effects.
|
||||
// @ts-expect-error intentionally exercise an undeclared failure
|
||||
raw: () => Effect.fail(new Error("raw failure")),
|
||||
// @ts-expect-error intentionally exercise an undeclared error name
|
||||
undeclared: (_input, context) => Effect.fail(context.error("unknown", "Unknown")),
|
||||
invalidError: (_input, context) => Effect.fail(context.error("known", "Invalid count", { count: 1.5 })),
|
||||
})
|
||||
const logged: unknown[] = []
|
||||
const result = yield* rpc
|
||||
.client(Broken)
|
||||
[method]()
|
||||
.pipe(
|
||||
Effect.catchIf(
|
||||
(error) => "type" in error && error.type === "rpc.internal",
|
||||
(error) => Effect.succeed(error),
|
||||
),
|
||||
Effect.provideService(Logger.CurrentLoggers, new Set([Logger.make((entry) => logged.push(entry.message))])),
|
||||
)
|
||||
expect(result).toEqual({ type: "rpc.internal", message: "RPC call failed" })
|
||||
expect(logged).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLM, LLMRequest, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { configure } from "@opencode-ai/ai/providers/openai"
|
||||
import { SessionModelTransport } from "../src/session/model-transport"
|
||||
import { WebSocketConstructor } from "../src/effect/websocket-constructor"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Deferred, Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { makeWebSocketServer } from "./lib/websocket-server"
|
||||
|
||||
const runtime = Layer.mergeAll(
|
||||
LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer), Layer.provide(FetchHttpClient.layer)),
|
||||
SessionModelTransport.layer.pipe(Layer.provide(WebSocketConstructor.layer)),
|
||||
)
|
||||
const it = testEffect(runtime)
|
||||
const sessionID = Session.ID.make("ses_checkpoint_transport")
|
||||
const checkpoint = { type: "compaction", encrypted_content: "opaque" }
|
||||
type Mode =
|
||||
| "success"
|
||||
| "missing"
|
||||
| "multiple"
|
||||
| "incomplete"
|
||||
| "disconnect"
|
||||
| "cancel"
|
||||
| "rejected"
|
||||
| "ambiguous"
|
||||
| "fallback"
|
||||
|
||||
const fixture = (mode: Mode) =>
|
||||
Effect.gen(function* () {
|
||||
const seen = yield* Deferred.make<void>()
|
||||
const requests: Array<Record<string, unknown>> = []
|
||||
const http: Array<Record<string, unknown>> = []
|
||||
let disconnect: (() => void) | undefined
|
||||
const server = yield* makeWebSocketServer({
|
||||
upgrade: () => mode !== "fallback" || disconnect === undefined,
|
||||
async http(request) {
|
||||
http.push(Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Unknown))(await request.json()))
|
||||
return new Response(
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_http",
|
||||
output: [checkpoint],
|
||||
usage: { input_tokens: 70, output_tokens: 7 },
|
||||
},
|
||||
})}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream", "x-response": "http" } },
|
||||
)
|
||||
},
|
||||
open(socket) {
|
||||
disconnect = () => socket.close()
|
||||
},
|
||||
message(socket, message) {
|
||||
const body = JSON.parse(message.toString())
|
||||
requests.push(body)
|
||||
const id = `resp_${requests.length}`
|
||||
const send = (event: unknown) => socket.send(JSON.stringify(event))
|
||||
if (body.input.at(-1)?.type !== "compaction_trigger") {
|
||||
const item = {
|
||||
type: "message",
|
||||
id: `msg_${requests.length}`,
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello" }],
|
||||
}
|
||||
send({ type: "response.created", response: { id } })
|
||||
send({ type: "response.output_item.added", output_index: 0, item })
|
||||
send({ type: "response.output_text.delta", item_id: item.id, delta: "Hello" })
|
||||
send({ type: "response.output_item.done", output_index: 0, item })
|
||||
send({ type: "response.completed", response: { id, output: [item] } })
|
||||
return
|
||||
}
|
||||
if (mode === "ambiguous") {
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
if (mode === "rejected") {
|
||||
send({ type: "error", error: { code: "previous_response_not_found", message: "missing baseline" } })
|
||||
return
|
||||
}
|
||||
send({ type: "response.created", response: { id } })
|
||||
if (mode !== "missing") send({ type: "response.output_item.done", output_index: 0, item: checkpoint })
|
||||
Deferred.doneUnsafe(seen, Effect.void)
|
||||
if (mode === "cancel") return
|
||||
if (mode === "disconnect") {
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
send({
|
||||
type: mode === "incomplete" ? "response.incomplete" : "response.completed",
|
||||
response: {
|
||||
id,
|
||||
output:
|
||||
mode === "missing"
|
||||
? []
|
||||
: mode === "multiple"
|
||||
? [checkpoint, { ...checkpoint, encrypted_content: "second" }]
|
||||
: [checkpoint],
|
||||
usage: { input_tokens: 100, output_tokens: 10 },
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
return {
|
||||
requests,
|
||||
headers: server.state.headers,
|
||||
http,
|
||||
seen,
|
||||
opens: () => server.state.opens,
|
||||
disconnect: () => disconnect?.(),
|
||||
request: LLM.request({
|
||||
model: configure({
|
||||
apiKey: "fixture",
|
||||
baseURL: server.url.replace(/^ws/, "http").replace(/responses$/, ""),
|
||||
headers: { "chatgpt-account-id": "account", "x-codex-beta-features": "remote_compaction_v2" },
|
||||
providerOptions: { parallelToolCalls: true },
|
||||
}).responses("fixture"),
|
||||
prompt: "First",
|
||||
promptCacheKey: "session-key",
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
it.live("trigger reuses the append baseline and clears it before the next generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture("success")
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const webSocket = transport.bind(sessionID)
|
||||
const first = yield* LLMClient.generate(server.request, { webSocket })
|
||||
const compacted = yield* LLMClient.compact(
|
||||
LLMRequest.update(server.request, {
|
||||
messages: [...server.request.messages, first.message],
|
||||
}),
|
||||
{ mechanism: "trigger", webSocket },
|
||||
)
|
||||
expect(server.requests[1]).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "compaction_trigger" }],
|
||||
})
|
||||
expect(compacted.responseID).toBe("resp_2")
|
||||
expect(compacted.usage).toMatchObject({ inputTokens: 100, outputTokens: 10 })
|
||||
expect(compacted.checkpoint.encrypted).toBe("opaque")
|
||||
const messages = [Message.assistant(compacted.checkpoint), Message.user("Continue")]
|
||||
yield* LLMClient.generate(LLMRequest.update(server.request, { messages }), { webSocket })
|
||||
expect(server.requests[2]?.previous_response_id).toBeUndefined()
|
||||
expect(server.requests[2]?.input).toMatchObject([
|
||||
{ type: "compaction", encrypted_content: "opaque" },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
|
||||
])
|
||||
expect(server.requests[1]?.stream).toBeUndefined()
|
||||
expect(server.requests[1]?.store).toBe(false)
|
||||
expect(server.headers[0]).toMatchObject({
|
||||
authorization: "Bearer fixture",
|
||||
"chatgpt-account-id": "account",
|
||||
"x-codex-beta-features": "remote_compaction_v2",
|
||||
})
|
||||
expect(server.opens()).toBe(1)
|
||||
expect(server.http).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const mode of ["missing", "multiple", "incomplete", "disconnect", "rejected", "ambiguous"] as const) {
|
||||
it.live(`trigger ${mode} does not retry, fall back, or commit a continuation checkpoint`, () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture(mode)
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const webSocket = transport.bind(sessionID)
|
||||
const first = yield* LLMClient.generate(server.request, { webSocket })
|
||||
const input = LLMRequest.update(server.request, { messages: [...server.request.messages, first.message] })
|
||||
const error = yield* LLMClient.compact(input, { mechanism: "trigger", webSocket }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe(
|
||||
["disconnect", "ambiguous", "rejected"].includes(mode) ? "Transport" : "InvalidProviderOutput",
|
||||
)
|
||||
if (mode === "rejected") expect(error.reason).toMatchObject({ delivery: "rejected", recovery: "retry-full" })
|
||||
if (mode === "ambiguous") expect(error.reason).toMatchObject({ delivery: "ambiguous" })
|
||||
expect(server.requests).toHaveLength(2)
|
||||
expect(server.http).toHaveLength(0)
|
||||
yield* LLMClient.generate(input, { webSocket })
|
||||
expect(server.requests[2]?.previous_response_id).toBeUndefined()
|
||||
expect(server.requests[2]?.input).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("cancelled trigger closes its connection without returning the partial checkpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture("cancel")
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const webSocket = transport.bind(sessionID)
|
||||
const running = yield* LLMClient.compact(server.request, { mechanism: "trigger", webSocket }).pipe(
|
||||
Effect.forkChild(),
|
||||
)
|
||||
yield* Deferred.await(server.seen)
|
||||
yield* Fiber.interrupt(running)
|
||||
yield* LLMClient.generate(server.request, { webSocket })
|
||||
expect(server.requests).toHaveLength(2)
|
||||
expect(server.opens()).toBe(2)
|
||||
expect(server.requests[1]?.previous_response_id).toBeUndefined()
|
||||
expect(server.http).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("trigger recovers over SSE with complete input after the old socket closes and reconnect is rejected", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture("fallback")
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const webSocket = transport.bind(sessionID)
|
||||
const first = yield* LLMClient.generate(server.request, { webSocket })
|
||||
server.disconnect()
|
||||
// Let the real close event reach the connector before the next send.
|
||||
yield* Effect.sleep("20 millis")
|
||||
const result = yield* LLMClient.compact(
|
||||
LLMRequest.update(server.request, {
|
||||
messages: [...server.request.messages, first.message],
|
||||
}),
|
||||
{ mechanism: "trigger", webSocket },
|
||||
)
|
||||
expect(result.responseID).toBe("resp_http")
|
||||
expect(result.usage).toMatchObject({ inputTokens: 70, outputTokens: 7 })
|
||||
expect(server.http).toHaveLength(1)
|
||||
expect(server.http[0]?.previous_response_id).toBeUndefined()
|
||||
expect(server.http[0]?.stream).toBe(true)
|
||||
expect(server.http[0]?.input).toHaveLength(3)
|
||||
expect(server.requests).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
@@ -1243,7 +1243,6 @@ describe("SessionTransfer", () => {
|
||||
const runningCompactionID = SessionMessage.ID.create()
|
||||
const completedCompactionID = SessionMessage.ID.create()
|
||||
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
|
||||
const providerState = { responseId: "summary-response" }
|
||||
|
||||
yield* transfer.import({
|
||||
data: {
|
||||
@@ -1298,8 +1297,6 @@ describe("SessionTransfer", () => {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
model,
|
||||
providerState,
|
||||
summary: "summary",
|
||||
recent: "recent",
|
||||
time: { created: DateTime.makeUnsafe(9) },
|
||||
@@ -1316,11 +1313,6 @@ describe("SessionTransfer", () => {
|
||||
completedCompactionID,
|
||||
])
|
||||
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
|
||||
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, providerState })
|
||||
expect((yield* transfer.export({ sessionID, sanitize: true })).messages.at(-1)).toMatchObject({
|
||||
model,
|
||||
providerState: { redacted: `compaction-provider-state:${completedCompactionID}` },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -445,17 +445,14 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.each([false, true])("classifies active and queued close (observed: %s)", async (observed) => {
|
||||
test("closes an active exchange without waiting for its Session permit", async () => {
|
||||
const started = Deferred.makeUnsafe<void>()
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () =>
|
||||
observed
|
||||
? Queue.offer(messages, "frame").pipe(Effect.asVoid)
|
||||
: Deferred.succeed(started, undefined).pipe(Effect.asVoid),
|
||||
sendText: () => Deferred.succeed(started, undefined),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
@@ -465,30 +462,17 @@ describe("SessionModelTransport", () => {
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
const item = exchange("active")
|
||||
const running = yield* collect(executor, {
|
||||
...item,
|
||||
driver: {
|
||||
...item.driver,
|
||||
observe: (_create, frame) => Deferred.succeed(started, undefined).pipe(Effect.as({ type: "frame", frame })),
|
||||
},
|
||||
}).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
const queued = yield* collect(executor, exchange("queued")).pipe(
|
||||
Effect.result,
|
||||
const running = yield* collect(transport.bind(session), exchange("active")).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
yield* transport.close(session)
|
||||
const result = yield* Effect.result(Fiber.join(running))
|
||||
|
||||
expect(yield* Fiber.join(running)).toMatchObject({
|
||||
expect(result).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "close", delivery: observed ? "accepted" : "ambiguous" } },
|
||||
})
|
||||
expect(yield* Fiber.join(queued)).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "queue", delivery: "not-sent" } },
|
||||
failure: { reason: { _tag: "Transport", code: "close", delivery: "ambiguous" } },
|
||||
})
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
@@ -561,43 +545,6 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("closes a connection returned after its owner closes during setup", async () => {
|
||||
const connecting = Deferred.makeUnsafe<void>()
|
||||
const release = Deferred.makeUnsafe<void>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Deferred.succeed(connecting, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as({
|
||||
sendText: () => Effect.die("Unexpected send after owner close"),
|
||||
messages: Stream.never,
|
||||
close: Effect.sync(() => closed++).pipe(Effect.asVoid),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const running = yield* collect(
|
||||
transport.bind(session),
|
||||
exchange("first", { fallback: () => Stream.die("Unexpected fallback after owner close") }),
|
||||
).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(connecting)
|
||||
yield* transport.close(session)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
|
||||
expect(yield* Fiber.join(running)).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "connect", delivery: "not-sent" } },
|
||||
})
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back once when connection setup fails before send", async () => {
|
||||
let fallbacks = 0
|
||||
const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) }
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { chmod, mkdir, readdir, rm } from "fs/promises"
|
||||
import { Cause, Context, Deferred, Duration, Effect, Exit, Fiber, Layer, LayerMap, Queue } from "effect"
|
||||
import { mkdir, rm } from "fs/promises"
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
@@ -17,14 +14,10 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionMove } from "@opencode-ai/core/session/move"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -81,372 +74,8 @@ const itWithUnavailableDestination = testEffect(
|
||||
],
|
||||
),
|
||||
)
|
||||
const itWithExecution = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Session.node, SessionExecution.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
// Windows does not enforce POSIX mode bits, and root can traverse mode-000 directories.
|
||||
const itWithPermissions =
|
||||
process.platform === "win32" || process.getuid?.() === 0 ? itWithExecution.live.skip : itWithExecution.live
|
||||
const itWithInstance = testEffect(Layer.empty)
|
||||
const sourceProbe = (options: { execution?: boolean } = {}) =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const source = AbsolutePath.make(path.join(tmp.path, "source"))
|
||||
const destination = AbsolutePath.make(tmp.path)
|
||||
yield* Effect.promise(() => mkdir(source))
|
||||
const probes = yield* Queue.unbounded<Deferred.Deferred<void>>()
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([Session.node, Bus.node, SessionExecution.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
...(options.execution ? [] : [SessionExecution.node.replace(SessionExecution.noopLayer)]),
|
||||
offlineModels,
|
||||
Instance.node.replace(
|
||||
makeGlobalNode({
|
||||
service: Instance.Service,
|
||||
deps: [LocationServiceMap.node],
|
||||
layer: Layer.effect(
|
||||
Instance.Service,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
return Instance.Service.of({
|
||||
provide: (session) => (effect) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.location.directory === source) {
|
||||
const release = yield* Deferred.make<void>()
|
||||
yield* Queue.offer(probes, release)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
return yield* effect.pipe(Effect.provide(locations.get(session.location)))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
)
|
||||
return {
|
||||
source,
|
||||
destination,
|
||||
probes,
|
||||
session: Context.get(context, Session.Service),
|
||||
bus: Context.get(context, Bus.Service),
|
||||
execution: Context.get(context, SessionExecution.Service),
|
||||
}
|
||||
})
|
||||
|
||||
describe("Session.move", () => {
|
||||
itWithInstance.live("moves through the bound service without depending on the Session facade", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = AbsolutePath.make(tmp.path)
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([SessionMove.node, SessionStore.node, Bus.node, Project.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
const moves = Context.get(context, SessionMove.Service)
|
||||
const store = Context.get(context, SessionStore.Service)
|
||||
const bus = Context.get(context, Bus.Service)
|
||||
const projects = Context.get(context, Project.Service)
|
||||
const sessionID = Session.ID.create()
|
||||
|
||||
// Call outside the construction context: the service owns all of its dependencies.
|
||||
expect(yield* moves.move({ sessionID, directory }).pipe(Effect.flip)).toEqual(
|
||||
new Session.NotFoundError({ sessionID }),
|
||||
)
|
||||
yield* projects.resolve(directory)
|
||||
yield* bus.publish(SessionEvent.Created, {
|
||||
sessionID,
|
||||
slug: "move-service",
|
||||
version: "test",
|
||||
projectID: Project.ID.global,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "missing")) }),
|
||||
})
|
||||
yield* moves.move({ sessionID, directory })
|
||||
expect(yield* store.get(sessionID)).toMatchObject({
|
||||
location: { directory },
|
||||
projectID: Project.ID.global,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
itWithInstance.live("delegates to the provided move service", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = AbsolutePath.make(tmp.path)
|
||||
const rejection = new Session.DestinationUnavailableError({ directory })
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(Session.node, [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
SessionMove.node.replace(Layer.succeed(SessionMove.Service, { move: () => Effect.fail(rejection) })),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
const sessions = Context.get(context, Session.Service)
|
||||
const created = yield* sessions.create({ location: Location.Ref.make({ directory }) })
|
||||
|
||||
expect(yield* sessions.move({ sessionID: created.id, directory }).pipe(Effect.flip)).toBe(rejection)
|
||||
expect(yield* sessions.inbox(created.id)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const broken of [false, true]) {
|
||||
itWithExecution.live(
|
||||
`moves an idle session from ${broken ? "broken" : "healthy"} source configuration`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const source = AbsolutePath.make(path.join(tmp.path, "source"))
|
||||
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
|
||||
if (broken)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(source, "opencode.json"), JSON.stringify({ instructions: ["{file:./missing.txt}"] })),
|
||||
)
|
||||
const session = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
yield* execution.awaitIdle(created.id)
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||
expect(yield* session.inbox(created.id)).toEqual([])
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
itWithPermissions(
|
||||
"recovers an idle session from an unreadable source directory",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const source = AbsolutePath.make(path.join(tmp.path, "source"))
|
||||
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
|
||||
const session = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => chmod(source, 0o755)))
|
||||
yield* Effect.promise(() => chmod(source, 0o000))
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
readdir(source).then(
|
||||
() => false,
|
||||
() => true,
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
yield* execution.awaitIdle(created.id)
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||
expect(yield* session.inbox(created.id)).toEqual([])
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
for (const broken of [false, true]) {
|
||||
itWithInstance.live(
|
||||
`uses the ${broken ? "broken" : "healthy discovery-disabled"} selected instance rather than the default Location`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const source = Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "source")) })
|
||||
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source.directory), mkdir(destination)]))
|
||||
const config = JSON.stringify({ instructions: ["{file:./missing.txt}"] })
|
||||
if (!broken) yield* Effect.promise(() => Bun.write(path.join(source.directory, "opencode.json"), config))
|
||||
const selectedID = Session.ID.create()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
Instance.node.replace(
|
||||
makeGlobalNode({
|
||||
service: Instance.Service,
|
||||
deps: [LocationServiceMap.node],
|
||||
layer: Layer.effect(
|
||||
Instance.Service,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const privateInstances = yield* LayerMap.make(
|
||||
() =>
|
||||
Instance.layer(source, {
|
||||
discovery: false,
|
||||
replacements: [
|
||||
...bindings,
|
||||
...(broken
|
||||
? [
|
||||
Config.node.replace(
|
||||
Config.configured({ project: false, global: false, content: config }),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
const selector = Instance.Service.of({
|
||||
provide: (session) =>
|
||||
Effect.provide(
|
||||
session.id === selectedID && session.location.directory === source.directory
|
||||
? privateInstances.get(session.id)
|
||||
: locations.get(session.location),
|
||||
),
|
||||
})
|
||||
const bindings: LayerNode.Replacements = [
|
||||
...replacements,
|
||||
Instance.node.replace(Layer.succeed(Instance.Service, selector)),
|
||||
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, locations)),
|
||||
]
|
||||
return selector
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
]
|
||||
const context = yield* Layer.build(AppNodeBuilder.build(Session.node, replacements))
|
||||
const session = Context.get(context, Session.Service)
|
||||
const created = yield* session.create({ id: selectedID, location: source })
|
||||
const pending = yield* session.synthetic({
|
||||
sessionID: created.id,
|
||||
text: "Keep pending",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination, delivery: "queue" })
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(broken ? destination : source.directory)
|
||||
const inbox = yield* session.inbox(created.id)
|
||||
expect(inbox[0]).toEqual(pending)
|
||||
if (broken) expect(inbox).toEqual([pending])
|
||||
if (!broken) expect(inbox.slice(1)).toMatchObject([{ type: "move", delivery: "queue" }])
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
for (const interrupt of ["source", "caller"] as const) {
|
||||
itWithInstance.live(`does not recover or enqueue a move when the ${interrupt} interrupts the probe`, () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sourceProbe()
|
||||
const created = yield* fixture.session.create({ location: Location.Ref.make({ directory: fixture.source }) })
|
||||
const pending = yield* fixture.session.synthetic({ sessionID: created.id, text: "Keep pending", resume: false })
|
||||
const moving = yield* fixture.session
|
||||
.move({ sessionID: created.id, directory: fixture.destination })
|
||||
.pipe(Effect.forkScoped)
|
||||
const release = yield* Queue.take(fixture.probes)
|
||||
|
||||
if (interrupt === "source") yield* Deferred.interrupt(release)
|
||||
if (interrupt === "caller") yield* Fiber.interrupt(moving)
|
||||
const exit = yield* Fiber.await(moving)
|
||||
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
expect((yield* fixture.session.get(created.id)).location.directory).toBe(fixture.source)
|
||||
expect(yield* fixture.session.inbox(created.id)).toEqual([pending])
|
||||
}).pipe(Effect.timeout("5 seconds")),
|
||||
)
|
||||
}
|
||||
|
||||
itWithInstance.live("does not recover if execution starts during the source probe", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sourceProbe({ execution: true })
|
||||
const created = yield* fixture.session.create({ location: Location.Ref.make({ directory: fixture.source }) })
|
||||
const moving = yield* fixture.session
|
||||
.move({ sessionID: created.id, directory: fixture.destination })
|
||||
.pipe(Effect.forkScoped)
|
||||
const release = yield* Queue.take(fixture.probes)
|
||||
|
||||
yield* fixture.execution.wake(created.id)
|
||||
// The real coordinator now owns execution; its separate instance acquisition stays suspended.
|
||||
yield* Queue.take(fixture.probes)
|
||||
expect(yield* fixture.execution.isActive(created.id)).toBe(true)
|
||||
yield* Deferred.die(release, new Error("source unavailable"))
|
||||
yield* Fiber.join(moving)
|
||||
|
||||
expect((yield* fixture.session.get(created.id)).location.directory).toBe(fixture.source)
|
||||
expect(yield* fixture.session.inbox(created.id)).toMatchObject([{ type: "move", delivery: "steer" }])
|
||||
expect(yield* fixture.execution.isActive(created.id)).toBe(true)
|
||||
yield* fixture.execution.interrupt(created.id)
|
||||
yield* fixture.execution.awaitIdle(created.id)
|
||||
}).pipe(Effect.timeout("5 seconds")),
|
||||
)
|
||||
|
||||
itWithInstance.live(
|
||||
"recovers a missing source without initializing its instance and retains destination workspace identity",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sourceProbe()
|
||||
const created = yield* fixture.session.create({
|
||||
location: Location.Ref.make({ directory: fixture.source, workspaceID: Workspace.ID.create() }),
|
||||
})
|
||||
yield* Effect.promise(() => rm(fixture.source, { recursive: true }))
|
||||
const workspaceID = Workspace.ID.create()
|
||||
|
||||
yield* fixture.session.move({ sessionID: created.id, directory: fixture.destination, workspaceID })
|
||||
|
||||
expect((yield* fixture.session.get(created.id)).location).toEqual(
|
||||
Location.Ref.make({ directory: fixture.destination, workspaceID }),
|
||||
)
|
||||
expect(yield* fixture.session.inbox(created.id)).toEqual([])
|
||||
expect(yield* Queue.size(fixture.probes)).toBe(0)
|
||||
}).pipe(Effect.timeout("5 seconds")),
|
||||
)
|
||||
|
||||
for (const changed of ["directory", "workspace"] as const) {
|
||||
itWithInstance.live(`allows inbox cancellation during a source probe and rejects stale ${changed} recovery`, () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sourceProbe()
|
||||
const created = yield* fixture.session.create({ location: Location.Ref.make({ directory: fixture.source }) })
|
||||
const pending = yield* fixture.session.synthetic({
|
||||
sessionID: created.id,
|
||||
text: "Cancel pending",
|
||||
resume: false,
|
||||
})
|
||||
const moving = yield* fixture.session
|
||||
.move({ sessionID: created.id, directory: fixture.destination })
|
||||
.pipe(Effect.exit, Effect.forkScoped)
|
||||
const release = yield* Queue.take(fixture.probes)
|
||||
|
||||
yield* fixture.session.cancelInbox({ sessionID: created.id, inboxID: pending.id }).pipe(
|
||||
Effect.timeout("2 seconds"),
|
||||
Effect.onError(() => Deferred.interrupt(release)),
|
||||
)
|
||||
expect(yield* fixture.session.inbox(created.id)).toEqual([])
|
||||
expect(moving.pollUnsafe()).toBeUndefined()
|
||||
const location = Location.Ref.make({
|
||||
directory: changed === "directory" ? fixture.destination : fixture.source,
|
||||
workspaceID: changed === "workspace" ? Workspace.ID.create() : undefined,
|
||||
})
|
||||
yield* fixture.bus.publish(SessionEvent.Moved, {
|
||||
sessionID: created.id,
|
||||
location,
|
||||
projectID: created.projectID,
|
||||
})
|
||||
yield* Deferred.die(release, new Error("source unavailable"))
|
||||
expect(Exit.isSuccess(yield* Fiber.join(moving))).toBe(true)
|
||||
|
||||
expect((yield* fixture.session.get(created.id)).location).toEqual(location)
|
||||
expect(yield* fixture.session.inbox(created.id)).toMatchObject([
|
||||
{ type: "move", payload: { location: { directory: fixture.destination } } },
|
||||
])
|
||||
}).pipe(Effect.timeout("5 seconds")),
|
||||
)
|
||||
}
|
||||
|
||||
itWithUnavailableDestination.effect("rejects an unavailable destination before admitting the move", () =>
|
||||
tmpdirScoped().pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
@@ -482,23 +111,15 @@ describe("Session.move", () => {
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(AbsolutePath.make(source))
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(1)
|
||||
const pending = yield* session.synthetic({
|
||||
sessionID: created.id,
|
||||
text: "Keep queued",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
yield* session.move({ sessionID: created.id, directory: destination, delivery: "queue" })
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(3)
|
||||
|
||||
yield* Effect.promise(() => rm(source, { recursive: true }))
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||
expect(yield* session.inbox(created.id)).toEqual([pending])
|
||||
expect(yield* session.inbox(created.id)).toEqual([])
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(2)
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(1)
|
||||
|
||||
yield* Effect.promise(() => mkdir(path.join(tmp.path, "other")))
|
||||
const steered = yield* session.create({
|
||||
|
||||
@@ -2141,13 +2141,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-prefix", "echo", { text: "x".repeat(4_000) }),
|
||||
TestLLM.textWithUsage("Earlier answer", "prefix-answer", 185_000),
|
||||
TestLLM.complete(
|
||||
{
|
||||
reason: { normalized: "stop" },
|
||||
providerMetadata: { [s.currentModel.provider]: { responseId: "summary" } },
|
||||
},
|
||||
LLMEvent.textDelta({ id: "prefix-summary", text: "## Objective\n- Checkpoint summary" }),
|
||||
),
|
||||
TestLLM.text("## Objective\n- Checkpoint summary", "prefix-summary"),
|
||||
)
|
||||
yield* s.runPrompt("Review these changes")
|
||||
if (reason === "manual") {
|
||||
@@ -2183,10 +2177,6 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.")
|
||||
expect(requestAgents[2]).toBe(Agent.ID.make("compaction"))
|
||||
expect(s.executions).toEqual(["x".repeat(4_000)])
|
||||
expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({
|
||||
model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant },
|
||||
providerState: { responseId: "summary" },
|
||||
})
|
||||
|
||||
// Compare wire content without the cache breakpoints that move to the new final message.
|
||||
const before = yield* compileRequest(LLMRequest.update(normal, { cache: "none" }))
|
||||
@@ -2219,14 +2209,8 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
: TestLLM.text("Let me search the codebase. I will fill in ## Objective later.", "invalid-summary")
|
||||
yield* s.llm.push(
|
||||
invalid.map((event) =>
|
||||
LLMEvent.is.stepFinish(event)
|
||||
? { ...event, providerMetadata: { openai: { responseId: "rejected-summary-state" } } }
|
||||
: event,
|
||||
),
|
||||
summary
|
||||
? [LLMEvent.textDelta({ id: "summary-recovered", text: "### Active\n- Recovered summary" })]
|
||||
: invalid,
|
||||
invalid,
|
||||
summary ? TestLLM.text("### Active\n- Recovered summary", "summary-recovered") : invalid,
|
||||
)
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
@@ -2236,7 +2220,6 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("did not fill in the required summary template")
|
||||
expect(s.requests.every((request) => request.toolChoice === undefined)).toBe(true)
|
||||
expect(s.executions).toEqual([])
|
||||
expect(JSON.stringify(yield* s.messages)).not.toContain("rejected-summary-state")
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject(
|
||||
summary
|
||||
? { status: "completed", summary: "### Active\n- Recovered summary" }
|
||||
|
||||
@@ -148,9 +148,7 @@ for (const fixture of [
|
||||
.all()
|
||||
const types = events.map((event) => event.type)
|
||||
const terminal = fixture.finish === "stop" ? "session.step.ended.1" : "session.step.failed.1"
|
||||
expect(types.filter((type) => type === "session.step.streamed.1")).toHaveLength(1)
|
||||
expect(types.filter((type) => type === terminal)).toHaveLength(1)
|
||||
expect(types.indexOf("session.step.streamed.1")).toBeLessThan(types.indexOf(terminal))
|
||||
expect(
|
||||
types.indexOf(fixture.toolChoice === "none" ? "session.tool.failed.2" : "session.tool.success.2"),
|
||||
).toBeLessThan(types.indexOf(terminal))
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "../../src/shell/parse.js"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const contexts = [
|
||||
(source: string) => source,
|
||||
(source: string) => `( ${source} )`,
|
||||
(source: string) => `{ ${source}; }`,
|
||||
(source: string) => `if true; then ${source}; fi`,
|
||||
(source: string) => `outer() { ${source}; }; outer`,
|
||||
]
|
||||
|
||||
const bodies = [
|
||||
"for value in one two; do scan_probe; done",
|
||||
"while true; do scan_probe; break; done",
|
||||
"until false; do scan_probe; break; done",
|
||||
"case value in value) scan_probe;; *) scan_other;; esac",
|
||||
]
|
||||
|
||||
describe("compound function acceptance", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
for (const head of ["probe()", "function probe", "function probe()", "probe-name()"])
|
||||
for (const body of bodies)
|
||||
for (const context of contexts) {
|
||||
const name = head.includes("probe-name") ? "probe-name" : "probe"
|
||||
const source = context(`${head} ${body}; ${name}`)
|
||||
test(`${shell}: ${source}`, async () => {
|
||||
// Braces preserve the function's behavior, but avoid Tree-sitter's recovery artifacts.
|
||||
const legacy = await Effect.runPromise(
|
||||
ShellParse.scan(context(`${head} { ${body}; }; ${name}`), shell, "/workspace"),
|
||||
)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test.each(bodies)("keeps compound function bodies inside command substitutions: %s", (body) => {
|
||||
const source = `printf '%s' "$( probe() ${body}; probe )"`
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands[0]?.resource).toBe(source)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
expect(result.commands.at(-1)?.words).toEqual(["probe"])
|
||||
})
|
||||
})
|
||||
|
||||
const values = [
|
||||
"one two",
|
||||
"'two words' one",
|
||||
"'cd' '/outside'",
|
||||
"'do' 'done'",
|
||||
"'(literal)' '$(scan_ignored)'",
|
||||
"one\\\ntwo",
|
||||
"$(printf one)",
|
||||
'"$(printf one)"',
|
||||
"<(printf one)",
|
||||
"",
|
||||
]
|
||||
const loops = values.flatMap((value) =>
|
||||
[
|
||||
`for value (${value}) scan_probe "$value"`,
|
||||
`for value (${value}) { scan_probe "$value"; }`,
|
||||
...(value
|
||||
? [
|
||||
`for value (${value}) do scan_probe "$value"; done`,
|
||||
`for value (${value}); do scan_probe "$value"; done`,
|
||||
`for value (${value})\ndo scan_probe "$value"; done`,
|
||||
`for value (${value}) # ignored\ndo scan_probe "$value"; done`,
|
||||
`for value (${value}) \\\ndo scan_probe "$value"; done`,
|
||||
]
|
||||
: []),
|
||||
].map((source) => ({ source, equivalent: `for value in ${value}; do scan_probe "$value"; done` })),
|
||||
)
|
||||
|
||||
describe("Zsh parenthesized loop acceptance", () => {
|
||||
for (const fixture of loops)
|
||||
for (const context of contexts) {
|
||||
const source = context(fixture.source)
|
||||
test(source, async () => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(context(fixture.equivalent), "zsh", "/workspace"))
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
}
|
||||
|
||||
test.each([
|
||||
"for x (one two) for y (a b) scan_probe",
|
||||
"for x (one two) scan_probe && scan_other",
|
||||
"for x (one two) scan_probe | scan_other",
|
||||
"printf '%s' \"$(for x (one two) scan_probe)\"",
|
||||
"for x (one two) { for y (a b); do scan_probe; done; }",
|
||||
"for x (one two) [[ $(scan_probe) == ok ]]",
|
||||
"for x (one two) (( 1 + $(scan_probe) ))",
|
||||
])("retains commands in nested shorthand loops: %s", (source) => {
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
if (source.includes("scan_other"))
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_other")
|
||||
})
|
||||
})
|
||||
|
||||
describe("real-shell compound syntax", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const executable = Bun.which(shell)
|
||||
test
|
||||
.skipIf(!executable)
|
||||
.each([
|
||||
...bodies.map((body) => `probe() ${body}; probe`),
|
||||
...bodies.map((body) => `printf '%s' "$(probe() ${body}; probe)"`),
|
||||
...(shell === "zsh" ? loops.map((fixture) => fixture.source) : []),
|
||||
])(`${shell}: %s`, (source) => {
|
||||
if (!executable) throw new Error(`${shell} is unavailable`)
|
||||
const execution = Bun.spawnSync(
|
||||
[
|
||||
executable,
|
||||
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
|
||||
"-c",
|
||||
`scan_probe() { printf 'executed\\n' >&2; }; ${source}; wait`,
|
||||
],
|
||||
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" }, timeout: 2_000 },
|
||||
)
|
||||
expect(execution.exitCode).toBe(0)
|
||||
expect(execution.stderr.toString()).toEqual(
|
||||
source.includes("value ()") ? "" : expect.stringContaining("executed\n"),
|
||||
)
|
||||
expect(ShellScan.scan(source).kind).toBe("scanned")
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,158 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "../../src/shell/parse.js"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const conditions = ["[[ -n <(scan_probe) ]]", "[[ -n >(scan_probe) ]]"]
|
||||
const contexts = [
|
||||
(source: string) => source,
|
||||
(source: string) => `( ${source} )`,
|
||||
(source: string) => `{ ${source}; }`,
|
||||
(source: string) => `if ${source}; then printf visible; fi`,
|
||||
(source: string) => `check() { ${source}; }; check`,
|
||||
(source: string) => `printf '%s' "$( ${source}; printf visible)"`,
|
||||
]
|
||||
|
||||
const functions = ["probe", "probe-name", "probe.name", "probe:name"].flatMap((name) =>
|
||||
[`${name}()`, `function ${name}`, `function ${name}()`].flatMap((head) =>
|
||||
[
|
||||
"{ scan_probe; }",
|
||||
"(scan_probe)",
|
||||
"if true; then scan_probe; fi",
|
||||
"[[ $(scan_probe) == ok ]]",
|
||||
"(( 1 + $(scan_probe) ))",
|
||||
].map((body) => `${head} ${body}; ${name}`),
|
||||
),
|
||||
)
|
||||
|
||||
describe("legacy-accepted shell syntax regressions", () => {
|
||||
test.each(["() { scan_probe; }", "probe() { scan_probe; }; probe"])(
|
||||
"preserves deeply indented function definitions: %s",
|
||||
async (source) => {
|
||||
const command = `( ${" ".repeat(32_000)}${source} )`
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, "zsh", "/workspace"))
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(command, "zsh", "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
test.each(conditions.flatMap((source) => contexts.map((context) => context(source))))(
|
||||
"retains conditional process substitutions and permission resources: %s",
|
||||
async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, "bash", "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, "bash", "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
test.each(
|
||||
["probe()", "probe \\\n()", "function \\\nprobe()", "function probe \\\n()"].flatMap((head) =>
|
||||
[" \\\n", " \\\n # ignored ) }\n", "# ignored \\\n"].flatMap((gap) =>
|
||||
contexts.map((context) => context(`${head}${gap}{ scan_probe; }; probe`)),
|
||||
),
|
||||
),
|
||||
)(`${shell} preserves line continuations at function boundaries: %s`, async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
|
||||
test.each(
|
||||
["probe()", "function probe", "function probe()"].flatMap((head) =>
|
||||
[" # ignored ) }\n", "\n# ignored ) }\n\n", " # first\n# second\n"].flatMap((gap) =>
|
||||
contexts.map((context) => context(`${head}${gap}{ scan_probe; }; probe`)),
|
||||
),
|
||||
),
|
||||
)(`${shell} preserves comments between a function head and its body: %s`, async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
|
||||
test.each(functions)(
|
||||
`${shell} preserves function resources, saved prefixes, and directories: %s`,
|
||||
async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
const executable = Bun.which(shell)
|
||||
test
|
||||
.skipIf(!executable)
|
||||
.each([
|
||||
"probe-name() { scan_probe; }; probe-name",
|
||||
"function probe.name { scan_probe; }; probe.name",
|
||||
"probe:name() if true; then scan_probe; fi; probe:name",
|
||||
"probe()# ignored ) }\n{ scan_probe; }; probe",
|
||||
"probe \\\n() \\\n{ scan_probe; }; probe",
|
||||
"function \\\nprobe() # ignored \\\n{ scan_probe; }; probe",
|
||||
...(shell === "bash" ? conditions : ["() { scan_probe; }"]),
|
||||
])(`${shell} really executes the extracted command: %s`, (source) => {
|
||||
if (!executable) throw new Error(`${shell} is unavailable`)
|
||||
const execution = Bun.spawnSync(
|
||||
[
|
||||
executable,
|
||||
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
|
||||
"-c",
|
||||
`scan_probe() { printf 'executed\\n' >&2; }; ${source}; wait`,
|
||||
],
|
||||
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
|
||||
)
|
||||
expect(execution.exitCode).toBe(0)
|
||||
expect(execution.stderr.toString()).toBe("executed\n")
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
})
|
||||
}
|
||||
|
||||
test.each([
|
||||
"() { scan_probe; }",
|
||||
"( () { scan_probe; } )",
|
||||
"{ () { scan_probe; }; }",
|
||||
"while() { scan_probe; break; }",
|
||||
"until() { scan_probe; break; }",
|
||||
])("preserves Zsh anonymous functions and parenthesized loop permissions: %s", async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, "zsh", "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
|
||||
// Tree-sitter recovers these valid Zsh forms with synthetic commands or truncated outer resources.
|
||||
// Pin both results rather than treating recovery artifacts as executable shell syntax.
|
||||
test.each([
|
||||
{
|
||||
source: "if () { scan_probe; }; then printf visible; fi",
|
||||
legacy: ["scan_probe", "then printf visible", "fi"],
|
||||
portable: ["scan_probe", "printf visible"],
|
||||
},
|
||||
{
|
||||
source: "check() { () { scan_probe; }; }; check",
|
||||
legacy: ["scan_probe", "}", "check"],
|
||||
portable: ["scan_probe", "check"],
|
||||
},
|
||||
{
|
||||
source: "printf '%s' \"$( () { scan_probe; }; printf visible)\"",
|
||||
legacy: ["printf '%s'", "scan_probe", "printf visible"],
|
||||
portable: ["printf '%s' \"$( () { scan_probe; }; printf visible)\"", "scan_probe", "printf visible"],
|
||||
},
|
||||
])("accepts anonymous-function compositions despite legacy recovery artifacts: $source", async (fixture) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(fixture.source, "zsh", "/workspace"))
|
||||
const portable = await Effect.runPromise(ShellParse.scanPortable(fixture.source, "zsh", "/workspace"))
|
||||
expect(legacy.commands.map((command) => command.resource)).toEqual([...fixture.legacy])
|
||||
expect(portable.commands.map((command) => command.resource)).toEqual([...fixture.portable])
|
||||
})
|
||||
|
||||
test.each([
|
||||
"[[ -n '<(scan_ignored)' ]]",
|
||||
'[[ -n "<(scan_ignored)" ]]',
|
||||
"[[ -n '>(scan_ignored)' ]]",
|
||||
'[[ -n ">(scan_ignored)" ]]',
|
||||
"[[ -n $'<(scan_ignored)' ]]",
|
||||
])("does not turn quoted process-substitution text into commands: %s", (source) => {
|
||||
expect(ShellScan.scan(source)).toEqual({ kind: "scanned", commands: [] })
|
||||
})
|
||||
})
|
||||
@@ -3,30 +3,7 @@ import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const pwsh = process.env.SHELL_SCAN_PWSH ?? Bun.which("pwsh")
|
||||
|
||||
// These ordinary forms must stay accepted, not disappear behind the oracle's opaque-result filter.
|
||||
const supported = [
|
||||
"Invoke-ProbeA; Invoke-ProbeB",
|
||||
"$result = Invoke-ProbeA; Invoke-ProbeB",
|
||||
"if (Invoke-ProbeA) { Invoke-ProbeB } else { Invoke-ProbeC }",
|
||||
"foreach ($item in (Invoke-ProbeA)) { Invoke-ProbeB }",
|
||||
"function Get-Probe { param($x); Invoke-ProbeB }; Invoke-ProbeA",
|
||||
"$x = @{ first = Invoke-ProbeA; second = @(Invoke-ProbeB; Invoke-ProbeC) }",
|
||||
'Invoke-ProbeA "$(Invoke-ProbeB "$(Invoke-ProbeC)")"',
|
||||
"Invoke-ProbeA | ForEach-Object { Invoke-ProbeB }",
|
||||
"Invoke-ProbeA @'\nliteral ; }\n'@; Invoke-ProbeB",
|
||||
'Invoke-ProbeA @"\n$(Invoke-ProbeB)\n"@; Invoke-ProbeC',
|
||||
"Invoke-ProbeA `\n argument; Invoke-ProbeB",
|
||||
"Invoke-ProbeA 2>&1; Invoke-ProbeB",
|
||||
"& 'Invoke-ProbeA' argument; Invoke-ProbeB",
|
||||
"Invoke-ProbeA --% literal; ignored\nInvoke-ProbeB",
|
||||
]
|
||||
|
||||
test.each(supported)("accepts supported PowerShell syntax without an opaque escape hatch: %s", (source) => {
|
||||
expect(ShellScan.scanPowerShell(source).kind).toBe("scanned")
|
||||
})
|
||||
|
||||
const fixtures = [
|
||||
...supported,
|
||||
...[
|
||||
"$result = Invoke-ProbeA; Invoke-ProbeB",
|
||||
"$result = (Invoke-ProbeA); Invoke-ProbeB",
|
||||
@@ -366,10 +343,6 @@ test.skipIf(!pwsh)(
|
||||
let executed = 0
|
||||
for (const result of results) {
|
||||
const scan = ShellScan.scanPowerShell(result.source)
|
||||
if (supported.includes(result.source)) {
|
||||
expect(result.errors, result.source).toEqual([])
|
||||
expect(scan.kind, result.source).toBe("scanned")
|
||||
}
|
||||
if (scan.kind === "opaque" || result.errors.length > 0) continue
|
||||
scanned++
|
||||
executed += result.executed.length
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
it.effect("detaches every registration of a failed group and refreshes every affected domain", () =>
|
||||
Effect.gen(function* () {
|
||||
const notices: string[] = []
|
||||
const failures: State.Failure[] = []
|
||||
let refresh = Effect.void
|
||||
let fail = false
|
||||
let calls = 0
|
||||
const grouped = State.group((failure, changed) => {
|
||||
failures.push(failure)
|
||||
refresh = changed
|
||||
})
|
||||
const first = State.create({
|
||||
name: "first",
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
editor: (value) => value,
|
||||
notify: () => Effect.sync(() => void notices.push("first")),
|
||||
})
|
||||
const second = State.create({
|
||||
name: "second",
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
editor: (value) => value,
|
||||
notify: () => Effect.sync(() => void notices.push("second")),
|
||||
})
|
||||
yield* first.transform((editor) => editor.values.push("healthy"))
|
||||
const registration = yield* first.transform((editor) => editor.values.push("grouped")).pipe(grouped)
|
||||
yield* first.transform((editor) => editor.values.push("also grouped")).pipe(grouped)
|
||||
yield* second
|
||||
.transform((editor) => {
|
||||
calls++
|
||||
editor.values.push("partial")
|
||||
if (fail) throw new Error("broken")
|
||||
})
|
||||
.pipe(grouped)
|
||||
const before = first.get()
|
||||
notices.length = 0
|
||||
fail = true
|
||||
yield* second.reload()
|
||||
|
||||
expect(first.get().values).toEqual(["healthy"])
|
||||
expect(second.get().values).toEqual([])
|
||||
expect(before.values).toEqual(["healthy", "grouped", "also grouped"])
|
||||
expect(failures).toHaveLength(1)
|
||||
expect(failures[0]?.state).toBe("second")
|
||||
expect(calls).toBe(2)
|
||||
|
||||
notices.length = 0
|
||||
// The group deduplicates its domain notifications without relying on an outer batch.
|
||||
yield* refresh
|
||||
expect(notices.toSorted()).toEqual(["first", "second"])
|
||||
yield* registration.dispose
|
||||
expect(notices).toHaveLength(2)
|
||||
yield* first.transform((editor) => editor.values.push("resurrected")).pipe(grouped)
|
||||
expect(first.get().values).toEqual(["healthy"])
|
||||
expect(failures).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("restarts an outer candidate when a nested read disables one of its contributors", () =>
|
||||
Effect.gen(function* () {
|
||||
let fail = false
|
||||
const grouped = State.group(() => {})
|
||||
const inner = State.create({ initial: () => ({ value: 0 }), editor: (value) => value })
|
||||
const outer = State.create({ initial: () => ({ value: 0 }), editor: (value) => value })
|
||||
yield* outer.transform((editor) => (editor.value += 10)).pipe(grouped)
|
||||
yield* inner
|
||||
.transform((editor) => {
|
||||
editor.value = 5
|
||||
if (fail) throw new Error("inner failed")
|
||||
})
|
||||
.pipe(grouped)
|
||||
yield* outer.transform((editor) => (editor.value += inner.get().value + 1))
|
||||
expect(outer.get().value).toBe(16)
|
||||
|
||||
fail = true
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* inner.reload()
|
||||
yield* outer.reload()
|
||||
expect(outer.get().value).toBe(1)
|
||||
expect(inner.get().value).toBe(0)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables multiple failing groups once each before publishing a complete fold", () =>
|
||||
Effect.gen(function* () {
|
||||
const reported: string[] = []
|
||||
const first = State.group(() => reported.push("first"))
|
||||
const second = State.group(() => reported.push("second"))
|
||||
const state = State.create({ initial: () => ({ values: [] as string[] }), editor: (value) => value })
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state
|
||||
.transform((editor) => {
|
||||
editor.values.push("first")
|
||||
throw "first failed"
|
||||
})
|
||||
.pipe(first)
|
||||
yield* state
|
||||
.transform((editor) => {
|
||||
editor.values.push("second")
|
||||
throw { message: "second failed" }
|
||||
})
|
||||
.pipe(second)
|
||||
yield* state.transform((editor) => editor.values.push("healthy"))
|
||||
}),
|
||||
)
|
||||
expect(state.get().values).toEqual(["healthy"])
|
||||
expect(reported).toEqual(["first", "second"])
|
||||
yield* state.reload()
|
||||
expect(reported).toEqual(["first", "second"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not disable a group for a notification failure after successful replay", () =>
|
||||
Effect.gen(function* () {
|
||||
let reported = 0
|
||||
let fail = true
|
||||
const grouped = State.group(() => reported++)
|
||||
const state = State.create({
|
||||
initial: () => ({ value: 0 }),
|
||||
editor: (value) => value,
|
||||
notify: () => (fail ? Effect.die("observer failed") : Effect.void),
|
||||
})
|
||||
yield* state.transform((editor) => editor.value++).pipe(grouped, Effect.exit)
|
||||
expect(reported).toBe(0)
|
||||
expect(state.get().value).toBe(1)
|
||||
fail = false
|
||||
yield* state.reload()
|
||||
expect(state.get().value).toBe(1)
|
||||
}),
|
||||
)
|
||||
@@ -526,150 +526,6 @@ describe("ShellTool scanner permissions", () => {
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellTool conditional process substitution", () => {
|
||||
const test = isWindows || !Bun.which("bash") ? permissionIt.live.skip : permissionIt.live
|
||||
for (const portable of [false, true]) {
|
||||
test(`${portable ? "native" : "legacy"}: a nested deny prevents the substitution from running`, () =>
|
||||
withScanner(
|
||||
portable,
|
||||
(registry, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(toolIdentity.agent, (agent) => {
|
||||
agent.permissions = [
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "printf *", effect: "deny" },
|
||||
]
|
||||
}),
|
||||
)
|
||||
const marker = path.join(directory.active, "marker")
|
||||
const result = yield* runPermissionCommand(
|
||||
registry,
|
||||
'[[ -n <(printf reached > marker) ]]; wait "$!"',
|
||||
marker,
|
||||
[],
|
||||
)
|
||||
expect(result.exit).toMatchObject({
|
||||
_tag: "Success",
|
||||
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
||||
}),
|
||||
"bash",
|
||||
))
|
||||
|
||||
for (const reply of ["reject", "once", "always"] as const) {
|
||||
test(`${portable ? "native" : "legacy"}: conditional substitutions respect ${reply}`, () =>
|
||||
withScanner(
|
||||
portable,
|
||||
(registry, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const location = yield* Location.Service
|
||||
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["wait *"] })
|
||||
const marker = path.join(directory.active, "marker")
|
||||
const command = '[[ -n <(printf reached > marker) ]]; wait "$!"'
|
||||
const result = yield* runPermissionCommand(registry, command, marker, [reply])
|
||||
expect(result.requests).toMatchObject([
|
||||
{ action: "shell", resources: ["printf reached > marker", 'wait "$!"'], save: ["printf *", "wait *"] },
|
||||
])
|
||||
if (reply === "reject") {
|
||||
expect(Exit.isFailure(result.exit)).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
||||
return
|
||||
}
|
||||
expect(result.exit).toMatchObject({
|
||||
_tag: "Success",
|
||||
value: { status: "completed", metadata: { exit: 0 } },
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("reached")
|
||||
yield* Effect.promise(() => fs.unlink(marker))
|
||||
const repeat = yield* runPermissionCommand(
|
||||
registry,
|
||||
command,
|
||||
marker,
|
||||
reply === "always" ? [] : ["reject"],
|
||||
)
|
||||
expect(repeat.requests).toHaveLength(reply === "always" ? 0 : 1)
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(reply === "always")
|
||||
}),
|
||||
"bash",
|
||||
))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellTool compound syntax approval compatibility", () => {
|
||||
for (const fixture of [
|
||||
{
|
||||
shell: "zsh",
|
||||
command: 'for value (a b) printf %s "$value"',
|
||||
equivalent: 'for value in a b; do printf %s "$value"; done',
|
||||
output: "ab",
|
||||
saved: ["printf *"],
|
||||
},
|
||||
{
|
||||
shell: "zsh",
|
||||
command: 'for value (a b) { printf %s "$value"; }',
|
||||
equivalent: 'for value in a b; do printf %s "$value"; done',
|
||||
output: "ab",
|
||||
saved: ["printf *"],
|
||||
},
|
||||
{
|
||||
shell: "zsh",
|
||||
command: 'for value ($(printf a)) do printf %s "$value"; done',
|
||||
equivalent: 'for value in $(printf a); do printf %s "$value"; done',
|
||||
output: "a",
|
||||
saved: ["printf *"],
|
||||
},
|
||||
{
|
||||
shell: "bash",
|
||||
command: 'probe() for value in a b; do printf %s "$value"; done; probe',
|
||||
equivalent: 'probe() { for value in a b; do printf %s "$value"; done; }; probe',
|
||||
output: "ab",
|
||||
saved: ["printf *", "probe *"],
|
||||
},
|
||||
{
|
||||
shell: "bash",
|
||||
command: 'printf %s "$(probe() case value in value) printf hello;; esac; probe)"',
|
||||
equivalent: 'printf %s "$(probe() { case value in value) printf hello;; esac; }; probe)"',
|
||||
output: "hello",
|
||||
saved: ["printf *", "probe *"],
|
||||
},
|
||||
]) {
|
||||
const test = isWindows || !Bun.which(fixture.shell) ? permissionIt.live.skip : permissionIt.live
|
||||
for (const portable of [false, true]) {
|
||||
test(`${fixture.shell} ${portable ? "native" : "legacy equivalent"}: ${fixture.command}`, () =>
|
||||
withScanner(
|
||||
portable,
|
||||
(registry, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const location = yield* Location.Service
|
||||
yield* saved.add({ projectID: location.project.id, action: "shell", resources: fixture.saved })
|
||||
const result = yield* runPermissionCommand(
|
||||
registry,
|
||||
portable ? fixture.command : fixture.equivalent,
|
||||
path.join(directory.active, "marker"),
|
||||
[],
|
||||
)
|
||||
expect(result.requests).toEqual([])
|
||||
expect(result.exit).toMatchObject({
|
||||
_tag: "Success",
|
||||
value: {
|
||||
status: "completed",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: fixture.output }, { type: "text" }],
|
||||
},
|
||||
})
|
||||
}),
|
||||
fixture.shell,
|
||||
))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellTool ordinary shell syntax", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const test = isWindows || !Bun.which(shell) ? permissionIt.live.skip : permissionIt.live
|
||||
|
||||
@@ -162,20 +162,6 @@ type PromptFooterInput = {
|
||||
readonly showDetails: boolean
|
||||
}
|
||||
|
||||
export type PanelPresentation = "panel" | "fullscreen"
|
||||
|
||||
/** Client-local state of the selected session panel. The host owns its layout and input scope. */
|
||||
export interface PanelInput {
|
||||
readonly sessionID: string
|
||||
readonly width: number
|
||||
readonly presentation: PanelPresentation
|
||||
readonly focused: boolean
|
||||
readonly canSplit: boolean
|
||||
readonly focus: () => void
|
||||
readonly close: () => void
|
||||
readonly toggleFullscreen: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
|
||||
* may render around, inside, or take over. Paths are absolute and
|
||||
@@ -194,7 +180,6 @@ export interface SlotMap {
|
||||
readonly "prompt.footer.status": PromptFooterInput
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "session.panel": PanelInput
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": { readonly sessionID: string }
|
||||
}
|
||||
@@ -218,58 +203,45 @@ export type SlotPath = keyof SlotMap
|
||||
* `render` receives the target slot's input, reactively. The `?: never`
|
||||
* fields make the variants mutually exclusive: a claim with two placement
|
||||
* keys is a type error, not a silent priority pick.
|
||||
*
|
||||
* `session.panel` is an exclusive named replacement selected by ui.panel.open,
|
||||
* not by plugin enable order. Its instance survives presentation changes.
|
||||
*/
|
||||
export type SlotClaim<Path extends SlotPath = SlotPath> = Path extends SlotPath
|
||||
? { readonly render: (input: SlotMap[Path]) => JSX.Element } & (Path extends "session.panel"
|
||||
? { readonly name: string }
|
||||
: { readonly name?: string }) &
|
||||
(Path extends "session.panel"
|
||||
? {
|
||||
readonly replace: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
}
|
||||
:
|
||||
| {
|
||||
readonly prepend: Path
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly append: Path
|
||||
readonly prepend?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly before: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly after: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly replace: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
})
|
||||
? { readonly render: (input: SlotMap[Path]) => JSX.Element } & (
|
||||
| {
|
||||
readonly prepend: Path
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly append: Path
|
||||
readonly prepend?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly before: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly after: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly replace: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
}
|
||||
)
|
||||
: never
|
||||
|
||||
export interface App {
|
||||
@@ -478,14 +450,6 @@ export interface UI {
|
||||
navigate(destination: Destination): void
|
||||
current(): Route
|
||||
}
|
||||
readonly panel: {
|
||||
/** Opens a named session.panel contribution owned by this plugin in the current session. */
|
||||
open(name: string, options?: { readonly presentation?: PanelPresentation }): boolean
|
||||
/** Closes this plugin's active panel. Other plugins' panels are unaffected. */
|
||||
close(): void
|
||||
/** This plugin's active panel, if any. Reactive when read in a Solid computation. */
|
||||
current(): { readonly name: string; readonly sessionID: string } | undefined
|
||||
}
|
||||
readonly tabs: {
|
||||
/** Returns whether session tabs are enabled for this TUI. */
|
||||
enabled(): boolean
|
||||
|
||||
@@ -8890,8 +8890,7 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "An absolute path or a path relative to the requested location. Defaults to the location directory."
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
@@ -8942,7 +8941,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
|
||||
"description": "List direct children of one directory relative to the requested location.",
|
||||
"summary": "List directory"
|
||||
}
|
||||
},
|
||||
@@ -13778,12 +13777,8 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"subagent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean",
|
||||
"description": "Deprecated alias for subagent."
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["template"],
|
||||
@@ -13904,7 +13899,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify"]
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
|
||||
@@ -34,8 +34,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
default_agent: Schema.String.pipe(optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
|
||||
description: "Disable updates or notify when one is available",
|
||||
update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({
|
||||
description: "Disable updates, notify when one is available, or install automatically",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
|
||||
@@ -9,6 +9,5 @@ export class Info extends Schema.Class<Info>("Config.Command")({
|
||||
description: Schema.String.pipe(optional),
|
||||
agent: Schema.String.pipe(optional),
|
||||
model: ConfigModel.Selection.pipe(optional),
|
||||
subagent: Schema.Boolean.pipe(optional),
|
||||
subtask: Schema.Boolean.annotate({ description: "Deprecated alias for subagent." }).pipe(optional),
|
||||
subtask: Schema.Boolean.pipe(optional),
|
||||
}) {}
|
||||
|
||||
@@ -585,8 +585,6 @@ export namespace Compaction {
|
||||
schema: {
|
||||
...Base,
|
||||
reason: Started.data.fields.reason,
|
||||
model: SessionMessage.CompactionCompleted.fields.model,
|
||||
providerState: SessionMessage.CompactionCompleted.fields.providerState,
|
||||
text: Schema.String,
|
||||
recent: Schema.String,
|
||||
},
|
||||
|
||||
@@ -250,8 +250,6 @@ export const CompactionCompleted = Schema.Struct({
|
||||
...CompactionBase,
|
||||
status: Schema.tag("completed"),
|
||||
reason: Schema.Literals(["auto", "manual"]),
|
||||
model: Model.Ref.pipe(optional),
|
||||
providerState: ProviderState.pipe(optional),
|
||||
summary: Schema.String,
|
||||
recent: Schema.String,
|
||||
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/effect"
|
||||
import type { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import type { Config, Scope } from "effect"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { EmbeddedHost } from "../internal/host"
|
||||
import type { SdkInstances } from "../internal/instances"
|
||||
|
||||
@@ -35,12 +35,9 @@ export const create: <R = never>(
|
||||
R = never,
|
||||
>(options: CreateOptions<R> = {}, embed: EmbedOptions = {}) {
|
||||
const host = yield* Effect.acquireRelease(EmbeddedHost.create(options, embed), (host) => Effect.promise(host.close))
|
||||
const httpClient = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
// FetchHttpClient reads Fetch at request time; callers must not replace this host's in-process transport.
|
||||
HttpClient.transformResponse(httpClient, Effect.provideService(FetchHttpClient.Fetch, host.fetch)),
|
||||
Effect.provide(
|
||||
FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, host.fetch)), Layer.fresh),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Context, Effect, Exit, Layer, Scope, Stream } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { AbsolutePath, Location, OpenCode, Session } from "../src/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
for (const entrypoint of ["create", "layer"] as const) {
|
||||
it.live(`${entrypoint} keeps requests and streams on its own transport despite an ambient Fetch`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const calls: string[] = []
|
||||
const ambient = Object.assign(
|
||||
(input: RequestInfo | URL) => {
|
||||
calls.push(input instanceof Request ? input.url : String(input))
|
||||
return Promise.reject(new Error("The caller's Fetch must not receive embedded SDK requests"))
|
||||
},
|
||||
{ preconnect: () => undefined },
|
||||
)
|
||||
const parent = yield* Effect.scope
|
||||
const scope = yield* Scope.fork(parent)
|
||||
const options: OpenCode.CreateOptions = {
|
||||
app: { version: "transport-test" },
|
||||
config: { directory: directory.path, project: false, content: "{}" },
|
||||
events: { persist: true },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
}
|
||||
const client = yield* (
|
||||
entrypoint === "create"
|
||||
? OpenCode.create(options).pipe(Scope.provide(scope))
|
||||
: Layer.buildWithScope(OpenCode.layer(options), scope).pipe(Effect.map(Context.get(OpenCode.Service)))
|
||||
).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
expect(yield* client.health.get()).toMatchObject({ healthy: true, version: "transport-test" })
|
||||
const session = yield* client.sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }),
|
||||
})
|
||||
expect((yield* client.sessions.get({ sessionID: session.id })).id).toBe(session.id)
|
||||
const events = yield* client.sessions.log({ sessionID: session.id }).pipe(Stream.runCollect)
|
||||
expect(events.some((event) => event.type === "session.created")).toBe(true)
|
||||
expect(yield* client.events.subscribe().pipe(Stream.take(1), Stream.runCollect)).toMatchObject([
|
||||
{ type: "server.connected" },
|
||||
])
|
||||
expect(yield* client.sessions.get({ sessionID: Session.ID.create() }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "SessionNotFoundError",
|
||||
})
|
||||
// Binding the SDK's transport must not change the caller's surrounding context.
|
||||
expect(yield* FetchHttpClient.Fetch).toBe(ambient)
|
||||
}).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient))
|
||||
expect(calls).toEqual([])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* client.health.get().pipe(Effect.provideService(FetchHttpClient.Fetch, ambient), Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
|
||||
return output === undefined ? {} : { output }
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error.type === "rpc.invalid_output" || error.type === "rpc.internal"
|
||||
error.type === "rpc.invalid_output"
|
||||
? new RpcInternalError({ type: error.type, message: error.message })
|
||||
: new RpcError({
|
||||
type: error.type,
|
||||
@@ -22,10 +22,12 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
|
||||
...(error.data === undefined ? {} : { data: error.data }),
|
||||
}),
|
||||
),
|
||||
// Defects outside handler execution are still logged, never echoed to the client.
|
||||
Effect.catchDefect((defect) =>
|
||||
Effect.logError("rpc call failed", { rpc: params.rpcID, method: params.method, defect }).pipe(
|
||||
Effect.andThen(Effect.fail(new RpcInternalError({ type: "rpc.internal", message: "RPC call failed" }))),
|
||||
Effect.catchDefect((error) =>
|
||||
Effect.fail(
|
||||
new RpcInternalError({
|
||||
type: "rpc.internal",
|
||||
message: error instanceof Error ? error.message : "RPC call failed",
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export * as ServerProcess from "./process"
|
||||
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
|
||||
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
|
||||
import {
|
||||
HttpMiddleware,
|
||||
@@ -114,7 +116,12 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
)
|
||||
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
|
||||
yield* status.ready
|
||||
return { address: bound.http.address, shutdown: shutdown.await }
|
||||
return {
|
||||
address: bound.http.address,
|
||||
shutdown: shutdown.await,
|
||||
updateAvailable: (version: string) =>
|
||||
Context.get(context, Bus.Service).publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
@@ -99,9 +100,12 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
)
|
||||
expect(event.status).toBe(200)
|
||||
expect(event.headers.get("content-encoding")).toBeNull()
|
||||
const body = event.body
|
||||
if (!body) return yield* Effect.die(new Error("Event response has no body"))
|
||||
yield* Effect.promise(() => body.cancel())
|
||||
if (!event.body) return yield* Effect.die(new Error("Event response has no body"))
|
||||
const reader = event.body.getReader()
|
||||
yield* Effect.promise(() => readUntil(reader, "server.connected"))
|
||||
yield* server.updateAvailable("2.0.0")
|
||||
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
|
||||
yield* Effect.promise(() => reader.cancel())
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
|
||||
@@ -126,3 +130,11 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
|
||||
while (true) {
|
||||
const next = await reader.read()
|
||||
if (next.done) throw new Error(`Event stream ended before ${expected}`)
|
||||
if (new TextDecoder().decode(next.value).includes(expected)) return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Rpc } from "@opencode-ai/schema/rpc"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { createEmbeddedRoutes } from "../src/routes"
|
||||
|
||||
const Broken = Rpc.define({
|
||||
id: "broken",
|
||||
methods: {
|
||||
handler: { input: Schema.String, output: Schema.String },
|
||||
schema: {
|
||||
input: Schema.String.check(
|
||||
Schema.makeFilter(() => {
|
||||
throw new Error("private schema detail")
|
||||
}),
|
||||
),
|
||||
output: Schema.String,
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
for (const method of ["handler", "schema"] as const) {
|
||||
it.live(`returns HTTP 500 without exposing the ${method} defect`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const context = yield* Layer.build(
|
||||
createEmbeddedRoutes({
|
||||
database: { path: ":memory:" },
|
||||
models: { fetch: false },
|
||||
config: { directory: directory.path, project: false, content: "{}" },
|
||||
fs: { filewatcher: false },
|
||||
}).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
)
|
||||
const sdk = Context.get(context, SdkPlugins.Service)
|
||||
yield* sdk.register(
|
||||
define({
|
||||
id: "broken-rpc",
|
||||
effect: (ctx) =>
|
||||
ctx.rpc
|
||||
.register(Broken, {
|
||||
handler: () => Effect.die(new Error("private handler detail")),
|
||||
schema: Effect.succeed,
|
||||
})
|
||||
.pipe(Effect.asVoid, Effect.orDie),
|
||||
}),
|
||||
)
|
||||
const handler = Context.get(context, HttpRouter.HttpRouter)
|
||||
.asHttpEffect()
|
||||
.pipe(HttpEffect.toWebHandlerWith(context))
|
||||
const url = new URL(`/api/rpc/broken/${method}`, "http://opencode.local")
|
||||
url.searchParams.set("location[directory]", directory.path)
|
||||
const response = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ input: "hello" }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
_tag: "RpcInternalError",
|
||||
type: "rpc.internal",
|
||||
message: "RPC call failed",
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
+33
-65
@@ -100,7 +100,6 @@ import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider, useStorage } from "./context/storage"
|
||||
import { SessionTerminalsProvider } from "./context/session-terminals"
|
||||
import { PanelProvider, usePanel } from "./context/panel"
|
||||
import { SessionFrame } from "./component/session-frame"
|
||||
import { createTuiClipboard } from "./clipboard"
|
||||
|
||||
@@ -187,7 +186,6 @@ export type TuiInput = {
|
||||
args: Args
|
||||
config: Config.Interface
|
||||
updater?: {
|
||||
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
|
||||
apply: (version: string) => Promise<void>
|
||||
}
|
||||
packages: PackageSource
|
||||
@@ -222,6 +220,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const service = managed
|
||||
? {
|
||||
reconnect: async (signal: AbortSignal) => {
|
||||
// Give the server a chance to respawn itself before starting client-side recovery.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
if (signal.aborted) throw signal.reason ?? new Error("Server reconnect cancelled")
|
||||
const endpoint = await managed.reconnect(signal)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return { api: OpenCode.make(next), url: endpoint.url }
|
||||
@@ -398,24 +399,22 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PanelProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
updater={input.updater}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</PanelProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
updater={input.updater}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
@@ -477,7 +476,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const panels = usePanel()
|
||||
const keymap = Keymap.use()
|
||||
const event = useEvent()
|
||||
const client = useClient()
|
||||
@@ -509,36 +507,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
"update-notifications",
|
||||
{ initial: { versions: [] } },
|
||||
)
|
||||
const showUpdate = (version: string) => {
|
||||
const updater = props.updater
|
||||
if (!updater || updateNotifications.versions.includes(version)) return
|
||||
void markUpdateNotification((draft) => {
|
||||
draft.versions = [...draft.versions, version].slice(-100)
|
||||
}).catch((error) => log.error("failed to persist update notification", { error }))
|
||||
const key = `update:${version}`
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogUpdate
|
||||
dialogKey={key}
|
||||
version={version}
|
||||
install={() => updater.apply(version)}
|
||||
restart={client.restart}
|
||||
/>
|
||||
),
|
||||
undefined,
|
||||
{ key },
|
||||
)
|
||||
dialog.setCentered(true)
|
||||
}
|
||||
onMount(() => {
|
||||
const updater = props.updater
|
||||
if (!updater) return
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
void updater.monitor(showUpdate, controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted) log.error("update monitor failed", { error })
|
||||
})
|
||||
})
|
||||
const tabsResize = createPaneResize({
|
||||
value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH,
|
||||
defaultValue: () => SESSION_SIDEBAR_WIDTH,
|
||||
@@ -612,22 +580,9 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () =>
|
||||
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
|
||||
const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const fullscreenPanel = () =>
|
||||
route.data.type === "session" &&
|
||||
panels.current()?.sessionID === route.data.sessionID &&
|
||||
panels.presentation() === "fullscreen"
|
||||
const tabsVisible = () => tabsAvailable() && !fullscreenPanel()
|
||||
const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const verticalTabsVisible = () => tabsVisible() && tabsVertical()
|
||||
|
||||
// Measure the prospective split layout, even while full-screen hides the tabs.
|
||||
createEffect(() => panels.setWidth(dimensions().width - (tabsAvailable() && tabsVertical() ? tabsResize.size() : 0)))
|
||||
createEffect(() => {
|
||||
const current = panels.current()
|
||||
if (!current || (route.data.type === "session" && route.data.sessionID === current.sessionID)) return
|
||||
panels.close()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = config.data.mouse
|
||||
})
|
||||
@@ -1260,6 +1215,19 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
})
|
||||
})
|
||||
|
||||
event.on("installation.update-available", (evt) => {
|
||||
const updater = props.updater
|
||||
const restart = client.restart
|
||||
if (!updater || !restart) return
|
||||
const version = evt.data.version
|
||||
if (updateNotifications.versions.includes(version)) return
|
||||
void markUpdateNotification((draft) => {
|
||||
draft.versions = [...draft.versions, version].slice(-100)
|
||||
}).catch((error) => log.error("failed to persist update notification", { error }))
|
||||
dialog.replace(() => <DialogUpdate version={version} install={() => updater.apply(version)} restart={restart} />)
|
||||
dialog.setCentered(true)
|
||||
})
|
||||
|
||||
event.on("tui.session.select", (evt, { workspace }) => {
|
||||
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
|
||||
route.navigate({
|
||||
|
||||
@@ -8,32 +8,22 @@ import { useDialog } from "../ui/dialog"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
type State =
|
||||
| { type: "ready"; active: "update" | "skip" }
|
||||
| { type: "ready"; active: "update" | "ignore" }
|
||||
| { type: "installing" }
|
||||
| { type: "restarting" }
|
||||
| { type: "failed"; message: string }
|
||||
|
||||
export function DialogUpdate(props: {
|
||||
dialogKey: string
|
||||
version: string
|
||||
install: () => Promise<void>
|
||||
restart?: () => Promise<void>
|
||||
}) {
|
||||
export function DialogUpdate(props: { version: string; install: () => Promise<void>; restart: () => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const [state, setState] = createSignal<State>({ type: "ready", active: "update" })
|
||||
const close = () => {
|
||||
if (dialog.key === props.dialogKey) dialog.clear()
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
setState({ type: "installing" })
|
||||
await props.install()
|
||||
if (props.restart) {
|
||||
setState({ type: "restarting" })
|
||||
await props.restart()
|
||||
}
|
||||
close()
|
||||
setState({ type: "restarting" })
|
||||
await props.restart()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
const beginInstall = () => {
|
||||
@@ -44,16 +34,16 @@ export function DialogUpdate(props: {
|
||||
const run = () => {
|
||||
const current = state()
|
||||
if (current.type !== "ready") return
|
||||
if (current.active === "skip") return close()
|
||||
if (current.active === "ignore") return dialog.clear()
|
||||
beginInstall()
|
||||
}
|
||||
|
||||
const toggle = () =>
|
||||
setState((current) =>
|
||||
current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current,
|
||||
current.type === "ready" ? { ...current, active: current.active === "update" ? "ignore" : "update" } : current,
|
||||
)
|
||||
|
||||
const selected = (action: "update" | "skip") => {
|
||||
const selected = (action: "update" | "ignore") => {
|
||||
const current = state()
|
||||
return current.type === "ready" && current.active === action
|
||||
}
|
||||
@@ -70,7 +60,7 @@ export function DialogUpdate(props: {
|
||||
bind: "return",
|
||||
title: "Confirm update action",
|
||||
group: "Dialog",
|
||||
run: () => (state().type === "failed" ? close() : run()),
|
||||
run: () => (state().type === "failed" ? dialog.clear() : run()),
|
||||
},
|
||||
{
|
||||
bind: "left",
|
||||
@@ -91,9 +81,9 @@ export function DialogUpdate(props: {
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Update available
|
||||
Update
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={close}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -101,17 +91,14 @@ export function DialogUpdate(props: {
|
||||
<Switch>
|
||||
<Match when={state().type === "ready"}>
|
||||
<text fg={theme.text.subdued}>
|
||||
An update is available. Applying will
|
||||
{props.restart
|
||||
? " restart the server and active sessions will be resumed."
|
||||
: " install the update but you will need to manually restart."}
|
||||
Update to v{props.version}? It will be applied in the background and active sessions will be restarted.
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={state().type === "installing"}>
|
||||
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}…</Spinner>
|
||||
<Spinner>Installing OpenCode {props.version}…</Spinner>
|
||||
</Match>
|
||||
<Match when={state().type === "restarting"}>
|
||||
<Spinner shimmer={theme.text.default}>Restarting the background service…</Spinner>
|
||||
<Spinner>Restarting the background service…</Spinner>
|
||||
</Match>
|
||||
<Match when={state().type === "failed"}>
|
||||
<text fg={theme.text.feedback.error.default}>{failure()}</text>
|
||||
@@ -127,7 +114,7 @@ export function DialogUpdate(props: {
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={theme.background.action.primary.focused}
|
||||
onMouseUp={close}
|
||||
onMouseUp={() => dialog.clear()}
|
||||
>
|
||||
<text fg={theme.text.action.primary.focused}>close</text>
|
||||
</box>
|
||||
@@ -136,19 +123,19 @@ export function DialogUpdate(props: {
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={["skip", "update"] as const}>
|
||||
<For each={["ignore", "update"] as const}>
|
||||
{(action) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
if (action === "skip") return close()
|
||||
if (action === "ignore") return dialog.clear()
|
||||
beginInstall()
|
||||
}}
|
||||
>
|
||||
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
|
||||
{action === "update" ? "Update" : "Skip"}
|
||||
{action === "update" ? "Update" : "Ignore"}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user