mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 23:46:16 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
261892cfab | ||
|
|
97303c39dd | ||
|
|
282c84d79f | ||
|
|
2dcfc89fab | ||
|
|
a222401f19 | ||
|
|
36da0d5c77 | ||
|
|
7819e7f503 | ||
|
|
6e63b970f3 | ||
|
|
610d7e952a | ||
|
|
ac874a6e90 | ||
|
|
f40ecefdef | ||
|
|
c370a1bdd0 | ||
|
|
309f4534fa | ||
|
|
0ae3bf743f | ||
|
|
f94eefaa50 | ||
|
|
a04d72bb39 |
+1
-38
@@ -247,8 +247,6 @@ it does not repair or truncate them.
|
||||
|
||||
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. Its `replacement` contains the next context window, including retained user messages. The client returns that result and usage directly, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
|
||||
|
||||
For `compact(request, { mechanism: "trigger" })`, script a `CompactionCheckpointResponse` instead. It carries `checkpoint`, `responseID`, and optional `usage`. Endpoint and trigger calls reject each other's fixtures; both share the same queue, gates, lazy recording, and fallback controls.
|
||||
|
||||
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
|
||||
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
|
||||
`testLayer`.
|
||||
@@ -261,7 +259,7 @@ This is different from prompt caching, server-side history storage, or truncatio
|
||||
|
||||
### Explicit compaction
|
||||
|
||||
`LLMClient.compact(request)` (equivalently, `{ mechanism: "endpoint" }`) is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response. This mechanism does not accept a WebSocket executor.
|
||||
`LLMClient.compact(request)` is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response.
|
||||
|
||||
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
|
||||
|
||||
@@ -281,41 +279,6 @@ Generation-only body overlays such as `stream` and `store` are not sent to the c
|
||||
|
||||
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. Anthropic does not expose this operation in this package; its in-band compaction remains available below. Compatible routes do not inherit an explicit compact endpoint simply because they use a Responses protocol.
|
||||
|
||||
### Streamed checkpoint compaction
|
||||
|
||||
OpenAI Responses also exposes a separate, explicitly selected mechanism:
|
||||
|
||||
```ts
|
||||
const result =
|
||||
yield *
|
||||
LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket, // Optional: without it, the request uses HTTP/SSE.
|
||||
})
|
||||
|
||||
result.checkpoint // Successful encrypted CompactionPart.
|
||||
result.responseID
|
||||
result.usage
|
||||
```
|
||||
|
||||
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request. It follows the [Codex V2 request shape](https://github.com/openai/codex/blob/728cb12/codex-rs/core/src/compact_remote_v2_attempt.rs), with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Session/cache identifiers, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
|
||||
|
||||
Only a successful `response.completed` with a response ID and exactly one logical encrypted checkpoint succeeds. Repeated item events are correlated by ID/output slot, including ID-less checkpoints. Other output is ignored, not returned as assistant text or dispatched as tools. Failed, incomplete, malformed, and interrupted responses return errors rather than partial checkpoints.
|
||||
|
||||
The result is **not a replacement window**. The caller selects retained history, combines it with `result.checkpoint`, and durably installs it before continuing. The operation does not choose a retention budget, prune messages, or modify the original request.
|
||||
|
||||
The supplied WebSocket executor can reuse a compatible append baseline for the compaction request. On completion the protocol supplies no continuation checkpoint, clearing the old baseline so the next generation sends the newly installed window in full. Validation occurs before transport completion is acknowledged. There is no operation-level retry or fallback to `/responses/compact`; existing safe transport fallback may use SSE, with full history and no connection-local response ID.
|
||||
|
||||
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
|
||||
|
||||
```ts
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
|
||||
}
|
||||
```
|
||||
|
||||
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
|
||||
|
||||
### Advanced: in-band compaction
|
||||
|
||||
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
|
||||
|
||||
@@ -42,7 +42,6 @@ const canonical = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
|
||||
if (!ProviderShared.isRecord(value)) return ProviderShared.encodeJson(value)
|
||||
return `{${Object.keys(value)
|
||||
.filter((key) => value[key] !== undefined)
|
||||
.sort()
|
||||
.map((key) => `${ProviderShared.encodeJson(key)}:${canonical(value[key])}`)
|
||||
.join(",")}}`
|
||||
@@ -150,12 +149,6 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
|
||||
}
|
||||
if (observation.type !== "completed") return observation
|
||||
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
|
||||
if (
|
||||
Array.isArray(request.input) &&
|
||||
request.input.some((item) => ProviderShared.isRecord(item) && item.type === "compaction_trigger")
|
||||
)
|
||||
return observation
|
||||
const responseID = event.response?.id
|
||||
if (!responseID || responseID.trim().length === 0) return observation
|
||||
return {
|
||||
|
||||
@@ -920,7 +920,7 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
|
||||
return parts.filter((part) => part !== undefined).join("\n\n")
|
||||
}
|
||||
|
||||
const outputItemID = (state: Pick<ParserState, "outputItems">, event: Event) =>
|
||||
const outputItemID = (state: ParserState, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
|
||||
@@ -932,11 +932,7 @@ const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
|
||||
|
||||
// An item without an id adopts the id already open in its output slot,
|
||||
// otherwise it gets a locally minted one.
|
||||
const resolveItem = (
|
||||
state: Pick<ParserState, "outputItems">,
|
||||
item: StreamItem,
|
||||
index: number | undefined,
|
||||
): OutputItem => ({
|
||||
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
|
||||
...item,
|
||||
id:
|
||||
item.id ??
|
||||
@@ -946,7 +942,7 @@ const resolveItem = (
|
||||
|
||||
// Registered output slots are authoritative for `item_id` routing, and items
|
||||
// are resolved here so everything downstream can rely on `item.id`.
|
||||
export const normalize = (state: Pick<ParserState, "outputItems">, input: Event): NormalizedEvent => ({
|
||||
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
|
||||
...input,
|
||||
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
|
||||
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import type { LLMRequest, JsonSchema, ToolDefinition } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
@@ -13,7 +13,6 @@ import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { ResponsesCompaction } from "./utils/responses-compaction.js"
|
||||
import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
@@ -104,18 +103,6 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
/** Request control, never conversation content. */
|
||||
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
|
||||
const CheckpointBody = Schema.Struct({
|
||||
...OpenAIResponsesBody.fields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
|
||||
store: Schema.Literal(false),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
@@ -175,35 +162,6 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
})
|
||||
})
|
||||
|
||||
const checkpointBody = {
|
||||
schema: CheckpointBody,
|
||||
from: Effect.fn("OpenAIResponses.checkpointBody")(function* (request: LLMRequest) {
|
||||
const native = yield* fromRequest(LLMRequest.update(request, { toolChoice: undefined }))
|
||||
const overlay = request.http?.body
|
||||
// Complete history is required for stateless replay and SSE recovery. Raw input overrides bypass that contract.
|
||||
if (
|
||||
overlay?.input !== undefined ||
|
||||
overlay?.previous_response_id !== undefined ||
|
||||
overlay?.conversation !== undefined
|
||||
)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Trigger compaction requires complete canonical history, not an input or continuation override",
|
||||
)
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(CheckpointBody))({
|
||||
...mergeJsonRecords(native, overlay),
|
||||
input: [...native.input, { type: "compaction_trigger" }],
|
||||
stream: true,
|
||||
store: false,
|
||||
parallel_tool_calls: true,
|
||||
tool_choice: undefined,
|
||||
context_management: undefined,
|
||||
text: undefined,
|
||||
max_output_tokens: undefined,
|
||||
max_tool_calls: undefined,
|
||||
})
|
||||
}),
|
||||
}
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
@@ -281,7 +239,7 @@ export const transport = channelTransport({
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
|
||||
compact: ResponsesCompaction.make(adapter),
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Route, type RouteBody, type TriggerCompactOperation } from "../../route/client.js"
|
||||
import { Protocol } from "../../route/protocol.js"
|
||||
import { CompactionCheckpointResponse, HttpOptions, LLMEvent, LLMRequest } from "../../schema/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
interface State {
|
||||
readonly parser: Pick<OpenResponses.ParserState, "id" | "provider" | "outputItems">
|
||||
readonly checkpoints: Readonly<Record<string, CompactionCheckpointResponse["checkpoint"]>>
|
||||
readonly responseID?: string
|
||||
}
|
||||
|
||||
const onOutputItem = Effect.fn("ResponsesCheckpoint.onOutputItem")(function* (
|
||||
state: State,
|
||||
input: OpenResponses.Event,
|
||||
) {
|
||||
const event = OpenResponses.normalize(state.parser, input)
|
||||
const item = event.item
|
||||
if (!item) return state
|
||||
const parser =
|
||||
event.output_index === undefined || state.parser.outputItems[event.output_index] === item.id
|
||||
? state.parser
|
||||
: { ...state.parser, outputItems: { ...state.parser.outputItems, [event.output_index]: item.id } }
|
||||
const next = parser === state.parser ? state : { ...state, parser }
|
||||
if (event.type === "response.output_item.added" || item.type !== "compaction") return next
|
||||
if (
|
||||
event.output_index !== undefined &&
|
||||
Object.entries(state.parser.outputItems).some(
|
||||
([index, id]) => id === item.id && Number(index) !== event.output_index,
|
||||
)
|
||||
)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction checkpoint appeared in multiple output slots")
|
||||
if (!item.encrypted_content)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction output is missing its encrypted content")
|
||||
const previous = state.checkpoints[item.id]
|
||||
if (previous && previous.encrypted !== item.encrypted_content)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction output changed after completion")
|
||||
if (previous) return next
|
||||
return {
|
||||
...next,
|
||||
checkpoints: {
|
||||
...state.checkpoints,
|
||||
[item.id]: { type: "compaction", provider: parser.provider, id: item.id, encrypted: item.encrypted_content },
|
||||
},
|
||||
} satisfies State
|
||||
})
|
||||
|
||||
/** Collect a trigger response before acknowledging transport completion. No generation output escapes. */
|
||||
export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
|
||||
Effect.fn("ResponsesCheckpoint.execute")(function* (request, executor, options) {
|
||||
const source = request.model.route
|
||||
let result: CompactionCheckpointResponse | undefined
|
||||
// Route registries erase the frame type. The codec validates that boundary before parsing.
|
||||
const event: Schema.Codec<OpenResponses.Event, unknown> = OpenResponses.protocol.stream.event
|
||||
const protocol = Protocol.make({
|
||||
id: source.protocol,
|
||||
body,
|
||||
stream: {
|
||||
event,
|
||||
initial: (request: LLMRequest): State => ({
|
||||
parser: { id: source.id, provider: request.model.provider, outputItems: {} },
|
||||
checkpoints: {},
|
||||
}),
|
||||
terminal: OpenResponses.terminal,
|
||||
step: Effect.fn("ResponsesCheckpoint.step")(function* (state: State, event: OpenResponses.Event) {
|
||||
if (event.response?.id && state.responseID && event.response.id !== state.responseID)
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response ID changed during execution")
|
||||
if (event.type === "response.created") return [{ ...state, responseID: event.response?.id }, []] as const
|
||||
if (event.type === "error" || event.type === "response.failed")
|
||||
return yield* OpenResponses.providerFailure(event, "Compaction request failed")
|
||||
if (event.type === "response.incomplete")
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response was incomplete")
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done")
|
||||
return [yield* onOutputItem(state, event), []] as const
|
||||
if (event.type !== "response.completed") return [state, []] as const
|
||||
const responseID = event.response?.id
|
||||
if (!responseID?.trim())
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response is missing its response ID")
|
||||
if (event.response?.status !== undefined && event.response.status !== "completed")
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response did not complete successfully")
|
||||
let next = state
|
||||
for (const [index, item] of (event.response?.output ?? []).entries()) {
|
||||
next = yield* onOutputItem(next, { type: "response.output_item.done", output_index: index, item })
|
||||
}
|
||||
const checkpoints = Object.values(next.checkpoints)
|
||||
const checkpoint = checkpoints[0]
|
||||
if (checkpoints.length !== 1 || !checkpoint)
|
||||
return yield* ProviderShared.eventError(
|
||||
source.id,
|
||||
"Compaction response must contain exactly one checkpoint",
|
||||
)
|
||||
result = new CompactionCheckpointResponse({
|
||||
checkpoint,
|
||||
responseID,
|
||||
usage: OpenResponses.mapUsage(event.response?.usage, OpenResponses.metadataKey(request.model)),
|
||||
})
|
||||
return [next, [LLMEvent.finish({ reason: { normalized: "stop" } })]] as const
|
||||
}),
|
||||
},
|
||||
})
|
||||
const route = Route.make({
|
||||
id: source.id,
|
||||
provider: source.provider,
|
||||
providerMetadataKey: source.providerMetadataKey,
|
||||
protocol,
|
||||
endpoint: source.endpoint,
|
||||
auth: source.auth,
|
||||
transport: source.transport,
|
||||
})
|
||||
const native = yield* body.from(request)
|
||||
// The body builder already applied and validated overlays. Do not let transport reapply them.
|
||||
const preparedRequest = LLMRequest.update(request, {
|
||||
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
|
||||
})
|
||||
const prepared = yield* route.prepareTransport(native, preparedRequest, options)
|
||||
yield* route.streamPrepared(prepared, preparedRequest, { http: executor }, options).pipe(Stream.runDrain)
|
||||
if (!result) return yield* ProviderShared.eventError(source.id, "Compaction response ended without a checkpoint")
|
||||
return result
|
||||
})
|
||||
|
||||
export * as ResponsesCheckpoint from "./responses-checkpoint.js"
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||
import type { CompactionOperations } from "./route/client.js"
|
||||
import type { CompactOperation } from "./route/client.js"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
@@ -10,7 +10,7 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
export interface Definition<
|
||||
ProviderSettings extends Settings = Settings,
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -39,7 +39,6 @@ export type Settings = ProviderPackage.Settings &
|
||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
|
||||
|
||||
const responsesRoute = OpenAIResponses.route.with({
|
||||
compact: { endpoint: OpenAIResponses.route.compact.endpoint },
|
||||
id: "azure-openai-responses",
|
||||
provider: id,
|
||||
auth: routeAuth,
|
||||
@@ -103,7 +102,7 @@ const auth = (input: Config) => {
|
||||
)
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
modelID: string | ModelID,
|
||||
@@ -169,7 +168,7 @@ const config = (settings: Settings): Config => {
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
typeof responsesRoute.compact
|
||||
CompactOperation
|
||||
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -73,7 +73,7 @@ const defaults = (input: Config) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
) =>
|
||||
@@ -132,11 +132,10 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
typeof OpenAIResponses.route.compact
|
||||
>["model"] = (modelID, settings) => {
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
return configure(config(settings)).responses(modelID)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
@@ -32,7 +32,7 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
compact: { endpoint: XAIResponses.compact },
|
||||
compact: XAIResponses.compact,
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
@@ -103,11 +103,10 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<
|
||||
Settings,
|
||||
XAIProviderOptionsInput,
|
||||
typeof responsesRoute.compact
|
||||
>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
+38
-113
@@ -14,7 +14,6 @@ import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
AIErrorReason,
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -39,7 +38,7 @@ export interface RouteBody<Body> {
|
||||
export interface Route<
|
||||
Body,
|
||||
Prepared = unknown,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
readonly compact: Compact
|
||||
readonly id: string
|
||||
@@ -54,15 +53,7 @@ export interface Route<
|
||||
readonly transport: Transport<Body, Prepared, unknown>
|
||||
readonly defaults: RouteDefaults
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: {
|
||||
<Next extends CompactionOperations | undefined>(
|
||||
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
|
||||
): Route<Body, Prepared, Next>
|
||||
(
|
||||
patch: Omit<RoutePatch<Body, Prepared>, "compact"> & { readonly compact?: undefined },
|
||||
): Route<Body, Prepared, Compact>
|
||||
(patch: RoutePatch<Body, Prepared>): Route<Body, Prepared>
|
||||
}
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
|
||||
readonly model: <Options extends ProviderOptions = ProviderOptions>(
|
||||
input: RouteMappedLanguageModelInput,
|
||||
) => LanguageModel<Options, Compact>
|
||||
@@ -83,7 +74,7 @@ export interface Route<
|
||||
// Normal call sites use `OpenAIChat.route`; callers only need body types
|
||||
// when preparing a request with a protocol-specific type assertion.
|
||||
// oxlint-disable-next-line typescript-eslint/no-explicit-any
|
||||
export type AnyRoute<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Route<
|
||||
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
|
||||
any,
|
||||
any,
|
||||
Compact
|
||||
@@ -110,7 +101,6 @@ export interface RouteDefaultsInput {
|
||||
}
|
||||
|
||||
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
readonly compact?: CompactionOperations
|
||||
readonly id?: string
|
||||
readonly provider?: string | ProviderID
|
||||
readonly providerMetadataKey?: string
|
||||
@@ -121,7 +111,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
|
||||
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
|
||||
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
route: AnyRoute<Compact>,
|
||||
mapped: RouteMappedLanguageModelInput,
|
||||
) => {
|
||||
@@ -172,7 +162,10 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly compact: CompactMethod
|
||||
readonly compact: (
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
readonly stream: StreamMethod
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
@@ -196,64 +189,12 @@ export type CompactOperation = (
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
|
||||
export type TriggerCompactOperation = (
|
||||
request: LLMRequest,
|
||||
executor: RequestExecutor.Interface,
|
||||
options: TriggerCompactOptions,
|
||||
) => Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
|
||||
/** Protocol capabilities, not deployment/model eligibility. */
|
||||
export interface CompactionOperations {
|
||||
readonly endpoint?: CompactOperation
|
||||
readonly trigger?: TriggerCompactOperation
|
||||
}
|
||||
|
||||
export interface EndpointCompactOptions extends Pick<StreamOptions, "http"> {
|
||||
readonly mechanism?: "endpoint"
|
||||
readonly webSocket?: never
|
||||
}
|
||||
|
||||
export interface TriggerCompactOptions extends StreamOptions {
|
||||
readonly mechanism: "trigger"
|
||||
}
|
||||
|
||||
// Keep the required route shape explicit: the schema class's self type erases its model parameter in assignability.
|
||||
export type CompactionRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, { readonly endpoint: CompactOperation }>
|
||||
}
|
||||
export type CheckpointRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, { readonly trigger: TriggerCompactOperation }>
|
||||
readonly model: LanguageModel<ProviderOptions, CompactOperation>
|
||||
}
|
||||
|
||||
export interface CompactMethod<R = never> {
|
||||
(request: CheckpointRequest, options: TriggerCompactOptions): Effect.Effect<CompactionCheckpointResponse, AIError, R>
|
||||
(request: CompactionRequest, options?: EndpointCompactOptions): Effect.Effect<CompactionResponse, AIError, R>
|
||||
}
|
||||
|
||||
export function canCompact(
|
||||
request: LLMRequest,
|
||||
options?: { readonly mechanism?: "endpoint" },
|
||||
): request is CompactionRequest
|
||||
export function canCompact(
|
||||
request: LLMRequest,
|
||||
options: { readonly mechanism: "trigger" },
|
||||
): request is CheckpointRequest
|
||||
export function canCompact(request: LLMRequest, options?: { readonly mechanism?: string }) {
|
||||
if (options?.mechanism === "trigger") return request.model.route.compact?.trigger !== undefined
|
||||
if (options?.mechanism !== undefined && options.mechanism !== "endpoint") return false
|
||||
return request.model.route.compact?.endpoint !== undefined
|
||||
}
|
||||
|
||||
const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefined) => {
|
||||
if (mechanism !== undefined && mechanism !== "endpoint" && mechanism !== "trigger")
|
||||
return ProviderShared.invalidRequest(`Unknown compaction mechanism: ${mechanism}`)
|
||||
return ProviderShared.unsupportedOperation({
|
||||
operation: mechanism === "trigger" ? "compact.trigger" : "compact",
|
||||
provider: request.model.provider,
|
||||
route: request.model.route.id,
|
||||
message: `${request.model.provider}/${request.model.route.id} does not support ${mechanism === "trigger" ? "trigger" : "explicit"} compaction`,
|
||||
})
|
||||
}
|
||||
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
|
||||
request.model.route.compact !== undefined
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
|
||||
@@ -275,7 +216,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
|
||||
}
|
||||
|
||||
export interface MakeInput<Body, Frame, Event, State> {
|
||||
readonly compact?: CompactionOperations
|
||||
readonly compact?: CompactOperation
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -297,7 +238,7 @@ export interface MakeInput<Body, Frame, Event, State> {
|
||||
}
|
||||
|
||||
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
readonly compact?: CompactionOperations
|
||||
readonly compact?: CompactOperation
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -385,10 +326,9 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
with: (patch: RoutePatch<Body, Prepared>) => {
|
||||
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
return build({
|
||||
...routeInput,
|
||||
compact: "compact" in patch ? compact : routeInput.compact,
|
||||
id: id ?? routeInput.id,
|
||||
provider: provider ?? routeInput.provider,
|
||||
providerMetadataKey:
|
||||
@@ -403,7 +343,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
},
|
||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
|
||||
makeRouteLanguageModel<Options, CompactionOperations | undefined>(route, input),
|
||||
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
|
||||
prepareTransport: (body, request, options) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
@@ -500,12 +440,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
|
||||
}
|
||||
|
||||
export function make<Body, Prepared, Frame, Event, State, Compact extends CompactionOperations>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: Compact },
|
||||
): Route<Body, Prepared, Compact>
|
||||
export function make<Body, Frame, Event, State, Compact extends CompactionOperations>(
|
||||
input: MakeInput<Body, Frame, Event, State> & { readonly compact: Compact },
|
||||
): Route<Body, HttpTransport.HttpPrepared<Frame>, Compact>
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, Prepared, CompactOperation>
|
||||
export function make<Body, Frame, Event, State>(
|
||||
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||
): Route<Body, Prepared>
|
||||
@@ -617,23 +557,14 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
|
||||
})
|
||||
}
|
||||
|
||||
export function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError, Service>
|
||||
export function compact(
|
||||
export const compact = (
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service>
|
||||
export function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
|
||||
return Effect.gen(function* () {
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
if (options?.mechanism === "trigger" && canCompact(request, options)) return yield* client.compact(request, options)
|
||||
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
|
||||
return yield* client.compact(request, options)
|
||||
return yield* unsupportedCompaction(request, options?.mechanism)
|
||||
return yield* client.compact(request, options)
|
||||
})
|
||||
}
|
||||
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
Stream.unwrap(
|
||||
@@ -647,27 +578,21 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const stream = streamRequestWith({ http: executor })
|
||||
function compact(
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError>
|
||||
function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
|
||||
return Effect.suspend((): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> => {
|
||||
if (options?.mechanism === "trigger" && canCompact(request, options))
|
||||
return request.model.route.compact.trigger(prepareRequest(request), executor, options)
|
||||
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
|
||||
return request.model.route.compact.endpoint(prepareRequest(request), executor, options)
|
||||
return unsupportedCompaction(request, options?.mechanism)
|
||||
})
|
||||
}
|
||||
return Service.of({
|
||||
stream,
|
||||
generate: generateWith(stream),
|
||||
compact,
|
||||
compact: (request, options) =>
|
||||
Effect.suspend(() => {
|
||||
const operation = request.model.route.compact
|
||||
if (!operation)
|
||||
return ProviderShared.unsupportedOperation({
|
||||
operation: "compact",
|
||||
provider: request.model.provider,
|
||||
route: request.model.route.id,
|
||||
message: `${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
|
||||
})
|
||||
return operation(prepareRequest(request), executor, options)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -9,12 +9,6 @@ export type {
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
StreamOptions,
|
||||
CompactMethod,
|
||||
CompactionOperations,
|
||||
CompactionRequest,
|
||||
CheckpointRequest,
|
||||
EndpointCompactOptions,
|
||||
TriggerCompactOptions,
|
||||
} from "./client.js"
|
||||
export * from "./executor.js"
|
||||
export { Auth } from "./auth.js"
|
||||
|
||||
@@ -97,21 +97,6 @@ export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.Co
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
/** A checkpoint only; retained history and replacement-window construction belong to the caller. */
|
||||
export class CompactionCheckpointResponse extends Schema.Class<CompactionCheckpointResponse>(
|
||||
"LLM.CompactionCheckpointResponse",
|
||||
)({
|
||||
checkpoint: CompactionPart.pipe(
|
||||
Schema.refine(
|
||||
(part): part is CompactionPart & { readonly encrypted: string; readonly text?: never } =>
|
||||
part.encrypted !== undefined && part.encrypted.length > 0,
|
||||
{ message: "A checkpoint response requires encrypted compaction content" },
|
||||
),
|
||||
),
|
||||
responseID: Schema.String.check(Schema.isPattern(/\S/)),
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
export const StepStart = Schema.Struct({
|
||||
type: Schema.tag("step-start"),
|
||||
index: Schema.Number,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { ModelID, ProviderID } from "./ids.js"
|
||||
import type { AnyRoute, CompactionOperations } from "../route/client.js"
|
||||
import type { AnyRoute, CompactOperation } from "../route/client.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
|
||||
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
|
||||
@@ -175,7 +175,7 @@ export namespace LanguageModelCompatibility {
|
||||
|
||||
export class LanguageModel<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
declare protected readonly _ProviderOptions: Options
|
||||
readonly id: ModelID
|
||||
@@ -194,7 +194,7 @@ export class LanguageModel<
|
||||
|
||||
static make<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
>(input: LanguageModel.Input<Compact>) {
|
||||
return new LanguageModel<Options, Compact>({
|
||||
id: ModelID.make(input.id),
|
||||
@@ -206,7 +206,7 @@ export class LanguageModel<
|
||||
})
|
||||
}
|
||||
|
||||
static input<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
): LanguageModel.ConstructorInput<Compact> {
|
||||
return {
|
||||
@@ -218,11 +218,11 @@ export class LanguageModel<
|
||||
}
|
||||
}
|
||||
|
||||
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options>,
|
||||
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
|
||||
): LanguageModel<Options, Compact>
|
||||
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
|
||||
): LanguageModel<Options, Compact>
|
||||
@@ -241,7 +241,7 @@ export class LanguageModel<
|
||||
}
|
||||
|
||||
export namespace LanguageModel {
|
||||
export type ConstructorInput<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = {
|
||||
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute<Compact>
|
||||
@@ -249,7 +249,7 @@ export namespace LanguageModel {
|
||||
readonly compatibility?: LanguageModelCompatibility
|
||||
}
|
||||
|
||||
export type Input<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Omit<
|
||||
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
|
||||
ConstructorInput<Compact>,
|
||||
"id" | "provider" | "defaults" | "compatibility"
|
||||
> & {
|
||||
|
||||
+11
-39
@@ -1,17 +1,10 @@
|
||||
export * as TestLLM from "./testing.js"
|
||||
|
||||
import {
|
||||
LLMClient,
|
||||
type CompactionRequest,
|
||||
type CheckpointRequest,
|
||||
type EndpointCompactOptions,
|
||||
type TriggerCompactOptions,
|
||||
} from "./route/client.js"
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
type FinishReasonDetails,
|
||||
type AIError,
|
||||
type LLMRequest,
|
||||
@@ -20,11 +13,7 @@ import {
|
||||
} from "./schema/index.js"
|
||||
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
|
||||
|
||||
export type Response =
|
||||
| readonly LLMEvent[]
|
||||
| Stream.Stream<LLMEvent, AIError>
|
||||
| CompactionResponse
|
||||
| CompactionCheckpointResponse
|
||||
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
@@ -143,38 +132,21 @@ const make = (options: LayerOptions) =>
|
||||
Stream.unwrap(
|
||||
take(request).pipe(
|
||||
Effect.map((response) => {
|
||||
if (response instanceof CompactionResponse || response instanceof CompactionCheckpointResponse)
|
||||
if (response instanceof CompactionResponse)
|
||||
return Stream.die("TestLLM generation requires an event response")
|
||||
return Stream.isStream(response) ? response : Stream.fromIterable(response)
|
||||
}),
|
||||
),
|
||||
)
|
||||
function compact(
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError>
|
||||
function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
function compact(
|
||||
request: LLMRequest,
|
||||
options?: EndpointCompactOptions | TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> {
|
||||
return take(request).pipe(
|
||||
Effect.flatMap((response): Effect.Effect<CompactionResponse | CompactionCheckpointResponse> => {
|
||||
if (options?.mechanism === "trigger")
|
||||
return response instanceof CompactionCheckpointResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM trigger compaction requires a CompactionCheckpointResponse")
|
||||
return response instanceof CompactionResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM compaction requires a CompactionResponse")
|
||||
}),
|
||||
)
|
||||
}
|
||||
const test = Test.of({
|
||||
compact,
|
||||
compact: (request) =>
|
||||
take(request).pipe(
|
||||
Effect.flatMap((response) =>
|
||||
response instanceof CompactionResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM compaction requires a CompactionResponse"),
|
||||
),
|
||||
),
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
CompactionCheckpointResponse,
|
||||
CompactionResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMRequest,
|
||||
} from "../../src/index.js"
|
||||
import {
|
||||
Anthropic,
|
||||
Azure,
|
||||
AmazonBedrock,
|
||||
AmazonBedrockMantle,
|
||||
OpenAI,
|
||||
OpenAICompatibleResponses,
|
||||
XAI,
|
||||
} from "../../src/providers.js"
|
||||
import type { WebSocketChannelExecutor } from "../../src/route.js"
|
||||
import type { RoutePatch } from "../../src/route/client.js"
|
||||
import type { OpenAIResponsesBody } from "../../src/protocols/openai-responses.js"
|
||||
import type { Prepared } from "../../src/protocols/open-responses-channel.js"
|
||||
|
||||
declare const webSocket: WebSocketChannelExecutor
|
||||
const model = OpenAI.configure().responses("fixture")
|
||||
const request = LLM.request({ model, prompt: "hello" })
|
||||
LLMClient.compact(request).pipe(Effect.map((result) => result satisfies CompactionResponse))
|
||||
LLMClient.compact(request, { mechanism: "endpoint" }).pipe(Effect.map((result) => result satisfies CompactionResponse))
|
||||
LLMClient.compact(request, { mechanism: "trigger", webSocket }).pipe(
|
||||
Effect.map((result) => {
|
||||
result satisfies CompactionCheckpointResponse
|
||||
result.checkpoint.encrypted satisfies string
|
||||
result.responseID satisfies string
|
||||
// @ts-expect-error A trigger does not return replacement history.
|
||||
result.replacement
|
||||
}),
|
||||
)
|
||||
// @ts-expect-error Endpoint compaction does not accept a WebSocket executor.
|
||||
LLMClient.compact(request, { mechanism: "endpoint", webSocket })
|
||||
// @ts-expect-error Omitting mechanism selects the HTTP endpoint.
|
||||
LLMClient.compact(request, { webSocket })
|
||||
// @ts-expect-error Unknown mechanisms do not have a permissive fallback overload.
|
||||
LLMClient.compact(request, { mechanism: "other" })
|
||||
|
||||
for (const selected of [
|
||||
model,
|
||||
OpenAI.model("fixture", {}),
|
||||
model.route.with({ headers: { fixture: "test" } }).model({ id: "fixture" }),
|
||||
LanguageModel.make(LanguageModel.input(model)),
|
||||
LanguageModel.update(model, { defaults: { generation: { maxTokens: 100 } } }),
|
||||
]) {
|
||||
LLMClient.compact(LLM.request({ model: selected }), { mechanism: "trigger" })
|
||||
}
|
||||
LLMClient.compact(new LLMRequest(LLMRequest.input(request)), { mechanism: "trigger" })
|
||||
LLMClient.compact(LLMRequest.update(request, { messages: [] }), { mechanism: "trigger" })
|
||||
|
||||
const azure = Azure.configure({ resourceName: "fixture" }).responses("fixture")
|
||||
const xai = XAI.configure().responses("fixture")
|
||||
LLMClient.compact(LLM.request({ model: azure }))
|
||||
LLMClient.compact(LLM.request({ model: xai }))
|
||||
// @ts-expect-error Azure must not inherit OpenAI's trigger operation.
|
||||
LLMClient.compact(LLM.request({ model: azure }), { mechanism: "trigger" })
|
||||
LLMClient.compact(LLM.request({ model: Azure.responsesModel("fixture", { resourceName: "fixture" }) }), {
|
||||
// @ts-expect-error Azure's package entrypoint must preserve its narrower capability.
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error xAI endpoint support does not imply trigger support.
|
||||
LLMClient.compact(LLM.request({ model: xai }), { mechanism: "trigger" })
|
||||
// @ts-expect-error xAI's package entrypoint must preserve its narrower capability.
|
||||
LLMClient.compact(LLM.request({ model: XAI.model("fixture", {}) }), { mechanism: "trigger" })
|
||||
|
||||
const unsupported = {
|
||||
bedrock: LLM.request({ model: AmazonBedrock.configure().model("fixture") }),
|
||||
mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }),
|
||||
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
|
||||
openai: LLM.request({ model: OpenAI.configure().chat("fixture") }),
|
||||
azure: LLM.request({ model: Azure.configure({ resourceName: "fixture" }).chat("fixture") }),
|
||||
xai: LLM.request({ model: XAI.configure().chat("fixture") }),
|
||||
compatible: LLM.request({
|
||||
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
|
||||
}),
|
||||
}
|
||||
// @ts-expect-error Bedrock does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.bedrock)
|
||||
// @ts-expect-error Bedrock does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.bedrock, { mechanism: "trigger" })
|
||||
// @ts-expect-error Mantle does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.mantle)
|
||||
// @ts-expect-error Mantle must not inherit trigger support from OpenAI's protocol.
|
||||
LLMClient.compact(unsupported.mantle, { mechanism: "trigger" })
|
||||
// @ts-expect-error Anthropic does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.anthropic)
|
||||
// @ts-expect-error Anthropic does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.anthropic, { mechanism: "trigger" })
|
||||
// @ts-expect-error OpenAI Chat does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.openai)
|
||||
// @ts-expect-error OpenAI Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.openai, { mechanism: "trigger" })
|
||||
// @ts-expect-error Azure Chat does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.azure)
|
||||
// @ts-expect-error Azure Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.azure, { mechanism: "trigger" })
|
||||
// @ts-expect-error xAI Chat does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.xai)
|
||||
// @ts-expect-error xAI Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.xai, { mechanism: "trigger" })
|
||||
// @ts-expect-error Generic protocol compatibility does not grant endpoint support.
|
||||
LLMClient.compact(unsupported.compatible)
|
||||
// @ts-expect-error Generic protocol compatibility does not grant trigger support.
|
||||
LLMClient.compact(unsupported.compatible, { mechanism: "trigger" })
|
||||
// @ts-expect-error Changing the model replaces its capability.
|
||||
LLMClient.compact(LLMRequest.update(request, { model: azure }), { mechanism: "trigger" })
|
||||
// @ts-expect-error Changing the route replaces its capability.
|
||||
LLMClient.compact(LLM.request({ model: LanguageModel.update(model, { route: azure.route }) }), { mechanism: "trigger" })
|
||||
LLMClient.compact(
|
||||
LLM.request({
|
||||
model: model.route.with({ compact: { endpoint: model.route.compact.endpoint } }).model({ id: "fixture" }),
|
||||
}),
|
||||
// @ts-expect-error Replacing route operations does not retain the old trigger capability.
|
||||
{ mechanism: "trigger" },
|
||||
)
|
||||
|
||||
declare const dynamic: LLMRequest
|
||||
declare const patch: Partial<LLMRequest.Input>
|
||||
declare const routePatch: RoutePatch<OpenAIResponsesBody, Prepared>
|
||||
// @ts-expect-error A dynamic operation override cannot preserve trigger support.
|
||||
LLMClient.compact(LLM.request({ model: model.route.with(routePatch).model({ id: "fixture" }) }), {
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error Explicitly removing operations removes trigger support.
|
||||
LLMClient.compact(LLM.request({ model: model.route.with({ compact: undefined }).model({ id: "fixture" }) }), {
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error Dynamic models must be narrowed.
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
if (LLMClient.canCompact(dynamic, { mechanism: "trigger" })) {
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
LLMClient.Service.use((client) => client.compact(dynamic, { mechanism: "trigger" }))
|
||||
}
|
||||
if (LLMClient.canCompact(dynamic)) {
|
||||
LLMClient.compact(dynamic)
|
||||
// @ts-expect-error Endpoint narrowing does not grant trigger support.
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
}
|
||||
// @ts-expect-error A dynamic model override cannot preserve trigger support.
|
||||
LLMClient.compact(LLMRequest.update(request, patch), { mechanism: "trigger" })
|
||||
@@ -1,374 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { CompactionCheckpointResponse, LLM, LLMClient, LLMRequest, LanguageModel, SystemPart } from "../../src/index.js"
|
||||
import { Anthropic, Azure, OpenAI, XAI } from "../../src/providers.js"
|
||||
import { Route } from "../../src/route/client.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse, scriptedResponses, truncatedStream } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("fixture"), prompt: "hello" })
|
||||
const trigger = { mechanism: "trigger" } as const
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(new URL(request.url).pathname).toBe("/v1/responses")
|
||||
expect(new URL(request.url).searchParams.get("deployment")).toBe("fixture")
|
||||
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
|
||||
expect(request.headers.authorization).toBe("Bearer fixture")
|
||||
expect(request.headers["chatgpt-account-id"]).toBe("fixture-account")
|
||||
expect(request.headers["x-codex-beta-features"]).toBe("remote_compaction_v2")
|
||||
expect(request.headers["x-deployment"]).toBe("resolved")
|
||||
const body = JSON.parse(text)
|
||||
expect(body).toMatchObject({
|
||||
model: "fixture",
|
||||
stream: true,
|
||||
store: false,
|
||||
instructions: "system\noperator",
|
||||
parallel_tool_calls: true,
|
||||
prompt_cache_key: "session-key",
|
||||
service_tier: "priority",
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "session", ttl: "1h" },
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }, { type: "compaction_trigger" }],
|
||||
})
|
||||
expect(body.tools).toHaveLength(1)
|
||||
expect(body.tools[0].name).toBe("lookup")
|
||||
expect(body.tool_choice).toBeUndefined()
|
||||
expect(body.context_management).toBeUndefined()
|
||||
expect(body.text).toBeUndefined()
|
||||
expect(body.max_output_tokens).toBeUndefined()
|
||||
expect(body.previous_response_id).toBeUndefined()
|
||||
return respond(
|
||||
sseEvents({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [checkpoint],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
input_tokens_details: { cached_tokens: 40 },
|
||||
output_tokens: 5,
|
||||
total_tokens: 105,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
).effect("trigger uses normal request preparation, configured deployment, and supplied subscription headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: string[] = []
|
||||
const route = Route.make({
|
||||
id: "fixture-responses",
|
||||
provider: "openai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
compact: OpenAIResponses.route.compact,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: request.model.route.auth,
|
||||
transport: OpenAIResponses.transport,
|
||||
headers: () => {
|
||||
calls.push("headers")
|
||||
return { "x-deployment": "resolved" }
|
||||
},
|
||||
}).with({ endpoint: { query: { deployment: "fixture" } } })
|
||||
const input = LLM.request({
|
||||
model: route.model({ id: "fixture" }),
|
||||
system: [SystemPart.make("system"), SystemPart.make("operator")],
|
||||
prompt: "hello",
|
||||
promptCacheKey: "session-key",
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
toolChoice: { type: "tool", name: "lookup" },
|
||||
generation: { maxTokens: 1 },
|
||||
providerOptions: {
|
||||
store: true,
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
contextManagement: [{ type: "compaction" }],
|
||||
},
|
||||
http: {
|
||||
headers: { "chatgpt-account-id": "fixture-account", "x-codex-beta-features": "remote_compaction_v2" },
|
||||
query: { trace: "request" },
|
||||
body: {
|
||||
service_tier: "priority",
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "session", ttl: "1h" },
|
||||
store: true,
|
||||
stream: false,
|
||||
text: { format: { type: "json_object" } },
|
||||
tool_choice: "required",
|
||||
},
|
||||
},
|
||||
})
|
||||
const original = LLMRequest.input(input)
|
||||
const result = yield* LLMClient.compact(input, {
|
||||
...trigger,
|
||||
http: (request, next) => {
|
||||
calls.push("http")
|
||||
return next(request)
|
||||
},
|
||||
})
|
||||
expect(result).toBeInstanceOf(CompactionCheckpointResponse)
|
||||
expect(result.checkpoint).toMatchObject({
|
||||
type: "compaction",
|
||||
provider: "openai",
|
||||
id: "cmp_1",
|
||||
encrypted: "opaque",
|
||||
})
|
||||
expect(result.responseID).toBe("resp_1")
|
||||
expect(result.usage).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 5,
|
||||
totalTokens: 105,
|
||||
cacheReadInputTokens: 40,
|
||||
})
|
||||
expect(LLMRequest.input(input)).toEqual(original)
|
||||
expect(calls).toEqual(["headers", "http"])
|
||||
const codec = Schema.fromJsonString(CompactionCheckpointResponse)
|
||||
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(result))).toEqual(result)
|
||||
expect("replacement" in result).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const id of ["cmp_1", undefined]) {
|
||||
for (const added of [true, false]) {
|
||||
const item = { type: "compaction", id, encrypted_content: "opaque" }
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.created", response: { id: "resp_1" } },
|
||||
...(added ? [{ type: "response.output_item.added", output_index: 0, item: { type: "compaction", id } }] : []),
|
||||
{ type: "response.output_item.done", output_index: 0, item },
|
||||
{ type: "response.output_item.done", output_index: 0, item },
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [item] } },
|
||||
),
|
||||
),
|
||||
).effect(`correlates repeated checkpoint events: id=${id}, added=${added}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request, trigger)
|
||||
expect(result.checkpoint.encrypted).toBe("opaque")
|
||||
expect(result.checkpoint.id).toBeString()
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" },
|
||||
},
|
||||
{ type: "response.output_text.delta", delta: "do not expose this" },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" }, checkpoint],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
).effect("ignores other output rather than generating an answer or dispatching tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request, trigger)
|
||||
expect(result.checkpoint.encrypted).toBe("opaque")
|
||||
expect("message" in result).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [name, events] of Object.entries({
|
||||
missing: [{ type: "response.completed", response: { id: "resp_1", output: [] } }],
|
||||
multiple: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, { ...checkpoint, id: "cmp_2" }] } },
|
||||
],
|
||||
duplicateSlots: [{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, checkpoint] } }],
|
||||
malformed: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ type: "compaction", id: "cmp_1" }] } },
|
||||
],
|
||||
empty: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ ...checkpoint, encrypted_content: "" }] } },
|
||||
],
|
||||
wrongType: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ ...checkpoint, encrypted_content: 42 }] } },
|
||||
],
|
||||
noResponseID: [{ type: "response.completed", response: { output: [checkpoint] } }],
|
||||
changedID: [
|
||||
{ type: "response.created", response: { id: "resp_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_2", output: [checkpoint] } },
|
||||
],
|
||||
changedCheckpoint: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [{ ...checkpoint, encrypted_content: "changed" }] },
|
||||
},
|
||||
],
|
||||
incomplete: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.incomplete", response: { id: "resp_1", incomplete_details: { reason: "max_output_tokens" } } },
|
||||
],
|
||||
failed: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.failed", response: { id: "resp_1", error: { code: "server_error", message: "failed" } } },
|
||||
],
|
||||
wrongStatus: [{ type: "response.completed", response: { id: "resp_1", status: "incomplete", output: [checkpoint] } }],
|
||||
})) {
|
||||
const wire = events.map((event) => ({ ...event, fixture_extra: "preserved" }))
|
||||
testEffect(
|
||||
fixedResponse(sseEvents(...wire), { headers: { "content-type": "text/event-stream", "x-fixture": "preserved" } }),
|
||||
).effect(`rejects ${name} checkpoint response and preserves original error context`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason.body).toBe(JSON.stringify(wire.at(-1)))
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-fixture": "preserved" } })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: checkpoint }))).effect(
|
||||
"rejects clean EOF without response.completed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
}),
|
||||
)
|
||||
testEffect(truncatedStream([sseEvents({ type: "response.output_item.done", item: checkpoint })])).effect(
|
||||
"does not return a checkpoint from an interrupted stream",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("Transport")
|
||||
}),
|
||||
)
|
||||
|
||||
for (const body of [{ input: [] }, { previous_response_id: "stale" }, { conversation: "stored" }]) {
|
||||
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
|
||||
`rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(LLMRequest.update(request, { http: { body } }), trigger).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("trigger capability follows selected routes, independently of endpoint support", () => {
|
||||
expect(LLMClient.canCompact(request, trigger)).toBe(true)
|
||||
for (const model of [
|
||||
Azure.configure({ resourceName: "fixture" }).responses("fixture"),
|
||||
XAI.configure().responses("fixture"),
|
||||
]) {
|
||||
expect(LLMClient.canCompact(LLM.request({ model }))).toBe(true)
|
||||
expect(LLMClient.canCompact(LLM.request({ model }), trigger)).toBe(false)
|
||||
expect(LLMClient.canCompact(LLMRequest.update(request, { model }), trigger)).toBe(false)
|
||||
expect(
|
||||
LLMClient.canCompact(
|
||||
LLM.request({ model: LanguageModel.update(request.model, { route: model.route }) }),
|
||||
trigger,
|
||||
),
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
|
||||
"untyped unsupported mechanisms and routes fail locally in both client surfaces",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* LLMClient.Service
|
||||
for (const model of [
|
||||
Anthropic.configure().model("fixture"),
|
||||
Azure.configure({ resourceName: "fixture" }).responses("fixture"),
|
||||
]) {
|
||||
const unsupported = LLM.request({ model })
|
||||
// @ts-expect-error Exercise untyped consumers; runtime must still reject unsupported routes.
|
||||
const error = yield* LLMClient.compact(unsupported, trigger).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("UnsupportedOperation")
|
||||
// @ts-expect-error The service has the same runtime guard.
|
||||
const serviceError = yield* client.compact(unsupported, trigger).pipe(Effect.flip)
|
||||
expect(serviceError.reason._tag).toBe("UnsupportedOperation")
|
||||
}
|
||||
// @ts-expect-error Exercise an unknown mechanism supplied by JavaScript.
|
||||
const error = yield* LLMClient.compact(request, { mechanism: "other" }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
// @ts-expect-error An empty string is not the default mechanism.
|
||||
const empty = yield* client.compact(request, { mechanism: "" }).pipe(Effect.flip)
|
||||
expect(empty.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
|
||||
for (const valid of [true, false]) {
|
||||
testEffect(fixedResponse("must not use HTTP")).effect(
|
||||
`acknowledges channel completion only after validation: valid=${valid}`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
let completed = 0
|
||||
const operation = LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket: {
|
||||
execute: () =>
|
||||
Effect.succeed({
|
||||
frames: Stream.make(
|
||||
JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: valid ? [checkpoint] : [] },
|
||||
}),
|
||||
),
|
||||
complete: Effect.sync(() => {
|
||||
completed++
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
const result = yield* Effect.result(operation)
|
||||
expect(result._tag).toBe(valid ? "Success" : "Failure")
|
||||
expect(completed).toBe(valid ? 1 : 0)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("checkpoint result schema rejects failed or unencrypted representations", () => {
|
||||
const decode = Schema.decodeUnknownSync(CompactionCheckpointResponse)
|
||||
for (const checkpoint of [
|
||||
{ type: "compaction", provider: "anthropic", text: null },
|
||||
{ type: "compaction", provider: "anthropic", text: "summary" },
|
||||
{ type: "compaction", provider: "openai", encrypted: "" },
|
||||
])
|
||||
expect(() => decode({ checkpoint, responseID: "resp_1" })).toThrow()
|
||||
expect(() =>
|
||||
decode({ checkpoint: { type: "compaction", provider: "openai", encrypted: "opaque" }, responseID: " " }),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
testEffect(
|
||||
scriptedResponses([
|
||||
sseEvents(
|
||||
{ type: "response.created", response: { id: "resp_discarded" } },
|
||||
{ type: "response.output_item.done", item: { ...checkpoint, encrypted_content: "discarded" } },
|
||||
{ type: "response.incomplete", response: { id: "resp_discarded", usage: { input_tokens: 999 } } },
|
||||
),
|
||||
sseEvents({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_success", output: [checkpoint], usage: { input_tokens: 12 } },
|
||||
}),
|
||||
]),
|
||||
).effect("an explicitly retried effect does not reuse failed-attempt checkpoint or metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const operation = LLMClient.compact(request, trigger)
|
||||
yield* operation.pipe(Effect.flip)
|
||||
const result = yield* operation
|
||||
expect(result.checkpoint.encrypted).toBe("opaque")
|
||||
expect(result.responseID).toBe("resp_success")
|
||||
expect(result.usage?.inputTokens).toBe(12)
|
||||
}),
|
||||
)
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
AIError,
|
||||
CompactionPart,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
@@ -80,47 +79,6 @@ describe("TestLLM legacy client", () => {
|
||||
})
|
||||
|
||||
describe("TestLLM first-class client", () => {
|
||||
it.effect("scripts trigger checkpoints lazily with gates, queue order, and fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
const request = LLM.request({ model: OpenAI.configure().responses("fixture"), prompt: "hello" })
|
||||
const checkpoint = new CompactionCheckpointResponse({
|
||||
checkpoint: { type: "compaction", provider: ProviderID.make("openai"), encrypted: "opaque" },
|
||||
responseID: "resp_fixture",
|
||||
})
|
||||
const endpoint = new CompactionResponse({ replacement: [] })
|
||||
yield* client.push(checkpoint, endpoint)
|
||||
const operation = LLMClient.compact(request, { mechanism: "trigger" })
|
||||
expect(yield* client.requests()).toEqual([])
|
||||
const gate = yield* client.gate()
|
||||
const fiber = yield* operation.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* client.wait(1)
|
||||
expect(fiber.pollUnsafe()).toBeUndefined()
|
||||
yield* gate.release
|
||||
expect(yield* Fiber.join(fiber)).toBe(checkpoint)
|
||||
expect(yield* client.compact(request)).toBe(endpoint)
|
||||
yield* client.serve((observed) => {
|
||||
expect(observed).toBe(request)
|
||||
return checkpoint
|
||||
})
|
||||
expect(yield* LLMClient.compact(request, { mechanism: "trigger" })).toBe(checkpoint)
|
||||
yield* client.always(checkpoint)
|
||||
expect(yield* LLMClient.compact(request, { mechanism: "trigger" })).toBe(checkpoint)
|
||||
yield* client.push(endpoint, checkpoint, checkpoint)
|
||||
expect(yield* client.compact(request, { mechanism: "trigger" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM trigger compaction requires a CompactionCheckpointResponse",
|
||||
)
|
||||
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM compaction requires a CompactionResponse",
|
||||
)
|
||||
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM generation requires an event response",
|
||||
)
|
||||
expect(yield* client.requests()).toHaveLength(7)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects response fixtures for the wrong operation", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts", "test/provider/checkpoint.test.ts"]
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
|
||||
const notices = page.locator('[data-slot="session-timeline-notice"]')
|
||||
await expect(notices).toHaveCount(4)
|
||||
await expect(notices.nth(0)).toContainText("Agent · explore")
|
||||
await expect(notices.nth(0)).toHaveText(/^Agent changed\s*Explore$/)
|
||||
await expect(notices.nth(1)).toContainText("explore finished · Search code")
|
||||
await expect(notices.nth(2)).toContainText("Continuing after restart")
|
||||
await expect(notices.nth(3)).toContainText("Skill · Review")
|
||||
@@ -182,20 +182,15 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await expect(card).not.toContainText("(background)")
|
||||
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
|
||||
const hint = page.locator('[data-component="session-background-hint"]')
|
||||
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
|
||||
const hint = page.getByRole("button", { name: /move running work to the background/i })
|
||||
await expect(hint).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [cardBox, hintBox, prefixBox] = await Promise.all([
|
||||
card.boundingBox(),
|
||||
hint.boundingBox(),
|
||||
hintPrefix.boundingBox(),
|
||||
])
|
||||
if (!cardBox || !hintBox || !prefixBox) return undefined
|
||||
const [cardBox, hintBox] = await Promise.all([card.boundingBox(), hint.boundingBox()])
|
||||
if (!cardBox || !hintBox) return undefined
|
||||
return {
|
||||
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
|
||||
aligned: Math.abs(cardBox.x - hintBox.x) < 2,
|
||||
ordered: cardBox.y < hintBox.y,
|
||||
}
|
||||
})
|
||||
@@ -220,10 +215,10 @@ test("navigates from a running subagent card and hides background controls in th
|
||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
|
||||
await page.locator('[data-component="task-tool-card"]').click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
|
||||
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: /move running work to the background/i })).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const name of ["shell", "subagent"] as const) {
|
||||
@@ -267,7 +262,7 @@ for (const name of ["shell", "subagent"] as const) {
|
||||
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
|
||||
const request = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
|
||||
@@ -286,9 +281,9 @@ test("shows a badge for active background work", async ({ page }) => {
|
||||
})
|
||||
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
const summary = page.getByRole("button", { name: "1 item running in background" })
|
||||
const summary = page.getByRole("button", { name: "1 background task running", exact: true })
|
||||
await expect(summary).toContainText("1")
|
||||
await expect(summary).toContainText("Running work in background")
|
||||
await expect(summary).toContainText("1 background task running")
|
||||
await summary.click()
|
||||
await expect(
|
||||
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
|
||||
@@ -387,7 +382,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
},
|
||||
})
|
||||
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
|
||||
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
|
||||
const used = page
|
||||
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
|
||||
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
@@ -396,7 +391,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
const summary = page.getByRole("button", { name: "2 items running in background" })
|
||||
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
|
||||
await expect(summary).toContainText("2")
|
||||
await summary.click()
|
||||
const list = page.locator('[data-component="session-background-list"]')
|
||||
|
||||
@@ -173,7 +173,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
|
||||
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
|
||||
"href",
|
||||
"#opencode-v2-icon-circle-exclamation",
|
||||
"#opencode-v2-icon-outline-hexagonal-warning",
|
||||
)
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -134,7 +134,7 @@ for (const name of ["read", "shell", "subagent"] as const) {
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeInViewport()
|
||||
if (name !== "read") {
|
||||
const hint = page.locator('[data-component="session-background-hint"]')
|
||||
const hint = page.getByRole("button", { name: /move running work to the background/i })
|
||||
await expect(hint).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
|
||||
await page.screenshot({ path: testInfo.outputPath(`working-grouped-${name}.png`) })
|
||||
|
||||
@@ -1,12 +1,60 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerSync } from "@/runtime/server/sync"
|
||||
|
||||
type ProjectApi = ServerApi["project"]
|
||||
type WorktreeApi = ServerApi["worktree"]
|
||||
|
||||
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(new Request(input, init).url)
|
||||
if (url.pathname === "/api/location")
|
||||
return Response.json({
|
||||
directory: "/repo",
|
||||
project: { id: "project", directory: "/repo", canonical: "/repo" },
|
||||
})
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
|
||||
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
|
||||
throw new Error(`Unexpected request: ${url.pathname}`)
|
||||
},
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
const [store, setStore] = createStore<ServerSync["data"]>({
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
project: [],
|
||||
provider_auth: {},
|
||||
config: {},
|
||||
reload: undefined,
|
||||
})
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
try {
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
|
||||
|
||||
setStore("project", (projects) => projects.map((project) => ({ ...project, name: "Renamed" })))
|
||||
expect(store.project[0]?.name).toBe("Renamed")
|
||||
setStore("project", [])
|
||||
expect(store.project).toEqual([])
|
||||
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
|
||||
expect(store.config).toEqual({})
|
||||
} finally {
|
||||
queryClient.clear()
|
||||
}
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const location = {} as ServerApi["location"]
|
||||
|
||||
@@ -79,25 +79,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
||||
setGlobalStore("project", next)
|
||||
}
|
||||
|
||||
const setBootStore = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && Array.isArray(input[1])) {
|
||||
setProjects(input[1] as Project[])
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
}) as typeof setGlobalStore
|
||||
|
||||
const bootstrap = useQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "bootstrap"],
|
||||
queryFn: async () => {
|
||||
await bootstrapGlobal({
|
||||
serverAPI: serverSDK.api,
|
||||
scope: serverSDK.scope,
|
||||
setGlobalStore: setBootStore,
|
||||
setGlobalStore,
|
||||
queryClient,
|
||||
})
|
||||
return Date.now()
|
||||
@@ -105,14 +93,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
enabled: connected(),
|
||||
}))
|
||||
|
||||
const set = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
||||
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
}) as typeof setGlobalStore
|
||||
|
||||
const paused = () => untrack(() => globalStore.reload) !== undefined
|
||||
|
||||
const queue = createRefreshQueue({
|
||||
@@ -216,7 +196,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
}
|
||||
|
||||
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
|
||||
setProjects((projects) =>
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
|
||||
)
|
||||
}
|
||||
@@ -275,7 +255,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set,
|
||||
set: setGlobalStore,
|
||||
child: children.child,
|
||||
disableMcp: children.disableMcp,
|
||||
// bootstrap,
|
||||
|
||||
@@ -965,6 +965,8 @@ export type SessionLogOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly text: string
|
||||
readonly recent: string
|
||||
}
|
||||
|
||||
@@ -138,17 +138,6 @@ export type SessionMessageCompactionRunning = {
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type SessionMessageCompactionCompleted = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
summary: string
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -521,6 +510,19 @@ export type SessionMessageAssistantReasoning = {
|
||||
time?: { created: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageCompactionCompleted = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState
|
||||
summary: string
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type ToolContent = ToolTextContent | ToolFileContent
|
||||
|
||||
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
|
||||
@@ -809,16 +811,6 @@ export type SessionCompactionStarted = {
|
||||
data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string }
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
|
||||
}
|
||||
|
||||
export type SessionCompactionFailed = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1351,6 +1343,23 @@ export type SessionToolCalled = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState1
|
||||
text: string
|
||||
recent: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
|
||||
|
||||
export type SessionMessageAssistantReasoning1 = {
|
||||
@@ -1971,6 +1980,7 @@ export type ConfigEntry =
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
subagent?: boolean
|
||||
subtask?: boolean
|
||||
}
|
||||
}
|
||||
@@ -3063,6 +3073,8 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "completed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
}
|
||||
@@ -3340,6 +3352,8 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "completed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
}
|
||||
@@ -3617,6 +3631,8 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "completed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
}
|
||||
|
||||
@@ -574,7 +574,7 @@ export function createData(config: CreateDataInput) {
|
||||
.location.get({ location: locationQuery(defaultLocation()) })
|
||||
.then((location) => {
|
||||
const key = locationKey(location)
|
||||
setStore("location", key, { ...store.location[key], info: location })
|
||||
setStore("location", key, { info: location })
|
||||
})
|
||||
.catch((error) => console.error("Failed to preload location", error))
|
||||
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
|
||||
@@ -1038,6 +1038,8 @@ export function createData(config: CreateDataInput) {
|
||||
Object.assign(current, {
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
})
|
||||
@@ -1048,6 +1050,8 @@ export function createData(config: CreateDataInput) {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
time: { created: event.created },
|
||||
@@ -1105,7 +1109,6 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
}
|
||||
setStore("location", key, (data) => ({
|
||||
...data,
|
||||
integration: data?.integration?.map((integration) => {
|
||||
if (integration.id !== event.data.integrationID) return integration
|
||||
const active = integration.connections.find(
|
||||
@@ -1147,7 +1150,6 @@ export function createData(config: CreateDataInput) {
|
||||
break
|
||||
case "vcs.branch.updated":
|
||||
setStore("location", locationKey(location), (data) => ({
|
||||
...data,
|
||||
vcs: {
|
||||
branch: {
|
||||
...data?.vcs?.branch,
|
||||
@@ -1165,7 +1167,6 @@ export function createData(config: CreateDataInput) {
|
||||
break
|
||||
case "shell.created":
|
||||
setStore("location", locationKey(location), (data) => ({
|
||||
...data,
|
||||
shell: {
|
||||
...data?.shell,
|
||||
[event.data.info.id]: { ...event.data.info, location },
|
||||
@@ -1175,7 +1176,6 @@ export function createData(config: CreateDataInput) {
|
||||
case "shell.exited":
|
||||
case "shell.deleted":
|
||||
setStore("location", locationKey(location), (data) => ({
|
||||
...data,
|
||||
shell: Object.fromEntries(Object.entries(data?.shell ?? {}).filter(([id]) => id !== event.data.id)),
|
||||
}))
|
||||
break
|
||||
@@ -1801,10 +1801,7 @@ export function createData(config: CreateDataInput) {
|
||||
const input = { location: locationQuery(ref ?? defaultLocation()) }
|
||||
const providers = await api().websearch.providers(input)
|
||||
const key = locationKey(providers.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
websearch: providers.data,
|
||||
})
|
||||
setStore("location", key, { websearch: providers.data })
|
||||
},
|
||||
},
|
||||
skill: locationResource("skill", (location) => api().skill.list({ location })),
|
||||
|
||||
@@ -98,13 +98,15 @@ test.each(["started", "cancelled", "failed"])(
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
if (kind === "started") {
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
|
||||
const model = { providerID: "demo", id: "model" }
|
||||
const providerState = { responseId: "summary-response" }
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.ended",
|
||||
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
|
||||
data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" },
|
||||
})
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
|
||||
{ type: "compaction", status: "completed", summary: "Summary" },
|
||||
{ type: "compaction", status: "completed", summary: "Summary", model, providerState },
|
||||
])
|
||||
}
|
||||
},
|
||||
|
||||
@@ -355,8 +355,10 @@ test("refreshes global credential events across every loaded location and worksp
|
||||
setup.data.location.integration.sync(location),
|
||||
setup.data.location.model.sync(location),
|
||||
setup.data.location.provider.sync(location),
|
||||
setup.data.location.reference.sync(location),
|
||||
]),
|
||||
)
|
||||
const references = locations.map((location) => setup.data.location.reference.list(location))
|
||||
requests.length = 0
|
||||
|
||||
const updated: OpenCodeEvent = {
|
||||
@@ -402,6 +404,9 @@ test("refreshes global credential events across every loaded location and worksp
|
||||
["/api/provider", "/other", "workspace-other"],
|
||||
]),
|
||||
)
|
||||
locations.forEach((location, index) =>
|
||||
expect(setup.data.location.reference.list(location)).toBe(references[index]),
|
||||
)
|
||||
requests.length = 0
|
||||
}
|
||||
} finally {
|
||||
@@ -469,6 +474,99 @@ test("refreshes references for the location an update names", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves sibling catalogs through location preload, branch, shell, and websearch updates", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const location = { directory: "/project", project: { id: "project", directory: "/project", canonical: "/project" } }
|
||||
const requests: string[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const pathname = new URL((input instanceof Request ? input : new Request(input, init)).url).pathname
|
||||
requests.push(pathname)
|
||||
if (pathname === "/api/session/active") return Response.json({})
|
||||
if (pathname === "/api/project") return Response.json([])
|
||||
if (pathname === "/api/location") return Response.json(location)
|
||||
if (pathname === "/api/vcs")
|
||||
return Response.json({ location, data: { branch: { current: "main", default: "main" } } })
|
||||
if (pathname === "/api/reference")
|
||||
return Response.json({
|
||||
location,
|
||||
data: [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
|
||||
})
|
||||
if (pathname === "/api/websearch/provider")
|
||||
return Response.json({ location, data: [{ id: "search", name: "Search" }] })
|
||||
throw new Error(`Unexpected request: ${pathname}`)
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: location.directory,
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const emit = (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details }))
|
||||
const shell = {
|
||||
id: "sh_first",
|
||||
status: "running" as const,
|
||||
command: "echo hello",
|
||||
cwd: location.directory,
|
||||
shell: "/bin/sh",
|
||||
file: "/shell-output",
|
||||
metadata: {},
|
||||
time: { started: 1 },
|
||||
}
|
||||
|
||||
try {
|
||||
// A live event may arrive before any location reads have populated this key.
|
||||
emit({ type: "shell.created", location, data: { info: shell } })
|
||||
expect(setup.data.shell.get(shell.id)).toMatchObject(shell)
|
||||
const first = setup.data.shell.get(shell.id)
|
||||
await Promise.all([setup.data.location.reference.sync(), setup.data.location.vcs.sync()])
|
||||
const references = setup.data.location.reference.list()
|
||||
expect(references?.map((reference) => [reference.name, reference.path])).toEqual([["docs", "/docs"]])
|
||||
expect(setup.data.shell.get(shell.id)).toBe(first)
|
||||
|
||||
emit({ type: "vcs.branch.updated", location, data: { branch: "feature" } })
|
||||
expect(setup.data.location.vcs.info()?.branch).toEqual({ current: "feature", default: "main" })
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
emit({ type: "shell.created", location, data: { info: { ...shell, id: "sh_second" } } })
|
||||
expect(setup.data.shell.list().map((shell) => shell.id)).toEqual(["sh_first", "sh_second"])
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
emit({ type: "shell.deleted", location, data: { id: "sh_second" } })
|
||||
expect(setup.data.shell.list().map((shell) => shell.id)).toEqual(["sh_first"])
|
||||
expect(setup.data.shell.get(shell.id)).toBe(first)
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
|
||||
await setup.data.location.websearch.refresh()
|
||||
expect(setup.data.location.websearch.list()).toEqual([{ id: "search", name: "Search" }])
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
emit({ type: "server.connected", data: {} })
|
||||
await wait(() => setup.data.location.info() !== undefined)
|
||||
expect(setup.data.location.info()).toMatchObject(location)
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
expect(setup.data.shell.get(shell.id)).toBe(first)
|
||||
expect(setup.data.location.vcs.info()?.branch).toEqual({ current: "feature", default: "main" })
|
||||
expect(requests.toSorted()).toEqual([
|
||||
"/api/location",
|
||||
"/api/project",
|
||||
"/api/reference",
|
||||
"/api/session/active",
|
||||
"/api/vcs",
|
||||
"/api/websearch/provider",
|
||||
])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("reports optimistic sessions as creating until the request settles", async () => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const api = OpenCode.make({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ConfigCommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
@@ -10,8 +9,11 @@ import { AppProcess } from "@opencode-ai/util/process"
|
||||
import path from "path"
|
||||
import { Effect, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SubagentJob } from "../../session/subagent-job.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
@@ -32,6 +34,9 @@ export const Plugin = define({
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const sessions = yield* Session.Service
|
||||
const agents = yield* Agent.Service
|
||||
const subagents = yield* SubagentJob.make
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
@@ -67,18 +72,14 @@ export const Plugin = define({
|
||||
yield* ctx.command.transform((editor) => {
|
||||
for (const document of loaded.documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
const subagent = command.subagent ?? command.subtask
|
||||
editor.add({
|
||||
name,
|
||||
description: command.description,
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||
})
|
||||
const commandAgent = agent === undefined ? undefined : (yield* ctx.agent.get({ agentID: agent })).data
|
||||
const model =
|
||||
command.model === undefined
|
||||
? commandAgent?.model
|
||||
@@ -89,15 +90,46 @@ export const Plugin = define({
|
||||
? {}
|
||||
: { variant: Model.VariantID.make(command.model.variant) }),
|
||||
}
|
||||
const text = yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
})
|
||||
if (subagent ?? commandAgent?.mode === "subagent") {
|
||||
const parent = yield* sessions.get(input.sessionID)
|
||||
const selected = yield* agents.select(agent ?? parent.agent)
|
||||
const child = yield* sessions.create({
|
||||
parentID: parent.id,
|
||||
title: command.description ?? name,
|
||||
agent: selected.id,
|
||||
model: model ?? selected.info?.model ?? parent.model,
|
||||
})
|
||||
yield* sessions.prompt({
|
||||
...input.prompt,
|
||||
sessionID: child.id,
|
||||
text: ["You are a subagent spawned by another session.", text].join("\n"),
|
||||
resume: false,
|
||||
})
|
||||
const recovery = {
|
||||
kind: "subagent" as const,
|
||||
parentSessionID: parent.id,
|
||||
childSessionID: child.id,
|
||||
agent: selected.id,
|
||||
description: command.description ?? name,
|
||||
}
|
||||
yield* subagents.start(recovery)
|
||||
yield* subagents.background(recovery)
|
||||
return
|
||||
}
|
||||
if (agent !== undefined) {
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
}),
|
||||
text,
|
||||
delivery: input.delivery,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
@@ -196,8 +228,8 @@ function evaluateTemplate(
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError((error) =>
|
||||
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
Effect.mapError(
|
||||
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { existsSync } from "fs"
|
||||
import { Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Option } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Instance } from "./instance.js"
|
||||
import { Location } from "./location.js"
|
||||
@@ -16,20 +15,48 @@ export function buildLocationServiceMap(
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const inner = yield* LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements: bindings }), {
|
||||
// Workspace-placed directories exist only inside the workspace, so a
|
||||
// local stat consults the wrong filesystem. Workspace liveness is
|
||||
// owned by placement; do not probe the sandbox here, which would
|
||||
// provision lazily-idle workspaces.
|
||||
idleTimeToLive: (ref) =>
|
||||
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
|
||||
})
|
||||
const owner = yield* Effect.scope
|
||||
const booting = MutableHashMap.empty<Location.Ref, object>()
|
||||
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = yield* LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const build = {}
|
||||
MutableHashMap.set(booting, ref, build)
|
||||
return Layer.fromBuild((memoMap, scope) =>
|
||||
Effect.suspend(() =>
|
||||
Layer.buildWithMemoMap(Instance.layer(ref, { replacements: bindings }), memoMap, scope),
|
||||
).pipe(
|
||||
Effect.onExit((exit) => {
|
||||
const finish = Effect.suspend(() => {
|
||||
// An explicitly invalidated build must not evict its replacement.
|
||||
if (Option.getOrUndefined(MutableHashMap.get(booting, ref)) !== build) return Effect.void
|
||||
MutableHashMap.remove(booting, ref)
|
||||
// Evict once per failed build, before its result reaches borrowers.
|
||||
return Exit.isFailure(exit) ? inner.invalidate(ref) : Effect.void
|
||||
})
|
||||
// With no borrowers, invalidation closes the entry's scope and
|
||||
// joins this lookup fiber. Let the owner finish that cleanup.
|
||||
return Exit.isFailure(exit)
|
||||
? finish.pipe(Effect.forkIn(owner, { startImmediately: true }), Effect.asVoid)
|
||||
: finish
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
// Retain healthy graphs. Boot failures, not local filesystem probes,
|
||||
// decide whether a location (including workspace placement) can retry.
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
const map = {
|
||||
...inner,
|
||||
get: (ref: Location.Ref) => inner.get(LocationServiceMap.canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => inner.contextEffect(LocationServiceMap.canonical(ref)),
|
||||
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(LocationServiceMap.canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => inner.invalidate(LocationServiceMap.canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) =>
|
||||
Effect.suspend(() => {
|
||||
const key = LocationServiceMap.canonical(ref)
|
||||
MutableHashMap.remove(booting, key)
|
||||
return inner.invalidate(key)
|
||||
}),
|
||||
}
|
||||
// Cached instances borrow their owner instead of retaining its Layer scope.
|
||||
const bindings: LayerNode.Replacements = [
|
||||
|
||||
+137
-30
@@ -5,7 +5,7 @@ import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import type { PersistentPty } from "./persistent-pty.js"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Logger, Queue, References, Scope, Semaphore } from "effect"
|
||||
import { Bus } from "./bus.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { PluginHost } from "./plugin/host.js"
|
||||
@@ -26,43 +26,64 @@ const layer = Layer.effect(
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const ready = yield* Latch.make(true)
|
||||
const pending = new Set<object>()
|
||||
const hold = () =>
|
||||
Effect.sync(() => {
|
||||
const token = {}
|
||||
pending.add(token)
|
||||
ready.closeUnsafe()
|
||||
return Effect.sync(() => {
|
||||
if (pending.delete(token) && pending.size === 0) ready.openUnsafe()
|
||||
})
|
||||
let closed = false
|
||||
const holdUnsafe = () => {
|
||||
if (closed) return Effect.void
|
||||
const token = {}
|
||||
pending.add(token)
|
||||
ready.closeUnsafe()
|
||||
return Effect.sync(() => {
|
||||
if (pending.delete(token) && pending.size === 0) ready.openUnsafe()
|
||||
})
|
||||
}
|
||||
const hold = () => Effect.sync(holdUnsafe)
|
||||
const pendingFailures = yield* Queue.unbounded<PendingFailure>()
|
||||
let discovered: readonly Failure[] = []
|
||||
let inventory: Plugin.Info[] = []
|
||||
const list = Effect.fn("Plugin.list")(function* () {
|
||||
return inventory
|
||||
})
|
||||
const host = yield* PluginHost.make({ list })
|
||||
const load = Effect.fnUntraced(function* (plugin: Generation) {
|
||||
const child = yield* Scope.fork(scope)
|
||||
const activation: Activation = { plugin, scope: yield* Scope.fork(scope) }
|
||||
const inherit = yield* State.inherit()
|
||||
const loaded = yield* Effect.suspend(() =>
|
||||
const grouped = State.group((failure, refresh) => {
|
||||
activation.failure = {
|
||||
error: `Plugin disabled after ${failure.state}.transform failed. Check server logs for details.`,
|
||||
ref: `err_${crypto.randomUUID().slice(0, 8)}`,
|
||||
}
|
||||
Queue.offerUnsafe(pendingFailures, {
|
||||
plugin,
|
||||
scope: activation.scope,
|
||||
failure,
|
||||
refresh,
|
||||
ref: activation.failure.ref,
|
||||
release: holdUnsafe(),
|
||||
})
|
||||
})
|
||||
const exit = yield* Effect.suspend(() =>
|
||||
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
|
||||
).pipe(
|
||||
grouped,
|
||||
inherit,
|
||||
Effect.updateContext((context: Context.Context<never>) =>
|
||||
Context.make(Scope.Scope, child).pipe(
|
||||
Context.make(Scope.Scope, activation.scope).pipe(
|
||||
Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)),
|
||||
Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)),
|
||||
),
|
||||
),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
Effect.onExit((exit) =>
|
||||
Exit.isFailure(exit) && !activation.failure ? Scope.close(activation.scope, exit) : Effect.void,
|
||||
),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isSuccess(loaded)) return { scope: child } as const
|
||||
if (activation.failure || Exit.isSuccess(exit)) return { activation } as const
|
||||
yield* Effect.logWarning("failed to load plugin", {
|
||||
"plugin.id": plugin.id,
|
||||
cause: loaded.cause,
|
||||
cause: exit.cause,
|
||||
})
|
||||
return { error: Cause.pretty(loaded.cause) } as const
|
||||
return { error: Cause.pretty(exit.cause) } as const
|
||||
})
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
@@ -81,6 +102,8 @@ const layer = Layer.effect(
|
||||
() =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
discovered = failures
|
||||
const current = Array.from(active.values())
|
||||
const changed = definitions.findIndex((definition, index) => {
|
||||
const entry = current[index]
|
||||
@@ -108,29 +131,36 @@ const layer = Layer.effect(
|
||||
([id, slot]) =>
|
||||
Effect.gen(function* () {
|
||||
active.delete(id)
|
||||
if (slot.loaded) yield* Scope.close(slot.loaded.scope, Exit.void)
|
||||
if (slot.activation && !slot.activation.failure)
|
||||
yield* Scope.close(slot.activation.scope, Exit.void)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
for (const definition of definitions.slice(prefix)) {
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
const slot = previous.get(definition.id)
|
||||
// Reordering healthy registrations does not authorize retrying a failed revision.
|
||||
if (slot?.activation?.failure && slot.plugin.revision === definition.revision) {
|
||||
active.set(definition.id, { ...slot, plugin: definition })
|
||||
continue
|
||||
}
|
||||
const result = yield* load(definition)
|
||||
if (result.activation !== undefined) {
|
||||
active.set(definition.id, {
|
||||
plugin: definition,
|
||||
loaded: { plugin: definition, scope: loaded.scope },
|
||||
activation: result.activation,
|
||||
})
|
||||
continue
|
||||
}
|
||||
active.set(definition.id, { plugin: definition, error: loaded.error })
|
||||
active.set(definition.id, { plugin: definition, error: result.error })
|
||||
|
||||
const fallback = previous.get(definition.id)?.loaded
|
||||
if (!fallback) continue
|
||||
const fallback = slot?.activation
|
||||
if (!fallback || fallback.failure) continue
|
||||
const restored = yield* load(fallback.plugin)
|
||||
if (restored.scope !== undefined) {
|
||||
if (restored.activation !== undefined) {
|
||||
active.set(definition.id, {
|
||||
plugin: definition,
|
||||
loaded: { plugin: fallback.plugin, scope: restored.scope },
|
||||
error: loaded.error,
|
||||
activation: restored.activation,
|
||||
error: result.error,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -149,9 +179,68 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
yield* Queue.take(pendingFailures).pipe(
|
||||
Effect.flatMap((item) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logWarning("disabled plugin after transform failure", {
|
||||
"plugin.id": item.plugin.id,
|
||||
state: item.failure.state,
|
||||
ref: item.ref,
|
||||
cause: Cause.die(item.failure.cause),
|
||||
})
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
// Failure is already recorded on its exact activation, so an old queued item
|
||||
// cannot disable a replacement and teardown need not wait for this worker.
|
||||
inventory = [...Array.from(active.values()).map(slotInfo), ...discovered]
|
||||
const refreshed = yield* State.batch(item.refresh).pipe(Effect.exit)
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
if (Exit.isFailure(refreshed))
|
||||
yield* Effect.logWarning("failed to refresh state after disabling plugin", {
|
||||
"plugin.id": item.plugin.id,
|
||||
ref: item.ref,
|
||||
cause: refreshed.cause,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
// Cleanup must also be scheduled if an inventory observer fails. User finalizers
|
||||
// may await readiness, so never join them under the activation lock or readiness hold.
|
||||
Effect.ensuring(
|
||||
Scope.close(item.scope, Exit.void).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to clean up disabled plugin", {
|
||||
"plugin.id": item.plugin.id,
|
||||
ref: item.ref,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
),
|
||||
),
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) =>
|
||||
Effect.logError("failed to report disabled plugin", {
|
||||
"plugin.id": item.plugin.id,
|
||||
ref: item.ref,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(item.release),
|
||||
),
|
||||
),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
const close = (exit: Exit.Exit<unknown, unknown>) =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
closed = true
|
||||
pending.clear()
|
||||
ready.openUnsafe()
|
||||
active.clear()
|
||||
yield* State.shutdown(Scope.close(scope, exit))
|
||||
}),
|
||||
@@ -168,19 +257,37 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
// `plugin` is the definition the slot was last asked to run; `loaded` is the generation actually
|
||||
// running, which stays an older fallback while the requested revision keeps failing setup.
|
||||
// `plugin` is the requested definition; `activation` is its last activation, which may have
|
||||
// failed or be an older fallback while the requested revision keeps failing setup.
|
||||
type Slot = {
|
||||
readonly plugin: Generation
|
||||
readonly loaded?: { readonly plugin: Generation; readonly scope: Scope.Closeable }
|
||||
readonly activation?: Activation
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// Share the activation across slot snapshots so teardown sees failures synchronously,
|
||||
// including failures discovered after activate() has captured its previous slots.
|
||||
type Activation = {
|
||||
readonly plugin: Generation
|
||||
readonly scope: Scope.Closeable
|
||||
failure?: { readonly error: string; readonly ref: string }
|
||||
}
|
||||
|
||||
type PendingFailure = {
|
||||
readonly plugin: Generation
|
||||
readonly scope: Scope.Closeable
|
||||
readonly failure: State.Failure
|
||||
readonly refresh: Effect.Effect<void>
|
||||
readonly ref: string
|
||||
readonly release: Effect.Effect<void>
|
||||
}
|
||||
|
||||
function slotInfo(slot: Slot): Plugin.Info {
|
||||
const failure = slot.activation?.failure ?? (slot.error === undefined ? undefined : { error: slot.error })
|
||||
return {
|
||||
id: Plugin.ID.make(slot.plugin.id),
|
||||
source: slot.plugin.source ?? { type: "builtin" },
|
||||
state: slot.error === undefined ? { status: "active" } : { status: "failed", error: slot.error },
|
||||
state: failure === undefined ? { status: "active" } : { status: "failed", ...failure },
|
||||
features: { server: true, ...slot.plugin.features },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,15 @@ const layer = Layer.effect(
|
||||
// The heterogeneous registry erases handlers after their selected schema validates input.
|
||||
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
|
||||
return execution
|
||||
}).pipe(Effect.catch((error) => encodeError(method, error)))
|
||||
}).pipe(
|
||||
Effect.catch((error) => encodeError(method, error)),
|
||||
// Normalize handler bugs here so direct callers can recover just like HTTP callers.
|
||||
Effect.catchDefect((defect) =>
|
||||
Effect.logError("rpc handler failed", { rpc: rpcID, method: name, defect }).pipe(
|
||||
Effect.andThen(Effect.fail(failure("rpc.internal", "RPC call failed"))),
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* encode(method.output, result).pipe(
|
||||
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Database } from "./database/database.js"
|
||||
import { SessionProjector } from "./session/projector.js"
|
||||
import { SessionMessageTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { AbsolutePath, RelativePath } from "./schema.js"
|
||||
import { RelativePath } from "./schema.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from "./session/error.js"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { SessionInbox } from "./session/inbox.js"
|
||||
import { InstructionState } from "./session/instruction-state.js"
|
||||
@@ -62,7 +61,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { Job } from "./job.js"
|
||||
import type { Command } from "./command.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
|
||||
@@ -168,15 +166,7 @@ export interface Interface {
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly move: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
directory: AbsolutePath
|
||||
workspaceID?: Location.Ref["workspaceID"]
|
||||
delivery?: SessionInbox.Delivery
|
||||
}) => Effect.Effect<
|
||||
void,
|
||||
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
|
||||
>
|
||||
readonly move: SessionMove.Interface["move"]
|
||||
readonly prompt: (
|
||||
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
|
||||
) => ReturnType<Session.Handle["prompt"]>
|
||||
@@ -232,18 +222,15 @@ const layer = Layer.effect(
|
||||
const db = database.db
|
||||
const bus = yield* Bus.Service
|
||||
const projects = yield* Project.Service
|
||||
const global = yield* Global.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const instances = yield* Instance.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const moves = yield* SessionMove.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const sessions = yield* Session.make()
|
||||
const admission = yield* SessionInbox.Service
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
const result = Service.of({
|
||||
@@ -410,45 +397,7 @@ const layer = Layer.effect(
|
||||
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
|
||||
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
|
||||
rename: (input) => sessions.forSession(input.sessionID).rename(input),
|
||||
move: Effect.fn("Session.move")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const payload = yield* SessionMove.prepare({ ...input, session }).pipe(
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(Project.Service, projects),
|
||||
Effect.provideService(LocationServiceMap.Service, locations),
|
||||
)
|
||||
const item = SessionInbox.Item.make({
|
||||
type: "move",
|
||||
payload,
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
yield* SessionInbox.serialized(
|
||||
input.sessionID,
|
||||
Effect.gen(function* () {
|
||||
const latest = yield* result.get(input.sessionID)
|
||||
const source = yield* fs.stat(latest.location.directory).pipe(Effect.orElseSucceed(() => undefined))
|
||||
// Active runners must hand off at a step boundary to retain their continuation.
|
||||
if ((!source || source.type !== "Directory") && !(yield* execution.isActive(input.sessionID))) {
|
||||
const cancellations = (yield* SessionInbox.moveIDs(db, input.sessionID)).map(
|
||||
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
|
||||
)
|
||||
const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...payload }] as const
|
||||
const first = cancellations[0]
|
||||
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid)
|
||||
return yield* bus.publishAll([first, ...cancellations.slice(1), moved])
|
||||
}
|
||||
yield* admission
|
||||
.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID: input.sessionID,
|
||||
item,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
move: moves.move,
|
||||
compact: (input) => sessions.forSession(input.sessionID).compact(input),
|
||||
wait: (sessionID) => sessions.forSession(sessionID).wait(),
|
||||
active: execution.active,
|
||||
@@ -499,10 +448,9 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
|
||||
SessionStore.node,
|
||||
Instance.node,
|
||||
SessionInbox.node,
|
||||
LocationServiceMap.node,
|
||||
SessionMove.node,
|
||||
SessionProjector.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
App.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -367,6 +367,7 @@ export const layer = Layer.effect(
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
let usage: SessionUsage.Recorded | undefined
|
||||
let providerState: SessionMessage.ProviderState | undefined
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? bus.publish(SessionEvent.UsageRecorded, {
|
||||
@@ -407,6 +408,7 @@ export const layer = Layer.effect(
|
||||
// Ignored tool calls never enter the follow-up history or need fabricated results.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
chunks.length = 0
|
||||
providerState = undefined
|
||||
yield* llm
|
||||
.stream(
|
||||
attempt === 0
|
||||
@@ -436,6 +438,10 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
providerState =
|
||||
event.providerMetadata?.[
|
||||
context.model.model.route.providerMetadataKey ?? context.model.model.provider
|
||||
]
|
||||
const step = SessionUsage.record(event.usage, context.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
@@ -482,6 +488,8 @@ export const layer = Layer.effect(
|
||||
yield* bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
model: context.model.ref,
|
||||
providerState,
|
||||
text: summary,
|
||||
recent: history.recent,
|
||||
})
|
||||
|
||||
@@ -176,13 +176,7 @@ export const layer = (options?: Options) =>
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
if (assistant?.type !== "assistant") return "Subagent completed without a text response."
|
||||
return (
|
||||
assistant.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("") || "Subagent completed without a text response."
|
||||
)
|
||||
return SubagentCompletion.text(assistant)
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -410,6 +410,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
})
|
||||
@@ -422,6 +424,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
status: "completed",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
time: { created },
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
export * as SessionMove from "./move.js"
|
||||
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Instance } from "../instance/service.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Project } from "../project.js"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import { NotFoundError } from "./error.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionExecution } from "./execution.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionProjector } from "./projector.js"
|
||||
import { SessionRunner } from "./runner/index.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
|
||||
export class DestinationNotFoundError extends Schema.TaggedError<DestinationNotFoundError>()(
|
||||
"Session.DestinationNotFoundError",
|
||||
@@ -26,36 +37,138 @@ export class DestinationUnavailableError extends Schema.TaggedError<DestinationU
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export const prepare = Effect.fn("SessionMove.prepare")(function* (input: {
|
||||
session: Session.Info
|
||||
directory: AbsolutePath
|
||||
workspaceID?: Location.Ref["workspaceID"]
|
||||
}) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const projects = yield* Project.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const value = input.directory.trim()
|
||||
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
||||
const directory = AbsolutePath.make(path.resolve(input.session.location.directory, expanded))
|
||||
const info = yield* fs.stat(directory).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!info) return yield* new DestinationNotFoundError({ directory })
|
||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
const project = yield* projects.resolve(directory)
|
||||
const payload: SessionInbox.MovePayload = {
|
||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
}
|
||||
yield* Location.Service.pipe(
|
||||
Effect.provide(locations.get(payload.location)),
|
||||
Effect.scoped,
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
|
||||
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
|
||||
export interface Interface {
|
||||
readonly move: (input: {
|
||||
sessionID: Session.ID
|
||||
directory: AbsolutePath
|
||||
workspaceID?: Location.Ref["workspaceID"]
|
||||
delivery?: SessionInbox.Delivery
|
||||
}) => Effect.Effect<
|
||||
void,
|
||||
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
|
||||
>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionMove") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const projects = yield* Project.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const instances = yield* Instance.Service
|
||||
const admission = yield* SessionInbox.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const get = Effect.fn("SessionMove.get")(function* (sessionID: Session.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* new NotFoundError({ sessionID })
|
||||
return session
|
||||
})
|
||||
|
||||
const resolveDestination = Effect.fn("SessionMove.resolveDestination")(function* (
|
||||
session: Session.Info,
|
||||
input: Parameters<Interface["move"]>[0],
|
||||
) {
|
||||
const value = input.directory.trim()
|
||||
const expanded =
|
||||
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
||||
const directory = AbsolutePath.make(path.resolve(session.location.directory, expanded))
|
||||
const info = yield* fs.stat(directory).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!info) return yield* new DestinationNotFoundError({ directory })
|
||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
const project = yield* projects.resolve(directory)
|
||||
const destination: SessionInbox.MovePayload = {
|
||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
}
|
||||
yield* locations.contextEffect(destination.location).pipe(
|
||||
Effect.scoped,
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
|
||||
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return payload
|
||||
return destination
|
||||
})
|
||||
|
||||
const sourceUnavailable = Effect.fn("SessionMove.sourceUnavailable")(function* (session: Session.Info) {
|
||||
if (yield* execution.isActive(session.id)) return false
|
||||
if (!(yield* fs.isDir(session.location.directory))) return true
|
||||
return yield* SessionRunner.Service.pipe(
|
||||
instances.provide(session),
|
||||
Effect.as(false),
|
||||
Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.succeed(true))),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
move: Effect.fn("SessionMove.move")(function* (input) {
|
||||
const session = yield* get(input.sessionID)
|
||||
const destination = yield* resolveDestination(session, input)
|
||||
// Probe outside the inbox lock so cancellation remains available during initialization.
|
||||
const unavailable = yield* sourceUnavailable(session)
|
||||
const item = SessionInbox.Item.make({
|
||||
type: "move",
|
||||
payload: destination,
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
yield* SessionInbox.serialized(
|
||||
input.sessionID,
|
||||
Effect.gen(function* () {
|
||||
const latest = yield* get(input.sessionID)
|
||||
// Only recover the placement we probed; active runners retain their step-boundary handoff.
|
||||
if (
|
||||
unavailable &&
|
||||
latest.location.directory === session.location.directory &&
|
||||
latest.location.workspaceID === session.location.workspaceID &&
|
||||
!(yield* execution.isActive(input.sessionID))
|
||||
) {
|
||||
const cancellations = (yield* SessionInbox.moveIDs(database.db, input.sessionID)).map(
|
||||
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
|
||||
)
|
||||
const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...destination }] as const
|
||||
const first = cancellations[0]
|
||||
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid)
|
||||
return yield* bus.publishAll([first, ...cancellations.slice(1), moved])
|
||||
}
|
||||
yield* admission
|
||||
.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID: input.sessionID,
|
||||
item,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
Project.node,
|
||||
LocationServiceMap.node,
|
||||
SessionStore.node,
|
||||
SessionExecution.node,
|
||||
Instance.node,
|
||||
SessionInbox.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -90,10 +90,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
}
|
||||
const assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
let providerFailed = false
|
||||
let outputStarted = false
|
||||
let stepStreamed = false
|
||||
let stepFailure: SessionError.Error | undefined
|
||||
let stepSettlement: StepRecord["finish"]
|
||||
|
||||
@@ -112,8 +110,6 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
const currentAssistantMessageID = () =>
|
||||
stepStarted ? Effect.succeed(assistantMessageID) : Effect.die(new Error("Tool event before assistant step start"))
|
||||
const streamed = Effect.fnUntraced(function* () {
|
||||
if (stepStreamed) return
|
||||
stepStreamed = true
|
||||
yield* bus.publish(SessionEvent.Step.Streamed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
@@ -367,9 +363,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
readonly snapshot?: Snapshot.ID
|
||||
readonly files?: readonly RelativePath[]
|
||||
}) {
|
||||
if (stepFailed || stepFailure === undefined) return
|
||||
if (stepFailure === undefined) return
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
stepFailed = true
|
||||
yield* bus.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
|
||||
@@ -3,6 +3,19 @@ export * as SubagentCompletion from "./subagent-completion.js"
|
||||
import { Effect } from "effect"
|
||||
import type { Job } from "../job.js"
|
||||
import type { Session } from "../session.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
|
||||
export const NO_TEXT = "Subagent completed without a text response."
|
||||
|
||||
export function text(message: SessionMessage.Info | undefined) {
|
||||
if (message?.type !== "assistant") return NO_TEXT
|
||||
return (
|
||||
message.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("") || NO_TEXT
|
||||
)
|
||||
}
|
||||
|
||||
export const deliver = Effect.fnUntraced(function* (
|
||||
sessions: Pick<Session.Interface, "synthetic">,
|
||||
@@ -16,7 +29,7 @@ export const deliver = Effect.fnUntraced(function* (
|
||||
const recovery = input.recovery
|
||||
const text =
|
||||
input.status === "completed"
|
||||
? (input.output ?? "Subagent completed without a text response.")
|
||||
? (input.output ?? NO_TEXT)
|
||||
: input.status === "error"
|
||||
? (input.error ?? "Subagent failed")
|
||||
: "Subagent cancelled"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export * as SubagentJob from "./subagent-job.js"
|
||||
|
||||
import { Effect, Scope } from "effect"
|
||||
import { Job } from "../job.js"
|
||||
import { Session } from "../session.js"
|
||||
import { SubagentCompletion } from "./subagent-completion.js"
|
||||
|
||||
type Recovery = Extract<Job.Recovery, { kind: "subagent" }>
|
||||
|
||||
interface Runner {
|
||||
start: (recovery: Recovery) => Effect.Effect<Job.Info>
|
||||
background: (recovery: Recovery) => Effect.Effect<void>
|
||||
notify: (recovery: Recovery, startedAt: number) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const make: Effect.Effect<Runner, never, Session.Service | Job.Service | Scope.Scope> = Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const jobs = yield* Job.Service
|
||||
const scope = yield* Scope.Scope
|
||||
// One observer per job generation, including continuations of the same child.
|
||||
const notifications = new Set<string>()
|
||||
|
||||
const notify = Effect.fn("SubagentJob.notify")(function* (recovery: Recovery, startedAt: number) {
|
||||
const key = `${recovery.childSessionID}:${startedAt}`
|
||||
if (notifications.has(key)) return
|
||||
notifications.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
const info = (yield* jobs.wait({ id: recovery.childSessionID })).info
|
||||
if (info) yield* SubagentCompletion.deliver(sessions, jobs, { ...info, recovery })
|
||||
}).pipe(
|
||||
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
start: (recovery: Recovery) =>
|
||||
jobs.start({
|
||||
id: recovery.childSessionID,
|
||||
type: "subagent",
|
||||
title: recovery.description,
|
||||
metadata: {},
|
||||
recovery,
|
||||
run: Effect.gen(function* () {
|
||||
yield* sessions.resume(recovery.childSessionID)
|
||||
const messages = yield* sessions.messages({ sessionID: recovery.childSessionID, order: "desc", limit: 20 })
|
||||
const assistant = messages.find(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
return SubagentCompletion.text(assistant)
|
||||
}),
|
||||
}),
|
||||
background: Effect.fn("SubagentJob.background")(function* (recovery: Recovery) {
|
||||
const info = yield* jobs.background(recovery.childSessionID)
|
||||
if (info) yield* notify(recovery, info.started_at)
|
||||
}),
|
||||
notify,
|
||||
}
|
||||
})
|
||||
@@ -297,6 +297,9 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
|
||||
metadata: meta,
|
||||
summary: redact("compaction-summary", message.id, message.summary),
|
||||
recent: redact("compaction-recent", message.id, message.recent),
|
||||
...(message.status === "completed"
|
||||
? { providerState: metadata("compaction-provider-state", message.id, message.providerState) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
return { ...message, metadata: meta }
|
||||
|
||||
@@ -149,6 +149,7 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
|
||||
const char = input[index]
|
||||
if (!wordStarted) wordStart = index
|
||||
if (!quote && !wordStarted) {
|
||||
if (char === " " || char === "\t") continue
|
||||
const structure = structures.at(-1)
|
||||
const token = /^[A-Za-z_][A-Za-z0-9_]*(?=[ \t\n;()<>]|$)/.exec(input.slice(index))?.[0]
|
||||
if (structure?.kind === "case" && structure.phase === "header" && token === "in") {
|
||||
@@ -166,13 +167,24 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
if (structure?.kind === "for" && structure.phase === "header" && char === "(" && input[index + 1] !== "(") {
|
||||
const values = bashExpansion(input, index, depth, "array")
|
||||
if (!values) return { kind: "opaque", reason: "compound-command" }
|
||||
finishCommand()
|
||||
const failure = addSubstitutions(values)
|
||||
if (failure) return failure
|
||||
commands.push(...nestedCommands.splice(0))
|
||||
// Zsh permits a sublist or brace group directly after the value list, without do/done.
|
||||
structure.phase = "do"
|
||||
if (!/^(?:[ \t\n;]|\\\n|#[^\n]*(?:\n|$))*do(?=[ \t\n;]|$)/.test(input.slice(values.end + 1))) structures.pop()
|
||||
index = values.end
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
if (!words.length && !hasRedirect && !compoundEnd) {
|
||||
const definition =
|
||||
/^(?:function[ \t]+[A-Za-z_][A-Za-z0-9_]*(?:[ \t]*\([ \t]*\))?|[A-Za-z_][A-Za-z0-9_]*[ \t]*\([ \t]*\))[ \t\n]*(?=[{(])/.exec(
|
||||
input.slice(index),
|
||||
)
|
||||
const definition = bashFunctionHead(input, index)
|
||||
if (definition && !header()) {
|
||||
index += definition[0].length - 1
|
||||
index += definition.length - 1
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
@@ -536,6 +548,14 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
|
||||
|
||||
type BashExpansion = { source: string; end: number; substitutions?: string[] }
|
||||
|
||||
function bashFunctionHead(input: string, start: number) {
|
||||
// Share recognition with delimiter scanning so case patterns in function bodies do not close the outer group.
|
||||
// Names need not be variable identifiers. Zsh permits anonymous functions, including in an if condition.
|
||||
return /^(?!if(?:[ \t]|\\\n)*\()(?:function[ \t]+(?:\\\n[ \t]*)*[A-Za-z_][A-Za-z0-9_.:-]*(?:(?:[ \t]|\\\n)*\([ \t]*\))?|(?:[A-Za-z_][A-Za-z0-9_.:-]*(?:[ \t]|\\\n)*)?\([ \t]*\))(?:[ \t\n]|\\\n|#[^\n]*(?:\n|$))*(?=[{(]|\[\[(?=[ \t\n])|(?:if|while|until|for|select|case)[ \t\n])/.exec(
|
||||
input.slice(start),
|
||||
)?.[0]
|
||||
}
|
||||
|
||||
function bashDelimited(input: string, start: number, depth: number): BashExpansion | undefined {
|
||||
if (depth >= MAX_SUBSTITUTION_DEPTH) return
|
||||
const close = input[start] === "{" ? "}" : ")"
|
||||
@@ -544,6 +564,7 @@ function bashDelimited(input: string, start: number, depth: number): BashExpansi
|
||||
let commandStart = true
|
||||
for (let index = start + 1; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (char === " " || char === "\t") continue
|
||||
if (char === "\\") {
|
||||
if (input[index + 1] !== "\n") commandStart = false
|
||||
index++
|
||||
@@ -566,6 +587,11 @@ function bashDelimited(input: string, start: number, depth: number): BashExpansi
|
||||
continue
|
||||
}
|
||||
const boundary = index === start + 1 || /[ \t\n;|&(){}]/.test(input[index - 1])
|
||||
const definition = commandStart && boundary ? bashFunctionHead(input, index) : undefined
|
||||
if (definition) {
|
||||
index += definition.length - 1
|
||||
continue
|
||||
}
|
||||
if (char === "#" && boundary) {
|
||||
const newline = input.indexOf("\n", index)
|
||||
if (newline < 0) return
|
||||
@@ -725,7 +751,10 @@ function bashExpansion(
|
||||
index = nested.end
|
||||
continue
|
||||
}
|
||||
if (kind === "array" && "<>=".includes(char) && input[index + 1] === "(") {
|
||||
if (
|
||||
((kind === "array" && "<>=".includes(char)) || (kind === "test" && "<>".includes(char))) &&
|
||||
input[index + 1] === "("
|
||||
) {
|
||||
const nested = bashDelimited(input, index + 1, depth + 1)
|
||||
if (!nested) return
|
||||
substitutions.push(nested.source)
|
||||
|
||||
+86
-19
@@ -31,6 +31,49 @@ export interface Transformable<Editor> {
|
||||
readonly reload: Reload
|
||||
}
|
||||
|
||||
export interface Failure {
|
||||
readonly state: string
|
||||
readonly cause: unknown
|
||||
}
|
||||
|
||||
type GroupedRegistration = {
|
||||
readonly remove: () => boolean
|
||||
readonly notify: Effect.Effect<void>
|
||||
}
|
||||
|
||||
type RegistrationGroup = {
|
||||
failed: boolean
|
||||
readonly registrations: Set<GroupedRegistration>
|
||||
readonly report: (failure: Failure, refresh: Effect.Effect<void>) => void
|
||||
}
|
||||
|
||||
const CurrentGroup = Context.Reference<RegistrationGroup | undefined>("@opencode/State/CurrentGroup", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
/**
|
||||
* Groups registrations without coupling State to plugin identity or asynchronous cleanup.
|
||||
* A failed group is detached synchronously; its supervisor must run refresh and close its scope.
|
||||
*/
|
||||
export function group(report: RegistrationGroup["report"]) {
|
||||
const group: RegistrationGroup = { failed: false, registrations: new Set(), report }
|
||||
return <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, CurrentGroup, group)
|
||||
}
|
||||
|
||||
function disable(group: RegistrationGroup, failure: Failure) {
|
||||
if (group.failed) return
|
||||
group.failed = true
|
||||
const notifications = new Set<Effect.Effect<void>>()
|
||||
for (const registration of group.registrations) {
|
||||
registration.remove()
|
||||
notifications.add(registration.notify)
|
||||
}
|
||||
group.report(
|
||||
failure,
|
||||
Effect.forEach(notifications, (notify) => notify, { discard: true }),
|
||||
)
|
||||
}
|
||||
|
||||
type Batch = {
|
||||
active: boolean
|
||||
readonly shutdown: boolean
|
||||
@@ -112,19 +155,38 @@ export interface Interface<State, Editor> extends Transformable<Editor> {
|
||||
|
||||
export function create<State, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
|
||||
let state = options.initial()
|
||||
const transforms: { run: TransformCallback<Editor> }[] = []
|
||||
const transforms = new Set<{ run: TransformCallback<Editor>; group: RegistrationGroup | undefined }>()
|
||||
let dirty = false
|
||||
let closed = false
|
||||
let version = 0
|
||||
|
||||
const invalidate = () => {
|
||||
dirty = true
|
||||
version++
|
||||
}
|
||||
|
||||
const get = () => {
|
||||
if (closed || !dirty) return state
|
||||
const next = options.initial()
|
||||
const editor = options.editor(next)
|
||||
for (const transform of transforms) transform.run(editor)
|
||||
// Only a complete fold becomes visible; a throwing callback leaves the previous value and stays dirty.
|
||||
state = next
|
||||
dirty = false
|
||||
return state
|
||||
while (true) {
|
||||
const started = version
|
||||
const next = options.initial()
|
||||
const editor = options.editor(next)
|
||||
for (const transform of transforms) {
|
||||
try {
|
||||
transform.run(editor)
|
||||
} catch (cause) {
|
||||
if (!transform.group) throw cause
|
||||
disable(transform.group, { state: options.name ?? "anonymous", cause })
|
||||
}
|
||||
// A nested read can disable a group that already contributed to this candidate.
|
||||
if (version !== started) break
|
||||
}
|
||||
if (version !== started) continue
|
||||
// Ungrouped failures still propagate; grouped failures restart from a fresh candidate.
|
||||
state = next
|
||||
dirty = false
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// One stable value per State, so a batch's notification Set holds it at most once.
|
||||
@@ -137,7 +199,7 @@ export function create<State, Editor>(options: Options<State, Editor>): Interfac
|
||||
const changed = Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
dirty = true
|
||||
invalidate()
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
if (batch.shutdown) {
|
||||
@@ -156,18 +218,23 @@ export function create<State, Editor>(options: Options<State, Editor>): Interfac
|
||||
transform: Effect.fn("State.transform")(function* (update) {
|
||||
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
|
||||
const scope = yield* Scope.Scope
|
||||
const group = yield* CurrentGroup
|
||||
if (group?.failed) return { dispose: Effect.void }
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const transform = { run: update }
|
||||
const dispose = Effect.uninterruptible(
|
||||
Effect.suspend(() => {
|
||||
const index = transforms.indexOf(transform)
|
||||
if (index < 0) return Effect.void
|
||||
transforms.splice(index, 1)
|
||||
return changed
|
||||
}),
|
||||
)
|
||||
transforms.push(transform)
|
||||
const transform = { run: update, group }
|
||||
const registration: GroupedRegistration = {
|
||||
remove: () => {
|
||||
if (!transforms.delete(transform)) return false
|
||||
group?.registrations.delete(registration)
|
||||
invalidate()
|
||||
return true
|
||||
},
|
||||
notify: changed,
|
||||
}
|
||||
const dispose = Effect.uninterruptible(Effect.suspend(() => (registration.remove() ? changed : Effect.void)))
|
||||
transforms.add(transform)
|
||||
group?.registrations.add(registration)
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
yield* changed
|
||||
return { dispose }
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as SubagentTool from "./subagent.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Job } from "../../job.js"
|
||||
@@ -10,10 +10,10 @@ import { Permission } from "../../permission.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
import { SubagentCompletion } from "../../session/subagent-completion.js"
|
||||
import { SubagentJob } from "../../session/subagent-job.js"
|
||||
|
||||
export const name = "subagent"
|
||||
|
||||
const NO_TEXT = "Subagent completed without a text response."
|
||||
const backgroundResult = (sessionID: SessionSchema.ID) => ({
|
||||
sessionID,
|
||||
status: "running" as const,
|
||||
@@ -60,42 +60,7 @@ export const Plugin = {
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* Permission.Service
|
||||
const scope = yield* Scope.Scope
|
||||
// One completion observer per job generation. Keyed by child plus start time so a fresh
|
||||
// continuation job is observable even while a settled generation's observer is finalizing.
|
||||
const notifications = new Set<string>()
|
||||
|
||||
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
|
||||
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
|
||||
const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) {
|
||||
const messages = yield* sessions.messages({ sessionID, order: "desc", limit: 20 })
|
||||
const assistant = messages.find(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT
|
||||
const text = assistant.content
|
||||
.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
return text.length > 0 ? text : NO_TEXT
|
||||
})
|
||||
|
||||
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
|
||||
recovery: Extract<Job.Recovery, { kind: "subagent" }>,
|
||||
startedAt: number,
|
||||
) {
|
||||
const key = `${recovery.childSessionID}:${startedAt}`
|
||||
if (notifications.has(key)) return
|
||||
notifications.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
const info = (yield* jobs.wait({ id: recovery.childSessionID })).info
|
||||
if (info) yield* SubagentCompletion.deliver(sessions, jobs, { ...info, recovery })
|
||||
}).pipe(
|
||||
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
const subagents = yield* SubagentJob.make
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((editor) =>
|
||||
@@ -225,18 +190,10 @@ export const Plugin = {
|
||||
agent: agent.name,
|
||||
description: input.description,
|
||||
}
|
||||
const info = yield* jobs.start({
|
||||
id: child.id,
|
||||
type: name,
|
||||
title: input.description,
|
||||
metadata: {},
|
||||
recovery,
|
||||
run: sessions.resume(child.id).pipe(Effect.andThen(latestAssistantText(child.id))),
|
||||
})
|
||||
yield* subagents.start(recovery)
|
||||
|
||||
if (background) {
|
||||
yield* jobs.background(info.id)
|
||||
yield* notifyWhenDone(recovery, info.started_at)
|
||||
yield* subagents.background(recovery)
|
||||
return backgroundResult(child.id)
|
||||
}
|
||||
|
||||
@@ -248,7 +205,7 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(recovery, result.info.started_at)
|
||||
yield* subagents.notify(recovery, result.info.started_at)
|
||||
return backgroundResult(child.id)
|
||||
}
|
||||
// Failure surfaces keep the sessionID visible so the model can continue the child.
|
||||
@@ -258,7 +215,11 @@ export const Plugin = {
|
||||
})
|
||||
if (result?.info.status === "cancelled")
|
||||
return yield* new ToolFailure({ message: `Subagent cancelled (sessionID: ${child.id})` })
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
|
||||
return {
|
||||
sessionID: child.id,
|
||||
status: "completed" as const,
|
||||
output: result?.info.output ?? SubagentCompletion.NO_TEXT,
|
||||
}
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
|
||||
@@ -172,7 +172,7 @@ export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>)
|
||||
description: command.description,
|
||||
agent: command.agent,
|
||||
model: modelSelection(command.model, command.variant),
|
||||
subtask: command.subtask,
|
||||
subagent: command.subtask,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { offlineModels } from "../fixture/models"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const llmLayer = TestLLM.testLayer({ fallback: TestLLM.text("Review complete", "review") })
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
llmLayer,
|
||||
AppNodeBuilder.build(LayerNode.group([Session.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
LayerNodePlatform.llmClient.replace(llmLayer),
|
||||
SessionRunnerModel.node.replace(
|
||||
Layer.succeed(SessionRunnerModel.Service, {
|
||||
resolve: (session) =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: session.model?.id ?? "parent", provider: "test", route: OpenAIChat.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
},
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
|
||||
|
||||
describe("command subagents", () => {
|
||||
for (const fixture of [
|
||||
{
|
||||
name: "native JSON",
|
||||
format: "json",
|
||||
command: { subagent: true, agent: "build", model: "test/override" },
|
||||
agent: "build",
|
||||
model: "override",
|
||||
},
|
||||
{
|
||||
name: "legacy Markdown",
|
||||
format: "markdown",
|
||||
command: { subtask: true, agent: "build" },
|
||||
agent: "build",
|
||||
model: "parent",
|
||||
},
|
||||
{
|
||||
name: "subagent mode by default",
|
||||
format: "json",
|
||||
command: { agent: "reviewer" },
|
||||
agent: "reviewer",
|
||||
model: "child",
|
||||
},
|
||||
] as const) {
|
||||
it.live(`runs ${fixture.name} in the background without switching the parent`, () =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* project(fixture.command, fixture.format)
|
||||
const sessions = yield* Session.Service
|
||||
const llm = yield* TestLLM.Test
|
||||
const gate = yield* llm.gate()
|
||||
|
||||
// This must return while the child's model is still blocked.
|
||||
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
|
||||
yield* gate.started
|
||||
const children = (yield* sessions.list({ parentID: parent.id })).data
|
||||
expect(children).toHaveLength(1)
|
||||
const child = children[0]
|
||||
if (!child) return yield* Effect.die("Expected a child session")
|
||||
expect(child).toMatchObject({ agent: fixture.agent, model: { id: fixture.model }, title: "Review code" })
|
||||
expect(yield* sessions.get(parent.id)).toMatchObject({ agent: "build", model: parentModel })
|
||||
expect(yield* sessions.context(parent.id)).toEqual([])
|
||||
expect(yield* llm.requests()).toHaveLength(1)
|
||||
expect((yield* sessions.context(child.id)).filter((message) => message.type === "user")).toMatchObject([
|
||||
{ text: "You are a subagent spawned by another session.\nReview changes: ready" },
|
||||
])
|
||||
yield* gate.release
|
||||
yield* llm.wait(2)
|
||||
yield* sessions.wait(parent.id)
|
||||
const notices = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
||||
expect(notices).toMatchObject([{ metadata: { source: "subagent", childID: child.id, state: "completed" } }])
|
||||
expect(notices[0]?.text).toContain("Review complete")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("subagent: false overrides subagent mode and the legacy alias", () =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* project({ subagent: false, subtask: true, agent: "reviewer" }, "json")
|
||||
const sessions = yield* Session.Service
|
||||
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
|
||||
yield* sessions.wait(parent.id)
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data).toEqual([])
|
||||
expect(yield* sessions.get(parent.id)).toMatchObject({
|
||||
agent: "reviewer",
|
||||
model: { id: "child" },
|
||||
})
|
||||
expect((yield* sessions.context(parent.id)).filter((message) => message.type === "user")).toMatchObject([
|
||||
{ text: "Review changes: ready" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function project(
|
||||
command: { agent?: string; model?: string; subagent?: boolean; subtask?: boolean },
|
||||
format: "json" | "markdown",
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const definition = { description: "Review code", template: "Review $ARGUMENTS: !`printf ready`", ...command }
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
agents: { reviewer: { mode: "subagent", model: "test/child" } },
|
||||
...(format === "markdown" ? {} : { commands: { review: definition } }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (format === "markdown")
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, ".opencode/commands/review.md"),
|
||||
[
|
||||
"---",
|
||||
"description: Review code",
|
||||
...Object.entries(command).map(([key, value]) => `${key}: ${value}`),
|
||||
"---",
|
||||
definition.template,
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const sessions = yield* Session.Service
|
||||
return yield* sessions.create({
|
||||
location: { directory: AbsolutePath.make(tmp.path) },
|
||||
title: "Parent session",
|
||||
agent: Agent.ID.make("build"),
|
||||
model: parentModel,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import { describe, expect } from "bun:test"
|
||||
import { DateTime, Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { advance, drain } from "../lib/clock"
|
||||
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
@@ -27,6 +30,8 @@ import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes
|
||||
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { offlineModels } from "../fixture/models"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
@@ -41,12 +46,25 @@ const shellLayer = Layer.succeed(
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
|
||||
LayerNode.group([
|
||||
Command.node,
|
||||
Bus.node,
|
||||
FSUtil.node,
|
||||
AppProcess.node,
|
||||
Location.node,
|
||||
ShellSelect.node,
|
||||
Session.node,
|
||||
Job.node,
|
||||
Agent.node,
|
||||
]),
|
||||
[
|
||||
Mcp.node.replace(emptyMcpLayer),
|
||||
Config.node.replace(emptyConfigLayer),
|
||||
Location.node.replace(testLocationLayer),
|
||||
ShellSelect.node.replace(shellLayer),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -828,30 +828,32 @@ describe("Config", () => {
|
||||
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates v1 command configuration", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
for (const subtask of [true, false]) {
|
||||
test(`migrates v1 command configuration with subtask: ${subtask}`, () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask,
|
||||
},
|
||||
},
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subagent: subtask,
|
||||
},
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subtask: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test("normalizes renamed permission actions when migrating v1 permissions", () => {
|
||||
expect(
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
@@ -37,7 +38,7 @@ import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))),
|
||||
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node, Job.node]))),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface WebSocketServerFixture {
|
||||
}
|
||||
|
||||
export interface WebSocketServerOptions {
|
||||
readonly http?: (request: Request) => Response | Promise<Response>
|
||||
readonly upgrade?: (request: Request) => boolean
|
||||
readonly open?: (socket: Bun.ServerWebSocket<ConnectionData>) => void
|
||||
readonly message?: (socket: Bun.ServerWebSocket<ConnectionData>, message: string | Buffer) => void
|
||||
@@ -35,7 +34,6 @@ export const makeWebSocketServer = (options: WebSocketServerOptions = {}) =>
|
||||
port: 0,
|
||||
fetch(request, server) {
|
||||
state.headers.push(Object.fromEntries(request.headers.entries()))
|
||||
if (request.headers.get("upgrade") !== "websocket" && options.http) return options.http(request)
|
||||
if ((options.upgrade?.(request) ?? true) && server.upgrade(request, { data: { id: connection++ } }))
|
||||
return undefined
|
||||
return new Response("WebSocket upgrade required", {
|
||||
|
||||
@@ -3,7 +3,24 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { DateTime, Duration, Effect, Equal, Hash, Layer, LayerMap, Option, RcMap, Schema, Stream } from "effect"
|
||||
import {
|
||||
Cause,
|
||||
DateTime,
|
||||
Deferred,
|
||||
Duration,
|
||||
Effect,
|
||||
Equal,
|
||||
Exit,
|
||||
Fiber,
|
||||
Hash,
|
||||
Layer,
|
||||
LayerMap,
|
||||
Option,
|
||||
RcMap,
|
||||
Schema,
|
||||
Scope,
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
@@ -13,6 +30,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -22,7 +40,7 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -61,6 +79,177 @@ const itWithActivity = testEffect(
|
||||
)
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
for (const failure of ["file", "permissions", "config reference"] as const) {
|
||||
for (const invalidate of [false, true]) {
|
||||
// The file-path fixture boots on Windows rather than failing during
|
||||
// discovery. The config-reference case covers repair on every OS.
|
||||
// Windows does not enforce POSIX directory modes, and root bypasses them.
|
||||
const test =
|
||||
(failure === "file" && process.platform === "win32") ||
|
||||
(failure === "permissions" && (process.platform === "win32" || process.getuid?.() === 0))
|
||||
? it.live.skip
|
||||
: it.live
|
||||
test(`retries after repairing ${failure}${invalidate ? " with explicit invalidation" : ""}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const directory = path.join(dir.path, "repaired")
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const load = Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
|
||||
|
||||
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
|
||||
if (failure === "permissions") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { mode: 0o000 }))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(directory, 0o755)))
|
||||
}
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
|
||||
)
|
||||
}
|
||||
const first = yield* Effect.exit(load)
|
||||
expect(Exit.isFailure(first)).toBe(true)
|
||||
if (failure === "config reference" && Exit.isFailure(first)) {
|
||||
expect(Cause.squash(first.cause)).toMatchObject({
|
||||
name: "ConfigInvalidError",
|
||||
data: { message: expect.stringContaining('bad file reference: "{file:username.txt}"') },
|
||||
})
|
||||
}
|
||||
if (!invalidate) expect(yield* locations.contextEffectOption(ref).pipe(Effect.scoped)).toEqual(Option.none())
|
||||
|
||||
if (failure === "file") {
|
||||
yield* Effect.promise(() => fs.rm(directory))
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
}
|
||||
if (failure === "permissions") yield* Effect.promise(() => fs.chmod(directory, 0o755))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
|
||||
}
|
||||
expect((yield* Effect.promise(() => fs.stat(directory))).isDirectory()).toBe(true)
|
||||
if (invalidate) yield* locations.invalidate(ref)
|
||||
const repaired = yield* Effect.exit(load)
|
||||
expect(Exit.isSuccess(repaired)).toBe(true)
|
||||
// A successful graph remains cached after its last borrower releases.
|
||||
expect(yield* load).toBe(yield* repaired)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
for (const failure of ["file", "missing", "config reference"] as const) {
|
||||
// A file-path Location boots on Windows; use the missing config reference there.
|
||||
const test = failure === "file" && process.platform === "win32" ? it.live.skip : it.live
|
||||
test(`keeps the repaired graph after concurrent ${failure} failures release`, () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const directory = path.join(dir.path, "concurrent")
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
|
||||
)
|
||||
}
|
||||
|
||||
const scopes = yield* Effect.forEach(Array.from({ length: 8 }), () =>
|
||||
Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void)),
|
||||
)
|
||||
const failures = yield* Effect.forEach(
|
||||
scopes,
|
||||
(scope) => locations.contextEffect(ref).pipe(Scope.provide(scope), Effect.exit),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(failures.every(Exit.isFailure)).toBe(true)
|
||||
if (failure === "file") yield* Effect.promise(() => fs.rm(directory))
|
||||
if (failure !== "config reference") yield* Effect.promise(() => fs.mkdir(directory))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
|
||||
}
|
||||
const repaired = yield* locations.contextEffect(ref)
|
||||
|
||||
yield* Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void))
|
||||
expect(yield* locations.contextEffect(ref)).toBe(repaired)
|
||||
expect(Option.getOrThrow(yield* locations.contextEffectOption(ref))).toBe(repaired)
|
||||
}))
|
||||
}
|
||||
|
||||
for (const disposition of ["retry", "invalidate", "interrupt"] as const) {
|
||||
testEffect(Layer.empty).live(
|
||||
disposition === "invalidate"
|
||||
? "does not let an invalidated boot failure evict its replacement"
|
||||
: disposition === "interrupt"
|
||||
? "finishes a failed boot after its acquisition scopes close"
|
||||
: "shares a failed boot across acquisition APIs and retries",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const builds = { started: 0 }
|
||||
const finalized: number[] = []
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
LocationWatcher.node.replace(
|
||||
LocationWatcher.node.mapLayer((layer) =>
|
||||
layer.pipe(
|
||||
Layer.tap(() =>
|
||||
Effect.gen(function* () {
|
||||
const build = ++builds.started
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => finalized.push(build)))
|
||||
if (build !== 1) return
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
yield* Effect.die("first boot failed")
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
])
|
||||
yield* Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const first = yield* locations.contextEffect(ref).pipe(Effect.scoped, Effect.exit, Effect.forkScoped)
|
||||
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
|
||||
yield* Deferred.await(entered)
|
||||
const scope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void))
|
||||
const second = yield* Location.Service.pipe(
|
||||
Effect.provide(locations.get(ref)),
|
||||
Effect.exit,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const third = yield* locations
|
||||
.contextEffectOption(ref)
|
||||
.pipe(Scope.provide(scope), Effect.exit, Effect.forkScoped({ startImmediately: true }))
|
||||
if (disposition === "invalidate") yield* locations.invalidate(ref)
|
||||
if (disposition === "interrupt") {
|
||||
yield* Fiber.interrupt(first)
|
||||
yield* Fiber.interrupt(second)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
}
|
||||
const replacement = disposition === "invalidate" ? yield* locations.contextEffect(ref) : undefined
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
if (disposition !== "interrupt") {
|
||||
expect(Exit.isFailure(yield* Fiber.join(first))).toBe(true)
|
||||
expect(Exit.isFailure(yield* Fiber.join(second))).toBe(true)
|
||||
}
|
||||
expect(Exit.isFailure(yield* Fiber.join(third).pipe(Effect.timeout("2 seconds")))).toBe(true)
|
||||
expect(finalized).toEqual([1])
|
||||
expect(builds.started).toBe(disposition === "invalidate" ? 2 : 1)
|
||||
const recovered = yield* locations.contextEffect(ref)
|
||||
if (replacement) expect(recovered).toBe(replacement)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* locations.contextEffect(ref)).toBe(recovered)
|
||||
expect(builds.started).toBe(2)
|
||||
}).pipe(Effect.provide(layer))
|
||||
expect(finalized).toEqual([1, 2])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
itWithActivity.effect("does not refresh lifetime from inferred Session routing", () =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
@@ -150,8 +339,8 @@ describe("LocationServiceMap", () => {
|
||||
expect(location.directory).toBe(directory)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
|
||||
|
||||
// A local ref with the same missing directory keeps the existing
|
||||
// behavior: dropped as soon as it goes idle so a retry can rebuild it.
|
||||
// A local ref with the same missing directory is dropped after its
|
||||
// boot failure so a retry can rebuild it.
|
||||
yield* Location.Service.pipe(Effect.provide(locations.get(localRef)), Effect.scoped, Effect.exit)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Fiber, Schema } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
it.live("removes a failed plugin's hooks and RPC handlers without affecting healthy plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
const cleaned = yield* Deferred.make<void>()
|
||||
const invoked: string[] = []
|
||||
let fail = false
|
||||
yield* plugins.activate(
|
||||
["broken", "healthy"].map((id) => ({
|
||||
id,
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
// Finalizers run in reverse order, so this signals after registration cleanup.
|
||||
if (id === "broken") yield* Effect.addFinalizer(() => Deferred.succeed(cleaned, undefined))
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: id, execute: () => Effect.void })
|
||||
if (id === "broken" && fail) throw new Error("transform failed")
|
||||
})
|
||||
yield* ctx.shell.hook("create.before", () => Effect.sync(() => void invoked.push(id)))
|
||||
yield* ctx.rpc
|
||||
.register(
|
||||
Rpc.define({ id, methods: { check: { input: Schema.Struct({}), output: Schema.String } }, events: {} }),
|
||||
{ check: () => Effect.succeed(id) },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (id === "broken") yield* Effect.addFinalizer(() => plugins.awaitActivation)
|
||||
}),
|
||||
})),
|
||||
)
|
||||
const trigger = hooks.trigger("shell", "create.before", {
|
||||
command: "echo fixture",
|
||||
cwd: ".",
|
||||
timeout: 1_000,
|
||||
shell: "sh",
|
||||
env: {},
|
||||
})
|
||||
yield* trigger
|
||||
expect(invoked).toEqual(["broken", "healthy"])
|
||||
expect(yield* rpc.call("broken", "check", {})).toBe("broken")
|
||||
expect(yield* rpc.call("healthy", "check", {})).toBe("healthy")
|
||||
expect((yield* commands.list()).map((command) => command.name)).toEqual(["broken", "healthy"])
|
||||
|
||||
fail = true
|
||||
yield* commands.reload()
|
||||
yield* Deferred.await(cleaned).pipe(Effect.timeout("1 second"))
|
||||
invoked.length = 0
|
||||
yield* trigger
|
||||
expect(invoked).toEqual(["healthy"])
|
||||
expect(yield* rpc.call("broken", "check", {}).pipe(Effect.flip)).toMatchObject({ type: "rpc.unavailable" })
|
||||
expect(yield* rpc.call("healthy", "check", {})).toBe("healthy")
|
||||
expect((yield* commands.list()).map((command) => command.name)).toEqual(["healthy"])
|
||||
expect((yield* plugins.list()).map((plugin) => plugin.state.status)).toEqual(["failed", "active"])
|
||||
}),
|
||||
)
|
||||
|
||||
Array.of("reload", "teardown").forEach((boundary) =>
|
||||
it.live(`does not join queued failed-plugin cleanup during ${boundary}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const escape = yield* Deferred.make<void>()
|
||||
const cleaned = yield* Deferred.make<void>()
|
||||
let fail = false
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "changing",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "changing", description: "old", execute: () => Effect.void })
|
||||
if (fail) throw new Error("changing failed")
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(entered, undefined).pipe(
|
||||
Effect.andThen(plugins.awaitActivation.pipe(Effect.raceFirst(Deferred.await(escape)))),
|
||||
Effect.andThen(Deferred.succeed(cleaned, undefined)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "trigger",
|
||||
revision: "1",
|
||||
effect: () =>
|
||||
Effect.addFinalizer(() =>
|
||||
boundary === "teardown"
|
||||
? commands.reload().pipe(Effect.andThen(commands.list()), Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
},
|
||||
])
|
||||
fail = true
|
||||
const activation = yield* (boundary === "reload" ? commands.reload() : Effect.void).pipe(
|
||||
Effect.andThen(
|
||||
plugins.activate([
|
||||
{
|
||||
id: "changing",
|
||||
revision: "2",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) =>
|
||||
editor.add({ name: "changing", description: "new", execute: () => Effect.void }),
|
||||
)
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
]),
|
||||
),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(entered)
|
||||
const result = yield* Fiber.join(activation).pipe(Effect.timeout("250 millis"), Effect.exit)
|
||||
// Allow teardown to finish even if activation incorrectly joins the old finalizer.
|
||||
yield* Deferred.succeed(escape, undefined)
|
||||
yield* Fiber.join(activation)
|
||||
yield* plugins.awaitActivation
|
||||
yield* Deferred.await(cleaned)
|
||||
expect(Exit.isSuccess(result)).toBe(true)
|
||||
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
|
||||
expect(yield* commands.get("changing")).toMatchObject({ description: "new" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not restore a disabled generation with a pending failure when its replacement fails setup", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const loads: string[] = []
|
||||
let fail = false
|
||||
const generation = (revision: string): Plugin.Generation => ({
|
||||
id: "replacement",
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
loads.push(revision)
|
||||
if (revision === "2") yield* Effect.die("setup failed")
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "replacement", execute: () => Effect.void })
|
||||
if (fail) throw new Error("replay failed")
|
||||
})
|
||||
}),
|
||||
})
|
||||
yield* plugins.activate([generation("1")])
|
||||
fail = true
|
||||
yield* commands.reload()
|
||||
yield* plugins.activate([generation("2")])
|
||||
yield* plugins.awaitActivation
|
||||
expect(loads).toEqual(["1", "2"])
|
||||
expect((yield* plugins.list())[0]?.state.status).toBe("failed")
|
||||
expect(yield* commands.get("replacement")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
Array.of("pending", "reported").forEach((status) =>
|
||||
it.live(`preserves ${status} failures when an earlier plugin changes`, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const loads: string[] = []
|
||||
let fail = true
|
||||
const generation = (id: string, revision: string): Plugin.Generation => ({
|
||||
id,
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
loads.push(`${id}@${revision}`)
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: id, execute: () => Effect.void })
|
||||
if (id === "broken" && fail) throw new Error("broken failed")
|
||||
})
|
||||
}),
|
||||
})
|
||||
const broken = generation("broken", "1")
|
||||
const later = generation("later", "1")
|
||||
yield* plugins.activate([generation("earlier", "1"), broken, later])
|
||||
if (status === "reported") yield* plugins.awaitActivation
|
||||
fail = false
|
||||
yield* plugins.activate([generation("earlier", "2"), broken, later])
|
||||
yield* plugins.awaitActivation
|
||||
expect(loads).toEqual(["earlier@1", "broken@1", "later@1", "earlier@2", "later@1"])
|
||||
const failed = (yield* plugins.list())[1]?.state
|
||||
expect(failed).toMatchObject({ status: "failed", ref: expect.stringMatching(/^err_/) })
|
||||
expect((yield* commands.list()).map((entry) => entry.name)).toEqual(["earlier", "later"])
|
||||
|
||||
// Reordering and removing other plugins must preserve the same failure too.
|
||||
yield* plugins.activate([later, broken, generation("earlier", "2")])
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list())[1]?.state).toEqual(failed)
|
||||
expect((yield* commands.list()).map((entry) => entry.name)).toEqual(["later", "earlier"])
|
||||
yield* plugins.activate([broken, later])
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list())[0]?.state).toEqual(failed)
|
||||
expect(loads.filter((entry) => entry === "broken@1")).toHaveLength(1)
|
||||
|
||||
yield* plugins.activate([generation("broken", "2"), later])
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
|
||||
expect(yield* commands.get("broken")).toBeDefined()
|
||||
expect(loads.filter((entry) => entry === "broken@2")).toHaveLength(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("continues failure reporting and cleanup after a plugin update observer fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const cleaned: string[] = []
|
||||
let fail = false
|
||||
let failPublication = true
|
||||
yield* plugins.activate(
|
||||
["first", "second"].map((id) => ({
|
||||
id,
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void cleaned.push(id)))
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: id, execute: () => Effect.void })
|
||||
if (fail) throw new Error(`${id} failed`)
|
||||
})
|
||||
}),
|
||||
})),
|
||||
)
|
||||
yield* Effect.acquireRelease(
|
||||
bus.listen((event) => {
|
||||
if (event.type !== Plugin.Event.Updated.type || !failPublication) return Effect.void
|
||||
failPublication = false
|
||||
return Effect.die("observer failed")
|
||||
}),
|
||||
(unsubscribe) => unsubscribe,
|
||||
)
|
||||
fail = true
|
||||
yield* commands.reload()
|
||||
const ready = yield* plugins.awaitActivation.pipe(Effect.timeout("250 millis"), Effect.exit)
|
||||
expect(Exit.isSuccess(ready)).toBe(true)
|
||||
expect((yield* plugins.list()).map((entry) => entry.state.status)).toEqual(["failed", "failed"])
|
||||
expect(cleaned.toSorted()).toEqual(["first", "second"])
|
||||
expect(yield* commands.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("settles readiness before shutdown joins a disabled plugin's finalizers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const escape = yield* Deferred.make<void>()
|
||||
let fail = false
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "closing",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "closing", execute: () => Effect.void })
|
||||
if (fail) throw new Error("closing failed")
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(entered, undefined).pipe(
|
||||
Effect.andThen(plugins.awaitActivation.pipe(Effect.raceFirst(Deferred.await(escape)))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
])
|
||||
fail = true
|
||||
const shutdown = yield* commands
|
||||
.reload()
|
||||
.pipe(Effect.andThen(plugins.close(Exit.void)), Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(entered)
|
||||
const result = yield* Fiber.join(shutdown).pipe(Effect.timeout("250 millis"), Effect.exit)
|
||||
// Release the fixture even on the old implementation, rather than hanging test teardown.
|
||||
yield* Deferred.succeed(escape, undefined)
|
||||
yield* Fiber.join(shutdown)
|
||||
expect(Exit.isSuccess(result)).toBe(true)
|
||||
const release = yield* plugins.hold()
|
||||
yield* plugins.awaitActivation
|
||||
let restarted = false
|
||||
yield* plugins.activate([
|
||||
{ id: "after-close", revision: "1", effect: () => Effect.sync(() => void (restarted = true)) },
|
||||
])
|
||||
yield* release
|
||||
expect(restarted).toBe(false)
|
||||
}),
|
||||
)
|
||||
@@ -1,12 +1,14 @@
|
||||
import { expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Clock, Effect } from "effect"
|
||||
import { Clock, Deferred, Effect } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { fromPromise } from "@opencode-ai/plugin/promise/adapter"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
@@ -154,6 +156,57 @@ it.effect("reports a failed plugin without blocking a healthy plugin", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables a plugin whose transform fails after setup without publishing its partial edits", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const integrations = yield* Integration.Service
|
||||
let cleaned = false
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "before",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) => editor.add({ name: "shared", description: "original", execute: () => Effect.void }))
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
{
|
||||
id: "broken",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void (cleaned = true)))
|
||||
yield* ctx.integration.transform((editor) => editor.update("broken", (entry) => (entry.name = "Broken")))
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "shared", description: "partial", execute: () => Effect.void })
|
||||
throw new Error("replay failed")
|
||||
})
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "after",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) => editor.add({ name: "healthy", execute: () => Effect.void }))
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
])
|
||||
|
||||
yield* plugins.awaitActivation
|
||||
expect(cleaned).toBe(true)
|
||||
expect((yield* plugins.list()).find((plugin) => plugin.id === "broken")?.state).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining("command.transform failed"),
|
||||
ref: expect.stringMatching(/^err_/),
|
||||
})
|
||||
expect(yield* commands.get("shared")).toMatchObject({ description: "original" })
|
||||
expect(yield* commands.get("healthy")).toBeDefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("broken"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the suffix after a failed plugin alive across identical activations", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -196,6 +249,204 @@ it.effect("keeps the suffix after a failed plugin alive across identical activat
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("attributes replay failure to the broken plugin rather than a later plugin reading the registry", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "broken-plugin",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform(() => {
|
||||
throw new Error("plugin failed")
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
{
|
||||
id: "reader",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.command.list().pipe(Effect.orDie)
|
||||
yield* ctx.command.transform((editor) => editor.add({ name: "reader", execute: () => Effect.void }))
|
||||
}),
|
||||
},
|
||||
])
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list()).map((entry) => `${entry.id}:${entry.state.status}`)).toEqual([
|
||||
"broken-plugin:failed",
|
||||
"reader:active",
|
||||
])
|
||||
expect(yield* commands.get("reader")).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables plugins after runtime reload failures without retrying an unchanged generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const reported: string[] = []
|
||||
yield* Effect.acquireRelease(
|
||||
bus.listen((event) =>
|
||||
event.type === Plugin.Event.Updated.type
|
||||
? plugins.list().pipe(
|
||||
Effect.tap((items) => Effect.sync(() => void reported.push(items[0]?.state.status ?? "empty"))),
|
||||
Effect.asVoid,
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
(unsubscribe) => unsubscribe,
|
||||
)
|
||||
let fail = false
|
||||
let loads = 0
|
||||
let reload = () => Effect.void
|
||||
const generation = (revision: string): Plugin.Generation => ({
|
||||
id: "runtime",
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
loads++
|
||||
reload = ctx.command.reload
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "runtime", execute: () => Effect.void })
|
||||
if (fail) throw new Error("private failure detail")
|
||||
})
|
||||
}),
|
||||
})
|
||||
const discovery = {
|
||||
source: { type: "local" as const, path: "/missing" },
|
||||
state: { status: "failed" as const, error: "Import failed" },
|
||||
features: { server: true },
|
||||
} satisfies Plugin.Info
|
||||
yield* plugins.activate([generation("1")], [discovery])
|
||||
expect(yield* commands.get("runtime")).toBeDefined()
|
||||
fail = true
|
||||
yield* reload()
|
||||
yield* plugins.awaitActivation
|
||||
const inventory = yield* plugins.list()
|
||||
expect(inventory[0]?.state).toMatchObject({ status: "failed", ref: expect.stringMatching(/^err_/) })
|
||||
expect(JSON.stringify(inventory[0]?.state)).not.toContain("private failure detail")
|
||||
expect(reported.at(-1)).toBe("failed")
|
||||
expect(inventory[1]).toEqual(discovery)
|
||||
expect(yield* commands.get("runtime")).toBeUndefined()
|
||||
|
||||
fail = false
|
||||
yield* plugins.activate([generation("1")], [discovery])
|
||||
expect(loads).toBe(1)
|
||||
expect(yield* commands.get("runtime")).toBeUndefined()
|
||||
yield* plugins.activate([generation("2")], [discovery])
|
||||
expect(loads).toBe(2)
|
||||
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
|
||||
expect(yield* commands.get("runtime")).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables plugins after replay failures discovered during setup without restoring the old generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const cleaned = yield* Deferred.make<void>()
|
||||
const loads: string[] = []
|
||||
const generation = (revision: string): Plugin.Generation => ({
|
||||
id: "replacement",
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
loads.push(revision)
|
||||
if (revision === "2")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
plugins.awaitActivation.pipe(Effect.andThen(Deferred.succeed(cleaned, undefined))),
|
||||
)
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "replacement", execute: () => Effect.void })
|
||||
if (revision === "2") throw new Error("replay failure")
|
||||
})
|
||||
if (revision === "2") {
|
||||
yield* ctx.command.list().pipe(Effect.orDie)
|
||||
yield* Effect.die("subsequent setup failure")
|
||||
}
|
||||
}),
|
||||
})
|
||||
yield* plugins.activate([generation("1")])
|
||||
yield* plugins.activate([generation("2")])
|
||||
yield* plugins.awaitActivation
|
||||
yield* Deferred.await(cleaned)
|
||||
expect(loads).toEqual(["1", "2"])
|
||||
expect((yield* plugins.list())[0]?.state).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining("command.transform"),
|
||||
})
|
||||
expect(yield* commands.get("replacement")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not let asynchronous plugin cleanup block recovered registry readiness", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const cleaned = yield* Deferred.make<void>()
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "async-cleanup",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.command.transform(() => {
|
||||
throw new Error("failed")
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
plugins.awaitActivation.pipe(Effect.andThen(Deferred.succeed(cleaned, undefined))),
|
||||
)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "healthy",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) => editor.add({ name: "healthy", execute: () => Effect.void }))
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
])
|
||||
yield* plugins.awaitActivation
|
||||
yield* Deferred.await(cleaned)
|
||||
expect(yield* commands.get("healthy")).toBeDefined()
|
||||
expect((yield* plugins.list())[0]?.state.status).toBe("failed")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retains Promise plugin groups for later registrations and ignores a disabled group's attempts", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
let register = async () => {}
|
||||
const definition = fromPromise({
|
||||
id: "promise-plugin",
|
||||
setup(ctx) {
|
||||
register = async () => {
|
||||
await ctx.command.transform((editor) => {
|
||||
editor.add({ name: "late", execute: async () => {} })
|
||||
throw new Error("late Promise failure")
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
yield* plugins.activate([{ ...definition, revision: "1" }])
|
||||
yield* Effect.promise(register)
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list())[0]?.state).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining("command.transform"),
|
||||
})
|
||||
expect(yield* commands.get("late")).toBeUndefined()
|
||||
yield* Effect.promise(register)
|
||||
expect(yield* commands.get("late")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reloading a plugin replaces its command implementation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect } from "bun:test"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Layer, Logger, Schema } from "effect"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(Rpc.node, [
|
||||
Location.node.replace(Layer.succeed(Location.Service, location({ directory: AbsolutePath.make("/rpc-project") }))),
|
||||
]),
|
||||
)
|
||||
const Broken = Rpc.define({
|
||||
id: "broken",
|
||||
methods: {
|
||||
dies: { input: Schema.Undefined, output: Schema.String },
|
||||
throws: { input: Schema.Undefined, output: Schema.String },
|
||||
raw: { input: Schema.Undefined, output: Schema.String },
|
||||
undeclared: { input: Schema.Undefined, output: Schema.String },
|
||||
invalidError: {
|
||||
input: Schema.Undefined,
|
||||
output: Schema.String,
|
||||
errors: { known: Schema.Struct({ count: Schema.Int }) },
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
for (const method of ["dies", "throws", "raw", "undeclared", "invalidError"] as const) {
|
||||
it.effect(`recovers from ${method} through the typed rpc.internal failure`, () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
yield* rpc.register(Broken, {
|
||||
dies: () => Effect.die(new Error("handler defect")),
|
||||
throws: () => {
|
||||
throw new Error("handler threw")
|
||||
},
|
||||
// Raw Promise rejections reach this boundary as failed Effects.
|
||||
// @ts-expect-error intentionally exercise an undeclared failure
|
||||
raw: () => Effect.fail(new Error("raw failure")),
|
||||
// @ts-expect-error intentionally exercise an undeclared error name
|
||||
undeclared: (_input, context) => Effect.fail(context.error("unknown", "Unknown")),
|
||||
invalidError: (_input, context) => Effect.fail(context.error("known", "Invalid count", { count: 1.5 })),
|
||||
})
|
||||
const logged: unknown[] = []
|
||||
const result = yield* rpc
|
||||
.client(Broken)
|
||||
[method]()
|
||||
.pipe(
|
||||
Effect.catchIf(
|
||||
(error) => "type" in error && error.type === "rpc.internal",
|
||||
(error) => Effect.succeed(error),
|
||||
),
|
||||
Effect.provideService(Logger.CurrentLoggers, new Set([Logger.make((entry) => logged.push(entry.message))])),
|
||||
)
|
||||
expect(result).toEqual({ type: "rpc.internal", message: "RPC call failed" })
|
||||
expect(logged).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLM, LLMRequest, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { configure } from "@opencode-ai/ai/providers/openai"
|
||||
import { SessionModelTransport } from "../src/session/model-transport"
|
||||
import { WebSocketConstructor } from "../src/effect/websocket-constructor"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Deferred, Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { makeWebSocketServer } from "./lib/websocket-server"
|
||||
|
||||
const runtime = Layer.mergeAll(
|
||||
LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer), Layer.provide(FetchHttpClient.layer)),
|
||||
SessionModelTransport.layer.pipe(Layer.provide(WebSocketConstructor.layer)),
|
||||
)
|
||||
const it = testEffect(runtime)
|
||||
const sessionID = Session.ID.make("ses_checkpoint_transport")
|
||||
const checkpoint = { type: "compaction", encrypted_content: "opaque" }
|
||||
type Mode =
|
||||
| "success"
|
||||
| "missing"
|
||||
| "multiple"
|
||||
| "incomplete"
|
||||
| "disconnect"
|
||||
| "cancel"
|
||||
| "rejected"
|
||||
| "ambiguous"
|
||||
| "fallback"
|
||||
|
||||
const fixture = (mode: Mode) =>
|
||||
Effect.gen(function* () {
|
||||
const seen = yield* Deferred.make<void>()
|
||||
const requests: Array<Record<string, unknown>> = []
|
||||
const http: Array<Record<string, unknown>> = []
|
||||
let disconnect: (() => void) | undefined
|
||||
const server = yield* makeWebSocketServer({
|
||||
upgrade: () => mode !== "fallback" || disconnect === undefined,
|
||||
async http(request) {
|
||||
http.push(Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Unknown))(await request.json()))
|
||||
return new Response(
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_http",
|
||||
output: [checkpoint],
|
||||
usage: { input_tokens: 70, output_tokens: 7 },
|
||||
},
|
||||
})}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream", "x-response": "http" } },
|
||||
)
|
||||
},
|
||||
open(socket) {
|
||||
disconnect = () => socket.close()
|
||||
},
|
||||
message(socket, message) {
|
||||
const body = JSON.parse(message.toString())
|
||||
requests.push(body)
|
||||
const id = `resp_${requests.length}`
|
||||
const send = (event: unknown) => socket.send(JSON.stringify(event))
|
||||
if (body.input.at(-1)?.type !== "compaction_trigger") {
|
||||
const item = {
|
||||
type: "message",
|
||||
id: `msg_${requests.length}`,
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello" }],
|
||||
}
|
||||
send({ type: "response.created", response: { id } })
|
||||
send({ type: "response.output_item.added", output_index: 0, item })
|
||||
send({ type: "response.output_text.delta", item_id: item.id, delta: "Hello" })
|
||||
send({ type: "response.output_item.done", output_index: 0, item })
|
||||
send({ type: "response.completed", response: { id, output: [item] } })
|
||||
return
|
||||
}
|
||||
if (mode === "ambiguous") {
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
if (mode === "rejected") {
|
||||
send({ type: "error", error: { code: "previous_response_not_found", message: "missing baseline" } })
|
||||
return
|
||||
}
|
||||
send({ type: "response.created", response: { id } })
|
||||
if (mode !== "missing") send({ type: "response.output_item.done", output_index: 0, item: checkpoint })
|
||||
Deferred.doneUnsafe(seen, Effect.void)
|
||||
if (mode === "cancel") return
|
||||
if (mode === "disconnect") {
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
send({
|
||||
type: mode === "incomplete" ? "response.incomplete" : "response.completed",
|
||||
response: {
|
||||
id,
|
||||
output:
|
||||
mode === "missing"
|
||||
? []
|
||||
: mode === "multiple"
|
||||
? [checkpoint, { ...checkpoint, encrypted_content: "second" }]
|
||||
: [checkpoint],
|
||||
usage: { input_tokens: 100, output_tokens: 10 },
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
return {
|
||||
requests,
|
||||
headers: server.state.headers,
|
||||
http,
|
||||
seen,
|
||||
opens: () => server.state.opens,
|
||||
disconnect: () => disconnect?.(),
|
||||
request: LLM.request({
|
||||
model: configure({
|
||||
apiKey: "fixture",
|
||||
baseURL: server.url.replace(/^ws/, "http").replace(/responses$/, ""),
|
||||
headers: { "chatgpt-account-id": "account", "x-codex-beta-features": "remote_compaction_v2" },
|
||||
providerOptions: { parallelToolCalls: true },
|
||||
}).responses("fixture"),
|
||||
prompt: "First",
|
||||
promptCacheKey: "session-key",
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
it.live("trigger reuses the append baseline and clears it before the next generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture("success")
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const webSocket = transport.bind(sessionID)
|
||||
const first = yield* LLMClient.generate(server.request, { webSocket })
|
||||
const compacted = yield* LLMClient.compact(
|
||||
LLMRequest.update(server.request, {
|
||||
messages: [...server.request.messages, first.message],
|
||||
}),
|
||||
{ mechanism: "trigger", webSocket },
|
||||
)
|
||||
expect(server.requests[1]).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "compaction_trigger" }],
|
||||
})
|
||||
expect(compacted.responseID).toBe("resp_2")
|
||||
expect(compacted.usage).toMatchObject({ inputTokens: 100, outputTokens: 10 })
|
||||
expect(compacted.checkpoint.encrypted).toBe("opaque")
|
||||
const messages = [Message.assistant(compacted.checkpoint), Message.user("Continue")]
|
||||
yield* LLMClient.generate(LLMRequest.update(server.request, { messages }), { webSocket })
|
||||
expect(server.requests[2]?.previous_response_id).toBeUndefined()
|
||||
expect(server.requests[2]?.input).toMatchObject([
|
||||
{ type: "compaction", encrypted_content: "opaque" },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
|
||||
])
|
||||
expect(server.requests[1]?.stream).toBeUndefined()
|
||||
expect(server.requests[1]?.store).toBe(false)
|
||||
expect(server.headers[0]).toMatchObject({
|
||||
authorization: "Bearer fixture",
|
||||
"chatgpt-account-id": "account",
|
||||
"x-codex-beta-features": "remote_compaction_v2",
|
||||
})
|
||||
expect(server.opens()).toBe(1)
|
||||
expect(server.http).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const mode of ["missing", "multiple", "incomplete", "disconnect", "rejected", "ambiguous"] as const) {
|
||||
it.live(`trigger ${mode} does not retry, fall back, or commit a continuation checkpoint`, () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture(mode)
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const webSocket = transport.bind(sessionID)
|
||||
const first = yield* LLMClient.generate(server.request, { webSocket })
|
||||
const input = LLMRequest.update(server.request, { messages: [...server.request.messages, first.message] })
|
||||
const error = yield* LLMClient.compact(input, { mechanism: "trigger", webSocket }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe(
|
||||
["disconnect", "ambiguous", "rejected"].includes(mode) ? "Transport" : "InvalidProviderOutput",
|
||||
)
|
||||
if (mode === "rejected") expect(error.reason).toMatchObject({ delivery: "rejected", recovery: "retry-full" })
|
||||
if (mode === "ambiguous") expect(error.reason).toMatchObject({ delivery: "ambiguous" })
|
||||
expect(server.requests).toHaveLength(2)
|
||||
expect(server.http).toHaveLength(0)
|
||||
yield* LLMClient.generate(input, { webSocket })
|
||||
expect(server.requests[2]?.previous_response_id).toBeUndefined()
|
||||
expect(server.requests[2]?.input).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("cancelled trigger closes its connection without returning the partial checkpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture("cancel")
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const webSocket = transport.bind(sessionID)
|
||||
const running = yield* LLMClient.compact(server.request, { mechanism: "trigger", webSocket }).pipe(
|
||||
Effect.forkChild(),
|
||||
)
|
||||
yield* Deferred.await(server.seen)
|
||||
yield* Fiber.interrupt(running)
|
||||
yield* LLMClient.generate(server.request, { webSocket })
|
||||
expect(server.requests).toHaveLength(2)
|
||||
expect(server.opens()).toBe(2)
|
||||
expect(server.requests[1]?.previous_response_id).toBeUndefined()
|
||||
expect(server.http).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("trigger recovers over SSE with complete input after the old socket closes and reconnect is rejected", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture("fallback")
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const webSocket = transport.bind(sessionID)
|
||||
const first = yield* LLMClient.generate(server.request, { webSocket })
|
||||
server.disconnect()
|
||||
// Let the real close event reach the connector before the next send.
|
||||
yield* Effect.sleep("20 millis")
|
||||
const result = yield* LLMClient.compact(
|
||||
LLMRequest.update(server.request, {
|
||||
messages: [...server.request.messages, first.message],
|
||||
}),
|
||||
{ mechanism: "trigger", webSocket },
|
||||
)
|
||||
expect(result.responseID).toBe("resp_http")
|
||||
expect(result.usage).toMatchObject({ inputTokens: 70, outputTokens: 7 })
|
||||
expect(server.http).toHaveLength(1)
|
||||
expect(server.http[0]?.previous_response_id).toBeUndefined()
|
||||
expect(server.http[0]?.stream).toBe(true)
|
||||
expect(server.http[0]?.input).toHaveLength(3)
|
||||
expect(server.requests).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
@@ -1243,6 +1243,7 @@ describe("SessionTransfer", () => {
|
||||
const runningCompactionID = SessionMessage.ID.create()
|
||||
const completedCompactionID = SessionMessage.ID.create()
|
||||
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
|
||||
const providerState = { responseId: "summary-response" }
|
||||
|
||||
yield* transfer.import({
|
||||
data: {
|
||||
@@ -1297,6 +1298,8 @@ describe("SessionTransfer", () => {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
model,
|
||||
providerState,
|
||||
summary: "summary",
|
||||
recent: "recent",
|
||||
time: { created: DateTime.makeUnsafe(9) },
|
||||
@@ -1313,6 +1316,11 @@ describe("SessionTransfer", () => {
|
||||
completedCompactionID,
|
||||
])
|
||||
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
|
||||
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, providerState })
|
||||
expect((yield* transfer.export({ sessionID, sanitize: true })).messages.at(-1)).toMatchObject({
|
||||
model,
|
||||
providerState: { redacted: `compaction-provider-state:${completedCompactionID}` },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { mkdir, rm } from "fs/promises"
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { chmod, mkdir, readdir, rm } from "fs/promises"
|
||||
import { Cause, Context, Deferred, Duration, Effect, Exit, Fiber, Layer, LayerMap, Queue } from "effect"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
@@ -14,10 +17,14 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionMove } from "@opencode-ai/core/session/move"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -74,8 +81,372 @@ const itWithUnavailableDestination = testEffect(
|
||||
],
|
||||
),
|
||||
)
|
||||
const itWithExecution = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Session.node, SessionExecution.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
// Windows does not enforce POSIX mode bits, and root can traverse mode-000 directories.
|
||||
const itWithPermissions =
|
||||
process.platform === "win32" || process.getuid?.() === 0 ? itWithExecution.live.skip : itWithExecution.live
|
||||
const itWithInstance = testEffect(Layer.empty)
|
||||
const sourceProbe = (options: { execution?: boolean } = {}) =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const source = AbsolutePath.make(path.join(tmp.path, "source"))
|
||||
const destination = AbsolutePath.make(tmp.path)
|
||||
yield* Effect.promise(() => mkdir(source))
|
||||
const probes = yield* Queue.unbounded<Deferred.Deferred<void>>()
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([Session.node, Bus.node, SessionExecution.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
...(options.execution ? [] : [SessionExecution.node.replace(SessionExecution.noopLayer)]),
|
||||
offlineModels,
|
||||
Instance.node.replace(
|
||||
makeGlobalNode({
|
||||
service: Instance.Service,
|
||||
deps: [LocationServiceMap.node],
|
||||
layer: Layer.effect(
|
||||
Instance.Service,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
return Instance.Service.of({
|
||||
provide: (session) => (effect) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.location.directory === source) {
|
||||
const release = yield* Deferred.make<void>()
|
||||
yield* Queue.offer(probes, release)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
return yield* effect.pipe(Effect.provide(locations.get(session.location)))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
)
|
||||
return {
|
||||
source,
|
||||
destination,
|
||||
probes,
|
||||
session: Context.get(context, Session.Service),
|
||||
bus: Context.get(context, Bus.Service),
|
||||
execution: Context.get(context, SessionExecution.Service),
|
||||
}
|
||||
})
|
||||
|
||||
describe("Session.move", () => {
|
||||
itWithInstance.live("moves through the bound service without depending on the Session facade", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = AbsolutePath.make(tmp.path)
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([SessionMove.node, SessionStore.node, Bus.node, Project.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
const moves = Context.get(context, SessionMove.Service)
|
||||
const store = Context.get(context, SessionStore.Service)
|
||||
const bus = Context.get(context, Bus.Service)
|
||||
const projects = Context.get(context, Project.Service)
|
||||
const sessionID = Session.ID.create()
|
||||
|
||||
// Call outside the construction context: the service owns all of its dependencies.
|
||||
expect(yield* moves.move({ sessionID, directory }).pipe(Effect.flip)).toEqual(
|
||||
new Session.NotFoundError({ sessionID }),
|
||||
)
|
||||
yield* projects.resolve(directory)
|
||||
yield* bus.publish(SessionEvent.Created, {
|
||||
sessionID,
|
||||
slug: "move-service",
|
||||
version: "test",
|
||||
projectID: Project.ID.global,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "missing")) }),
|
||||
})
|
||||
yield* moves.move({ sessionID, directory })
|
||||
expect(yield* store.get(sessionID)).toMatchObject({
|
||||
location: { directory },
|
||||
projectID: Project.ID.global,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
itWithInstance.live("delegates to the provided move service", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = AbsolutePath.make(tmp.path)
|
||||
const rejection = new Session.DestinationUnavailableError({ directory })
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(Session.node, [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
SessionMove.node.replace(Layer.succeed(SessionMove.Service, { move: () => Effect.fail(rejection) })),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
const sessions = Context.get(context, Session.Service)
|
||||
const created = yield* sessions.create({ location: Location.Ref.make({ directory }) })
|
||||
|
||||
expect(yield* sessions.move({ sessionID: created.id, directory }).pipe(Effect.flip)).toBe(rejection)
|
||||
expect(yield* sessions.inbox(created.id)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const broken of [false, true]) {
|
||||
itWithExecution.live(
|
||||
`moves an idle session from ${broken ? "broken" : "healthy"} source configuration`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const source = AbsolutePath.make(path.join(tmp.path, "source"))
|
||||
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
|
||||
if (broken)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(source, "opencode.json"), JSON.stringify({ instructions: ["{file:./missing.txt}"] })),
|
||||
)
|
||||
const session = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
yield* execution.awaitIdle(created.id)
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||
expect(yield* session.inbox(created.id)).toEqual([])
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
itWithPermissions(
|
||||
"recovers an idle session from an unreadable source directory",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const source = AbsolutePath.make(path.join(tmp.path, "source"))
|
||||
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
|
||||
const session = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => chmod(source, 0o755)))
|
||||
yield* Effect.promise(() => chmod(source, 0o000))
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
readdir(source).then(
|
||||
() => false,
|
||||
() => true,
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
yield* execution.awaitIdle(created.id)
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||
expect(yield* session.inbox(created.id)).toEqual([])
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
for (const broken of [false, true]) {
|
||||
itWithInstance.live(
|
||||
`uses the ${broken ? "broken" : "healthy discovery-disabled"} selected instance rather than the default Location`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const source = Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "source")) })
|
||||
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source.directory), mkdir(destination)]))
|
||||
const config = JSON.stringify({ instructions: ["{file:./missing.txt}"] })
|
||||
if (!broken) yield* Effect.promise(() => Bun.write(path.join(source.directory, "opencode.json"), config))
|
||||
const selectedID = Session.ID.create()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
Instance.node.replace(
|
||||
makeGlobalNode({
|
||||
service: Instance.Service,
|
||||
deps: [LocationServiceMap.node],
|
||||
layer: Layer.effect(
|
||||
Instance.Service,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const privateInstances = yield* LayerMap.make(
|
||||
() =>
|
||||
Instance.layer(source, {
|
||||
discovery: false,
|
||||
replacements: [
|
||||
...bindings,
|
||||
...(broken
|
||||
? [
|
||||
Config.node.replace(
|
||||
Config.configured({ project: false, global: false, content: config }),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
const selector = Instance.Service.of({
|
||||
provide: (session) =>
|
||||
Effect.provide(
|
||||
session.id === selectedID && session.location.directory === source.directory
|
||||
? privateInstances.get(session.id)
|
||||
: locations.get(session.location),
|
||||
),
|
||||
})
|
||||
const bindings: LayerNode.Replacements = [
|
||||
...replacements,
|
||||
Instance.node.replace(Layer.succeed(Instance.Service, selector)),
|
||||
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, locations)),
|
||||
]
|
||||
return selector
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
]
|
||||
const context = yield* Layer.build(AppNodeBuilder.build(Session.node, replacements))
|
||||
const session = Context.get(context, Session.Service)
|
||||
const created = yield* session.create({ id: selectedID, location: source })
|
||||
const pending = yield* session.synthetic({
|
||||
sessionID: created.id,
|
||||
text: "Keep pending",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination, delivery: "queue" })
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(broken ? destination : source.directory)
|
||||
const inbox = yield* session.inbox(created.id)
|
||||
expect(inbox[0]).toEqual(pending)
|
||||
if (broken) expect(inbox).toEqual([pending])
|
||||
if (!broken) expect(inbox.slice(1)).toMatchObject([{ type: "move", delivery: "queue" }])
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
for (const interrupt of ["source", "caller"] as const) {
|
||||
itWithInstance.live(`does not recover or enqueue a move when the ${interrupt} interrupts the probe`, () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sourceProbe()
|
||||
const created = yield* fixture.session.create({ location: Location.Ref.make({ directory: fixture.source }) })
|
||||
const pending = yield* fixture.session.synthetic({ sessionID: created.id, text: "Keep pending", resume: false })
|
||||
const moving = yield* fixture.session
|
||||
.move({ sessionID: created.id, directory: fixture.destination })
|
||||
.pipe(Effect.forkScoped)
|
||||
const release = yield* Queue.take(fixture.probes)
|
||||
|
||||
if (interrupt === "source") yield* Deferred.interrupt(release)
|
||||
if (interrupt === "caller") yield* Fiber.interrupt(moving)
|
||||
const exit = yield* Fiber.await(moving)
|
||||
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
expect((yield* fixture.session.get(created.id)).location.directory).toBe(fixture.source)
|
||||
expect(yield* fixture.session.inbox(created.id)).toEqual([pending])
|
||||
}).pipe(Effect.timeout("5 seconds")),
|
||||
)
|
||||
}
|
||||
|
||||
itWithInstance.live("does not recover if execution starts during the source probe", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sourceProbe({ execution: true })
|
||||
const created = yield* fixture.session.create({ location: Location.Ref.make({ directory: fixture.source }) })
|
||||
const moving = yield* fixture.session
|
||||
.move({ sessionID: created.id, directory: fixture.destination })
|
||||
.pipe(Effect.forkScoped)
|
||||
const release = yield* Queue.take(fixture.probes)
|
||||
|
||||
yield* fixture.execution.wake(created.id)
|
||||
// The real coordinator now owns execution; its separate instance acquisition stays suspended.
|
||||
yield* Queue.take(fixture.probes)
|
||||
expect(yield* fixture.execution.isActive(created.id)).toBe(true)
|
||||
yield* Deferred.die(release, new Error("source unavailable"))
|
||||
yield* Fiber.join(moving)
|
||||
|
||||
expect((yield* fixture.session.get(created.id)).location.directory).toBe(fixture.source)
|
||||
expect(yield* fixture.session.inbox(created.id)).toMatchObject([{ type: "move", delivery: "steer" }])
|
||||
expect(yield* fixture.execution.isActive(created.id)).toBe(true)
|
||||
yield* fixture.execution.interrupt(created.id)
|
||||
yield* fixture.execution.awaitIdle(created.id)
|
||||
}).pipe(Effect.timeout("5 seconds")),
|
||||
)
|
||||
|
||||
itWithInstance.live(
|
||||
"recovers a missing source without initializing its instance and retains destination workspace identity",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sourceProbe()
|
||||
const created = yield* fixture.session.create({
|
||||
location: Location.Ref.make({ directory: fixture.source, workspaceID: Workspace.ID.create() }),
|
||||
})
|
||||
yield* Effect.promise(() => rm(fixture.source, { recursive: true }))
|
||||
const workspaceID = Workspace.ID.create()
|
||||
|
||||
yield* fixture.session.move({ sessionID: created.id, directory: fixture.destination, workspaceID })
|
||||
|
||||
expect((yield* fixture.session.get(created.id)).location).toEqual(
|
||||
Location.Ref.make({ directory: fixture.destination, workspaceID }),
|
||||
)
|
||||
expect(yield* fixture.session.inbox(created.id)).toEqual([])
|
||||
expect(yield* Queue.size(fixture.probes)).toBe(0)
|
||||
}).pipe(Effect.timeout("5 seconds")),
|
||||
)
|
||||
|
||||
for (const changed of ["directory", "workspace"] as const) {
|
||||
itWithInstance.live(`allows inbox cancellation during a source probe and rejects stale ${changed} recovery`, () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sourceProbe()
|
||||
const created = yield* fixture.session.create({ location: Location.Ref.make({ directory: fixture.source }) })
|
||||
const pending = yield* fixture.session.synthetic({
|
||||
sessionID: created.id,
|
||||
text: "Cancel pending",
|
||||
resume: false,
|
||||
})
|
||||
const moving = yield* fixture.session
|
||||
.move({ sessionID: created.id, directory: fixture.destination })
|
||||
.pipe(Effect.exit, Effect.forkScoped)
|
||||
const release = yield* Queue.take(fixture.probes)
|
||||
|
||||
yield* fixture.session.cancelInbox({ sessionID: created.id, inboxID: pending.id }).pipe(
|
||||
Effect.timeout("2 seconds"),
|
||||
Effect.onError(() => Deferred.interrupt(release)),
|
||||
)
|
||||
expect(yield* fixture.session.inbox(created.id)).toEqual([])
|
||||
expect(moving.pollUnsafe()).toBeUndefined()
|
||||
const location = Location.Ref.make({
|
||||
directory: changed === "directory" ? fixture.destination : fixture.source,
|
||||
workspaceID: changed === "workspace" ? Workspace.ID.create() : undefined,
|
||||
})
|
||||
yield* fixture.bus.publish(SessionEvent.Moved, {
|
||||
sessionID: created.id,
|
||||
location,
|
||||
projectID: created.projectID,
|
||||
})
|
||||
yield* Deferred.die(release, new Error("source unavailable"))
|
||||
expect(Exit.isSuccess(yield* Fiber.join(moving))).toBe(true)
|
||||
|
||||
expect((yield* fixture.session.get(created.id)).location).toEqual(location)
|
||||
expect(yield* fixture.session.inbox(created.id)).toMatchObject([
|
||||
{ type: "move", payload: { location: { directory: fixture.destination } } },
|
||||
])
|
||||
}).pipe(Effect.timeout("5 seconds")),
|
||||
)
|
||||
}
|
||||
|
||||
itWithUnavailableDestination.effect("rejects an unavailable destination before admitting the move", () =>
|
||||
tmpdirScoped().pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
@@ -111,15 +482,23 @@ describe("Session.move", () => {
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(AbsolutePath.make(source))
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(1)
|
||||
const pending = yield* session.synthetic({
|
||||
sessionID: created.id,
|
||||
text: "Keep queued",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
yield* session.move({ sessionID: created.id, directory: destination, delivery: "queue" })
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(3)
|
||||
|
||||
yield* Effect.promise(() => rm(source, { recursive: true }))
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||
expect(yield* session.inbox(created.id)).toEqual([])
|
||||
expect(yield* session.inbox(created.id)).toEqual([pending])
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(1)
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(2)
|
||||
|
||||
yield* Effect.promise(() => mkdir(path.join(tmp.path, "other")))
|
||||
const steered = yield* session.create({
|
||||
|
||||
@@ -2141,7 +2141,13 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-prefix", "echo", { text: "x".repeat(4_000) }),
|
||||
TestLLM.textWithUsage("Earlier answer", "prefix-answer", 185_000),
|
||||
TestLLM.text("## Objective\n- Checkpoint summary", "prefix-summary"),
|
||||
TestLLM.complete(
|
||||
{
|
||||
reason: { normalized: "stop" },
|
||||
providerMetadata: { [s.currentModel.provider]: { responseId: "summary" } },
|
||||
},
|
||||
LLMEvent.textDelta({ id: "prefix-summary", text: "## Objective\n- Checkpoint summary" }),
|
||||
),
|
||||
)
|
||||
yield* s.runPrompt("Review these changes")
|
||||
if (reason === "manual") {
|
||||
@@ -2177,6 +2183,10 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.")
|
||||
expect(requestAgents[2]).toBe(Agent.ID.make("compaction"))
|
||||
expect(s.executions).toEqual(["x".repeat(4_000)])
|
||||
expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({
|
||||
model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant },
|
||||
providerState: { responseId: "summary" },
|
||||
})
|
||||
|
||||
// Compare wire content without the cache breakpoints that move to the new final message.
|
||||
const before = yield* compileRequest(LLMRequest.update(normal, { cache: "none" }))
|
||||
@@ -2209,8 +2219,14 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
: TestLLM.text("Let me search the codebase. I will fill in ## Objective later.", "invalid-summary")
|
||||
yield* s.llm.push(
|
||||
invalid,
|
||||
summary ? TestLLM.text("### Active\n- Recovered summary", "summary-recovered") : invalid,
|
||||
invalid.map((event) =>
|
||||
LLMEvent.is.stepFinish(event)
|
||||
? { ...event, providerMetadata: { openai: { responseId: "rejected-summary-state" } } }
|
||||
: event,
|
||||
),
|
||||
summary
|
||||
? [LLMEvent.textDelta({ id: "summary-recovered", text: "### Active\n- Recovered summary" })]
|
||||
: invalid,
|
||||
)
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
@@ -2220,6 +2236,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("did not fill in the required summary template")
|
||||
expect(s.requests.every((request) => request.toolChoice === undefined)).toBe(true)
|
||||
expect(s.executions).toEqual([])
|
||||
expect(JSON.stringify(yield* s.messages)).not.toContain("rejected-summary-state")
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject(
|
||||
summary
|
||||
? { status: "completed", summary: "### Active\n- Recovered summary" }
|
||||
|
||||
@@ -148,7 +148,9 @@ for (const fixture of [
|
||||
.all()
|
||||
const types = events.map((event) => event.type)
|
||||
const terminal = fixture.finish === "stop" ? "session.step.ended.1" : "session.step.failed.1"
|
||||
expect(types.filter((type) => type === "session.step.streamed.1")).toHaveLength(1)
|
||||
expect(types.filter((type) => type === terminal)).toHaveLength(1)
|
||||
expect(types.indexOf("session.step.streamed.1")).toBeLessThan(types.indexOf(terminal))
|
||||
expect(
|
||||
types.indexOf(fixture.toolChoice === "none" ? "session.tool.failed.2" : "session.tool.success.2"),
|
||||
).toBeLessThan(types.indexOf(terminal))
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "../../src/shell/parse.js"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const contexts = [
|
||||
(source: string) => source,
|
||||
(source: string) => `( ${source} )`,
|
||||
(source: string) => `{ ${source}; }`,
|
||||
(source: string) => `if true; then ${source}; fi`,
|
||||
(source: string) => `outer() { ${source}; }; outer`,
|
||||
]
|
||||
|
||||
const bodies = [
|
||||
"for value in one two; do scan_probe; done",
|
||||
"while true; do scan_probe; break; done",
|
||||
"until false; do scan_probe; break; done",
|
||||
"case value in value) scan_probe;; *) scan_other;; esac",
|
||||
]
|
||||
|
||||
describe("compound function acceptance", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
for (const head of ["probe()", "function probe", "function probe()", "probe-name()"])
|
||||
for (const body of bodies)
|
||||
for (const context of contexts) {
|
||||
const name = head.includes("probe-name") ? "probe-name" : "probe"
|
||||
const source = context(`${head} ${body}; ${name}`)
|
||||
test(`${shell}: ${source}`, async () => {
|
||||
// Braces preserve the function's behavior, but avoid Tree-sitter's recovery artifacts.
|
||||
const legacy = await Effect.runPromise(
|
||||
ShellParse.scan(context(`${head} { ${body}; }; ${name}`), shell, "/workspace"),
|
||||
)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test.each(bodies)("keeps compound function bodies inside command substitutions: %s", (body) => {
|
||||
const source = `printf '%s' "$( probe() ${body}; probe )"`
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands[0]?.resource).toBe(source)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
expect(result.commands.at(-1)?.words).toEqual(["probe"])
|
||||
})
|
||||
})
|
||||
|
||||
const values = [
|
||||
"one two",
|
||||
"'two words' one",
|
||||
"'cd' '/outside'",
|
||||
"'do' 'done'",
|
||||
"'(literal)' '$(scan_ignored)'",
|
||||
"one\\\ntwo",
|
||||
"$(printf one)",
|
||||
'"$(printf one)"',
|
||||
"<(printf one)",
|
||||
"",
|
||||
]
|
||||
const loops = values.flatMap((value) =>
|
||||
[
|
||||
`for value (${value}) scan_probe "$value"`,
|
||||
`for value (${value}) { scan_probe "$value"; }`,
|
||||
...(value
|
||||
? [
|
||||
`for value (${value}) do scan_probe "$value"; done`,
|
||||
`for value (${value}); do scan_probe "$value"; done`,
|
||||
`for value (${value})\ndo scan_probe "$value"; done`,
|
||||
`for value (${value}) # ignored\ndo scan_probe "$value"; done`,
|
||||
`for value (${value}) \\\ndo scan_probe "$value"; done`,
|
||||
]
|
||||
: []),
|
||||
].map((source) => ({ source, equivalent: `for value in ${value}; do scan_probe "$value"; done` })),
|
||||
)
|
||||
|
||||
describe("Zsh parenthesized loop acceptance", () => {
|
||||
for (const fixture of loops)
|
||||
for (const context of contexts) {
|
||||
const source = context(fixture.source)
|
||||
test(source, async () => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(context(fixture.equivalent), "zsh", "/workspace"))
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
}
|
||||
|
||||
test.each([
|
||||
"for x (one two) for y (a b) scan_probe",
|
||||
"for x (one two) scan_probe && scan_other",
|
||||
"for x (one two) scan_probe | scan_other",
|
||||
"printf '%s' \"$(for x (one two) scan_probe)\"",
|
||||
"for x (one two) { for y (a b); do scan_probe; done; }",
|
||||
"for x (one two) [[ $(scan_probe) == ok ]]",
|
||||
"for x (one two) (( 1 + $(scan_probe) ))",
|
||||
])("retains commands in nested shorthand loops: %s", (source) => {
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
if (source.includes("scan_other"))
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_other")
|
||||
})
|
||||
})
|
||||
|
||||
describe("real-shell compound syntax", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const executable = Bun.which(shell)
|
||||
test
|
||||
.skipIf(!executable)
|
||||
.each([
|
||||
...bodies.map((body) => `probe() ${body}; probe`),
|
||||
...bodies.map((body) => `printf '%s' "$(probe() ${body}; probe)"`),
|
||||
...(shell === "zsh" ? loops.map((fixture) => fixture.source) : []),
|
||||
])(`${shell}: %s`, (source) => {
|
||||
if (!executable) throw new Error(`${shell} is unavailable`)
|
||||
const execution = Bun.spawnSync(
|
||||
[
|
||||
executable,
|
||||
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
|
||||
"-c",
|
||||
`scan_probe() { printf 'executed\\n' >&2; }; ${source}; wait`,
|
||||
],
|
||||
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" }, timeout: 2_000 },
|
||||
)
|
||||
expect(execution.exitCode).toBe(0)
|
||||
expect(execution.stderr.toString()).toEqual(
|
||||
source.includes("value ()") ? "" : expect.stringContaining("executed\n"),
|
||||
)
|
||||
expect(ShellScan.scan(source).kind).toBe("scanned")
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "../../src/shell/parse.js"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const conditions = ["[[ -n <(scan_probe) ]]", "[[ -n >(scan_probe) ]]"]
|
||||
const contexts = [
|
||||
(source: string) => source,
|
||||
(source: string) => `( ${source} )`,
|
||||
(source: string) => `{ ${source}; }`,
|
||||
(source: string) => `if ${source}; then printf visible; fi`,
|
||||
(source: string) => `check() { ${source}; }; check`,
|
||||
(source: string) => `printf '%s' "$( ${source}; printf visible)"`,
|
||||
]
|
||||
|
||||
const functions = ["probe", "probe-name", "probe.name", "probe:name"].flatMap((name) =>
|
||||
[`${name}()`, `function ${name}`, `function ${name}()`].flatMap((head) =>
|
||||
[
|
||||
"{ scan_probe; }",
|
||||
"(scan_probe)",
|
||||
"if true; then scan_probe; fi",
|
||||
"[[ $(scan_probe) == ok ]]",
|
||||
"(( 1 + $(scan_probe) ))",
|
||||
].map((body) => `${head} ${body}; ${name}`),
|
||||
),
|
||||
)
|
||||
|
||||
describe("legacy-accepted shell syntax regressions", () => {
|
||||
test.each(["() { scan_probe; }", "probe() { scan_probe; }; probe"])(
|
||||
"preserves deeply indented function definitions: %s",
|
||||
async (source) => {
|
||||
const command = `( ${" ".repeat(32_000)}${source} )`
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, "zsh", "/workspace"))
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(command, "zsh", "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
test.each(conditions.flatMap((source) => contexts.map((context) => context(source))))(
|
||||
"retains conditional process substitutions and permission resources: %s",
|
||||
async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, "bash", "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, "bash", "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
test.each(
|
||||
["probe()", "probe \\\n()", "function \\\nprobe()", "function probe \\\n()"].flatMap((head) =>
|
||||
[" \\\n", " \\\n # ignored ) }\n", "# ignored \\\n"].flatMap((gap) =>
|
||||
contexts.map((context) => context(`${head}${gap}{ scan_probe; }; probe`)),
|
||||
),
|
||||
),
|
||||
)(`${shell} preserves line continuations at function boundaries: %s`, async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
|
||||
test.each(
|
||||
["probe()", "function probe", "function probe()"].flatMap((head) =>
|
||||
[" # ignored ) }\n", "\n# ignored ) }\n\n", " # first\n# second\n"].flatMap((gap) =>
|
||||
contexts.map((context) => context(`${head}${gap}{ scan_probe; }; probe`)),
|
||||
),
|
||||
),
|
||||
)(`${shell} preserves comments between a function head and its body: %s`, async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
|
||||
test.each(functions)(
|
||||
`${shell} preserves function resources, saved prefixes, and directories: %s`,
|
||||
async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
const executable = Bun.which(shell)
|
||||
test
|
||||
.skipIf(!executable)
|
||||
.each([
|
||||
"probe-name() { scan_probe; }; probe-name",
|
||||
"function probe.name { scan_probe; }; probe.name",
|
||||
"probe:name() if true; then scan_probe; fi; probe:name",
|
||||
"probe()# ignored ) }\n{ scan_probe; }; probe",
|
||||
"probe \\\n() \\\n{ scan_probe; }; probe",
|
||||
"function \\\nprobe() # ignored \\\n{ scan_probe; }; probe",
|
||||
...(shell === "bash" ? conditions : ["() { scan_probe; }"]),
|
||||
])(`${shell} really executes the extracted command: %s`, (source) => {
|
||||
if (!executable) throw new Error(`${shell} is unavailable`)
|
||||
const execution = Bun.spawnSync(
|
||||
[
|
||||
executable,
|
||||
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
|
||||
"-c",
|
||||
`scan_probe() { printf 'executed\\n' >&2; }; ${source}; wait`,
|
||||
],
|
||||
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
|
||||
)
|
||||
expect(execution.exitCode).toBe(0)
|
||||
expect(execution.stderr.toString()).toBe("executed\n")
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
})
|
||||
}
|
||||
|
||||
test.each([
|
||||
"() { scan_probe; }",
|
||||
"( () { scan_probe; } )",
|
||||
"{ () { scan_probe; }; }",
|
||||
"while() { scan_probe; break; }",
|
||||
"until() { scan_probe; break; }",
|
||||
])("preserves Zsh anonymous functions and parenthesized loop permissions: %s", async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, "zsh", "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
|
||||
// Tree-sitter recovers these valid Zsh forms with synthetic commands or truncated outer resources.
|
||||
// Pin both results rather than treating recovery artifacts as executable shell syntax.
|
||||
test.each([
|
||||
{
|
||||
source: "if () { scan_probe; }; then printf visible; fi",
|
||||
legacy: ["scan_probe", "then printf visible", "fi"],
|
||||
portable: ["scan_probe", "printf visible"],
|
||||
},
|
||||
{
|
||||
source: "check() { () { scan_probe; }; }; check",
|
||||
legacy: ["scan_probe", "}", "check"],
|
||||
portable: ["scan_probe", "check"],
|
||||
},
|
||||
{
|
||||
source: "printf '%s' \"$( () { scan_probe; }; printf visible)\"",
|
||||
legacy: ["printf '%s'", "scan_probe", "printf visible"],
|
||||
portable: ["printf '%s' \"$( () { scan_probe; }; printf visible)\"", "scan_probe", "printf visible"],
|
||||
},
|
||||
])("accepts anonymous-function compositions despite legacy recovery artifacts: $source", async (fixture) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(fixture.source, "zsh", "/workspace"))
|
||||
const portable = await Effect.runPromise(ShellParse.scanPortable(fixture.source, "zsh", "/workspace"))
|
||||
expect(legacy.commands.map((command) => command.resource)).toEqual([...fixture.legacy])
|
||||
expect(portable.commands.map((command) => command.resource)).toEqual([...fixture.portable])
|
||||
})
|
||||
|
||||
test.each([
|
||||
"[[ -n '<(scan_ignored)' ]]",
|
||||
'[[ -n "<(scan_ignored)" ]]',
|
||||
"[[ -n '>(scan_ignored)' ]]",
|
||||
'[[ -n ">(scan_ignored)" ]]',
|
||||
"[[ -n $'<(scan_ignored)' ]]",
|
||||
])("does not turn quoted process-substitution text into commands: %s", (source) => {
|
||||
expect(ShellScan.scan(source)).toEqual({ kind: "scanned", commands: [] })
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,30 @@ import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const pwsh = process.env.SHELL_SCAN_PWSH ?? Bun.which("pwsh")
|
||||
|
||||
// These ordinary forms must stay accepted, not disappear behind the oracle's opaque-result filter.
|
||||
const supported = [
|
||||
"Invoke-ProbeA; Invoke-ProbeB",
|
||||
"$result = Invoke-ProbeA; Invoke-ProbeB",
|
||||
"if (Invoke-ProbeA) { Invoke-ProbeB } else { Invoke-ProbeC }",
|
||||
"foreach ($item in (Invoke-ProbeA)) { Invoke-ProbeB }",
|
||||
"function Get-Probe { param($x); Invoke-ProbeB }; Invoke-ProbeA",
|
||||
"$x = @{ first = Invoke-ProbeA; second = @(Invoke-ProbeB; Invoke-ProbeC) }",
|
||||
'Invoke-ProbeA "$(Invoke-ProbeB "$(Invoke-ProbeC)")"',
|
||||
"Invoke-ProbeA | ForEach-Object { Invoke-ProbeB }",
|
||||
"Invoke-ProbeA @'\nliteral ; }\n'@; Invoke-ProbeB",
|
||||
'Invoke-ProbeA @"\n$(Invoke-ProbeB)\n"@; Invoke-ProbeC',
|
||||
"Invoke-ProbeA `\n argument; Invoke-ProbeB",
|
||||
"Invoke-ProbeA 2>&1; Invoke-ProbeB",
|
||||
"& 'Invoke-ProbeA' argument; Invoke-ProbeB",
|
||||
"Invoke-ProbeA --% literal; ignored\nInvoke-ProbeB",
|
||||
]
|
||||
|
||||
test.each(supported)("accepts supported PowerShell syntax without an opaque escape hatch: %s", (source) => {
|
||||
expect(ShellScan.scanPowerShell(source).kind).toBe("scanned")
|
||||
})
|
||||
|
||||
const fixtures = [
|
||||
...supported,
|
||||
...[
|
||||
"$result = Invoke-ProbeA; Invoke-ProbeB",
|
||||
"$result = (Invoke-ProbeA); Invoke-ProbeB",
|
||||
@@ -343,6 +366,10 @@ test.skipIf(!pwsh)(
|
||||
let executed = 0
|
||||
for (const result of results) {
|
||||
const scan = ShellScan.scanPowerShell(result.source)
|
||||
if (supported.includes(result.source)) {
|
||||
expect(result.errors, result.source).toEqual([])
|
||||
expect(scan.kind, result.source).toBe("scanned")
|
||||
}
|
||||
if (scan.kind === "opaque" || result.errors.length > 0) continue
|
||||
scanned++
|
||||
executed += result.executed.length
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
it.effect("detaches every registration of a failed group and refreshes every affected domain", () =>
|
||||
Effect.gen(function* () {
|
||||
const notices: string[] = []
|
||||
const failures: State.Failure[] = []
|
||||
let refresh = Effect.void
|
||||
let fail = false
|
||||
let calls = 0
|
||||
const grouped = State.group((failure, changed) => {
|
||||
failures.push(failure)
|
||||
refresh = changed
|
||||
})
|
||||
const first = State.create({
|
||||
name: "first",
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
editor: (value) => value,
|
||||
notify: () => Effect.sync(() => void notices.push("first")),
|
||||
})
|
||||
const second = State.create({
|
||||
name: "second",
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
editor: (value) => value,
|
||||
notify: () => Effect.sync(() => void notices.push("second")),
|
||||
})
|
||||
yield* first.transform((editor) => editor.values.push("healthy"))
|
||||
const registration = yield* first.transform((editor) => editor.values.push("grouped")).pipe(grouped)
|
||||
yield* first.transform((editor) => editor.values.push("also grouped")).pipe(grouped)
|
||||
yield* second
|
||||
.transform((editor) => {
|
||||
calls++
|
||||
editor.values.push("partial")
|
||||
if (fail) throw new Error("broken")
|
||||
})
|
||||
.pipe(grouped)
|
||||
const before = first.get()
|
||||
notices.length = 0
|
||||
fail = true
|
||||
yield* second.reload()
|
||||
|
||||
expect(first.get().values).toEqual(["healthy"])
|
||||
expect(second.get().values).toEqual([])
|
||||
expect(before.values).toEqual(["healthy", "grouped", "also grouped"])
|
||||
expect(failures).toHaveLength(1)
|
||||
expect(failures[0]?.state).toBe("second")
|
||||
expect(calls).toBe(2)
|
||||
|
||||
notices.length = 0
|
||||
// The group deduplicates its domain notifications without relying on an outer batch.
|
||||
yield* refresh
|
||||
expect(notices.toSorted()).toEqual(["first", "second"])
|
||||
yield* registration.dispose
|
||||
expect(notices).toHaveLength(2)
|
||||
yield* first.transform((editor) => editor.values.push("resurrected")).pipe(grouped)
|
||||
expect(first.get().values).toEqual(["healthy"])
|
||||
expect(failures).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("restarts an outer candidate when a nested read disables one of its contributors", () =>
|
||||
Effect.gen(function* () {
|
||||
let fail = false
|
||||
const grouped = State.group(() => {})
|
||||
const inner = State.create({ initial: () => ({ value: 0 }), editor: (value) => value })
|
||||
const outer = State.create({ initial: () => ({ value: 0 }), editor: (value) => value })
|
||||
yield* outer.transform((editor) => (editor.value += 10)).pipe(grouped)
|
||||
yield* inner
|
||||
.transform((editor) => {
|
||||
editor.value = 5
|
||||
if (fail) throw new Error("inner failed")
|
||||
})
|
||||
.pipe(grouped)
|
||||
yield* outer.transform((editor) => (editor.value += inner.get().value + 1))
|
||||
expect(outer.get().value).toBe(16)
|
||||
|
||||
fail = true
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* inner.reload()
|
||||
yield* outer.reload()
|
||||
expect(outer.get().value).toBe(1)
|
||||
expect(inner.get().value).toBe(0)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables multiple failing groups once each before publishing a complete fold", () =>
|
||||
Effect.gen(function* () {
|
||||
const reported: string[] = []
|
||||
const first = State.group(() => reported.push("first"))
|
||||
const second = State.group(() => reported.push("second"))
|
||||
const state = State.create({ initial: () => ({ values: [] as string[] }), editor: (value) => value })
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state
|
||||
.transform((editor) => {
|
||||
editor.values.push("first")
|
||||
throw "first failed"
|
||||
})
|
||||
.pipe(first)
|
||||
yield* state
|
||||
.transform((editor) => {
|
||||
editor.values.push("second")
|
||||
throw { message: "second failed" }
|
||||
})
|
||||
.pipe(second)
|
||||
yield* state.transform((editor) => editor.values.push("healthy"))
|
||||
}),
|
||||
)
|
||||
expect(state.get().values).toEqual(["healthy"])
|
||||
expect(reported).toEqual(["first", "second"])
|
||||
yield* state.reload()
|
||||
expect(reported).toEqual(["first", "second"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not disable a group for a notification failure after successful replay", () =>
|
||||
Effect.gen(function* () {
|
||||
let reported = 0
|
||||
let fail = true
|
||||
const grouped = State.group(() => reported++)
|
||||
const state = State.create({
|
||||
initial: () => ({ value: 0 }),
|
||||
editor: (value) => value,
|
||||
notify: () => (fail ? Effect.die("observer failed") : Effect.void),
|
||||
})
|
||||
yield* state.transform((editor) => editor.value++).pipe(grouped, Effect.exit)
|
||||
expect(reported).toBe(0)
|
||||
expect(state.get().value).toBe(1)
|
||||
fail = false
|
||||
yield* state.reload()
|
||||
expect(state.get().value).toBe(1)
|
||||
}),
|
||||
)
|
||||
@@ -526,6 +526,150 @@ describe("ShellTool scanner permissions", () => {
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellTool conditional process substitution", () => {
|
||||
const test = isWindows || !Bun.which("bash") ? permissionIt.live.skip : permissionIt.live
|
||||
for (const portable of [false, true]) {
|
||||
test(`${portable ? "native" : "legacy"}: a nested deny prevents the substitution from running`, () =>
|
||||
withScanner(
|
||||
portable,
|
||||
(registry, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(toolIdentity.agent, (agent) => {
|
||||
agent.permissions = [
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "printf *", effect: "deny" },
|
||||
]
|
||||
}),
|
||||
)
|
||||
const marker = path.join(directory.active, "marker")
|
||||
const result = yield* runPermissionCommand(
|
||||
registry,
|
||||
'[[ -n <(printf reached > marker) ]]; wait "$!"',
|
||||
marker,
|
||||
[],
|
||||
)
|
||||
expect(result.exit).toMatchObject({
|
||||
_tag: "Success",
|
||||
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
||||
}),
|
||||
"bash",
|
||||
))
|
||||
|
||||
for (const reply of ["reject", "once", "always"] as const) {
|
||||
test(`${portable ? "native" : "legacy"}: conditional substitutions respect ${reply}`, () =>
|
||||
withScanner(
|
||||
portable,
|
||||
(registry, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const location = yield* Location.Service
|
||||
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["wait *"] })
|
||||
const marker = path.join(directory.active, "marker")
|
||||
const command = '[[ -n <(printf reached > marker) ]]; wait "$!"'
|
||||
const result = yield* runPermissionCommand(registry, command, marker, [reply])
|
||||
expect(result.requests).toMatchObject([
|
||||
{ action: "shell", resources: ["printf reached > marker", 'wait "$!"'], save: ["printf *", "wait *"] },
|
||||
])
|
||||
if (reply === "reject") {
|
||||
expect(Exit.isFailure(result.exit)).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
||||
return
|
||||
}
|
||||
expect(result.exit).toMatchObject({
|
||||
_tag: "Success",
|
||||
value: { status: "completed", metadata: { exit: 0 } },
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("reached")
|
||||
yield* Effect.promise(() => fs.unlink(marker))
|
||||
const repeat = yield* runPermissionCommand(
|
||||
registry,
|
||||
command,
|
||||
marker,
|
||||
reply === "always" ? [] : ["reject"],
|
||||
)
|
||||
expect(repeat.requests).toHaveLength(reply === "always" ? 0 : 1)
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(reply === "always")
|
||||
}),
|
||||
"bash",
|
||||
))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellTool compound syntax approval compatibility", () => {
|
||||
for (const fixture of [
|
||||
{
|
||||
shell: "zsh",
|
||||
command: 'for value (a b) printf %s "$value"',
|
||||
equivalent: 'for value in a b; do printf %s "$value"; done',
|
||||
output: "ab",
|
||||
saved: ["printf *"],
|
||||
},
|
||||
{
|
||||
shell: "zsh",
|
||||
command: 'for value (a b) { printf %s "$value"; }',
|
||||
equivalent: 'for value in a b; do printf %s "$value"; done',
|
||||
output: "ab",
|
||||
saved: ["printf *"],
|
||||
},
|
||||
{
|
||||
shell: "zsh",
|
||||
command: 'for value ($(printf a)) do printf %s "$value"; done',
|
||||
equivalent: 'for value in $(printf a); do printf %s "$value"; done',
|
||||
output: "a",
|
||||
saved: ["printf *"],
|
||||
},
|
||||
{
|
||||
shell: "bash",
|
||||
command: 'probe() for value in a b; do printf %s "$value"; done; probe',
|
||||
equivalent: 'probe() { for value in a b; do printf %s "$value"; done; }; probe',
|
||||
output: "ab",
|
||||
saved: ["printf *", "probe *"],
|
||||
},
|
||||
{
|
||||
shell: "bash",
|
||||
command: 'printf %s "$(probe() case value in value) printf hello;; esac; probe)"',
|
||||
equivalent: 'printf %s "$(probe() { case value in value) printf hello;; esac; }; probe)"',
|
||||
output: "hello",
|
||||
saved: ["printf *", "probe *"],
|
||||
},
|
||||
]) {
|
||||
const test = isWindows || !Bun.which(fixture.shell) ? permissionIt.live.skip : permissionIt.live
|
||||
for (const portable of [false, true]) {
|
||||
test(`${fixture.shell} ${portable ? "native" : "legacy equivalent"}: ${fixture.command}`, () =>
|
||||
withScanner(
|
||||
portable,
|
||||
(registry, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const location = yield* Location.Service
|
||||
yield* saved.add({ projectID: location.project.id, action: "shell", resources: fixture.saved })
|
||||
const result = yield* runPermissionCommand(
|
||||
registry,
|
||||
portable ? fixture.command : fixture.equivalent,
|
||||
path.join(directory.active, "marker"),
|
||||
[],
|
||||
)
|
||||
expect(result.requests).toEqual([])
|
||||
expect(result.exit).toMatchObject({
|
||||
_tag: "Success",
|
||||
value: {
|
||||
status: "completed",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: fixture.output }, { type: "text" }],
|
||||
},
|
||||
})
|
||||
}),
|
||||
fixture.shell,
|
||||
))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellTool ordinary shell syntax", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const test = isWindows || !Bun.which(shell) ? permissionIt.live.skip : permissionIt.live
|
||||
|
||||
@@ -8890,7 +8890,8 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
],
|
||||
"description": "An absolute path or a path relative to the requested location. Defaults to the location directory."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
@@ -8941,7 +8942,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List direct children of one directory relative to the requested location.",
|
||||
"description": "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
|
||||
"summary": "List directory"
|
||||
}
|
||||
},
|
||||
@@ -13777,8 +13778,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"subtask": {
|
||||
"subagent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean",
|
||||
"description": "Deprecated alias for subagent."
|
||||
}
|
||||
},
|
||||
"required": ["template"],
|
||||
|
||||
@@ -9,5 +9,6 @@ export class Info extends Schema.Class<Info>("Config.Command")({
|
||||
description: Schema.String.pipe(optional),
|
||||
agent: Schema.String.pipe(optional),
|
||||
model: ConfigModel.Selection.pipe(optional),
|
||||
subtask: Schema.Boolean.pipe(optional),
|
||||
subagent: Schema.Boolean.pipe(optional),
|
||||
subtask: Schema.Boolean.annotate({ description: "Deprecated alias for subagent." }).pipe(optional),
|
||||
}) {}
|
||||
|
||||
@@ -585,6 +585,8 @@ export namespace Compaction {
|
||||
schema: {
|
||||
...Base,
|
||||
reason: Started.data.fields.reason,
|
||||
model: SessionMessage.CompactionCompleted.fields.model,
|
||||
providerState: SessionMessage.CompactionCompleted.fields.providerState,
|
||||
text: Schema.String,
|
||||
recent: Schema.String,
|
||||
},
|
||||
|
||||
@@ -250,6 +250,8 @@ export const CompactionCompleted = Schema.Struct({
|
||||
...CompactionBase,
|
||||
status: Schema.tag("completed"),
|
||||
reason: Schema.Literals(["auto", "manual"]),
|
||||
model: Model.Ref.pipe(optional),
|
||||
providerState: ProviderState.pipe(optional),
|
||||
summary: Schema.String,
|
||||
recent: Schema.String,
|
||||
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/effect"
|
||||
import type { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import type { Config, Scope } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { EmbeddedHost } from "../internal/host"
|
||||
import type { SdkInstances } from "../internal/instances"
|
||||
|
||||
@@ -35,9 +35,12 @@ export const create: <R = never>(
|
||||
R = never,
|
||||
>(options: CreateOptions<R> = {}, embed: EmbedOptions = {}) {
|
||||
const host = yield* Effect.acquireRelease(EmbeddedHost.create(options, embed), (host) => Effect.promise(host.close))
|
||||
const httpClient = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
|
||||
Effect.provide(
|
||||
FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, host.fetch)), Layer.fresh),
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
// FetchHttpClient reads Fetch at request time; callers must not replace this host's in-process transport.
|
||||
HttpClient.transformResponse(httpClient, Effect.provideService(FetchHttpClient.Fetch, host.fetch)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Context, Effect, Exit, Layer, Scope, Stream } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { AbsolutePath, Location, OpenCode, Session } from "../src/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
for (const entrypoint of ["create", "layer"] as const) {
|
||||
it.live(`${entrypoint} keeps requests and streams on its own transport despite an ambient Fetch`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const calls: string[] = []
|
||||
const ambient = Object.assign(
|
||||
(input: RequestInfo | URL) => {
|
||||
calls.push(input instanceof Request ? input.url : String(input))
|
||||
return Promise.reject(new Error("The caller's Fetch must not receive embedded SDK requests"))
|
||||
},
|
||||
{ preconnect: () => undefined },
|
||||
)
|
||||
const parent = yield* Effect.scope
|
||||
const scope = yield* Scope.fork(parent)
|
||||
const options: OpenCode.CreateOptions = {
|
||||
app: { version: "transport-test" },
|
||||
config: { directory: directory.path, project: false, content: "{}" },
|
||||
events: { persist: true },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
}
|
||||
const client = yield* (
|
||||
entrypoint === "create"
|
||||
? OpenCode.create(options).pipe(Scope.provide(scope))
|
||||
: Layer.buildWithScope(OpenCode.layer(options), scope).pipe(Effect.map(Context.get(OpenCode.Service)))
|
||||
).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
expect(yield* client.health.get()).toMatchObject({ healthy: true, version: "transport-test" })
|
||||
const session = yield* client.sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }),
|
||||
})
|
||||
expect((yield* client.sessions.get({ sessionID: session.id })).id).toBe(session.id)
|
||||
const events = yield* client.sessions.log({ sessionID: session.id }).pipe(Stream.runCollect)
|
||||
expect(events.some((event) => event.type === "session.created")).toBe(true)
|
||||
expect(yield* client.events.subscribe().pipe(Stream.take(1), Stream.runCollect)).toMatchObject([
|
||||
{ type: "server.connected" },
|
||||
])
|
||||
expect(yield* client.sessions.get({ sessionID: Session.ID.create() }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "SessionNotFoundError",
|
||||
})
|
||||
// Binding the SDK's transport must not change the caller's surrounding context.
|
||||
expect(yield* FetchHttpClient.Fetch).toBe(ambient)
|
||||
}).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient))
|
||||
expect(calls).toEqual([])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* client.health.get().pipe(Effect.provideService(FetchHttpClient.Fetch, ambient), Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
|
||||
return output === undefined ? {} : { output }
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error.type === "rpc.invalid_output"
|
||||
error.type === "rpc.invalid_output" || error.type === "rpc.internal"
|
||||
? new RpcInternalError({ type: error.type, message: error.message })
|
||||
: new RpcError({
|
||||
type: error.type,
|
||||
@@ -22,12 +22,10 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
|
||||
...(error.data === undefined ? {} : { data: error.data }),
|
||||
}),
|
||||
),
|
||||
Effect.catchDefect((error) =>
|
||||
Effect.fail(
|
||||
new RpcInternalError({
|
||||
type: "rpc.internal",
|
||||
message: error instanceof Error ? error.message : "RPC call failed",
|
||||
}),
|
||||
// Defects outside handler execution are still logged, never echoed to the client.
|
||||
Effect.catchDefect((defect) =>
|
||||
Effect.logError("rpc call failed", { rpc: params.rpcID, method: params.method, defect }).pipe(
|
||||
Effect.andThen(Effect.fail(new RpcInternalError({ type: "rpc.internal", message: "RPC call failed" }))),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect } from "bun:test"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Rpc } from "@opencode-ai/schema/rpc"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { createEmbeddedRoutes } from "../src/routes"
|
||||
|
||||
const Broken = Rpc.define({
|
||||
id: "broken",
|
||||
methods: {
|
||||
handler: { input: Schema.String, output: Schema.String },
|
||||
schema: {
|
||||
input: Schema.String.check(
|
||||
Schema.makeFilter(() => {
|
||||
throw new Error("private schema detail")
|
||||
}),
|
||||
),
|
||||
output: Schema.String,
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
for (const method of ["handler", "schema"] as const) {
|
||||
it.live(`returns HTTP 500 without exposing the ${method} defect`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const context = yield* Layer.build(
|
||||
createEmbeddedRoutes({
|
||||
database: { path: ":memory:" },
|
||||
models: { fetch: false },
|
||||
config: { directory: directory.path, project: false, content: "{}" },
|
||||
fs: { filewatcher: false },
|
||||
}).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
)
|
||||
const sdk = Context.get(context, SdkPlugins.Service)
|
||||
yield* sdk.register(
|
||||
define({
|
||||
id: "broken-rpc",
|
||||
effect: (ctx) =>
|
||||
ctx.rpc
|
||||
.register(Broken, {
|
||||
handler: () => Effect.die(new Error("private handler detail")),
|
||||
schema: Effect.succeed,
|
||||
})
|
||||
.pipe(Effect.asVoid, Effect.orDie),
|
||||
}),
|
||||
)
|
||||
const handler = Context.get(context, HttpRouter.HttpRouter)
|
||||
.asHttpEffect()
|
||||
.pipe(HttpEffect.toWebHandlerWith(context))
|
||||
const url = new URL(`/api/rpc/broken/${method}`, "http://opencode.local")
|
||||
url.searchParams.set("location[directory]", directory.path)
|
||||
const response = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ input: "hello" }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
_tag: "RpcInternalError",
|
||||
type: "rpc.internal",
|
||||
message: "RPC call failed",
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -52,7 +52,11 @@ function Plugins(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
|
||||
const plugins = usePlugin()
|
||||
const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length)
|
||||
const failed = createMemo(
|
||||
() =>
|
||||
plugins.list().filter((item) => item.status === "failed").length +
|
||||
plugins.server().filter((item) => item.state.status === "failed").length,
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={failed()}>
|
||||
|
||||
@@ -60,12 +60,16 @@ export default Plugin.define({
|
||||
context.data.on("session.execution.interrupted", (event) => ended(event.data.sessionID)),
|
||||
context.data.on("session.execution.failed", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (terminal.has(sessionID)) return
|
||||
if (errored.has(sessionID)) {
|
||||
ended(sessionID)
|
||||
return
|
||||
}
|
||||
errored.add(sessionID)
|
||||
notify(context, sessionID, event.data.error.message, "error")
|
||||
const route = context.ui.router.current()
|
||||
if (route.type === "session" && route.sessionID === sessionID)
|
||||
context.ui.toast.show({ title: "Session failed", message: event.data.error.message, variant: "error" })
|
||||
ended(sessionID)
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -79,7 +79,9 @@ export function PluginsDialog(props: {
|
||||
...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
|
||||
]
|
||||
})
|
||||
const visibleEntries = createMemo(() => entries().filter((entry) => showInternal() || !entry.internal))
|
||||
const visibleEntries = createMemo(() =>
|
||||
entries().filter((entry) => showInternal() || !entry.internal || status(entry) === "failed"),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (visibleEntries().some((entry) => entry.key === focused())) return
|
||||
const first = visibleEntries().find((entry) => entry.runtime === "tui") ?? visibleEntries()[0]
|
||||
@@ -286,9 +288,7 @@ function source(plugin: PluginInfo, context: Plugin.Context) {
|
||||
function isLocal(entry: Entry) {
|
||||
if (entry.runtime === "server") return entry.plugin.source.type === "local"
|
||||
const target = entry.target
|
||||
return (
|
||||
target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target)
|
||||
)
|
||||
return target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target)
|
||||
}
|
||||
|
||||
function status(entry: Entry) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
|
||||
import type { MarkdownCodeBlockRenderer, MarkdownOptions } from "@opentui/core"
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
@@ -33,6 +33,7 @@ import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot,
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverPluginTargets, freshSpecifier, localSource } from "./discovery"
|
||||
import { isMissingPath } from "../util/config-directories"
|
||||
import { createMarkdownRenderer } from "./markdown"
|
||||
|
||||
export interface PackageSource {
|
||||
readonly prepare: (spec: string, install?: boolean) => Promise<Host.Target>
|
||||
@@ -52,6 +53,7 @@ type RegisteredPlugin = {
|
||||
type Value = {
|
||||
readonly ready: () => boolean
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly server: () => readonly PluginInfo[]
|
||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly slots: {
|
||||
@@ -83,30 +85,21 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
|
||||
const PluginContext = createContext<Value>()
|
||||
let sourceVersion = Date.now()
|
||||
|
||||
export function combineMarkdownRenderers(
|
||||
sources: ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
|
||||
): MarkdownOptions["renderNode"] {
|
||||
const renderers = new Map<string, MarkdownCodeBlockRenderer>()
|
||||
for (const source of sources) {
|
||||
for (const [language, render] of Object.entries(source)) renderers.set(language, render)
|
||||
}
|
||||
if (renderers.size === 0) return undefined
|
||||
return createMarkdownCodeBlockRenderer(renderers)
|
||||
}
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const [serverPlugins, setServerPlugins] = createSignal<
|
||||
ReadonlyArray<
|
||||
PluginInfo & { readonly state: { readonly status: "active" } } & {
|
||||
readonly source: { readonly type: "package" } | { readonly type: "local" }
|
||||
}
|
||||
>
|
||||
>([])
|
||||
const [serverPlugins, setServerPlugins] = createSignal<readonly PluginInfo[]>([])
|
||||
const serverTuiPlugins = createMemo(() =>
|
||||
serverPlugins().filter(
|
||||
(plugin): plugin is PluginInfo & { readonly source: { readonly type: "package" } | { readonly type: "local" } } =>
|
||||
plugin.state.status === "active" &&
|
||||
plugin.features.tui === true &&
|
||||
(plugin.source.type === "package" || plugin.source.type === "local"),
|
||||
),
|
||||
)
|
||||
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
@@ -125,12 +118,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
sourceVersions.set(entrypoint, { digest, generation })
|
||||
return generation
|
||||
}
|
||||
const markdown = createMemo(() =>
|
||||
combineMarkdownRenderers(
|
||||
Object.values(store.registrations).flatMap((registration) =>
|
||||
registration.active ? [registration.markdown] : [],
|
||||
),
|
||||
),
|
||||
const markdown = createMarkdownRenderer(() =>
|
||||
Object.values(store.registrations).flatMap((registration) => (registration.active ? [registration.markdown] : [])),
|
||||
)
|
||||
const clearContributions = (id: string) => {
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
@@ -271,7 +260,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
install: true,
|
||||
optional: true,
|
||||
})),
|
||||
...serverPlugins().map((plugin) => ({
|
||||
...serverTuiPlugins().map((plugin) => ({
|
||||
entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path),
|
||||
install: false,
|
||||
optional: true,
|
||||
@@ -487,7 +476,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify([serverPlugins(), config.data.plugins ?? []]),
|
||||
() => JSON.stringify([serverTuiPlugins(), config.data.plugins ?? []]),
|
||||
() => {
|
||||
npmFailures.clear()
|
||||
void enqueue(reconcile).then(
|
||||
@@ -500,20 +489,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
const syncServerPlugins = () =>
|
||||
client.api.plugin
|
||||
.list({ location: data.location.default() })
|
||||
.then((response) =>
|
||||
setServerPlugins(
|
||||
response.data.filter(
|
||||
(
|
||||
plugin,
|
||||
): plugin is PluginInfo & { readonly state: { readonly status: "active" } } & {
|
||||
readonly source: { readonly type: "package" } | { readonly type: "local" }
|
||||
} =>
|
||||
plugin.state.status === "active" &&
|
||||
plugin.features.tui === true &&
|
||||
(plugin.source.type === "package" || plugin.source.type === "local"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((response) => {
|
||||
const failed = response.data.filter(
|
||||
(plugin) =>
|
||||
plugin.state.status === "failed" &&
|
||||
!serverPlugins().some(
|
||||
(previous) =>
|
||||
serverPluginName(previous) === serverPluginName(plugin) && isDeepEqual(previous.state, plugin.state),
|
||||
),
|
||||
)
|
||||
setServerPlugins(response.data)
|
||||
const first = failed[0]
|
||||
if (!first) return
|
||||
host.toast.show({
|
||||
variant: "error",
|
||||
title: failed.length === 1 ? `Plugin failed: ${serverPluginName(first)}` : `${failed.length} plugins failed`,
|
||||
message:
|
||||
(failed.length > 1 ? `${failed.map(serverPluginName).join(", ")}\n` : "") + "Run /plugins to view details.",
|
||||
action: { label: "Open plugins", run: () => host.keymap.dispatch("plugins.list") },
|
||||
})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
createEffect(
|
||||
on(
|
||||
@@ -552,6 +547,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
value={{
|
||||
ready: () => store.ready,
|
||||
list: () => store.states,
|
||||
server: serverPlugins,
|
||||
registered: () =>
|
||||
Object.entries(store.registrations).map(([id, plugin]) => ({
|
||||
id,
|
||||
@@ -572,6 +568,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
)
|
||||
}
|
||||
|
||||
function serverPluginName(plugin: PluginInfo) {
|
||||
return (
|
||||
plugin.id ??
|
||||
(plugin.source.type === "package"
|
||||
? plugin.source.target
|
||||
: plugin.source.type === "local"
|
||||
? plugin.source.path
|
||||
: plugin.source.type)
|
||||
)
|
||||
}
|
||||
|
||||
async function disposeAll(cleanups: Dispose[]) {
|
||||
const failures: unknown[] = []
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup().catch((error) => failures.push(error))
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer } from "@opentui/core"
|
||||
import { isShallowEqual } from "remeda"
|
||||
import { createMemo } from "solid-js"
|
||||
|
||||
export function createMarkdownRenderer(
|
||||
sources: () => ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
|
||||
) {
|
||||
// Changing renderNode makes OpenTUI destroy and rebuild every Markdown block.
|
||||
// Only invalidate it when the effective last-wins language handlers change.
|
||||
const renderers = createMemo(
|
||||
() => Object.fromEntries(sources().flatMap((source) => Object.entries(source))),
|
||||
undefined,
|
||||
{ equals: isShallowEqual },
|
||||
)
|
||||
return createMemo(() =>
|
||||
Object.keys(renderers()).length === 0 ? undefined : createMarkdownCodeBlockRenderer(renderers()),
|
||||
)
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
backgroundColor={
|
||||
active() ? theme.background.action.primary.focused : theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
onMouseMove={() => setStore("selected", index())}
|
||||
>
|
||||
<text
|
||||
fg={active() ? theme.text.action.primary.focused : theme.text.action.primary.default}
|
||||
|
||||
@@ -215,7 +215,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
? theme.background.action.primary.selected
|
||||
: theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
onMouseMove={() => setStore("selected", index())}
|
||||
onMouseUp={() => {
|
||||
setStore("selected", index())
|
||||
navigate({ type: "session", sessionID: entry.sessionID })
|
||||
|
||||
@@ -84,7 +84,7 @@ export function TerminalsTab(props: { sessionID: string; visibleTerminalID?: str
|
||||
? theme.background.action.primary.selected
|
||||
: theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setSelected(index())}
|
||||
onMouseMove={() => setSelected(index())}
|
||||
onMouseUp={() => {
|
||||
setSelected(index())
|
||||
select()
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createEventStream, createFetch, directory, json, type FetchHandler } fr
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
import type { TuiInput } from "../src/app"
|
||||
import type { Config } from "../src/config"
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
|
||||
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
@@ -1368,6 +1369,168 @@ test.each(["manual", "select"] as const)(
|
||||
},
|
||||
)
|
||||
|
||||
test.each([100, 44])(
|
||||
"execution failure keeps the empty session composer and draft usable at width %s",
|
||||
async (width) => {
|
||||
await using state = await tmpdir()
|
||||
const session = {
|
||||
id: "ses_failure",
|
||||
projectID: "proj_test",
|
||||
location: { directory },
|
||||
title: "Failure fixture",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
await using setup = await createAppFixture({
|
||||
width,
|
||||
state: state.path,
|
||||
args: { sessionID: session.id },
|
||||
config: { animations: false, tabs: { enabled: false } },
|
||||
fetch: (url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
||||
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: [], cursor: {} })
|
||||
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
|
||||
return json({ data: [] })
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
setup.mockInput.pressKey("u", { ctrl: true })
|
||||
await setup.mockInput.typeText("Keep this draft")
|
||||
await setup.waitForFrame((frame) => frame.includes("Keep this draft"))
|
||||
setup.events.emit({
|
||||
id: "evt_execution_failed",
|
||||
created: 2,
|
||||
type: "session.execution.failed",
|
||||
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: session.id,
|
||||
error: { type: "unknown", message: 'Plugin "broken-skills" failed during skill.transform.' },
|
||||
},
|
||||
})
|
||||
await setup.waitForFrame((frame) => frame.includes("Session failed"))
|
||||
expect(setup.captureCharFrame()).toContain("broken-skills")
|
||||
expect(setup.captureCharFrame()).toContain("skill.transform")
|
||||
expect(setup.captureCharFrame()).toContain("Keep this draft")
|
||||
expect(setup.captureCharFrame()).not.toContain("Select directory")
|
||||
await setup.mockInput.typeText(" intact")
|
||||
await setup.waitForFrame((frame) => frame.includes("Keep this draft intact"))
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
[100, true],
|
||||
[44, true],
|
||||
[100, false],
|
||||
[44, false],
|
||||
] as const)("server plugin failures are visible at width %s (already failed: %s)", async (width, initial) => {
|
||||
await using state = await tmpdir()
|
||||
const failure: PluginInfo["state"] = {
|
||||
status: "failed",
|
||||
error: "Plugin disabled after command.transform failed. Check server logs for details.",
|
||||
ref: "err_fixture",
|
||||
}
|
||||
let inventory: PluginInfo[] = [
|
||||
{
|
||||
id: "broken",
|
||||
source: { type: "builtin" },
|
||||
features: { server: true },
|
||||
state: initial ? failure : { status: "active" },
|
||||
},
|
||||
{ id: "healthy", source: { type: "builtin" }, features: { server: true }, state: { status: "active" } },
|
||||
]
|
||||
let requests = 0
|
||||
await using setup = await createAppFixture({
|
||||
width,
|
||||
state: state.path,
|
||||
fetch: (url) => {
|
||||
if (url.pathname !== "/api/plugin") return undefined
|
||||
requests++
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory, canonical: directory } },
|
||||
data: inventory,
|
||||
})
|
||||
},
|
||||
})
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
if (!initial) {
|
||||
expect(setup.captureCharFrame()).not.toContain("Plugin failed")
|
||||
inventory = inventory.map((plugin) => (plugin.id === "broken" ? { ...plugin, state: failure } : plugin))
|
||||
setup.events.emit({ id: "evt_failure", created: 1, type: "plugin.updated", data: {} })
|
||||
}
|
||||
await setup.waitForFrame((frame) => frame.includes("Plugin failed:") && frame.includes("broken"))
|
||||
expect(setup.captureCharFrame()).toContain("/plugins")
|
||||
expect(setup.captureCharFrame()).toContain("1 plugin failed")
|
||||
|
||||
const lines = setup.captureCharFrame().split("\n")
|
||||
const row = lines.findIndex((line) => line.includes("Open plugins"))
|
||||
expect(row).toBeGreaterThanOrEqual(0)
|
||||
const line = lines[row]
|
||||
if (!line) throw new Error("Open plugins action is missing")
|
||||
await setup.mockMouse.click(line.indexOf("Open plugins"), row)
|
||||
await setup.waitForFrame((frame) => frame.includes("ctrl+a") && frame.includes("broken"))
|
||||
expect(setup.captureCharFrame()).toContain("broken")
|
||||
expect(setup.captureCharFrame()).not.toContain("healthy")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame((frame) => frame.includes("Server plugin error") && frame.includes("transform failed"))
|
||||
expect(setup.captureCharFrame()).toContain("Plugin disabled")
|
||||
expect(setup.captureCharFrame()).toContain("transform failed")
|
||||
expect(setup.captureCharFrame()).toContain("err_fixture")
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => frame.includes("ctrl+a"))
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("ctrl+a"))
|
||||
expect(setup.captureCharFrame()).toContain("1 plugin failed")
|
||||
|
||||
const seen = requests
|
||||
setup.events.emit({ id: "evt_repeat", created: 2, type: "plugin.updated", data: {} })
|
||||
setup.events.emit({ id: "evt_reconnect", type: "server.connected", data: {} })
|
||||
await setup.waitFor(() => requests >= seen + 2)
|
||||
await setup.flush()
|
||||
expect(setup.captureCharFrame()).not.toContain("Plugin failed:")
|
||||
|
||||
inventory = inventory.map((plugin) => ({ ...plugin, state: { status: "active" } }))
|
||||
setup.events.emit({ id: "evt_recovered", created: 3, type: "plugin.updated", data: {} })
|
||||
await setup.waitForFrame((frame) => !frame.includes("1 plugin failed"))
|
||||
inventory = inventory.map((plugin) => (plugin.id === "broken" ? { ...plugin, state: failure } : plugin))
|
||||
setup.events.emit({ id: "evt_failed_again", created: 4, type: "plugin.updated", data: {} })
|
||||
await setup.waitForFrame((frame) => frame.includes("Plugin failed:") && frame.includes("broken"))
|
||||
})
|
||||
|
||||
test("server plugin failures share one notice and use source names before an ID is known", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createAppFixture({
|
||||
state: state.path,
|
||||
fetch: (url) =>
|
||||
url.pathname === "/api/plugin"
|
||||
? json({
|
||||
location: { directory, project: { id: "proj_test", directory, canonical: directory } },
|
||||
data: [
|
||||
{
|
||||
source: { type: "package", target: "missing-package" },
|
||||
features: {},
|
||||
state: { status: "failed", error: "Package missing" },
|
||||
},
|
||||
{
|
||||
source: { type: "local", path: "/fixture/broken.ts" },
|
||||
features: {},
|
||||
state: { status: "failed", error: "Invalid plugin" },
|
||||
},
|
||||
],
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("2 plugins failed"))
|
||||
expect(setup.captureCharFrame()).toContain("missing-package")
|
||||
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
|
||||
expect(setup.captureCharFrame()).toContain("Open plugins")
|
||||
})
|
||||
|
||||
async function createAppFixture(
|
||||
input: {
|
||||
width?: number
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import Notifications from "../../../../src/feature-plugins/system/notifications"
|
||||
import type { OpenCodeEvent, PermissionAsked } from "@opencode-ai/client"
|
||||
import type { AttentionNotifyOptions, Context } from "@opencode-ai/plugin/tui/context"
|
||||
import type { AttentionNotifyOptions, Context, Route, ToastOptions } from "@opencode-ai/plugin/tui/context"
|
||||
|
||||
type Session = { id: string; title: string; parentID?: string }
|
||||
|
||||
async function setup() {
|
||||
async function setup(route: Route = { type: "session", sessionID: "session" }) {
|
||||
const notifications: AttentionNotifyOptions[] = []
|
||||
const toasts: ToastOptions[] = []
|
||||
const handlers = new Map<OpenCodeEvent["type"], ((event: OpenCodeEvent) => void)[]>()
|
||||
const session = (id: string, title: string, parentID?: string): Session => ({
|
||||
id,
|
||||
@@ -21,6 +22,10 @@ async function setup() {
|
||||
}
|
||||
|
||||
await Notifications.setup({
|
||||
ui: {
|
||||
router: { current: () => route },
|
||||
toast: { show: (toast: ToastOptions) => toasts.push(toast) },
|
||||
},
|
||||
attention: {
|
||||
async notify(input: AttentionNotifyOptions) {
|
||||
notifications.push(input)
|
||||
@@ -52,6 +57,7 @@ async function setup() {
|
||||
|
||||
return {
|
||||
notifications,
|
||||
toasts,
|
||||
emit(event: OpenCodeEvent) {
|
||||
for (const handler of handlers.get(event.type) ?? []) handler(event)
|
||||
},
|
||||
@@ -140,6 +146,27 @@ const permissionNotification: AttentionNotifyOptions = {
|
||||
}
|
||||
|
||||
describe("internal notifications TUI plugin", () => {
|
||||
test("shows execution failures in the viewed session without needing an assistant message", async () => {
|
||||
const harness = await setup()
|
||||
harness.emit(executionStarted("started"))
|
||||
harness.emit(executionFailed("failed"))
|
||||
harness.emit(executionFailed("duplicate"))
|
||||
expect(harness.toasts).toEqual([{ title: "Session failed", message: "boom", variant: "error" }])
|
||||
harness.emit(executionStarted("retry"))
|
||||
harness.emit(executionFailed("failed-again"))
|
||||
expect(harness.toasts).toHaveLength(2)
|
||||
})
|
||||
|
||||
test.each<Route>([{ type: "home" }, { type: "session", sessionID: "other" }])(
|
||||
"keeps other sessions' failures out of the current composer (%j)",
|
||||
async (route) => {
|
||||
const harness = await setup(route)
|
||||
harness.emit(executionFailed("failed"))
|
||||
expect(harness.toasts).toEqual([])
|
||||
expect(harness.notifications).toHaveLength(1)
|
||||
},
|
||||
)
|
||||
|
||||
test("notifies for form and permission requests with blurred notifications and always-on sounds", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onMount } from "solid-js"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import type { TuiKeybind } from "../../../src/config/keybind"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
@@ -9,8 +10,13 @@ import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider } from "../../../src/context/location"
|
||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { TuiAppProvider } from "../../../src/context/runtime"
|
||||
import { SessionTerminalsProvider, useSessionTerminals } from "../../../src/context/session-terminals"
|
||||
import { StorageProvider, useStorage } from "../../../src/context/storage"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
@@ -22,9 +28,22 @@ const sessions = {
|
||||
}
|
||||
|
||||
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
|
||||
const terminals = ["First terminal", "Second terminal"].map((title, index) => ({
|
||||
id: `pty-${index}`,
|
||||
title,
|
||||
command: "/bin/sh",
|
||||
args: [],
|
||||
cwd: directory,
|
||||
status: "running",
|
||||
pid: index + 1,
|
||||
sessionID: "parent",
|
||||
foregroundProcess: null,
|
||||
size: { cols: 100, rows: 20 },
|
||||
output: { head: 0, tail: 0 },
|
||||
}))
|
||||
|
||||
async function renderComposer(
|
||||
defaultTab: "subagents" | "shell",
|
||||
defaultTab: "subagents" | "shell" | "terminals",
|
||||
keybinds: Partial<TuiKeybind.Keybinds>,
|
||||
focusedTextarea = false,
|
||||
) {
|
||||
@@ -32,10 +51,14 @@ async function renderComposer(
|
||||
const interrupted: string[] = []
|
||||
const removed: string[] = []
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const temporary = await tmpdir()
|
||||
let closed = 0
|
||||
let dispatch!: ReturnType<typeof Keymap.use>["dispatch"]
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
let storage!: ReturnType<typeof useStorage>
|
||||
const calls = createFetch((url, request) => {
|
||||
if (url.pathname === "/api/experimental/session/parent/terminal") return json({ data: terminals })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json({ data: { "child-a": { type: "running" }, "child-b": { type: "running" } } })
|
||||
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
|
||||
@@ -61,6 +84,8 @@ async function renderComposer(
|
||||
|
||||
function Content() {
|
||||
const data = useData()
|
||||
const terminals = useSessionTerminals()
|
||||
storage = useStorage()
|
||||
route = useRoute()
|
||||
dispatch = Keymap.use().dispatch
|
||||
onMount(() => {
|
||||
@@ -69,6 +94,7 @@ async function renderComposer(
|
||||
data.session.sync("child-a"),
|
||||
data.session.sync("child-b"),
|
||||
data.shell.sync(),
|
||||
terminals.refresh("parent"),
|
||||
])
|
||||
.then(() => wait(() => data.session.status("child-a") === "running"))
|
||||
.then(() => ready.resolve(), ready.reject)
|
||||
@@ -76,7 +102,13 @@ async function renderComposer(
|
||||
return (
|
||||
<>
|
||||
{focusedTextarea && <textarea focused={true} initialValue="draft" />}
|
||||
<Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
||||
<Composer
|
||||
sessionID="parent"
|
||||
open={open()}
|
||||
defaultTab={defaultTab}
|
||||
visibleTerminalID="pty-0"
|
||||
onClose={() => closed++}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -92,23 +124,31 @@ async function renderComposer(
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts directory={directory}>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ keybinds, session: { terminal: false } })}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider directory={process.cwd()}>
|
||||
<LocationProvider>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<Content />
|
||||
</ThemeProvider>
|
||||
</RouteProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
<AppExit />
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
<TestTuiContexts directory={directory} paths={{ state: temporary.path }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ keybinds, session: { terminal: true } })}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider directory={process.cwd()}>
|
||||
<LocationProvider>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<ToastProvider>
|
||||
<SessionTerminalsProvider>
|
||||
<Content />
|
||||
</SessionTerminalsProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</RouteProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
<AppExit />
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 20, kittyKeyboard: true },
|
||||
@@ -122,9 +162,85 @@ async function renderComposer(
|
||||
route: () => route.data,
|
||||
dispatch: (command: string) => dispatch(command),
|
||||
closed: () => closed,
|
||||
setOpen,
|
||||
selected: () =>
|
||||
app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.filter((span) => span.attributes & TextAttributes.BOLD)
|
||||
.map((span) => span.text.trim()),
|
||||
async dispose() {
|
||||
app.renderer.destroy()
|
||||
await storage.flush()
|
||||
await temporary[Symbol.asyncDispose]()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ tab: "subagents", first: "Build: First", second: "Build: Second" },
|
||||
{ tab: "shell", first: "bun test", second: "bun dev" },
|
||||
{ tab: "terminals", first: "First terminal", second: "Second terminal" },
|
||||
] as const
|
||||
|
||||
test.each([...tabs])("opening $tab under a stationary pointer preserves selection", async ({ tab, first, second }) => {
|
||||
const composer = await renderComposer(tab, {})
|
||||
try {
|
||||
const row = composer.app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.findIndex((line) => line.includes(second))
|
||||
expect(row).toBeGreaterThan(0)
|
||||
expect(composer.selected()).toContain(first)
|
||||
|
||||
composer.setOpen(false)
|
||||
await composer.app.renderOnce()
|
||||
await composer.app.mockMouse.moveTo(10, row)
|
||||
composer.setOpen(true)
|
||||
await composer.app.renderOnce()
|
||||
await composer.app.renderOnce()
|
||||
|
||||
expect(composer.selected()).toContain(first)
|
||||
expect(composer.selected()).not.toContain(second)
|
||||
} finally {
|
||||
await composer.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([...tabs])("moving within a $tab row selects it after opening", async ({ tab, first, second }) => {
|
||||
const composer = await renderComposer(tab, {})
|
||||
try {
|
||||
const row = composer.app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.findIndex((line) => line.includes(second))
|
||||
expect(row).toBeGreaterThan(0)
|
||||
|
||||
composer.setOpen(false)
|
||||
await composer.app.renderOnce()
|
||||
await composer.app.mockMouse.moveTo(10, row)
|
||||
composer.setOpen(true)
|
||||
await composer.app.renderOnce()
|
||||
await composer.app.renderOnce()
|
||||
await composer.app.mockMouse.moveTo(11, row)
|
||||
await composer.app.renderOnce()
|
||||
|
||||
expect(composer.selected()).toContain(second)
|
||||
expect(composer.selected()).not.toContain(first)
|
||||
|
||||
composer.app.mockInput.pressArrow("up")
|
||||
await composer.app.renderOnce()
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.selected()).toContain(first)
|
||||
|
||||
await composer.app.mockMouse.moveTo(12, row)
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.selected()).toContain(second)
|
||||
} finally {
|
||||
await composer.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("disabled subagent bindings have no component fallbacks", async () => {
|
||||
const composer = await renderComposer("subagents", {
|
||||
"composer.subagent.up": "none",
|
||||
@@ -146,7 +262,7 @@ test("disabled subagent bindings have no component fallbacks", async () => {
|
||||
composer.dispatch("composer.subagent.select")
|
||||
expect(composer.route()).toMatchObject({ type: "session", sessionID: "child-a" })
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
await composer.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -169,7 +285,7 @@ test("disabled shell bindings have no component fallbacks", async () => {
|
||||
await wait(() => composer.removed.length === 1)
|
||||
expect(composer.removed).toEqual(["sh-a"])
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
await composer.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -183,7 +299,7 @@ test("configured composer bindings work with a focused textarea", async () => {
|
||||
await wait(() => composer.removed.length === 1)
|
||||
expect(composer.removed).toEqual(["sh-a"])
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
await composer.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -194,7 +310,7 @@ test("ctrl+c closes the active composer", async () => {
|
||||
composer.app.mockInput.pressKey("c", { ctrl: true })
|
||||
await composer.app.waitFor(() => composer.closed() === 1)
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
await composer.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
CodeRenderable,
|
||||
MarkdownRenderable,
|
||||
SyntaxStyle,
|
||||
TextRenderable,
|
||||
type MarkdownCodeBlockRenderer,
|
||||
} from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createMarkdownRenderer } from "../src/plugin/markdown"
|
||||
|
||||
test("unrelated plugin toggles preserve mounted Markdown blocks", async () => {
|
||||
const output = await createTestRenderer({ width: 80, height: 12, remote: true, useThread: false })
|
||||
const handler: MarkdownCodeBlockRenderer = () =>
|
||||
new TextRenderable(output.renderer, { content: "Custom fence", height: 1 })
|
||||
const [sources, setSources] = createSignal<ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>>([
|
||||
{ example: handler },
|
||||
{},
|
||||
])
|
||||
await render(() => {
|
||||
const renderNode = createMarkdownRenderer(sources)
|
||||
return (
|
||||
<markdown
|
||||
syntaxStyle={SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })}
|
||||
renderNode={renderNode()}
|
||||
content={"A plain paragraph.\n\n```example\nFence content\n```"}
|
||||
streaming={false}
|
||||
internalBlockMode="top-level"
|
||||
/>
|
||||
)
|
||||
}, output.renderer)
|
||||
try {
|
||||
output.renderer.start()
|
||||
await output.waitForFrame((frame) => frame.includes("Custom fence"))
|
||||
const markdown = output.renderer.root.getChildren()[0]
|
||||
if (!(markdown instanceof MarkdownRenderable)) throw new Error("Expected Markdown")
|
||||
const initial = markdown.getChildren()
|
||||
expect(initial).toHaveLength(2)
|
||||
expect(output.captureCharFrame()).toContain("Custom fence")
|
||||
|
||||
for (const active of [false, true, false, true]) {
|
||||
setSources([{ example: handler }, ...(active ? [{}] : [])])
|
||||
await output.renderOnce()
|
||||
expect(markdown.getChildren()[0] === initial[0]).toBe(true)
|
||||
expect(markdown.getChildren()[1] === initial[1]).toBe(true)
|
||||
expect(initial.every((block) => !block.isDestroyed)).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
output.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("effective mappings preserve identity through reordered and shadowed contributions", () => {
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
const first: MarkdownCodeBlockRenderer = () => undefined
|
||||
const second: MarkdownCodeBlockRenderer = () => undefined
|
||||
const [sources, setSources] = createSignal<ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>>([
|
||||
{ example: first },
|
||||
{ example: second, other: first },
|
||||
])
|
||||
const renderNode = createMarkdownRenderer(sources)
|
||||
const initial = renderNode()
|
||||
|
||||
setSources([{ other: first, example: second }])
|
||||
expect(renderNode()).toBe(initial)
|
||||
setSources([{ example: first }, { other: first, example: second }])
|
||||
expect(renderNode()).toBe(initial)
|
||||
|
||||
setSources([{ example: first, other: first }])
|
||||
expect(renderNode()).not.toBe(initial)
|
||||
setSources([])
|
||||
expect(renderNode()).toBeUndefined()
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("changing and removing a Markdown handler refreshes existing messages", async () => {
|
||||
const output = await createTestRenderer({ width: 80, height: 12, remote: true, useThread: false })
|
||||
const first: MarkdownCodeBlockRenderer = () =>
|
||||
new TextRenderable(output.renderer, { content: "First renderer", height: 1 })
|
||||
const second: MarkdownCodeBlockRenderer = () =>
|
||||
new TextRenderable(output.renderer, { content: "Second renderer", height: 1 })
|
||||
const [sources, setSources] = createSignal<ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>>([
|
||||
{ example: first },
|
||||
])
|
||||
await render(() => {
|
||||
const renderNode = createMarkdownRenderer(sources)
|
||||
return (
|
||||
<markdown
|
||||
syntaxStyle={SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })}
|
||||
renderNode={renderNode()}
|
||||
content={"```example\nFence content\n```"}
|
||||
streaming={false}
|
||||
internalBlockMode="top-level"
|
||||
/>
|
||||
)
|
||||
}, output.renderer)
|
||||
try {
|
||||
output.renderer.start()
|
||||
await output.waitForFrame((frame) => frame.includes("First renderer"))
|
||||
|
||||
setSources([{ example: first }, { example: second }])
|
||||
await output.waitForFrame((frame) => frame.includes("Second renderer"))
|
||||
|
||||
setSources([{ example: first }])
|
||||
await output.waitForFrame((frame) => frame.includes("First renderer"))
|
||||
|
||||
setSources([])
|
||||
await output.waitForFrame((frame) => frame.includes("Fence content"))
|
||||
expect(output.renderer.root.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(CodeRenderable)
|
||||
|
||||
setSources([{ example: second }])
|
||||
await output.waitForFrame((frame) => frame.includes("Second renderer"))
|
||||
} finally {
|
||||
output.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -8890,7 +8890,8 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
],
|
||||
"description": "An absolute path or a path relative to the requested location. Defaults to the location directory."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
@@ -8941,7 +8942,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List direct children of one directory relative to the requested location.",
|
||||
"description": "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
|
||||
"summary": "List directory"
|
||||
}
|
||||
},
|
||||
@@ -13777,8 +13778,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"subtask": {
|
||||
"subagent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean",
|
||||
"description": "Deprecated alias for subagent."
|
||||
}
|
||||
},
|
||||
"required": ["template"],
|
||||
|
||||
@@ -8890,7 +8890,8 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
],
|
||||
"description": "An absolute path or a path relative to the requested location. Defaults to the location directory."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
@@ -8941,7 +8942,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List direct children of one directory relative to the requested location.",
|
||||
"description": "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
|
||||
"summary": "List directory"
|
||||
}
|
||||
},
|
||||
@@ -13777,8 +13778,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"subtask": {
|
||||
"subagent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean",
|
||||
"description": "Deprecated alias for subagent."
|
||||
}
|
||||
},
|
||||
"required": ["template"],
|
||||
|
||||
@@ -55,15 +55,16 @@ Add commands under the `commands` key in any OpenCode JSON or JSONC
|
||||
|
||||
## Fields
|
||||
|
||||
| Field | Required | Behavior |
|
||||
| ------------- | --------- | ---------------------------------------------------------------------- |
|
||||
| `template` | JSON only | Prompt template. In a Markdown command, the file body supplies it. |
|
||||
| `description` | No | Text shown with the command in command listings and discovery. |
|
||||
| `agent` | No | Agent activated before the prompt runs. |
|
||||
| `model` | No | Model override in `provider/model` or `provider/model#variant` format. |
|
||||
| `subtask` | No | Accepted as a boolean, but currently has no execution effect in V2. |
|
||||
| Field | Required | Behavior |
|
||||
| ------------- | --------- | --------------------------------------------------------------------------------- |
|
||||
| `template` | JSON only | Prompt template. In a Markdown command, the file body supplies it. |
|
||||
| `description` | No | Text shown with the command in command listings and discovery. |
|
||||
| `agent` | No | Agent that runs the command. |
|
||||
| `model` | No | Model override in `provider/model` or `provider/model#variant` format. |
|
||||
| `subagent` | No | Run in a background child session, or use `false` to stay in the current session. |
|
||||
| `subtask` | No | Deprecated alias for `subagent`. |
|
||||
|
||||
The four optional fields can be used in JSON or YAML frontmatter. Do not put
|
||||
The optional fields can be used in JSON or YAML frontmatter. Do not put
|
||||
`template` in frontmatter because the Markdown body always supplies it.
|
||||
|
||||
## Arguments
|
||||
@@ -128,16 +129,33 @@ automatically attach that file.
|
||||
|
||||
## Agent, model, and execution
|
||||
|
||||
Running a command evaluates its arguments and shell blocks, submits the result
|
||||
as a durable user prompt in the current session, and schedules normal model
|
||||
execution.
|
||||
Commands evaluate their arguments and shell blocks before submitting a durable
|
||||
user prompt. Commands run in the current session unless background delegation
|
||||
is enabled as described below.
|
||||
|
||||
If `agent` is set, it overrides the active agent when the command is invoked
|
||||
For current-session commands, `agent` overrides the active agent when the command is invoked
|
||||
and becomes the session's active agent. If `model` is set, it overrides the
|
||||
model. Otherwise, a model configured on the command's agent takes precedence
|
||||
over the model active at invocation.
|
||||
|
||||
Although `subtask` is accepted in JSON and frontmatter, V2 currently ignores
|
||||
it: commands run in the current session and do not create a child session.
|
||||
Selecting an agent whose mode is `subagent` also does not turn the command into
|
||||
a subtask.
|
||||
### Background subagents
|
||||
|
||||
Set `subagent: true` to run a command in a background child session. The parent
|
||||
keeps its agent and model, stays available for other work, and receives the
|
||||
child's result or failure when it finishes.
|
||||
|
||||
```md title=".opencode/commands/review.md"
|
||||
---
|
||||
description: Review changes in the background
|
||||
agent: general
|
||||
subagent: true
|
||||
---
|
||||
|
||||
Review $ARGUMENTS for bugs and missing tests.
|
||||
```
|
||||
|
||||
- `true` forces child execution, including for an agent with `mode: primary`.
|
||||
- `false` forces execution in the current session.
|
||||
- When omitted, a command targeting an agent with `mode: subagent` runs in the background.
|
||||
- The child uses the command's model override, then the selected agent's model, then the parent's model.
|
||||
- Legacy `subtask` is still accepted in JSON and Markdown. If both fields are present, `subagent` takes precedence.
|
||||
|
||||
@@ -272,7 +272,7 @@ Existing skill files and automatic `.opencode/skills/` discovery do not change.
|
||||
|
||||
### Commands
|
||||
|
||||
Rename the singular `command` map to `commands`. Join a separate model `variant` to the model reference:
|
||||
Rename the singular `command` map to `commands` and `subtask` to `subagent`. Join a separate model `variant` to the model reference:
|
||||
|
||||
```jsonc
|
||||
// V1
|
||||
@@ -281,7 +281,8 @@ Rename the singular `command` map to `commands`. Join a separate model `variant`
|
||||
"review": {
|
||||
"template": "Review the current changes.",
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"variant": "high"
|
||||
"variant": "high",
|
||||
"subtask": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,13 +292,15 @@ Rename the singular `command` map to `commands`. Join a separate model `variant`
|
||||
"commands": {
|
||||
"review": {
|
||||
"template": "Review the current changes.",
|
||||
"model": "anthropic/claude-sonnet-4-5#high"
|
||||
"model": "anthropic/claude-sonnet-4-5#high",
|
||||
"subagent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`template`, `description`, `agent`, and `subtask` keep their names. Existing Markdown command definitions remain supported.
|
||||
`template`, `description`, and `agent` keep their names. Legacy `subtask` remains accepted; delegated commands now run
|
||||
automatically in the background and report their results to the parent session. Existing Markdown command definitions remain supported.
|
||||
See [Commands](/commands).
|
||||
|
||||
### References
|
||||
@@ -462,16 +465,19 @@ V1 command files may use `command/` or `commands/`. V2 discovers both. The prefe
|
||||
```
|
||||
|
||||
Move files from `command/` to the same relative path under `commands/` to preserve command names. The Markdown body remains
|
||||
the command template, and `description`, `agent`, and `subtask` frontmatter keep the same names. If frontmatter has separate
|
||||
the command template, and `description` and `agent` frontmatter keep the same names. Rename `subtask` to `subagent` to use
|
||||
the native name for background delegation. If frontmatter has separate
|
||||
`model` and `variant` fields, append the variant to the model and remove `variant`:
|
||||
|
||||
```yaml
|
||||
# V1
|
||||
model: anthropic/claude-sonnet-4-5
|
||||
variant: high
|
||||
subtask: true
|
||||
|
||||
# V2
|
||||
model: anthropic/claude-sonnet-4-5#high
|
||||
subagent: true
|
||||
```
|
||||
|
||||
See [Commands](/commands).
|
||||
|
||||
Reference in New Issue
Block a user