Compare commits

..

1 Commits

Author SHA1 Message Date
Hona d6c1b96bbf fix(app): hide built-in plugins 2026-08-19 01:39:43 +00:00
133 changed files with 983 additions and 6061 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
on:
workflow_dispatch:
push:
branches: [dev, beta, v2]
branches: [dev, beta]
paths:
- "bun.lock"
- "package.json"
-27
View File
@@ -185,33 +185,6 @@ pmap -x <pid> | sort -k3 -nr | head -25
Heap serialization itself can temporarily increase RSS and allocator high-water marks, so record `ps`/`smaps_rollup` both before and after capture. Large anonymous mappings with a comparatively small live heap require native-allocation or allocator investigation; they cannot be explained from JavaScript retainer paths alone.
## CPU profiles
The CLI installs a `SIGPROF` listener on non-Windows processes in `packages/cli/src/cpu-profile.ts`. One signal starts a ten-second CPU profile and stops it automatically; additional signals are ignored while a profile is active. There is no CPU profile CLI flag or environment variable.
1. Get the PID from the health endpoint. For shared-service performance, target the server PID returned here rather than the short wrapper or TUI process:
```bash
opencode2 api get /api/health
```
Use `bun dev api get /api/health` instead when targeting the local/dev channel.
2. Start the capture:
```bash
kill -PROF <server-pid>
```
3. Wait for `CPU profile written` in the channel's log before opening the file. Profiles are written to the same log directory as `cpu-<pid>-<timestamp>.cpuprofile`; the log's `path=` field is authoritative:
```bash
grep 'CPU profile' ~/.local/share/opencode/log/opencode.log | tail
find ~/.local/share/opencode/log -maxdepth 1 -name 'cpu-<server-pid>-*.cpuprofile' -printf '%T@ %s %p\n' | sort -nr | head
```
Use `opencode-local.log` for a local/dev process. Load the completed `.cpuprofile` in Chrome DevTools or another V8 CPU profile viewer and inspect the hottest functions, call stacks, and self time during the controlled workload.
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-IxkSw0gK/qkMHZGVHqjwgM9BKhzbQX6hyF9SWUNtpzg=",
"aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=",
"aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=",
"x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo="
"x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
}
}
+2 -2
View File
@@ -10,7 +10,7 @@
]).nodeModules.${stdenvNoCC.hostPlatform.system},
}:
let
packageJson = lib.pipe ../packages/cli/package.json [
packageJson = lib.pipe ../packages/opencode/package.json [
builtins.readFile
builtins.fromJSON
];
@@ -52,7 +52,7 @@ stdenvNoCC.mkDerivation {
--cpu="${bunCpu}" \
--os="${bunOs}" \
--filter '!./' \
--filter './packages/cli' \
--filter './packages/opencode' \
--filter './packages/desktop' \
--filter './packages/app' \
--frozen-lockfile \
+10 -8
View File
@@ -48,13 +48,13 @@ stdenvNoCC.mkDerivation (finalAttrs: {
env.OPENCODE_DISABLE_MODELS_FETCH = true;
env.OPENCODE_VERSION = finalAttrs.version;
env.OPENCODE_CHANNEL = "prod";
env.NODE_OPTIONS = "--max-old-space-size=4096";
buildPhase = ''
runHook preBuild
cd ./packages/cli
cd ./packages/opencode
bun --bun ./script/build.ts --single --skip-install
bun --bun ./script/schema.ts schema.json
runHook postBuild
'';
@@ -62,9 +62,10 @@ stdenvNoCC.mkDerivation (finalAttrs: {
installPhase = ''
runHook preInstall
install -Dm755 dist/cli-*/bin/opencode2 $out/bin/opencode2
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
wrapProgram $out/bin/opencode2 \
wrapProgram $out/bin/opencode \
--prefix PATH : ${
lib.makeBinPath (
[
@@ -80,9 +81,9 @@ stdenvNoCC.mkDerivation (finalAttrs: {
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions
installShellCompletion --cmd opencode2 \
--bash <($out/bin/opencode2 completion) \
--zsh <(SHELL=/bin/zsh $out/bin/opencode2 completion)
installShellCompletion --cmd opencode \
--bash <($out/bin/opencode completion) \
--zsh <(SHELL=/bin/zsh $out/bin/opencode completion)
'';
nativeInstallCheckInputs = [
@@ -94,6 +95,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
versionCheckProgramArg = "--version";
passthru = {
jsonschema = "${placeholder "out"}/share/opencode/schema.json";
env = finalAttrs.env;
};
@@ -101,7 +103,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
description = "The open source coding agent";
homepage = "https://opencode.ai";
license = lib.licenses.mit;
mainProgram = "opencode2";
mainProgram = "opencode";
inherit (node_modules.meta) platforms;
};
})
+1 -1
View File
@@ -193,7 +193,7 @@ If you find yourself copying a 3-to-5-line snippet between two protocols, lift i
`LLMRequest.system` is the initial privileged prompt that applies ahead of the conversation. `Message.system(...)` is a separate, provider-neutral chronological operator update inside `LLMRequest.messages`; it applies only from its position in history onward and accepts text content only.
Native chronological system messages are route/model-specific. Open Responses lowers them to standard `developer` messages, while Anthropic Messages lowers them to native system messages for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
Native chronological system messages are route/model-specific. Anthropic Messages lowers them natively for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
```text
<system-update>
+9 -29
View File
@@ -90,7 +90,6 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
role: Schema.tag("assistant"),
@@ -141,11 +140,6 @@ export const Tool = Schema.Struct({
export const ToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]),
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
Schema.Struct({
type: Schema.tag("allowed_tools"),
mode: Schema.Literals(["auto", "none", "required"]),
tools: Schema.Array(Schema.Struct({ type: Schema.tag("function"), name: Schema.String })),
}),
])
// Fields shared between the HTTP body and the WebSocket `response.create`
@@ -159,7 +153,6 @@ export const coreFields = {
tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean),
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
prompt_cache_key: Schema.optional(Schema.String),
include: optionalArray(OpenResponsesOptions.ResponseIncludableSchema),
@@ -175,8 +168,6 @@ export const coreFields = {
}),
),
max_output_tokens: Schema.optional(Schema.Number),
max_tool_calls: Schema.optional(Schema.Int),
parallel_tool_calls: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
}
@@ -448,10 +439,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
for (const message of request.messages) {
if (message.role === "system") {
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
})
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user")
input[input.length - 1] = {
role: "user",
content: [...previous.content, { type: "input_text", text: part.text }],
}
else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] })
continue
}
@@ -585,19 +580,6 @@ const lowerOptions = (request: LLMRequest) => {
: {}),
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
...(options.truncation ? { truncation: options.truncation } : {}),
}
}
const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
return {
type: "allowed_tools" as const,
mode: allowed.mode,
tools: allowed.toolNames.map((name) => ({ type: "function" as const, name })),
}
}
@@ -620,9 +602,7 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
tool_choice: request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined,
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -121,8 +121,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined,
} satisfies OpenAIResponsesBody
})
@@ -1,4 +1,4 @@
import { Option, Schema } from "effect"
import { Schema } from "effect"
import { TextVerbosity, type LLMRequest } from "../../schema/index.js"
export const ResponseIncludables = [
@@ -11,62 +11,52 @@ export const ResponseIncludables = [
"reasoning.encrypted_content",
"message.output_text.logprobs",
] as const
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
export type ResponseIncludable = (typeof ResponseIncludables)[number]
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number]
export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number]
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(ResponseIncludables)
const SERVICE_TIERS = new Set<string>(ServiceTiers)
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value)
export const ReasoningEffort = Schema.String
export const TextVerbositySchema = TextVerbosity
export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
(value): value is ResponseIncludable => typeof value === "string",
{ title: "ResponseIncludable" },
)
export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables)
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export const TruncationSchema = Schema.Literals(Truncations)
export const AllowedTools = Schema.Struct({
toolNames: Schema.Array(Schema.String),
mode: Schema.optional(Schema.Literals(["auto", "none", "required"])),
})
export type AllowedTools = typeof AllowedTools.Type
export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
textVerbosity: Schema.optional(TextVerbositySchema),
serviceTier: Schema.optional(ServiceTierSchema),
truncation: Schema.optional(TruncationSchema),
allowedTools: Schema.optional(AllowedTools),
maxToolCalls: Schema.optional(Schema.Int),
parallelToolCalls: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
export type Resolved = Omit<Options, "allowedTools"> & {
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
export interface Resolved {
readonly instructions?: string
readonly store?: boolean
readonly reasoningEffort?: string
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity>
readonly serviceTier?: ServiceTier
}
const decodeOptions = Schema.decodeUnknownOption(Options)
export const resolve = (request: LLMRequest): Resolved => {
const input = Option.getOrUndefined(
decodeOptions(request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]),
)
if (!input) return {}
const input = request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]
const include = Array.isArray(input?.include)
? input.include.filter((entry): entry is ResponseIncludable => INCLUDABLES.has(entry))
: []
const reasoningSummary = input?.reasoningSummary
return {
...input,
include: input.include?.length ? input.include : undefined,
allowedTools:
input.allowedTools && input.allowedTools.toolNames.length > 0
? { ...input.allowedTools, mode: input.allowedTools.mode ?? "auto" }
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
store: typeof input?.store === "boolean" ? input.store : undefined,
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
reasoningSummary:
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
? reasoningSummary
: undefined,
include: include.length > 0 ? include : undefined,
textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined,
serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined,
}
}
@@ -1,7 +1,16 @@
import type { Options } from "../protocols/utils/open-responses-options.js"
import type { ProviderOptions } from "../schema/index.js"
import type { ResponseIncludable, ServiceTier } from "../protocols/utils/open-responses-options.js"
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema/index.js"
export type OpenResponsesOptionsInput = Options & { readonly [key: string]: unknown }
export interface OpenResponsesOptionsInput {
readonly [key: string]: unknown
readonly instructions?: string
readonly store?: boolean
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: TextVerbosity
readonly serviceTier?: ServiceTier
}
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
readonly openresponses?: OpenResponsesOptionsInput
@@ -19,7 +19,6 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
@@ -76,7 +75,6 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
export const baseten = define(profiles.baseten)
+3 -13
View File
@@ -7,7 +7,6 @@ import {
type FinishReasonDetails,
type AIError,
type LLMRequest,
type ProviderMetadata,
type UsageInput,
} from "./schema/index.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
@@ -34,22 +33,13 @@ export interface LayerOptions {
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
export const complete = (
options: {
readonly reason: FinishReasonDetails
readonly usage?: UsageInput
readonly providerMetadata?: ProviderMetadata
},
options: { readonly reason: FinishReasonDetails; readonly usage?: UsageInput },
...events: readonly LLMEvent[]
) => [
LLMEvent.stepStart({ index: 0 }),
...events,
LLMEvent.stepFinish({
index: 0,
reason: options.reason,
usage: options.usage,
providerMetadata: options.providerMetadata,
}),
LLMEvent.finish({ reason: options.reason, providerMetadata: options.providerMetadata }),
LLMEvent.stepFinish({ index: 0, reason: options.reason, usage: options.usage }),
LLMEvent.finish({ reason: options.reason }),
]
export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events)
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { OpenAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
@@ -56,28 +56,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("lowers chronological system updates as standard developer messages", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
it.effect("rejects OpenAI-native tools", () =>
Effect.gen(function* () {
const model = configure({
@@ -123,36 +101,13 @@ describe("Open Responses-compatible route", () => {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
providerOptions: {
openresponses: {
reasoningEffort: "low",
store: true,
truncation: "auto",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
parallelToolCalls: false,
},
},
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Think.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
)
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." }))
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
store: true,
truncation: "auto",
tool_choice: {
type: "allowed_tools",
mode: "auto",
tools: [{ type: "function", name: "lookup" }],
},
max_tool_calls: 2,
parallel_tool_calls: false,
})
}),
)
@@ -241,18 +241,27 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers chronological system updates to developer messages in order", () =>
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
messages: [
Message.user("Before."),
Message.system("Treat </system-update> literally."),
Message.assistant("After."),
],
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{
role: "user",
content: [
{ type: "input_text", text: "Before." },
{ type: "input_text", text: "<system-update>\nTreat &lt;/system-update&gt; literally.\n</system-update>" },
],
},
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
@@ -1274,20 +1283,11 @@ describe("OpenAI Responses route", () => {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think",
promptCacheKey: "session_123",
tools: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
],
toolChoice: "none",
providerOptions: {
openai: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
truncation: "disabled",
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
maxToolCalls: 4,
parallelToolCalls: false,
},
},
}),
@@ -1298,17 +1298,6 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.truncation).toBe("disabled")
expect(prepared.body.tool_choice).toEqual({
type: "allowed_tools",
mode: "required",
tools: [
{ type: "function", name: "read" },
{ type: "function", name: "grep" },
],
})
expect(prepared.body.max_tool_calls).toBe(4)
expect(prepared.body.parallel_tool_calls).toBe(false)
}),
)
@@ -1334,17 +1323,20 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes forward-compatible includable values through", () =>
it.effect("filters unknown includable values out of the include array", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "hi",
// The user passed one invalid entry alongside a valid one. Keep the
// valid one so the request still succeeds rather than failing on a
// typo from upstream config.
providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
}),
)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content", "bogus.thing"])
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
}),
)
@@ -1358,13 +1350,13 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes an unknown includable value through", () =>
it.effect("treats an all-invalid include as no include at all", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
)
expect(prepared.body.include).toEqual(["bogus.thing"])
expect(prepared.body.include).toBeUndefined()
}),
)
@@ -7,7 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server"
import { pluginLabel } from "@/utils/plugin"
import { pluginLabels } from "@/utils/plugin"
import { ExternalLink } from "./external-link"
type SkillItem = {
@@ -102,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
const globalPlugins = createMemo(() => pluginLabels(globalPluginList.latest ?? []))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
return pluginLabels(projectPluginList.latest ?? []).filter((name) => !shared.has(name))
})
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
@@ -6,7 +6,7 @@ import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useMcpToggle } from "@/context/mcp"
import { pluginLabel } from "@/utils/plugin"
import { pluginLabels } from "@/utils/plugin"
import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css"
@@ -45,9 +45,7 @@ export const SettingsExtensionsV2: Component = () => {
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
)
const plugins = createMemo<PluginRowItem[]>(() =>
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
)
const plugins = createMemo<PluginRowItem[]>(() => pluginLabels(pluginList.latest ?? []).map((name) => ({ name })))
createEffect(() => {
if (serverSdk.connection.status() !== "connected") return
@@ -6,7 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { pluginLabel } from "@/utils/plugin"
import { pluginLabels } from "@/utils/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -39,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
const plugins = createMemo(() => pluginLabels(pluginList.latest ?? []))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
@@ -62,16 +62,13 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
})
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
const animation = controlledAnimationFrames(targetWindow)
const route = targetWindow.document.createElement("section")
const viewport = targetWindow.document.createElement("div")
const route = document.createElement("section")
const viewport = document.createElement("div")
route.append(viewport)
targetWindow.document.body.append(route)
document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow,
targetWindow: window,
scrollOffset: 79_400,
options: {
horizontal: false,
@@ -86,23 +83,20 @@ test("keeps checking until stale reset-delay callbacks can no longer win", async
instance.scrollOffset = offset
})
try {
mutations.remove(route)
mutations.append(targetWindow.document.body, route)
animation.run(16)
expect(instance.scrollOffset).toBe(0)
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(1)
expect(instance.scrollOffset).toBe(0)
instance.scrollOffset = 79_400
animation.run(32)
animation.run(48)
instance.scrollOffset = 79_400
await new Promise((resolve) => setTimeout(resolve, 25))
await frames(3)
expect(instance.scrollOffset).toBe(0)
expect(calls).toEqual([0, 0])
expect(animation.pending()).toBe(0)
} finally {
cleanup?.()
await targetWindow.happyDOM.close()
}
expect(instance.scrollOffset).toBe(0)
expect(calls).toEqual([0, 0])
cleanup?.()
route.remove()
})
test.each([
@@ -241,29 +235,3 @@ function controlledMutations(targetWindow: Window) {
},
}
}
function controlledAnimationFrames(targetWindow: Window) {
let time = 0
let id = 0
const callbacks = new Map<number, FrameRequestCallback>()
Object.defineProperty(targetWindow.performance, "now", { value: () => time })
Object.defineProperty(targetWindow, "requestAnimationFrame", {
value: (callback: FrameRequestCallback) => {
id += 1
callbacks.set(id, callback)
return id
},
})
Object.defineProperty(targetWindow, "cancelAnimationFrame", {
value: (frame: number) => callbacks.delete(frame),
})
return {
run(at: number) {
time = at
const pending = [...callbacks.values()]
callbacks.clear()
pending.forEach((callback) => callback(at))
},
pending: () => callbacks.size,
}
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import { pluginLabels } from "./plugin"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
]
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
})
})
+4
View File
@@ -6,3 +6,7 @@ export function pluginLabel(plugin: PluginInfo) {
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
export function pluginLabels(plugins: readonly PluginInfo[]) {
return plugins.filter((plugin) => plugin.source.type !== "builtin").map(pluginLabel)
}
+4 -25
View File
@@ -1,5 +1,6 @@
import { Argument, Flag } from "effect/unstable/cli"
import { Argument, Command, Flag } from "effect/unstable/cli"
import { Spec } from "../framework/spec"
import { GlobalFlags } from "./global-flags"
declare const OPENCODE_CLI_NAME: string | undefined
@@ -159,29 +160,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
}),
Spec.make("plugin", {
description: "Manage plugins",
commands: [
Spec.make("list", {
description: "List plugins",
params: {
builtin: Flag.boolean("builtin").pipe(
Flag.withDescription("Include built-in server plugins"),
Flag.withDefault(false),
),
},
}),
Spec.make("add", {
description: "Install a plugin and add it to the global configuration",
params: {
package: Argument.string("package").pipe(Argument.withDescription("npm registry package specifier")),
},
}),
Spec.make("remove", {
description: "Remove a plugin from global configuration",
params: {
package: Argument.string("package").pipe(Argument.withDescription("configured package specifier")),
},
}),
],
commands: [Spec.make("list", { description: "List active plugins" })],
}),
Spec.make("models", {
description: "List all available models",
@@ -342,4 +321,4 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
],
})
export const Commands = Root
export const Commands = { ...Root, spec: Root.spec.pipe(Command.withGlobalFlags(GlobalFlags.all)) }
+12
View File
@@ -0,0 +1,12 @@
export * as GlobalFlags from "./global-flags"
import { Flag, GlobalFlag } from "effect/unstable/cli"
export const CpuProfile = GlobalFlag.setting("cpu-profile")({
flag: Flag.string("cpu-profile").pipe(
Flag.withDescription("Write a CPU profile to this path when the process stops"),
Flag.optional,
),
})
export const all = [CpuProfile] as const
@@ -84,12 +84,8 @@ export default Runtime.handler(Commands, (input) =>
update: (update) => runPromise(config.update(update)),
},
packages: {
resolve: (spec, install = true) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
resolve: (spec) =>
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
@@ -1,82 +0,0 @@
import { EOL } from "node:os"
import path from "node:path"
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { resolveConfigPath } from "../mcp/add"
import { Config } from "../../../config"
export default Runtime.handler(
Commands.commands.plugin.commands.add,
Effect.fn("cli.plugin.add")(function* (input) {
if (!(yield* Effect.promise(() => Npm.isRegistryPackage(input.package))))
return yield* Effect.fail(
new Error("Plugin target must be an npm registry package name, version, tag, or semver range"),
)
const npm = yield* Npm.Service
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
if (!target)
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
if (target === "server") {
const global = yield* Global.Service
const configPath = yield* Effect.promise(() => resolveConfigPath(global.config))
const changed = yield* Effect.promise(() => writePluginConfig(configPath, input.package))
process.stdout.write(
changed
? `Plugin "${input.package}" installed and added to ${configPath}${EOL}`
: `Plugin "${input.package}" is already configured in ${configPath}${EOL}`,
)
return
}
const config = yield* Config.Service
yield* config.update((draft) => {
if (configured(draft.plugins, input.package)) return
draft.plugins = [...(draft.plugins ?? []), input.package]
})
process.stdout.write(`TUI plugin "${input.package}" installed and added to ${config.path}${EOL}`)
}),
)
export function configurationTarget(server?: string, tui?: string) {
if (server) return "server" as const
if (tui) return "tui" as const
}
export async function writePluginConfig(configPath: string, spec: string) {
const text = await readFile(configPath, "utf8").catch((error) => {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return "{}"
throw error
})
const errors: ParseError[] = []
const config: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length || typeof config !== "object" || config === null || Array.isArray(config))
throw new Error(`Invalid global configuration: ${configPath}`)
const plugins = "plugins" in config ? config.plugins : undefined
if (plugins !== undefined && !Array.isArray(plugins)) throw new Error(`Invalid plugins configuration: ${configPath}`)
if (configured(plugins, spec)) return false
const updated = applyEdits(
text,
modify(text, ["plugins"], [...(plugins ?? []), spec], { formattingOptions: { tabSize: 2, insertSpaces: true } }),
)
await mkdir(path.dirname(configPath), { recursive: true })
const temporary = configPath + ".tmp"
await writeFile(temporary, updated.endsWith("\n") ? updated : updated + "\n", { mode: 0o600 })
await rename(temporary, configPath)
return true
}
function configured(plugins: readonly unknown[] | undefined, spec: string) {
return plugins?.some(
(entry) =>
entry === spec || (typeof entry === "object" && entry !== null && "package" in entry && entry.package === spec),
)
}
@@ -5,69 +5,24 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
Effect.fn("cli.plugin.list")(function* (input) {
Effect.fn("cli.plugin.list")(function* () {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const config = yield* Config.Service
const global = yield* Global.Service
const info = yield* config.get()
const discovered = yield* Effect.promise(() =>
tuiPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
)
const output = format(
response.data,
[
...(info.plugins ?? []).flatMap((entry) => {
const target = typeof entry === "string" ? entry : entry.package
return target.startsWith("-") ? [] : [{ target, source: "configured" as const }]
}),
...discovered.map((target) => ({ target, source: "discovered" as const })),
],
input.builtin,
)
if (!output) {
process.stdout.write("No plugins found" + EOL)
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b)))
if (plugins.length === 0) {
process.stdout.write("No plugins loaded" + EOL)
return
}
process.stdout.write(output + EOL)
process.stdout.write(plugins.map(name).join(EOL) + EOL)
}),
)
export function format(
plugins: readonly PluginInfo[],
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
builtin = false,
) {
const server = plugins
.filter((plugin) => builtin || plugin.source.type !== "builtin")
.toSorted((a, b) => name(a).localeCompare(name(b)))
.map((plugin) => `${name(plugin)} (${plugin.status})`)
const advertised = plugins.flatMap((plugin) =>
plugin.status === "active" && plugin.tui && plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
: [],
)
const targets = [...tui, ...advertised]
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
.toSorted((a, b) => a.target.localeCompare(b.target))
.map((plugin) => `${plugin.target} (${plugin.source})`)
return [
targets.length ? ["TUI", ...targets].join(EOL) : undefined,
server.length ? ["Server", ...server].join(EOL) : undefined,
]
.filter((section) => section !== undefined)
.join(EOL + EOL)
}
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
@@ -1,74 +0,0 @@
import { EOL } from "node:os"
import path from "node:path"
import { readFile, rename, writeFile } from "node:fs/promises"
import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import { Global } from "@opencode-ai/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Config } from "../../../config"
import { resolveConfigPath } from "../mcp/add"
export default Runtime.handler(
Commands.commands.plugin.commands.remove,
Effect.fn("cli.plugin.remove")(function* (input) {
const global = yield* Global.Service
const configPath = yield* Effect.promise(() => resolveConfigPath(global.config))
const server = yield* Effect.promise(() => removePluginConfig(configPath, input.package))
const config = yield* Config.Service
const info = yield* config.get()
const tui = configured(info.plugins, input.package)
if (tui)
yield* config.update((draft) => {
draft.plugins = draft.plugins?.filter((entry) => !matches(entry, input.package))
})
const removed = [server ? configPath : undefined, tui ? config.path : undefined].filter(
(file) => file !== undefined,
)
process.stdout.write(
removed.length
? `Plugin "${input.package}" removed from ${removed.join(", ")}${EOL}`
: `Plugin "${input.package}" is not configured${EOL}`,
)
}),
)
export async function removePluginConfig(configPath: string, spec: string) {
const text = await readFile(configPath, "utf8").catch((error) => {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return undefined
throw error
})
if (text === undefined) return false
const errors: ParseError[] = []
const config: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length || typeof config !== "object" || config === null || Array.isArray(config))
throw new Error(`Invalid global configuration: ${configPath}`)
const plugins = "plugins" in config ? config.plugins : undefined
if (plugins !== undefined && !Array.isArray(plugins)) throw new Error(`Invalid plugins configuration: ${configPath}`)
if (!configured(plugins, spec)) return false
const updated = applyEdits(
text,
modify(
text,
["plugins"],
plugins?.filter((entry) => !matches(entry, spec)),
{
formattingOptions: { tabSize: 2, insertSpaces: true },
},
),
)
const temporary = configPath + ".tmp"
await writeFile(temporary, updated.endsWith("\n") ? updated : updated + "\n", { mode: 0o600 })
await rename(temporary, configPath)
return true
}
function configured(plugins: readonly unknown[] | undefined, spec: string) {
return plugins?.some((entry) => matches(entry, spec)) ?? false
}
function matches(entry: unknown, spec: string) {
return entry === spec || (typeof entry === "object" && entry !== null && "package" in entry && entry.package === spec)
}
+2 -28
View File
@@ -1,36 +1,10 @@
export * as CpuProfile from "./cpu-profile"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem, Queue } from "effect"
import { Effect, FileSystem } from "effect"
import { Session } from "node:inspector"
import path from "node:path"
export const listen = Effect.gen(function* () {
const global = yield* Global.Service
if (process.platform === "win32") return
const signals = yield* Queue.dropping<void>(1)
yield* Effect.acquireRelease(
Effect.sync(() => {
const handler = () => Queue.offerUnsafe(signals, undefined)
process.on("SIGPROF", handler)
return handler
}),
(handler) => Effect.sync(() => process.off("SIGPROF", handler)),
)
yield* Effect.gen(function* () {
yield* Queue.take(signals)
const file = path.join(
global.log,
`cpu-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.cpuprofile`,
)
yield* run(file, Effect.sleep("10 seconds")).pipe(
Effect.catchCause((cause) => Effect.logError("Failed to capture CPU profile", { path: file, cause })),
)
yield* Queue.poll(signals)
}).pipe(Effect.forever, Effect.forkScoped({ startImmediately: true }))
})
function run<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
export function run<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
const target = path.resolve(file)
return Effect.acquireUseRelease(
Effect.gen(function* () {
+19 -2
View File
@@ -1,10 +1,13 @@
import { Effect, FileSystem, Scope } from "effect"
import { Effect, FileSystem, Option, Scope } from "effect"
import { Command } from "effect/unstable/cli"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
import { Npm } from "@opencode-ai/util/npm"
import { GlobalFlags } from "../commands/global-flags"
import { CpuProfile } from "../cpu-profile"
import path from "node:path"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -87,7 +90,21 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
Command.withHandler((input) =>
Effect.gen(function* () {
const module = yield* Effect.promise(handler.load)
return yield* module.default(input)
const cpuProfile = Option.getOrUndefined(yield* GlobalFlags.CpuProfile)
if (!cpuProfile) return yield* module.default(input)
const target = path.resolve(cpuProfile)
const previous = process.env.OPENCODE_CPU_PROFILE
process.env.OPENCODE_CPU_PROFILE = target
return yield* (
node.name === "serve" ? CpuProfile.run(target, module.default(input)) : module.default(input)
).pipe(
Effect.ensuring(
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
else process.env.OPENCODE_CPU_PROFILE = previous
}),
),
)
}),
),
)
-4
View File
@@ -13,7 +13,6 @@ import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Npm } from "@opencode-ai/util/npm"
import { Heap } from "./heap"
import { CpuProfile } from "./cpu-profile"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
@@ -39,8 +38,6 @@ const Handlers = Runtime.handlers(Commands, {
},
plugin: {
list: () => import("./commands/handlers/plugin/list"),
add: () => import("./commands/handlers/plugin/add"),
remove: () => import("./commands/handlers/plugin/remove"),
},
models: () => import("./commands/handlers/models"),
export: () => import("./commands/handlers/export"),
@@ -62,7 +59,6 @@ const Handlers = Runtime.handlers(Commands, {
Effect.gen(function* () {
yield* Heap.listen
yield* CpuProfile.listen
const runFork = Effect.runForkWith(yield* Effect.context<never>())
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
runFork(Effect.logError("uncaught exception", { cause, origin }))
+1 -7
View File
@@ -80,13 +80,7 @@ async function run(input: RunCommandInput, options: ExecutionOptions) {
}
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
const client = OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
// Bun's default five-minute deadline terminates the event stream used by long-running sessions.
fetch: ((request: RequestInfo | URL, init?: RequestInit) =>
fetch(request, { ...init, timeout: false } as BunFetchRequestInit)) as typeof fetch,
})
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const explicit = parseRunModel(input.model)
const target = await resolveSessionTarget({
client,
@@ -110,6 +110,7 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
...selfCommand(),
"serve",
"--service",
...(process.env.OPENCODE_CPU_PROFILE ? ["--cpu-profile", process.env.OPENCODE_CPU_PROFILE] : []),
],
}
})
-18
View File
@@ -1,18 +0,0 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { CpuProfile } from "../src/cpu-profile"
test("subscribes and unsubscribes SIGPROF with the CLI scope", async () => {
const listeners = process.listenerCount("SIGPROF")
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* CpuProfile.listen
expect(process.listenerCount("SIGPROF")).toBe(listeners + (process.platform === "win32" ? 0 : 1))
}),
).pipe(Effect.provideService(Global.Service, Global.make()), Effect.provide(NodeFileSystem.layer)),
)
expect(process.listenerCount("SIGPROF")).toBe(listeners)
})
-30
View File
@@ -1,30 +0,0 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { parse } from "jsonc-parser"
import { configurationTarget, writePluginConfig } from "../src/commands/handlers/plugin/add"
test("routes packages according to their exported runtimes", () => {
expect(configurationTarget("server.js", "tui.js")).toBe("server")
expect(configurationTarget("server.js", undefined)).toBe("server")
expect(configurationTarget(undefined, "tui.js")).toBe("tui")
expect(configurationTarget(undefined, undefined)).toBeUndefined()
})
test("adds a package to global plugin config without replacing unrelated settings", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "opencode.jsonc")
await Bun.write(file, '{\n // retained\n "model": "provider/model",\n "plugins": ["first"]\n}\n')
try {
expect(await writePluginConfig(file, "second@1.0.0")).toBe(true)
expect(await writePluginConfig(file, "second@1.0.0")).toBe(false)
const text = await Bun.file(file).text()
expect(text).toContain("// retained")
expect(parse(text)).toEqual({
model: "provider/model",
plugins: ["first", "second@1.0.0"],
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
-46
View File
@@ -1,46 +0,0 @@
import { expect, test } from "bun:test"
import { EOL } from "node:os"
import { format } from "../src/commands/handlers/plugin/list"
test("formats server and TUI plugins in sections without builtins", () => {
expect(
format(
[
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
{
id: "acme.dual",
source: { type: "package", package: "acme-plugin@1.0.0" },
status: "active",
tui: true,
},
{
source: { type: "package", package: "broken-plugin" },
status: "failed",
error: "broken",
tui: false,
},
],
[
{ target: "tui-only", source: "configured" },
{ target: "/tmp/local.ts", source: "discovered" },
],
),
).toBe(
[
"TUI",
"/tmp/local.ts (discovered)",
"acme-plugin@1.0.0 (advertised)",
"tui-only (configured)",
"",
"Server",
"acme.dual (active)",
"broken-plugin (failed)",
].join(EOL),
)
})
test("includes builtins when requested", () => {
expect(
format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true),
).toBe(["Server", "opencode.agent (active)"].join(EOL))
})
-23
View File
@@ -1,23 +0,0 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { parse } from "jsonc-parser"
import { removePluginConfig } from "../src/commands/handlers/plugin/remove"
test("removes string and object package entries without replacing unrelated settings", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "opencode.jsonc")
await Bun.write(
file,
'{\n // retained\n "model": "provider/model",\n "plugins": ["remove-me", { "package": "remove-me", "options": {} }, "keep-me"]\n}\n',
)
try {
expect(await removePluginConfig(file, "remove-me")).toBe(true)
expect(await removePluginConfig(file, "remove-me")).toBe(false)
const text = await Bun.file(file).text()
expect(text).toContain("// retained")
expect(parse(text)).toEqual({ model: "provider/model", plugins: ["keep-me"] })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
+23
View File
@@ -19,6 +19,29 @@ test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
})
test("managed service forwards the CPU profile path to the server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-profile-"))
const profile = path.join(root, "server.cpuprofile")
try {
const previous = process.env.OPENCODE_CPU_PROFILE
process.env.OPENCODE_CPU_PROFILE = profile
try {
const options = await Effect.runPromise(
ServiceConfig.options().pipe(
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
Effect.provide(NodeFileSystem.layer),
),
)
expect(options.command.slice(-2)).toEqual(["--cpu-profile", profile])
} finally {
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
else process.env.OPENCODE_CPU_PROFILE = previous
}
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
+1 -1
View File
@@ -30,7 +30,7 @@
"generate": "bun run script/build.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api",
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.test.json"
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/schema": "workspace:*",
+85 -179
View File
@@ -12,10 +12,10 @@ import type { Brand } from "effect"
import type { Model } from "@opencode-ai/schema/model"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { Event } from "@opencode-ai/schema/event"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
import type { Skill } from "@opencode-ai/schema/skill"
import type { Event } from "@opencode-ai/schema/event"
import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode-ai/schema/event-log"
@@ -139,46 +139,36 @@ export type Endpoint5_5Input = { readonly sessionID: Session.ID }
export type Endpoint5_5Output = Session.Info
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly recent?: number | undefined }
export type Endpoint5_6Output = {
readonly session: Session.Info
readonly children: ReadonlyArray<Session.Info>
readonly inbox: ReadonlyArray<SessionInbox.Info>
readonly messages: ReadonlyArray<SessionMessage.Info>
readonly seq: Event.Seq
}
export type SessionSnapshotOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
export type Endpoint5_6Input = { readonly sessionID: Session.ID }
export type Endpoint5_6Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
export type Endpoint5_7Input = { readonly sessionID: Session.ID }
export type Endpoint5_7Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type Endpoint5_7Output = Session.Info
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type Endpoint5_8Output = Session.Info
export type SessionForkOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_8Output = void
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_9Output = void
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_10Output = void
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_11Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_11Output = void
export type SessionRenameOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_12Input = {
export type Endpoint5_11Input = {
readonly sessionID: Session.ID
readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_12Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_11Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_13Input = {
export type Endpoint5_12Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -189,10 +179,10 @@ export type Endpoint5_13Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_12Output = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_14Input = {
export type Endpoint5_13Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
@@ -205,19 +195,19 @@ export type Endpoint5_14Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_14Output = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_13Output = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_15Input = {
export type Endpoint5_14Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly skill: Skill.ID
readonly resume?: boolean | undefined
}
export type Endpoint5_15Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_14Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_16Input = {
export type Endpoint5_15Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -226,98 +216,97 @@ export type Endpoint5_16Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_16Output = SessionInbox.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_15Output = SessionInbox.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_17Input = {
export type Endpoint5_16Input = {
readonly sessionID: Session.ID
readonly id?: Event.ID | undefined
readonly command: string
}
export type Endpoint5_17Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_16Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_18Input = {
export type Endpoint5_17Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_18Output = SessionInbox.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_17Output = SessionInbox.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
export type Endpoint5_19Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
export type Endpoint5_18Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_20Input = {
export type Endpoint5_19Input = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly files?: boolean | undefined
}
export type Endpoint5_20Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_19Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
export type Endpoint5_20Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
export type Endpoint5_24Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_24Output = void
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_25Output = void
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_26Output = void
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_27Output = void
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_28Output = ReadonlyArray<InstructionEntry.Info>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_28Output, E>
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_29Input = {
export type Endpoint5_28Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
export type Endpoint5_29Output = void
export type Endpoint5_28Output = void
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_29Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_29Input,
) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_30Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_30Input,
) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_30Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_31Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
export type Endpoint5_32Input = {
export type Endpoint5_31Input = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
readonly ephemeral?: boolean | undefined
}
export type Endpoint5_32Output =
export type Endpoint5_31Output =
| (
| {
readonly id: Event.ID
@@ -590,8 +579,6 @@ export type Endpoint5_32Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly cost: number & Brand.Brand<"Money.USD">
readonly tokens: {
readonly input: number
@@ -614,9 +601,6 @@ export type Endpoint5_32Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly finish?: "content-filter" | undefined
readonly rawFinish?: string | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
@@ -910,101 +894,24 @@ export type Endpoint5_32Output =
}
}
)
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.usage.updated"
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly cost: number & Brand.Brand<"Money.USD">
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.text.delta"
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly ordinal: number
readonly delta: string
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.reasoning.delta"
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly ordinal: number
readonly delta: string
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.input.delta"
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly delta: string
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.progress"
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly metadata: { readonly [x: string]: Schema.Json }
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.delta"
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly text: string }
}
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_32Input) => Stream.Stream<Endpoint5_32Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
export type Endpoint5_33Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type Endpoint5_32Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
export type Endpoint5_33Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type Endpoint5_34Input = { readonly sessionID: Session.ID }
export type Endpoint5_34Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_34Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_35Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
export type Endpoint5_36Input = { readonly sessionID: Session.ID; readonly variables: { readonly [x: string]: string } }
export type Endpoint5_36Output = void
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_36Input) => Effect.Effect<Endpoint5_36Output, E>
export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly variables: { readonly [x: string]: string } }
export type Endpoint5_35Output = void
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -1013,7 +920,6 @@ export interface SessionApi<E = never> {
readonly export: SessionExportOperation<E>
readonly active: SessionActiveOperation<E>
readonly get: SessionGetOperation<E>
readonly snapshot: SessionSnapshotOperation<E>
readonly remove: SessionRemoveOperation<E>
readonly fork: SessionForkOperation<E>
readonly switchAgent: SessionSwitchAgentOperation<E>
+80 -91
View File
@@ -86,8 +86,6 @@ import type {
Endpoint5_34Output,
Endpoint5_35Input,
Endpoint5_35Output,
Endpoint5_36Input,
Endpoint5_36Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -352,56 +350,48 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
preserveEffect<Endpoint5_6Output>()(
raw["session.snapshot"]({ params: { sessionID: input["sessionID"] }, query: { recent: input["recent"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
preserveEffect<Endpoint5_7Output>()(
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
preserveEffect<Endpoint5_8Output>()(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
preserveEffect<Endpoint5_8Output>()(
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
preserveEffect<Endpoint5_9Output>()(
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
raw["session.move"]({
params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -420,8 +410,8 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
),
)
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -442,16 +432,16 @@ const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14I
),
)
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
raw["session.skill"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -468,16 +458,16 @@ const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16I
),
)
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
raw["session.compact"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], delivery: input["delivery"] },
@@ -487,13 +477,13 @@ const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18I
),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -503,19 +493,27 @@ const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20I
),
)
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
preserveEffect<Endpoint5_21Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()(
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -523,70 +521,62 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_31Output>()(
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveStream<Endpoint5_32Output>()(
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveStream<Endpoint5_31Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
query: { after: input["after"], follow: input["follow"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
@@ -594,29 +584,29 @@ const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32I
),
)
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveEffect<Endpoint5_32Output>()(
raw["session.interrupt"]({
params: { sessionID: input["sessionID"] },
query: { continue: input["continue"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_36 = (raw: RawClient["server.session"]) => (input: Endpoint5_36Input) =>
preserveEffect<Endpoint5_36Output>()(
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
raw["session.environment"]({
params: { sessionID: input["sessionID"] },
payload: { variables: input["variables"] },
@@ -630,30 +620,29 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
export: Endpoint5_3(raw),
active: Endpoint5_4(raw),
get: Endpoint5_5(raw),
snapshot: Endpoint5_6(raw),
remove: Endpoint5_7(raw),
fork: Endpoint5_8(raw),
switchAgent: Endpoint5_9(raw),
switchModel: Endpoint5_10(raw),
rename: Endpoint5_11(raw),
move: Endpoint5_12(raw),
prompt: Endpoint5_13(raw),
command: Endpoint5_14(raw),
skill: Endpoint5_15(raw),
synthetic: Endpoint5_16(raw),
shell: Endpoint5_17(raw),
compact: Endpoint5_18(raw),
wait: Endpoint5_19(raw),
revert: { stage: Endpoint5_20(raw), clear: Endpoint5_21(raw), commit: Endpoint5_22(raw) },
context: Endpoint5_23(raw),
inbox: { list: Endpoint5_24(raw), cancel: Endpoint5_25(raw), steer: Endpoint5_26(raw), queue: Endpoint5_27(raw) },
instructions: { entry: { list: Endpoint5_28(raw), put: Endpoint5_29(raw), remove: Endpoint5_30(raw) } },
generate: Endpoint5_31(raw),
log: Endpoint5_32(raw),
interrupt: Endpoint5_33(raw),
background: Endpoint5_34(raw),
message: Endpoint5_35(raw),
environment: Endpoint5_36(raw),
remove: Endpoint5_6(raw),
fork: Endpoint5_7(raw),
switchAgent: Endpoint5_8(raw),
switchModel: Endpoint5_9(raw),
rename: Endpoint5_10(raw),
move: Endpoint5_11(raw),
prompt: Endpoint5_12(raw),
command: Endpoint5_13(raw),
skill: Endpoint5_14(raw),
synthetic: Endpoint5_15(raw),
shell: Endpoint5_16(raw),
compact: Endpoint5_17(raw),
wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw),
inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
generate: Endpoint5_30(raw),
log: Endpoint5_31(raw),
interrupt: Endpoint5_32(raw),
background: Endpoint5_33(raw),
message: Endpoint5_34(raw),
environment: Endpoint5_35(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -20,8 +20,6 @@ import type {
SessionActiveOutput,
SessionGetInput,
SessionGetOutput,
SessionSnapshotInput,
SessionSnapshotOutput,
SessionRemoveInput,
SessionRemoveOutput,
SessionForkInput,
@@ -516,18 +514,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
snapshot: (input: SessionSnapshotInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionSnapshotOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/snapshot`,
query: { recent: input["recent"] },
successStatus: 200,
declaredStatuses: [404, 500, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: SessionRemoveInput, requestOptions?: RequestOptions) =>
request<SessionRemoveOutput>(
{
@@ -857,9 +843,9 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`,
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
query: { after: input["after"], follow: input["follow"] },
successStatus: 200,
declaredStatuses: [404, 409, 401, 400],
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
+48 -109
View File
@@ -512,51 +512,6 @@ export type SessionRevertCommitted = {
data: { sessionID: string; to: string }
}
export type SessionTextDelta = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.text.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
}
export type SessionReasoningDelta = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.reasoning.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
}
export type SessionToolInputDelta = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.input.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; delta: string }
}
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; metadata: { [x: string]: JsonValue } }
}
export type SessionCompactionDelta = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.delta"
location?: LocationRef
data: { sessionID: string; text: string }
}
export type ModelsDevRefreshed = {
id: string
created: number
@@ -602,6 +557,51 @@ export type AgentUpdated = {
data: {}
}
export type SessionTextDelta = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.text.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
}
export type SessionReasoningDelta = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.reasoning.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
}
export type SessionToolInputDelta = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.input.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; delta: string }
}
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; metadata: { [x: string]: JsonValue } }
}
export type SessionCompactionDelta = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.delta"
location?: LocationRef
data: { sessionID: string; text: string }
}
export type FilesystemChanged = {
id: string
created: number
@@ -1108,8 +1108,6 @@ export type SessionStepEnded = {
sessionID: string
assistantMessageID: string
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState1
cost: MoneyUSD
tokens: TokenUsageInfo
snapshot?: string
@@ -1147,9 +1145,6 @@ export type SessionStepFailed = {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
finish?: "content-filter"
rawFinish?: string
providerState?: SessionMessageProviderState1
cost?: MoneyUSD
tokens?: TokenUsageInfo
snapshot?: string
@@ -1926,8 +1921,6 @@ export type SessionMessageAssistant = {
content: Array<SessionMessageAssistantText | SessionMessageAssistantReasoning | SessionMessageAssistantTool>
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
@@ -2001,15 +1994,7 @@ export type FormCreated = {
data: { form: FormInfo1 }
}
export type SessionLogItem =
| SessionEventDurable
| SessionUsageUpdated
| SessionTextDelta
| SessionReasoningDelta
| SessionToolInputDelta
| SessionToolProgress
| SessionCompactionDelta
| EventLogSynced
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields }
@@ -2019,16 +2004,6 @@ export type FormInfo = { id: string; sessionID: string; title: string; metadata?
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
export type SessionSnapshotResponse = {
data: {
session: SessionInfo
children: Array<SessionInfo>
inbox: Array<SessionInboxInfo>
messages: Array<SessionMessageInfo>
seq: number
}
}
export type SessionMessagesResponse = {
data: Array<SessionMessageInfo>
cursor: { previous?: string | null; next?: string | null }
@@ -2243,16 +2218,6 @@ export const isInstructionEntryValueTooLargeError = (value: unknown): value is I
"_tag" in value &&
value["_tag"] === "InstructionEntryValueTooLargeError"
export type SeqUnavailableError = {
readonly _tag: "SeqUnavailableError"
readonly sessionID: string
readonly after: number
readonly head?: number | undefined
readonly message: string
}
export const isSeqUnavailableError = (value: unknown): value is SeqUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SeqUnavailableError"
export type ProviderNotFoundError = {
readonly _tag: "ProviderNotFoundError"
readonly providerID: string
@@ -2726,8 +2691,6 @@ export type SessionImportInput = {
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -2995,8 +2958,6 @@ export type SessionImportInput = {
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3264,8 +3225,6 @@ export type SessionImportInput = {
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3331,13 +3290,6 @@ export type SessionGetInput = { readonly sessionID: { readonly sessionID: string
export type SessionGetOutput = { data: SessionInfo }["data"]
export type SessionSnapshotInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly recent?: { readonly recent?: number | undefined }["recent"]
}
export type SessionSnapshotOutput = SessionSnapshotResponse["data"]
export type SessionRemoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionRemoveOutput = void
@@ -3976,21 +3928,8 @@ export type SessionGenerateOutput = SessionGenerateResponse["data"]
export type SessionLogInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly after?: {
readonly after?: number | undefined
readonly follow?: boolean | undefined
readonly ephemeral?: boolean | undefined
}["after"]
readonly follow?: {
readonly after?: number | undefined
readonly follow?: boolean | undefined
readonly ephemeral?: boolean | undefined
}["follow"]
readonly ephemeral?: {
readonly after?: number | undefined
readonly follow?: boolean | undefined
readonly ephemeral?: boolean | undefined
}["ephemeral"]
readonly after?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["after"]
readonly follow?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["follow"]
}
export type SessionLogOutput = SessionLogItem
+1 -7
View File
@@ -601,8 +601,6 @@ export function createData(config: CreateDataInput) {
existing.retry = undefined
existing.error = undefined
existing.finish = undefined
existing.rawFinish = undefined
existing.providerState = undefined
existing.time.completed = undefined
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
return
@@ -630,8 +628,6 @@ export function createData(config: CreateDataInput) {
if (!currentAssistant) return
currentAssistant.time.completed = event.created
currentAssistant.finish = event.data.finish
currentAssistant.rawFinish = event.data.rawFinish
currentAssistant.providerState = event.data.providerState
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
if (event.data.snapshot)
@@ -644,9 +640,7 @@ export function createData(config: CreateDataInput) {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.created
currentAssistant.finish = event.data.finish ?? "error"
currentAssistant.rawFinish = event.data.rawFinish
currentAssistant.providerState = event.data.providerState
currentAssistant.finish = "error"
currentAssistant.error = event.data.error
currentAssistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
-248
View File
@@ -1,248 +0,0 @@
import { batch, createSignal, onCleanup, untrack } from "solid-js"
import type { Signal } from "solid-js"
import type { OpenCodeClient, OpenCodeEvent, SessionPromptInput } from "../promise"
import { isSeqUnavailableError } from "../promise"
import { createData } from "./data"
import type { CreateDataInput } from "./data"
import { Engine } from "./engine/engine"
type SessionApi = Pick<OpenCodeClient["session"], "snapshot" | "log" | "prompt">
// The legacy layer reconciles handed-off values into its own store, mutating
// them in place — so anything shared with it must be a copy, never engine
// state. Engine data is plain JSON, so a recursive copy suffices.
function clone<T>(value: T): T {
if (value === null || typeof value !== "object") return value
if (Array.isArray(value)) return value.map(clone) as T
const copy: Record<string, unknown> = {}
for (const key in value) copy[key] = clone(value[key as keyof T])
return copy as T
}
const ambientSessionEvents = new Set<OpenCodeEvent["type"]>([
"session.created",
"session.deleted",
"session.renamed",
"session.execution.started",
"session.execution.succeeded",
"session.execution.failed",
"session.execution.interrupted",
])
/** How many recent messages a session snapshot fetch requests. */
export const SNAPSHOT_RECENT = 200
export function createEngineTransport(api: () => SessionApi): Engine.SessionTransport {
return {
snapshot(sessionID) {
return api().snapshot({ sessionID, recent: SNAPSHOT_RECENT })
},
async *stream(sessionID, after, signal) {
try {
for await (const item of api().log(
{ sessionID, after, follow: true, ephemeral: true },
signal ? { signal } : undefined,
)) {
if (item.type !== "session.forked") yield item
}
} catch (error) {
if (isSeqUnavailableError(error)) throw new Engine.SeqUnavailable()
throw error
}
},
async submit(input) {
try {
await api().prompt({ ...input.request, sessionID: input.sessionID, id: input.id })
} catch (error) {
if (isTypedError(error)) throw new Engine.SubmitRejected(error.message)
throw error
}
},
}
}
export function createEngineData(config: CreateDataInput) {
const legacy = createData({
...config,
event: {
on: config.event.on,
listen(handler) {
return config.event.listen((event) => {
if (event.name.startsWith("session.") && !ambientSessionEvents.has(event.name)) return
handler(event)
})
},
},
})
const engines = new Map<string, Promise<Engine.SessionEngine>>()
const families = new Set<string>()
const invalidated = new Set<string>()
const failures = new Set<(failure: Engine.IntentFailure) => void>()
const cleanups = new Set<() => void>()
const transport = createEngineTransport(() => config.api().session)
let connected = false
// One signal per session holding the engine's immutable view. The fold is a
// persistent structure — unchanged subtrees keep their object identity
// across publishes — so keyed consumers get row stability from reference
// equality, and the engine's publish guard already drops identity-unchanged
// views. Reactivity is per session: any change to a session's view re-runs
// that session's readers.
const signals = new Map<string, Signal<Engine.SessionView | undefined>>()
const viewSignal = (sessionID: string) => {
const existing = signals.get(sessionID)
if (existing) return existing
const created = createSignal<Engine.SessionView | undefined>(undefined)
signals.set(sessionID, created)
return created
}
const view = (sessionID: string) => viewSignal(sessionID)[0]()
const update = (sessionID: string, next: Engine.SessionView) => {
const [read, write] = viewSignal(sessionID)
const previous = untrack(read)
batch(() => {
write(next)
if (next.session !== previous?.session) {
const current = legacy.session.get(sessionID)
if (!current || current.time.updated <= next.session.time.updated) {
legacy.session.remember(clone(next.session))
}
}
if (families.has(sessionID) && next.children !== previous?.children) {
next.children.forEach((child) => legacy.session.remember(clone(child)))
}
})
}
const ensure = (sessionID: string) => {
const existing = engines.get(sessionID)
if (existing) return existing
const created = Engine.createSessionEngine(sessionID, transport).then((engine) => {
update(sessionID, engine.view())
cleanups.add(engine.subscribe((view) => update(sessionID, view)))
cleanups.add(engine.subscribeFailures((failure) => failures.forEach((listener) => listener(failure))))
return engine
})
engines.set(sessionID, created)
void created.catch(() => engines.delete(sessionID))
return created
}
const sync = async (sessionID: string) => {
const engine = await ensure(sessionID)
if (invalidated.delete(sessionID)) await engine.refresh()
await engine.ready()
}
cleanups.add(
config.event.on("server.connected", () => {
if (!connected) {
connected = true
return
}
engines.forEach((engine) => void engine.then((handle) => handle.refresh()).catch(() => undefined))
}),
)
onCleanup(() => {
cleanups.forEach((cleanup) => cleanup())
engines.forEach((engine) => void engine.then((handle) => handle.stop()))
})
return {
...legacy,
on: config.event.on,
listen: config.event.listen,
session: {
...legacy.session,
async sync(sessionID: string, options?: { readonly children?: boolean }) {
if (options?.children) families.add(sessionID)
await sync(sessionID)
if (!options?.children) return
view(sessionID)?.children.forEach((child) => legacy.session.remember(clone(child)))
},
invalidate(sessionID: string) {
invalidated.add(sessionID)
},
status(sessionID: string) {
if (view(sessionID)?.active === "running") return "running"
return legacy.session.status(sessionID)
},
input: {
list(sessionID: string) {
return (
view(sessionID)
?.pending.filter((item) => item.type !== "compaction")
.map((item) => item.id) ?? legacy.session.input.list(sessionID)
)
},
has(sessionID: string, inboxID: string) {
return (
view(sessionID)?.pending.some((item) => item.type !== "compaction" && item.id === inboxID) ??
legacy.session.input.has(sessionID, inboxID)
)
},
},
pending: {
list(sessionID: string) {
void ensure(sessionID)
return [...(view(sessionID)?.pending ?? [])]
},
sync(sessionID: string) {
return sync(sessionID)
},
invalidate(sessionID: string) {
invalidated.add(sessionID)
},
},
message: {
list(sessionID: string) {
void ensure(sessionID)
return [...(view(sessionID)?.messages ?? [])]
},
get(sessionID: string, messageID: string) {
void ensure(sessionID)
return view(sessionID)?.messages.find((message) => message.id === messageID)
},
sync(sessionID: string) {
return sync(sessionID)
},
invalidate(sessionID: string) {
invalidated.add(sessionID)
},
},
async prompt(input: SessionPromptInput) {
return (await ensure(input.sessionID)).submit({
id: input.id ?? undefined,
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
metadata: input.metadata,
delivery: input.delivery,
resume: input.resume,
})
},
failures: {
listen(listener: (failure: Engine.IntentFailure) => void) {
failures.add(listener)
return () => failures.delete(listener)
},
},
},
}
}
function isTypedError(error: unknown): error is { readonly _tag: string; readonly message: string } {
return (
typeof error === "object" &&
error !== null &&
"_tag" in error &&
typeof error._tag === "string" &&
"message" in error &&
typeof error.message === "string"
)
}
export type EngineData = ReturnType<typeof createEngineData>
-498
View File
@@ -1,498 +0,0 @@
import type {
EventLogSynced,
SessionCompactionDelta,
SessionInboxInfo,
SessionInboxItem,
SessionMessageInfo,
SessionPromptInput,
SessionReasoningDelta,
SessionTextDelta,
SessionToolInputDelta,
SessionToolProgress,
SessionUsageUpdated,
} from "../../promise"
import { SessionFold } from "./fold"
import type { DurableSessionEvent, SessionFoldState, SessionSnapshot } from "./fold"
export type EphemeralSessionEvent =
| SessionTextDelta
| SessionReasoningDelta
| SessionToolInputDelta
| SessionToolProgress
| SessionCompactionDelta
| SessionUsageUpdated
export type SessionStreamItem = DurableSessionEvent | EphemeralSessionEvent | EventLogSynced
export type Intent = {
readonly id: string
readonly item: Extract<SessionInboxItem, { readonly type: "user" | "synthetic" }>
readonly request: Omit<SessionPromptInput, "sessionID" | "id">
readonly created: number
}
export type SubmitInput = {
readonly id: string
readonly sessionID: string
readonly request: Intent["request"]
}
export type IntentFailure = {
readonly intent: Intent
readonly reason: string
}
export class SubmitRejected extends Error {
readonly _tag = "SubmitRejected"
constructor(readonly reason: string) {
super(reason)
}
}
export class SeqUnavailable extends Error {
readonly _tag = "SeqUnavailable"
}
export interface SessionTransport {
readonly snapshot: (sessionID: string) => Promise<SessionSnapshot>
readonly stream: (sessionID: string, after: number, signal?: AbortSignal) => AsyncIterable<SessionStreamItem>
readonly submit: (input: SubmitInput) => Promise<void>
}
export type SessionView = SessionFoldState & {
readonly pending: ReadonlyArray<SessionInboxInfo>
}
export interface SessionEngine {
readonly sessionID: string
readonly view: () => SessionView
readonly submit: (input: Intent["request"] & { readonly id?: string }) => Intent
readonly subscribe: (listener: (view: SessionView) => void) => () => void
readonly subscribeFailures: (listener: (failure: IntentFailure) => void) => () => void
readonly ready: () => Promise<void>
readonly refresh: () => Promise<void>
readonly settled: () => Promise<void>
readonly stop: () => void
}
export type SessionEngineOptions = {
readonly makeID?: () => string
readonly now?: () => number
readonly reconnect?: () => Promise<void>
}
type Overlay = ReadonlyMap<string, OverlayEntry>
type OverlayEntry =
| { readonly type: "text"; readonly value: string }
| { readonly type: "reasoning"; readonly value: string }
| { readonly type: "tool-input"; readonly value: string }
| { readonly type: "tool-progress"; readonly metadata: SessionToolProgress["data"]["metadata"] }
| { readonly type: "compaction"; readonly value: string }
| { readonly type: "usage"; readonly value: SessionUsageUpdated["data"] }
type EngineState = {
readonly folded: SessionFoldState
readonly outbox: ReadonlyArray<Intent>
readonly overlay: Overlay
readonly synced: boolean
}
export async function createSessionEngine(
sessionID: string,
transport: SessionTransport,
options: SessionEngineOptions = {},
): Promise<SessionEngine> {
let counter = 0
const makeID = options.makeID ?? (() => `msg_${Date.now().toString(36)}_${++counter}`)
const now = options.now ?? Date.now
const reconnect = options.reconnect ?? (() => new Promise<void>((resolve) => setTimeout(resolve, 100)))
let state: EngineState = {
folded: SessionFold.fromSnapshot(await transport.snapshot(sessionID)),
outbox: [],
overlay: new Map(),
synced: false,
}
const listeners = new Set<(view: SessionView) => void>()
const failureListeners = new Set<(failure: IntentFailure) => void>()
const settled = new Set<() => void>()
const ready = Promise.withResolvers<void>()
let sent: string | undefined
let stopped = false
let sending = false
let refreshing: Promise<void> | undefined
const abort = new AbortController()
const publish = (next: EngineState) => {
const previous = state
state = next
// Views derive from folded/outbox/overlay only, so synced flips and stale
// replays (where the fold returns its input) need no render or notify.
if (next.folded !== previous.folded || next.outbox !== previous.outbox || next.overlay !== previous.overlay) {
const view = render(state)
listeners.forEach((listener) => listener(view))
}
if (state.outbox.length > 0) return
settled.forEach((resolve) => resolve())
settled.clear()
}
const applySnapshot = (snapshot: SessionSnapshot, synced = false) => {
const folded = SessionFold.fromSnapshot(snapshot)
const acknowledged = new Set([
...folded.messages.map((message) => message.id),
...folded.inbox.map((item) => item.id),
])
publish({
folded,
outbox: state.outbox.filter((intent) => !acknowledged.has(intent.id)),
overlay: new Map(),
synced,
})
}
const applyDurable = (event: DurableSessionEvent) => {
if (event.type === "session.inbox.enqueued" && sent === event.data.inboxID) sent = undefined
publish({
folded: SessionFold.apply(state.folded, event),
outbox:
event.type === "session.inbox.enqueued"
? state.outbox.filter((intent) => intent.id !== event.data.inboxID)
: state.outbox,
overlay: clearOverlay(state.overlay, event),
synced: state.synced,
})
send()
}
const reject = (intent: Intent, reason: string) => {
publish({ ...state, outbox: state.outbox.filter((item) => item.id !== intent.id) })
failureListeners.forEach((listener) => listener({ intent, reason }))
}
const send = () => {
if (!state.synced || sending || stopped) return
const intent = state.outbox[0]
if (!intent || sent === intent.id) return
sending = true
sent = intent.id
void (async () => {
try {
await transport.submit({ id: intent.id, sessionID, request: intent.request })
} catch (error) {
if (!(error instanceof SubmitRejected)) return
sent = undefined
reject(intent, error.reason)
}
})().finally(() => {
sending = false
send()
})
}
const sync = async () => {
while (!stopped) {
try {
for await (const item of transport.stream(sessionID, state.folded.seq, abort.signal)) {
if (stopped) return
if (item.type === "log.synced") {
// A marker past the fold means the server skipped events it could not
// replay for this cursor; recover through a fresh snapshot.
if (item.seq !== undefined && item.seq > state.folded.seq) throw new SeqUnavailable()
sent = undefined
publish({ ...state, synced: true })
ready.resolve()
send()
continue
}
if ("durable" in item) {
applyDurable(item)
continue
}
publish({ ...state, overlay: applyOverlay(state.overlay, item) })
}
} catch (error) {
if (error instanceof SeqUnavailable) {
try {
applySnapshot(await transport.snapshot(sessionID))
} catch {
await reconnect()
}
continue
}
}
if (stopped) return
publish({ ...state, synced: false })
await reconnect()
}
}
void sync()
return {
sessionID,
view: () => render(state),
submit(input) {
const intent: Intent = {
id: input.id ?? makeID(),
created: now(),
request: input,
item: {
type: "user",
delivery: input.delivery ?? "steer",
payload: {
text: input.text,
agents: input.agents?.map((agent) => ({ ...agent })),
metadata: input.metadata,
},
},
}
publish({ ...state, outbox: [...state.outbox, intent] })
send()
return intent
},
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
subscribeFailures(listener) {
failureListeners.add(listener)
return () => failureListeners.delete(listener)
},
ready: () => ready.promise,
refresh() {
if (refreshing) return refreshing
refreshing = transport
.snapshot(sessionID)
.then((snapshot) => {
if (snapshot.seq < state.folded.seq) return
applySnapshot(snapshot, state.synced)
send()
})
.finally(() => {
refreshing = undefined
})
return refreshing
},
settled() {
if (state.outbox.length === 0) return Promise.resolve()
return new Promise<void>((resolve) => settled.add(resolve))
},
stop() {
stopped = true
abort.abort()
publish({ ...state, synced: false })
},
}
}
// Render runs per ephemeral event, so everything an event did not touch must
// keep its reference: the adapter diffs consecutive views by identity to
// decide what to write into the reactive store. Both caches key on persistent
// inputs (a fold, outbox, or usage entry keeps its identity until it actually
// changes), so per-delta renders only reapply the overlay.
export function render(state: Pick<EngineState, "folded" | "outbox" | "overlay">): SessionView {
const base = renderBase(state.folded, state.outbox)
return {
...state.folded,
session: usageSession(state.folded, state.overlay.get("usage")),
messages: applyOverlayToMessages(base.messages, state.overlay),
pending: base.pending,
}
}
const bases = new WeakMap<SessionFoldState, ReturnType<typeof buildBase>>()
function renderBase(folded: SessionFoldState, outbox: EngineState["outbox"]) {
const hit = bases.get(folded)
if (hit && hit.outbox === outbox) return hit
const base = buildBase(folded, outbox)
bases.set(folded, base)
return base
}
function buildBase(folded: SessionFoldState, outbox: EngineState["outbox"]) {
const pending =
outbox.length === 0
? folded.inbox
: [
...folded.inbox,
...outbox.map(
(intent): SessionInboxInfo => ({
id: intent.id,
sessionID: folded.session.id,
timeCreated: intent.created,
...intent.item,
}),
),
]
const appended = pendingMessages(folded, pending)
return {
outbox,
pending,
messages: appended.length === 0 ? folded.messages : [...folded.messages, ...appended],
}
}
function pendingMessages(folded: SessionFoldState, pending: ReadonlyArray<SessionInboxInfo>) {
if (pending.length === 0) return []
const messageIDs = new Set(folded.messages.map((message) => message.id))
return pending.flatMap((item): ReadonlyArray<SessionMessageInfo> => {
if (item.type !== "compaction" && item.delivery === "queue") return []
if (messageIDs.has(item.id)) return []
const message = SessionFold.messageFromInbox(item)
return message ? [message] : []
})
}
const usageSessions = new WeakMap<
Extract<OverlayEntry, { type: "usage" }>,
{ base: SessionFoldState["session"]; session: SessionFoldState["session"] }
>()
function usageSession(folded: SessionFoldState, entry: OverlayEntry | undefined) {
if (entry?.type !== "usage") return folded.session
const hit = usageSessions.get(entry)
if (hit && hit.base === folded.session) return hit.session
const session = { ...folded.session, cost: entry.value.cost, tokens: entry.value.tokens }
usageSessions.set(entry, { base: folded.session, session })
return session
}
function applyOverlay(overlay: Overlay, event: EphemeralSessionEvent): Overlay {
const next = new Map(overlay)
switch (event.type) {
case "session.text.delta": {
const key = partKey("text", event.data.assistantMessageID, event.data.ordinal)
const current = next.get(key)
next.set(key, {
type: "text",
value: (current?.type === "text" ? current.value : "") + event.data.delta,
})
return next
}
case "session.reasoning.delta": {
const key = partKey("reasoning", event.data.assistantMessageID, event.data.ordinal)
const current = next.get(key)
next.set(key, {
type: "reasoning",
value: (current?.type === "reasoning" ? current.value : "") + event.data.delta,
})
return next
}
case "session.tool.input.delta": {
const key = toolKey("tool-input", event.data.assistantMessageID, event.data.id)
const current = next.get(key)
next.set(key, {
type: "tool-input",
value: (current?.type === "tool-input" ? current.value : "") + event.data.delta,
})
return next
}
case "session.tool.progress":
next.set(toolKey("tool-progress", event.data.assistantMessageID, event.data.id), {
type: "tool-progress",
metadata: event.data.metadata,
})
return next
case "session.compaction.delta": {
const current = next.get("compaction")
next.set("compaction", {
type: "compaction",
value: (current?.type === "compaction" ? current.value : "") + event.data.text,
})
return next
}
case "session.usage.updated":
next.set("usage", { type: "usage", value: event.data })
return next
}
}
function clearOverlay(overlay: Overlay, event: DurableSessionEvent): Overlay {
switch (event.type) {
case "session.text.ended":
return removeOverlay(overlay, partKey("text", event.data.assistantMessageID, event.data.ordinal))
case "session.reasoning.ended":
return removeOverlay(overlay, partKey("reasoning", event.data.assistantMessageID, event.data.ordinal))
case "session.tool.input.ended":
case "session.tool.called":
return removeOverlay(overlay, toolKey("tool-input", event.data.assistantMessageID, event.data.id))
case "session.tool.success":
case "session.tool.failed":
return removeOverlay(overlay, toolKey("tool-progress", event.data.assistantMessageID, event.data.id))
case "session.compaction.ended":
case "session.compaction.failed":
return removeOverlay(overlay, "compaction")
case "session.step.ended":
case "session.step.failed":
case "session.usage.recorded":
return removeOverlay(overlay, "usage")
default:
return overlay
}
}
function removeOverlay(overlay: Overlay, key: string): Overlay {
if (!overlay.has(key)) return overlay
const next = new Map(overlay)
next.delete(key)
return next
}
function applyOverlayToMessages(messages: ReadonlyArray<SessionMessageInfo>, overlay: Overlay) {
if (overlay.size === 0) return messages
// Remap only the messages the overlay actually touches so everything else
// keeps its identity.
const compacting = overlay.has("compaction")
const touched = new Set<string>()
overlay.forEach((_, key) => {
const id = keyMessageID(key)
if (id) touched.add(id)
})
if (touched.size === 0 && !compacting) return messages
return messages.map((message): SessionMessageInfo => {
if (message.type === "compaction" && message.status === "running") {
if (!compacting) return message
const entry = overlay.get("compaction")
return entry?.type === "compaction" ? { ...message, summary: message.summary + entry.value } : message
}
if (message.type !== "assistant" || !touched.has(message.id)) return message
const ordinals = { text: 0, reasoning: 0 }
const content = message.content.map((part) => {
if (part.type === "text") {
const entry = overlay.get(partKey("text", message.id, ordinals.text++))
return entry?.type === "text" ? { ...part, text: part.text + entry.value } : part
}
if (part.type === "reasoning") {
const entry = overlay.get(partKey("reasoning", message.id, ordinals.reasoning++))
return entry?.type === "reasoning" ? { ...part, text: part.text + entry.value } : part
}
const input = overlay.get(toolKey("tool-input", message.id, part.id))
if (input?.type === "tool-input" && part.state.status === "streaming")
return { ...part, state: { ...part.state, input: part.state.input + input.value } }
const progress = overlay.get(toolKey("tool-progress", message.id, part.id))
if (progress?.type === "tool-progress" && part.state.status === "running")
return { ...part, state: { ...part.state, metadata: progress.metadata } }
return part
})
return content.some((part, index) => part !== message.content[index]) ? { ...message, content } : message
})
}
function partKey(type: "text" | "reasoning", messageID: string, ordinal: number) {
return `${type}:${messageID}:${ordinal}`
}
function toolKey(type: "tool-input" | "tool-progress", messageID: string, toolID: string) {
return `${type}:${messageID}:${toolID}`
}
// Second segment of a part or tool key; undefined for the segmentless
// "compaction" and "usage" keys.
function keyMessageID(key: string) {
return key.split(":")[1]
}
export * as Engine from "./engine"
-583
View File
@@ -1,583 +0,0 @@
import type {
SessionEventDurable,
SessionInboxInfo,
SessionInfo,
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
TokenUsageInfo,
} from "../../promise"
export type SessionFoldState = {
readonly session: SessionInfo
readonly children: ReadonlyArray<SessionInfo>
readonly inbox: ReadonlyArray<SessionInboxInfo>
readonly messages: ReadonlyArray<SessionMessageInfo>
readonly active: "idle" | "running"
readonly deleted: boolean
readonly seq: number
}
export type SessionSnapshot = Omit<SessionFoldState, "active" | "deleted"> & {
readonly active?: SessionFoldState["active"]
}
export type DurableSessionEvent = Exclude<SessionEventDurable, { readonly type: "session.forked" }>
export function fromSnapshot(snapshot: SessionSnapshot): SessionFoldState {
return { ...snapshot, active: snapshot.active ?? "idle", deleted: false }
}
export function apply(state: SessionFoldState, event: DurableSessionEvent): SessionFoldState {
if (event.durable.seq <= state.seq) return state
const current = { ...state, seq: event.durable.seq }
switch (event.type) {
case "session.created":
return current
case "session.deleted":
return { ...current, deleted: true }
case "session.usage.recorded":
return { ...current, session: addUsage(state.session, event.data.cost, event.data.tokens, event.created) }
case "session.agent.selected":
return append(
{
...current,
session: {
...state.session,
agent: event.data.agent,
time: { ...state.session.time, updated: event.created },
},
},
{
id: messageID(event.id),
type: "agent-switched",
agent: event.data.agent,
previous: event.data.previous ?? state.session.agent,
metadata: event.metadata,
time: { created: event.created },
},
)
case "session.model.selected":
return append(
{
...current,
session: {
...state.session,
model: event.data.model,
time: { ...state.session.time, updated: event.created },
},
},
{
id: messageID(event.id),
type: "model-switched",
model: event.data.model,
previous: event.data.previous ?? state.session.model,
metadata: event.metadata,
time: { created: event.created },
},
)
case "session.moved":
return append(
{
...current,
session: {
...state.session,
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
time: { ...state.session.time, updated: event.created },
},
},
{
id: messageID(event.id),
type: "location-switched",
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous: {
location: state.session.location,
projectID: state.session.projectID,
subpath: state.session.subpath,
},
metadata: event.metadata,
time: { created: event.created },
},
)
case "session.renamed":
return {
...current,
session: { ...state.session, title: event.data.title, time: { ...state.session.time, updated: event.created } },
}
case "session.inbox.enqueued":
return {
...current,
session: { ...state.session, time: { ...state.session.time, updated: event.created } },
inbox: state.inbox.some((item) => item.id === event.data.inboxID)
? state.inbox
: [
...state.inbox,
{
id: event.data.inboxID,
sessionID: event.data.sessionID,
timeCreated: event.created,
...event.data.item,
},
],
}
case "session.inbox.delivered": {
const item = state.inbox.find((item) => item.id === event.data.inboxID)
const next = { ...current, inbox: state.inbox.filter((item) => item.id !== event.data.inboxID) }
if (!item) return next
const delivered = messageFromInbox(item, event.created)
return delivered ? append(next, delivered) : next
}
case "session.inbox.cancelled":
return { ...current, inbox: state.inbox.filter((item) => item.id !== event.data.inboxID) }
case "session.inbox.delivery.changed":
return {
...current,
inbox: state.inbox.map((item) =>
item.id === event.data.inboxID ? { ...item, delivery: event.data.delivery } : item,
),
}
case "session.execution.started":
return { ...current, active: "running" }
case "session.execution.succeeded":
case "session.execution.failed":
case "session.execution.interrupted":
return { ...updateActiveAssistant(current, (message) => without(message, "retry")), active: "idle" }
case "session.instructions.updated":
if (event.data.text === undefined) return current
return append(current, {
id: messageID(event.id),
type: "system",
text: event.data.text,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
})
case "session.synthetic":
return append(current, {
id: messageID(event.id),
type: "synthetic",
text: event.data.text,
description: event.data.description,
metadata: event.data.metadata,
time: { created: event.created },
})
case "session.skill.activated":
return append(current, {
id: messageID(event.id),
type: "skill",
skill: event.data.id,
name: event.data.name,
text: event.data.text,
metadata: event.metadata,
time: { created: event.created },
})
case "session.shell.started":
return append(current, {
id: messageID(event.id),
type: "shell",
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
metadata: event.metadata,
time: { created: event.created },
})
case "session.shell.ended":
return updateMessage(
current,
(message) => message.type === "shell" && message.shellID === event.data.shell.id,
(message) => {
if (message.type !== "shell") return message
return {
...message,
status: event.data.shell.status,
exit: event.data.shell.exit,
output: event.data.output,
time: { ...message.time, completed: event.created },
}
},
true,
)
case "session.step.started": {
const existing = state.messages.some((message) => message.id === event.data.assistantMessageID)
if (existing)
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
...without(message, "retry", "error", "finish"),
agent: event.data.agent,
model: event.data.model,
time: without(message.time, "completed"),
snapshot: event.data.snapshot ? { ...message.snapshot, start: event.data.snapshot } : message.snapshot,
}))
return append(
updateActiveAssistant(current, (message) => ({
...without(message, "retry"),
time: { ...message.time, completed: event.created },
})),
{
id: event.data.assistantMessageID,
type: "assistant",
agent: event.data.agent,
model: event.data.model,
metadata: event.metadata,
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.created },
},
)
}
case "session.step.ended":
return withUsage(
updateAssistant(current, event.data.assistantMessageID, (message) => ({
...message,
finish: event.data.finish,
cost: event.data.cost,
tokens: event.data.tokens,
time: { ...message.time, completed: event.created },
snapshot:
event.data.snapshot || event.data.files
? { ...message.snapshot, end: event.data.snapshot, files: event.data.files }
: message.snapshot,
})),
event.data.cost,
event.data.tokens,
event.created,
)
case "session.step.failed": {
const failed = updateAssistant(current, event.data.assistantMessageID, (message) => ({
...without(message, "retry"),
finish: "error",
error: event.data.error,
cost: event.data.cost ?? message.cost,
tokens: event.data.tokens ?? message.tokens,
time: { ...message.time, completed: event.created },
snapshot:
event.data.snapshot || event.data.files
? { ...message.snapshot, end: event.data.snapshot, files: event.data.files }
: message.snapshot,
}))
if (event.data.cost === undefined || event.data.tokens === undefined) return failed
return withUsage(failed, event.data.cost, event.data.tokens, event.created)
}
case "session.text.started":
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
...message,
content: insertOrdinal(message.content, "text", event.data.ordinal, { type: "text", text: "" }),
}))
case "session.text.ended":
return updateContent(current, event.data.assistantMessageID, "text", event.data.ordinal, (part) => {
const next = { ...part, text: event.data.text }
return event.data.state === undefined ? without(next, "state") : { ...next, state: event.data.state }
})
case "session.reasoning.started":
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
...message,
content: insertOrdinal(message.content, "reasoning", event.data.ordinal, {
type: "reasoning",
text: "",
state: event.data.state,
time: { created: event.created },
}),
}))
case "session.reasoning.ended":
return updateContent(current, event.data.assistantMessageID, "reasoning", event.data.ordinal, (part) => ({
...part,
text: event.data.text,
state: event.data.state ?? part.state,
time: { created: part.time?.created ?? event.created, completed: event.created },
}))
case "session.tool.input.started":
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
...message,
content: [
...message.content,
{
type: "tool",
id: event.data.id,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
},
],
}))
case "session.tool.input.ended":
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) =>
part.state.status === "streaming" ? { ...part, state: { ...part.state, input: event.data.text } } : part,
)
case "session.tool.called":
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => ({
...part,
executed: event.data.executed,
providerState: event.data.state,
state: { status: "running", input: event.data.input, metadata: {} },
time: { ...part.time, ran: event.created },
}))
case "session.tool.success":
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => {
if (part.state.status !== "running") return part
return {
...part,
executed: event.data.executed || part.executed === true,
providerResultState: event.data.resultState,
state: {
status: "completed",
input: part.state.input,
content: event.data.content,
metadata: event.data.metadata,
},
time: { ...part.time, completed: event.created },
}
})
case "session.tool.failed":
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => {
if (part.state.status !== "streaming" && part.state.status !== "running") return part
return {
...part,
executed: event.data.executed || part.executed === true,
providerResultState: event.data.resultState,
state: {
status: "error",
error: event.data.error,
input: typeof part.state.input === "string" ? {} : part.state.input,
content: event.data.content,
metadata: event.data.metadata,
},
time: { ...part.time, completed: event.created },
}
})
case "session.retry.scheduled":
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
...message,
retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error },
}))
case "session.compaction.started":
return append(
{
...current,
inbox: event.data.inputID ? state.inbox.filter((item) => item.id !== event.data.inputID) : state.inbox,
},
{
id: event.data.inputID ?? messageID(event.id),
type: "compaction",
status: "running",
reason: event.data.reason,
summary: "",
recent: event.data.recent,
metadata: event.metadata,
time: { created: event.created },
},
)
case "session.compaction.ended": {
const running = state.messages.findLast(
(message) => message.type === "compaction" && message.status === "running",
)
if (!running)
return append(current, {
id: messageID(event.id),
type: "compaction",
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
metadata: event.metadata,
time: { created: event.created },
})
return updateMessage(
current,
(message) => message.id === running.id,
(message) => ({
...message,
type: "compaction",
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
}),
)
}
case "session.compaction.failed": {
const running = state.messages.findLast(
(message) => message.type === "compaction" && message.status === "running",
)
const failed = {
id: running?.id ?? event.data.inputID ?? messageID(event.id),
type: "compaction" as const,
status: "failed" as const,
reason: event.data.reason,
error: event.data.error,
metadata: running?.metadata ?? event.metadata,
time: running?.time ?? { created: event.created },
}
const next = {
...current,
inbox: event.data.inputID ? state.inbox.filter((item) => item.id !== event.data.inputID) : state.inbox,
}
return running
? updateMessage(
next,
(message) => message.id === running.id,
() => failed,
)
: append(next, failed)
}
case "session.revert.staged":
return {
...current,
session: {
...state.session,
revert: event.data.revert,
time: { ...state.session.time, updated: event.created },
},
}
case "session.revert.cleared":
return {
...current,
session: without({ ...state.session, time: { ...state.session.time, updated: event.created } }, "revert"),
}
case "session.revert.committed":
return {
...current,
session: without({ ...state.session, time: { ...state.session.time, updated: event.created } }, "revert"),
messages: state.messages.filter((message) => message.id < event.data.to),
inbox: state.inbox.filter((item) => item.id < event.data.to),
}
}
}
function messageID(eventID: string) {
return eventID.replace(/^evt_/, "msg_")
}
// Clearing a field must delete the key, not assign undefined: fold state
// round-trips through JSON snapshots, which cannot represent undefined-valued
// keys, so replay and fromSnapshot must agree on key presence.
function without<T extends object, K extends keyof T>(value: T, ...keys: ReadonlyArray<K>): Omit<T, K> {
const next = { ...value }
for (const key of keys) delete next[key]
return next
}
export function messageFromInbox(item: SessionInboxInfo, created = item.timeCreated): SessionMessageInfo | undefined {
if (item.type === "user") return { id: item.id, type: "user", ...item.payload, time: { created } }
if (item.type === "synthetic") return { id: item.id, type: "synthetic", ...item.payload, time: { created } }
}
function append(state: SessionFoldState, item: SessionMessageInfo) {
if (state.messages.some((message) => message.id === item.id)) return state
return { ...state, messages: [...state.messages, item] }
}
function updateMessage(
state: SessionFoldState,
predicate: (message: SessionMessageInfo) => boolean,
update: (message: SessionMessageInfo) => SessionMessageInfo,
last = false,
) {
const index = last ? state.messages.findLastIndex(predicate) : state.messages.findIndex(predicate)
if (index < 0) return state
return {
...state,
messages: state.messages.map((message, position) => (position === index ? update(message) : message)),
}
}
function updateAssistant(
state: SessionFoldState,
messageID: string,
update: (message: SessionMessageAssistant) => SessionMessageAssistant,
) {
return updateMessage(
state,
(message) => message.id === messageID && message.type === "assistant",
(message) => (message.type === "assistant" ? update(message) : message),
)
}
function updateActiveAssistant(
state: SessionFoldState,
update: (message: SessionMessageAssistant) => SessionMessageAssistant,
) {
return updateMessage(
state,
(message) => message.type === "assistant" && message.time.completed === undefined,
(message) => (message.type === "assistant" ? update(message) : message),
true,
)
}
function updateContent<Type extends "text" | "reasoning">(
state: SessionFoldState,
messageID: string,
type: Type,
ordinal: number,
update: (
part: Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
) => Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
) {
return updateAssistant(state, messageID, (message) => {
const position = message.content.flatMap((part, index) => (part.type === type ? [index] : []))[ordinal]
const part = position === undefined ? undefined : message.content[position]
if (!part || part.type !== type) return message
return {
...message,
content: message.content.map((item, index) =>
index === position
? update(part as Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>)
: item,
),
}
})
}
function updateTool(
state: SessionFoldState,
messageID: string,
toolID: string,
update: (part: SessionMessageAssistantTool) => SessionMessageAssistantTool,
) {
return updateAssistant(state, messageID, (message) => {
const index = message.content.findLastIndex((part) => part.type === "tool" && part.id === toolID)
if (index < 0) return message
return {
...message,
content: message.content.map((part, position) =>
position === index && part.type === "tool" ? update(part) : part,
),
}
})
}
function insertOrdinal<Type extends SessionMessageAssistant["content"][number]["type"]>(
content: SessionMessageAssistant["content"],
type: Type,
ordinal: number,
part: Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
) {
if (content.filter((item) => item.type === type)[ordinal]) return content
return [...content, part]
}
function addUsage(session: SessionInfo, cost: number, tokens: TokenUsageInfo, updated: number): SessionInfo {
return {
...session,
cost: session.cost + cost,
tokens: {
input: session.tokens.input + tokens.input,
output: session.tokens.output + tokens.output,
reasoning: session.tokens.reasoning + tokens.reasoning,
cache: {
read: session.tokens.cache.read + tokens.cache.read,
write: session.tokens.cache.write + tokens.cache.write,
},
},
time: { ...session.time, updated },
}
}
function withUsage(state: SessionFoldState, cost: number, tokens: TokenUsageInfo, updated: number) {
return { ...state, session: addUsage(state.session, cost, tokens, updated) }
}
export * as SessionFold from "./fold"
-1
View File
@@ -1,3 +1,2 @@
export * from "./data"
export * from "./connection"
export * from "./engine-data"
-145
View File
@@ -1,145 +0,0 @@
// Proves the generated-client wiring the engine laws take for granted: the
// adapter in src/solid/engine-data.ts must speak the real snapshot/log/prompt
// API shapes and translate the generated typed errors into the engine's own
// (the SeqUnavailable path is what laws 7-9 in test/sync-engine-laws.test.ts
// rely on in production).
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { Engine } from "../src/solid/engine/engine"
import { SNAPSHOT_RECENT, createEngineData, createEngineTransport } from "../src/solid/engine-data"
import { FakeSessionServer } from "./fixture/sync-engine"
describe("engine data transport", () => {
test("uses snapshot and ephemeral follow log contracts", async () => {
const server = new FakeSessionServer("ses_transport")
const calls: Array<unknown> = []
const transport = createEngineTransport(() => ({
async snapshot(input) {
calls.push(input)
// The generated client returns mutable arrays; the fixture's snapshot
// is readonly, so mirror the wire shape here.
const value = server.snapshotValue()
return { ...value, children: [...value.children], inbox: [...value.inbox], messages: [...value.messages] }
},
async *log(input) {
calls.push(input)
yield { type: "log.synced" as const, aggregateID: input.sessionID, seq: 0 }
},
async prompt() {
throw new Error("unused")
},
}))
expect(await transport.snapshot(server.sessionID)).toEqual(server.snapshotValue())
const items: Array<Engine.SessionStreamItem> = []
for await (const item of transport.stream(server.sessionID, 0)) items.push(item)
expect(items).toEqual([{ type: "log.synced", aggregateID: server.sessionID, seq: 0 }])
expect(calls).toEqual([
{ sessionID: server.sessionID, recent: SNAPSHOT_RECENT },
{ sessionID: server.sessionID, after: 0, follow: true, ephemeral: true },
])
})
test("preserves the prompt request and client-minted ID", async () => {
const requests: Array<unknown> = []
const transport = createEngineTransport(() => ({
async snapshot() {
throw new Error("unused")
},
async *log() {
throw new Error("unused")
},
async prompt(input) {
requests.push(input)
return {
id: input.id!,
sessionID: input.sessionID,
timeCreated: 1,
type: "user",
payload: { text: input.text },
delivery: input.delivery ?? "steer",
}
},
}))
await transport.submit({
id: "msg_client",
sessionID: "ses_submit",
request: {
text: "hello",
files: [{ uri: "file:///tmp/example.txt", name: "example.txt" }],
delivery: "queue",
},
})
expect(requests).toEqual([
{
id: "msg_client",
sessionID: "ses_submit",
text: "hello",
files: [{ uri: "file:///tmp/example.txt", name: "example.txt" }],
delivery: "queue",
},
])
})
test("a failed initial attach does not poison the session cache", async () => {
const server = new FakeSessionServer("ses_attach_retry")
server.faults.loseSnapshots = 1
const api = {
session: {
snapshot: (input: { sessionID: string }) => server.snapshot(input.sessionID),
log: (input: { sessionID: string; after: number }) => server.stream(input.sessionID, input.after),
prompt: () => Promise.reject(new Error("unused")),
},
}
await createRoot(async (dispose) => {
const data = createEngineData({
api: () => api as never,
directory: "/workspace",
event: { on: () => () => {}, listen: () => () => {} },
})
// The server is down when the session first opens…
await expect(data.session.sync(server.sessionID)).rejects.toThrow("snapshot lost")
// …and the next sync attaches with a fresh engine instead of a cached rejection.
await data.session.sync(server.sessionID)
expect(data.session.get(server.sessionID)?.id).toBe(server.sessionID)
dispose()
})
})
test("translates generated typed failures", async () => {
// These literals mirror the generated client's error DTO shapes
// (SeqUnavailableError / InvalidRequestError in src/promise/generated);
// they must change if the generated error schema does.
const transport = createEngineTransport(() => ({
async snapshot() {
throw new Error("unused")
},
async *log() {
throw { _tag: "SeqUnavailableError", sessionID: "ses_errors", after: 2, head: 1, message: "gone" }
},
async prompt() {
throw { _tag: "InvalidRequestError", message: "invalid" }
},
}))
const streamError = await collectError(transport.stream("ses_errors", 2))
expect(streamError).toBeInstanceOf(Engine.SeqUnavailable)
await expect(
transport.submit({ id: "msg_client", sessionID: "ses_errors", request: { text: "invalid" } }),
).rejects.toEqual(new Engine.SubmitRejected("invalid"))
})
})
async function collectError(iterable: AsyncIterable<unknown>) {
try {
for await (const item of iterable) void item
} catch (error) {
return error
}
throw new Error("stream did not fail")
}
-231
View File
@@ -1,231 +0,0 @@
// In-memory model of the server's session log, used by the engine laws
// (sync-engine-laws.test.ts), the chaos simulation (sync-engine-sim.test.ts),
// and the legacy bug catalog (legacy-divergence.test.ts). It folds with the
// REAL SessionFold, so `truth()` is the same interpretation of events a
// converged client must reach, and its admission dedupes by inbox ID exactly
// like the server's inbox projector. Faults are injected per call through the
// `faults` record; `cutConnections` and `prune` model disconnects and lost
// retention.
import type { SessionInfo, SessionMessageInfo } from "../../src/promise"
import { Engine } from "../../src/solid/engine/engine"
import type { DurableSessionEvent, SessionFoldState, SessionSnapshot } from "../../src/solid/engine/fold"
import { SessionFold } from "../../src/solid/engine/fold"
export class FakeSessionServer implements Engine.SessionTransport {
readonly events: Array<DurableSessionEvent> = []
readonly admitted: Array<string> = []
readonly faults = {
loseRequests: 0,
loseResponses: 0,
loseSnapshots: 0,
reject: 0,
latency: 0,
}
private folded: SessionFoldState
private readonly tails = new Set<AsyncQueue<Engine.SessionStreamItem>>()
private eventCounter = 0
constructor(
readonly sessionID: string,
readonly time = 1_717_171_717_000,
) {
this.folded = SessionFold.fromSnapshot(emptySnapshot(sessionID, time))
}
async snapshot(sessionID: string) {
await this.pause()
this.assertSession(sessionID)
if (this.faults.loseSnapshots > 0) {
this.faults.loseSnapshots--
throw new Error("snapshot lost")
}
return this.snapshotValue()
}
async *stream(sessionID: string, after: number, signal?: AbortSignal): AsyncIterable<Engine.SessionStreamItem> {
await this.pause()
this.assertSession(sessionID)
if (after > this.folded.seq) throw new Engine.SeqUnavailable()
const replay = this.events.filter((event) => event.durable.seq > after)
// Honest replay contract: a cursor is only admitted when retained events fully cover (after, seq].
if (replay.length < this.folded.seq - after) throw new Engine.SeqUnavailable()
const queue = new AsyncQueue<Engine.SessionStreamItem>()
const abort = () => queue.fail(new Error("stream aborted"))
signal?.addEventListener("abort", abort, { once: true })
this.tails.add(queue)
try {
for (const event of replay) yield event
yield { type: "log.synced", aggregateID: sessionID, seq: this.folded.seq }
while (true) yield await queue.take()
} finally {
signal?.removeEventListener("abort", abort)
this.tails.delete(queue)
}
}
async submit(input: Engine.SubmitInput) {
await this.pause()
this.assertSession(input.sessionID)
const existing = this.events.find(
(event) => event.type === "session.inbox.enqueued" && event.data.inboxID === input.id,
)
if (existing) return
if (this.faults.loseRequests > 0) {
this.faults.loseRequests--
throw new Error("request lost")
}
if (this.faults.reject > 0) {
this.faults.reject--
throw new Engine.SubmitRejected("rejected")
}
this.admitted.push(input.id)
this.publish({
id: `evt_${String(++this.eventCounter).padStart(8, "0")}`,
created: this.time,
type: "session.inbox.enqueued",
durable: { aggregateID: this.sessionID, seq: this.folded.seq + 1, version: 1 },
data: {
sessionID: this.sessionID,
inboxID: input.id,
item: {
type: "user",
delivery: input.request.delivery ?? "steer",
payload: {
text: input.request.text,
agents: input.request.agents?.map((agent) => ({ ...agent })),
metadata: input.request.metadata,
},
},
},
})
if (this.faults.loseResponses > 0) {
this.faults.loseResponses--
throw new Error("response lost")
}
}
cutConnections() {
this.tails.forEach((tail) => tail.fail(new Error("connection cut")))
}
/** Drop retained event history, simulating `events.persist` off or pruned retention. */
prune() {
this.events.length = 0
}
/** Clear every injected fault. */
heal() {
for (const fault of Object.keys(this.faults) as Array<keyof FakeSessionServer["faults"]>) this.faults[fault] = 0
}
seq() {
return this.folded.seq
}
truth() {
return Engine.render({ folded: this.folded, outbox: [], overlay: new Map() })
}
snapshotValue(): SessionSnapshot {
return {
session: this.folded.session,
children: this.folded.children,
inbox: this.folded.inbox,
messages: this.folded.messages,
seq: this.folded.seq,
active: this.folded.active,
}
}
private publish(event: DurableSessionEvent) {
this.events.push(event)
this.folded = SessionFold.apply(this.folded, event)
this.tails.forEach((tail) => tail.offer(event))
}
private assertSession(sessionID: string) {
if (sessionID !== this.sessionID) throw new Error(`unknown session: ${sessionID}`)
}
private async pause() {
for (let step = 0; step < this.faults.latency; step++) await Promise.resolve()
}
}
/**
* Reconnect option that holds the engine's first reconnect until released,
* so a test can advance the server "while disconnected". Later reconnects
* pass through instantly.
*/
export function reconnectGate() {
let open = false
let release: (() => void) | undefined
return {
reconnect: () =>
new Promise<void>((resolve) => {
if (open) return resolve()
release = () => {
open = true
resolve()
}
}),
holding: () => release !== undefined,
release: () => release!(),
}
}
export async function until(check: () => boolean, message = "condition did not become true") {
for (let attempt = 0; attempt < 500; attempt++) {
if (check()) return
await Bun.sleep(1)
}
throw new Error(message)
}
export function userMessages(messages: ReadonlyArray<SessionMessageInfo>) {
return messages.filter(
(message): message is Extract<SessionMessageInfo, { readonly type: "user" }> => message.type === "user",
)
}
function emptySnapshot(sessionID: string, time: number): SessionSnapshot {
const session: SessionInfo = {
id: sessionID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: time, updated: time },
location: { directory: "/workspace" },
}
return { session, children: [], inbox: [], messages: [], seq: 0 }
}
class AsyncQueue<Value> {
private readonly values: Array<Value> = []
private readonly waiting: Array<{
readonly resolve: (value: Value) => void
readonly reject: (error: Error) => void
}> = []
private error?: Error
offer(value: Value) {
const waiter = this.waiting.shift()
if (waiter) {
waiter.resolve(value)
return
}
this.values.push(value)
}
fail(error: Error) {
this.error = error
this.waiting.splice(0).forEach((waiter) => waiter.reject(error))
}
take() {
if (this.values.length) return Promise.resolve(this.values.shift()!)
if (this.error) return Promise.reject(this.error)
return new Promise<Value>((resolve, reject) => this.waiting.push({ resolve, reject }))
}
}
@@ -1,258 +0,0 @@
// Divergence catalog: weird states the legacy data layer (createData) can get
// into that the sync engine cannot. Each test drives the REAL legacy layer —
// or, for the retry test, its raw ID-less prompt protocol — and PASSES by
// demonstrating the bug, with a pointer to the engine law or mechanism that
// rules the same state out. If a test here starts failing, the legacy layer
// got fixed — celebrate and delete the test.
//
// Companion clean-behavior proofs: test/sync-engine-laws.test.ts.
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData } from "../src/solid/data"
import type { CreateDataInput } from "../src/solid/data"
import type { OpenCodeEvent, SessionMessageInfo } from "../src/promise"
import { FakeSessionServer } from "./fixture/sync-engine"
const sessionID = "ses_legacy"
const assistantID = "msg_assistant"
describe("legacy data layer divergence catalog", () => {
test("a dropped durable event desyncs the transcript silently and forever", async () => {
// Server truth: the assistant message finished with text "FINAL".
// The client misses only the `session.text.ended` event (blip mid-stream).
const legacy = await hydrated()
legacy.dispatch(textStarted())
for (let index = 0; index < 5; index++) legacy.dispatch(textDelta("x"))
// ...the `ended` event with the durable final text never arrives.
// The transcript is stuck on accumulated deltas, disagreeing with the
// server, and nothing in the layer can ever notice: there is no sequence
// cursor, no gap check, no recovery path. Only a manual refetch heals it.
expect(legacy.text()).toBe("xxxxx")
legacy.dispose()
// Engine: durable events carry seqs; a gap surfaces as SeqUnavailable or a
// marker past the fold, forcing snapshot recovery (laws 7 and 8).
})
test("a late delta corrupts a completed message", async () => {
// Events delivered slightly out of order: the final text lands, then a
// straggling delta from the finished stream arrives.
const legacy = await hydrated()
legacy.dispatch(textStarted())
legacy.dispatch(textDelta("Hel"))
legacy.dispatch(textEnded("Hello"))
legacy.dispatch(textDelta("lo"))
// The handler appends onto whatever text part it finds — including a
// completed one. The final message is permanently corrupted.
expect(legacy.text()).toBe("Hellolo")
legacy.dispose()
// Engine: deltas are ephemeral overlay entries cleared by the durable
// lifecycle events, and the ordered log cannot deliver a delta after its
// own `ended` — there is no durable state for a straggler to corrupt.
})
test("a slow fetch rewinds the store past already-rendered live events", async () => {
// The initial message fetch is in flight when a live prompt admission
// arrives. The user's message renders... then the stale fetch resolves.
let resolveFetch: ((messages: SessionMessageInfo[]) => void) | undefined
const legacy = makeLegacy({
list: () => new Promise<SessionMessageInfo[]>((resolve) => (resolveFetch = resolve)),
})
const syncing = legacy.data.session.message.sync(sessionID)
legacy.dispatch(inboxEnqueued("msg_user"))
expect(legacy.data.session.message.get(sessionID, "msg_user")).toBeDefined()
resolveFetch!([]) // the fetch was served before the admission — stale
await syncing
// The message the user just watched appear is gone. It returns only if
// some later event or refetch happens to bring it back.
expect(legacy.data.session.message.get(sessionID, "msg_user")).toBeUndefined()
legacy.dispose()
// Engine: hydration is a seq-stamped snapshot, and a stale refresh cannot
// move the fold behind the live log (law 10, refresh monotonicity).
})
test("delivered-before-enqueued leaves a phantom pending row forever", async () => {
// Reordered delivery: the `delivered` event arrives before its `enqueued`.
const legacy = await hydrated()
legacy.dispatch(inboxDelivered("msg_user")) // no-op: nothing to deliver yet
legacy.dispatch(inboxEnqueued("msg_user")) // adds the pending row
// The delivered event was already consumed, so the row the server has
// long since promoted sits in "pending" until a manual refetch.
expect(legacy.data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_user"])
legacy.dispose()
// Engine: the transport is a single ordered log, so this ordering cannot
// be observed live; a reconnect replays from the seq cursor, and any gap
// fails the cursor check and recovers via snapshot (laws 7 and 8).
})
test("a retry after a lost response admits the prompt twice", async () => {
// Both protocols drive the same server admission logic (FakeSessionServer
// dedupes by inbox ID exactly like the real projector). The only
// difference is who mints the ID.
// Legacy protocol: the request carries no ID, so the server mints a fresh
// one per attempt and cannot recognize a retry. The response to the first
// send is lost, the user presses enter again — the transcript now has the
// prompt twice.
const legacyServer = new FakeSessionServer(sessionID)
legacyServer.faults.loseResponses = 1
let minted = 0
const legacySend = (text: string) =>
legacyServer.submit({ id: `msg_minted_${++minted}`, sessionID, request: { text } })
await legacySend("hello").catch(() => {})
await legacySend("hello")
expect(legacyServer.admitted).toHaveLength(2)
// Engine protocol: the retry reuses the client-minted ID and the same
// server admits exactly once. (Law 1 proves this end-to-end through the
// real engine retry loop; this is the raw protocol contrast.)
const engineServer = new FakeSessionServer(sessionID)
engineServer.faults.loseResponses = 1
const engineSend = () => engineServer.submit({ id: "msg_client", sessionID, request: { text: "hello" } })
await engineSend().catch(() => {})
await engineSend()
expect(engineServer.admitted).toEqual(["msg_client"])
})
test("a dropped execution event leaves an interrupted session spinning forever", async () => {
// The user hits interrupt; the server stops the run; the terminal
// `session.execution.interrupted` event is lost in a reconnect blip.
const legacy = await hydrated()
legacy.dispatch(executionStarted())
// Status only ever changes on the terminal event (lost) or a full
// reconnect's active-session refetch — until one of those happens the
// spinner spins over a session the server already stopped.
expect(legacy.data.session.status(sessionID)).toBe("running")
legacy.dispose()
// Engine: activity is folded durable state behind the seq cursor, so the
// gap itself is detected and snapshot recovery resyncs activity with the
// server (laws 7 and 8 pin the mechanism).
})
})
// Also part of the catalog, straight from the legacy source: the layer
// documents its own event-vs-fetch race — see the session.created "band-aid"
// comment in src/solid/data.ts (skipping racy initial reads so live events
// are not overwritten by stale fetches).
function makeLegacy(overrides: { list?: () => Promise<SessionMessageInfo[]> } = {}) {
let handler: ((event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void) | undefined
const api = {
session: {
get: async () => ({
id: sessionID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
location: { directory: "/workspace" },
}),
},
message: {
list: async () => ({
data: overrides.list ? await overrides.list() : transcript().toReversed(),
cursor: {},
}),
},
} as unknown as ReturnType<CreateDataInput["api"]>
return createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/workspace",
event: {
on: () => () => {},
listen(next) {
handler = next
return () => {}
},
},
})
return {
data,
dispose,
dispatch(event: { type: OpenCodeEvent["type"] } & Record<string, unknown>) {
handler?.({ name: event.type, details: event as unknown as OpenCodeEvent })
},
text() {
const message = data.session.message.get(sessionID, assistantID)
const part =
message?.type === "assistant" ? message.content.findLast((item) => item.type === "text") : undefined
return part?.type === "text" ? part.text : undefined
},
}
})
}
async function hydrated() {
const legacy = makeLegacy()
await legacy.data.session.sync(sessionID)
await legacy.data.session.message.sync(sessionID)
return legacy
}
function transcript(): SessionMessageInfo[] {
return [
{ id: "msg_earlier", type: "user", text: "earlier", time: { created: 1 } },
{
id: assistantID,
type: "assistant",
time: { created: 2 },
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
},
]
}
const textStarted = () => ({
id: "evt_start",
created: 3,
type: "session.text.started" as const,
data: { sessionID, assistantMessageID: assistantID, ordinal: 0 },
})
let deltaCount = 0
const textDelta = (delta: string) => ({
id: `evt_delta_${++deltaCount}`,
created: 4,
type: "session.text.delta" as const,
data: { sessionID, assistantMessageID: assistantID, ordinal: 0, delta },
})
const textEnded = (text: string) => ({
id: "evt_end",
created: 5,
type: "session.text.ended" as const,
data: { sessionID, assistantMessageID: assistantID, ordinal: 0, text },
})
const inboxEnqueued = (inboxID: string) => ({
id: "evt_enqueued",
created: 6,
type: "session.inbox.enqueued" as const,
data: {
sessionID,
inboxID,
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
})
const inboxDelivered = (inboxID: string) => ({
id: "evt_delivered",
created: 7,
type: "session.inbox.delivered" as const,
data: { sessionID, inboxID },
})
const executionStarted = () => ({
id: "evt_execution",
created: 8,
type: "session.execution.started" as const,
data: { sessionID },
})
@@ -1,201 +0,0 @@
// Laws of the session sync engine: each test pins one property the engine
// must hold under transport faults. Cited by number from
// test/legacy-divergence.test.ts (the legacy bug catalog these laws rule out)
// and stress-tested together by test/sync-engine-sim.test.ts. The server
// model lives in test/fixture/sync-engine.ts and folds with the real
// SessionFold, so `server.truth()` is the state a converged client must show.
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { Engine } from "../src/solid/engine/engine"
import { FakeSessionServer, reconnectGate, until, userMessages } from "./fixture/sync-engine"
describe("session sync engine laws", () => {
test("1. idempotency: lost responses converge to one admitted message", async () => {
const server = new FakeSessionServer("ses_idempotency")
server.faults.loseResponses = 1
const engine = await Engine.createSessionEngine(server.sessionID, server, {
now: () => server.time,
reconnect: async () => {},
})
engine.submit({ id: "msg_1", text: "hello" })
await until(() => engine.view().seq === 1)
// The admit landed but the response was lost; the reconnect makes the
// engine resend the same client-minted ID — that resend is what
// idempotency must absorb.
server.cutConnections()
await engine.settled()
expect(server.admitted).toEqual(["msg_1"])
expect(userMessages(engine.view().messages)).toHaveLength(1)
engine.stop()
})
test("2. echo determinism: folding the echo does not change rendered messages", async () => {
const server = new FakeSessionServer("ses_echo")
const engine = await Engine.createSessionEngine(server.sessionID, server, { now: () => server.time })
await engine.ready()
// The "echo" is the server's inbox.enqueued event for our own submit:
// folding it over the optimistic render must be invisible — no flicker.
engine.submit({ id: "msg_1", text: "instant" })
const before = engine.view().messages
await engine.settled()
expect(engine.view().messages).toEqual(before)
engine.stop()
})
test("3. sync opacity: the fold cannot see intents or the engine", () => {
const source = readFileSync(new URL("../src/solid/engine/fold.ts", import.meta.url), "utf8")
const code = source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "")
expect(code).not.toMatch(/\boutbox\b/)
expect(code).not.toContain("./engine")
expect(code).not.toMatch(/\bintents?\b/i)
})
test("4. ordering: a burst admits in submission order", async () => {
const server = new FakeSessionServer("ses_ordering")
const engine = await Engine.createSessionEngine(server.sessionID, server)
for (const value of [1, 2, 3, 4, 5]) engine.submit({ id: `msg_${value}`, text: `m${value}` })
await engine.settled()
expect(server.admitted).toEqual(["msg_1", "msg_2", "msg_3", "msg_4", "msg_5"])
engine.stop()
})
test("5. convergence: drained clients equal the server fold", async () => {
const server = new FakeSessionServer("ses_convergence")
const a = await Engine.createSessionEngine(server.sessionID, server, { makeID: () => "msg_a" })
const b = await Engine.createSessionEngine(server.sessionID, server, { makeID: () => "msg_b" })
a.submit({ text: "from a" })
b.submit({ text: "from b" })
await Promise.all([a.settled(), b.settled()])
await until(() => a.view().seq === server.seq() && b.view().seq === server.seq())
expect(a.view()).toEqual(server.truth())
expect(b.view()).toEqual(server.truth())
a.stop()
b.stop()
})
test("6. failure atomicity: typed rejection removes and surfaces the intent", async () => {
const server = new FakeSessionServer("ses_failure")
server.faults.reject = 1
const engine = await Engine.createSessionEngine(server.sessionID, server)
const failures: Array<Engine.IntentFailure> = []
engine.subscribeFailures((failure) => failures.push(failure))
const before = engine.view()
const intent = engine.submit({ id: "msg_1", text: "doomed" })
expect(userMessages(engine.view().messages)).toHaveLength(1)
await until(() => failures.length === 1)
expect(engine.view()).toEqual(before)
expect(failures).toEqual([{ intent, reason: "rejected" }])
engine.stop()
})
test("7. lossy history: reconnect without retained events recovers via snapshot", async () => {
const server = new FakeSessionServer("ses_lossy")
const gate = reconnectGate()
const engine = await Engine.createSessionEngine(server.sessionID, server, {
now: () => server.time,
reconnect: gate.reconnect,
})
engine.submit({ id: "msg_1", text: "first" })
await engine.settled()
server.cutConnections()
await until(gate.holding)
// While disconnected the session advances, then history is dropped: the
// reconnect cursor cannot be replayed and must recover via snapshot.
await server.submit({ id: "msg_2", sessionID: server.sessionID, request: { text: "second" } })
server.prune()
gate.release()
await until(() => engine.view().seq === 2)
expect(engine.view()).toEqual(server.truth())
engine.stop()
})
test("8. attach gaps: a synced marker past the fold forces snapshot recovery", async () => {
const server = new FakeSessionServer("ses_marker_gap")
await server.submit({ id: "msg_1", sessionID: server.sessionID, request: { text: "hello" } })
const stale = { ...server.snapshotValue(), messages: [], inbox: [], seq: 0 }
let attempts = 0
const engine = await Engine.createSessionEngine(
server.sessionID,
{
snapshot: (sessionID) => (attempts === 0 ? Promise.resolve(stale) : server.snapshot(sessionID)),
async *stream(sessionID, after, signal) {
attempts++
if (attempts === 1) {
// Dishonest attach: the marker admits the cursor but skips the replay range.
yield { type: "log.synced" as const, aggregateID: sessionID, seq: server.snapshotValue().seq }
return
}
yield* server.stream(sessionID, after, signal)
},
submit: (input) => server.submit(input),
},
{ reconnect: async () => {} },
)
await engine.ready()
expect(attempts).toBe(2)
expect(engine.view()).toEqual(server.truth())
engine.stop()
})
test("9. outage recovery: failed recovery snapshots retry until the server returns", async () => {
const server = new FakeSessionServer("ses_outage")
const gate = reconnectGate()
const engine = await Engine.createSessionEngine(server.sessionID, server, {
now: () => server.time,
reconnect: gate.reconnect,
})
engine.submit({ id: "msg_1", text: "first" })
await engine.settled()
server.cutConnections()
await until(gate.holding)
// A server restart while disconnected: history is gone, and the server
// stays unreachable for the first snapshot attempts of the recovery.
await server.submit({ id: "msg_2", sessionID: server.sessionID, request: { text: "second" } })
server.prune()
server.faults.loseSnapshots = 3
gate.release()
await until(() => engine.view().seq === 2)
expect(server.faults.loseSnapshots).toBe(0)
expect(engine.view()).toEqual(server.truth())
engine.stop()
})
test("10. refresh monotonicity: a stale snapshot refresh cannot move the fold behind the live log", async () => {
const server = new FakeSessionServer("ses_refresh_race")
const stale = server.snapshotValue()
let refresh = false
const transport: Engine.SessionTransport = {
snapshot: (sessionID) => (refresh ? Promise.resolve(stale) : server.snapshot(sessionID)),
stream: (sessionID, after) => server.stream(sessionID, after),
submit: (input) => server.submit(input),
}
const engine = await Engine.createSessionEngine(server.sessionID, transport)
await engine.ready()
engine.submit({ id: "msg_1", text: "newer than snapshot" })
await until(() => engine.view().seq === 1)
refresh = true
await engine.refresh()
expect(engine.view().seq).toBe(1)
// ...and the un-echoed intent survives the rejected refresh.
expect(engine.view().pending.map((item) => item.id)).toEqual(["msg_1"])
engine.stop()
})
})
@@ -1,127 +0,0 @@
// Seeded chaos simulation: two engine clients share one FakeSessionServer
// while every fault the fixture can inject is thrown at them at random, then
// all faults heal and both clients must converge exactly to the server's
// truth. This stress-tests the laws of test/sync-engine-laws.test.ts in
// combination; failures reproduce deterministically from the seed.
import { describe, expect, test } from "bun:test"
import { Engine } from "../src/solid/engine/engine"
import { FakeSessionServer, until, userMessages } from "./fixture/sync-engine"
type Client = {
readonly name: string
readonly engine: Engine.SessionEngine
readonly submitted: Array<string>
readonly rejected: Set<string>
readonly views: Array<Engine.SessionView>
}
describe("session sync engine simulation", () => {
for (const seed of [1, 2, 3, 42, 1337, 90210]) {
test(`seed ${seed}: two clients converge through chaotic transport faults`, async () => {
const random = mulberry32(seed)
const server = new FakeSessionServer(`ses_sim_${seed}`)
const clients = await Promise.all([makeClient("a", server), makeClient("b", server)])
// Chaos phase. Per step: 45% submit from a random client, 10% cut all
// connections, 10% lose a response, 8% lose a burst of requests,
// 7% reject an admission, 7% lose a snapshot fetch, 13% shift latency.
for (let step = 0; step < 80; step++) {
const roll = random()
if (roll < 0.45) {
const client = pick(clients, random)
const intent = client.engine.submit({ text: `step-${step}` })
client.submitted.push(intent.id)
} else if (roll < 0.55) {
server.cutConnections()
} else if (roll < 0.65) {
server.faults.loseResponses++
} else if (roll < 0.73) {
server.faults.loseRequests += 1 + Math.floor(random() * 2)
} else if (roll < 0.8) {
server.faults.reject++
} else if (roll < 0.87) {
server.faults.loseSnapshots++
} else {
server.faults.latency = Math.floor(random() * 6)
}
await advance(2 + Math.floor(random() * 8))
}
// Drain phase: heal all faults, then repeatedly cut connections —
// reconnecting is what makes the engine resend intents whose responses
// were lost, so every submitted ID ends up admitted or rejected.
server.heal()
for (let attempt = 0; attempt < 100; attempt++) {
server.cutConnections()
await advance(4)
const accounted = clients.every(
(client) =>
client.submitted.filter((id) => server.admitted.includes(id) || client.rejected.has(id)).length ===
client.submitted.length,
)
if (accounted) break
}
await until(
() => clients.every((client) => client.engine.view().seq === server.seq()),
`seed ${seed} did not converge`,
)
expect(new Set(server.admitted).size).toBe(server.admitted.length)
for (const client of clients) {
const expected = client.submitted.filter((id) => !client.rejected.has(id))
const observed = server.admitted.filter((id) => client.submitted.includes(id))
expect(observed).toEqual(expected)
expect(client.engine.view()).toEqual(server.truth())
assertNoFlicker(client.views, server.admitted, `${seed}/${client.name}`)
client.engine.stop()
}
})
}
})
async function makeClient(name: string, server: FakeSessionServer): Promise<Client> {
let counter = 0
const engine = await Engine.createSessionEngine(server.sessionID, server, {
makeID: () => `msg_${name}${String(++counter).padStart(4, "0")}`,
now: () => server.time,
reconnect: async () => {},
})
const client: Client = { name, engine, submitted: [], rejected: new Set(), views: [engine.view()] }
engine.subscribe((view) => client.views.push(view))
engine.subscribeFailures((failure) => client.rejected.add(failure.intent.id))
return client
}
// Once an admitted message first renders, it appears exactly once in every
// subsequent view — it never disappears or duplicates.
function assertNoFlicker(views: ReadonlyArray<Engine.SessionView>, admitted: ReadonlyArray<string>, label: string) {
for (const id of admitted) {
const first = views.findIndex((view) => userMessages(view.messages).some((message) => message.id === id))
expect(first, `${label}: ${id} never rendered`).toBeGreaterThanOrEqual(0)
for (const view of views.slice(first)) {
const rows = userMessages(view.messages).filter((message) => message.id === id)
expect(rows, `${label}: ${id} disappeared or duplicated`).toHaveLength(1)
}
}
}
// One "step" is one microtask turn — each fixture `pause()` under
// `faults.latency` consumes one — followed by a macrotask flush.
async function advance(steps: number) {
for (let step = 0; step < steps; step++) await Promise.resolve()
await Bun.sleep(0)
}
function pick<Value>(values: ReadonlyArray<Value>, random: () => number) {
return values[Math.floor(random() * values.length)]!
}
function mulberry32(seed: number) {
return () => {
seed |= 0
seed = (seed + 0x6d2b79f5) | 0
const first = Math.imul(seed ^ (seed >>> 15), 1 | seed)
const second = (first + Math.imul(first ^ (first >>> 7), 61 | first)) ^ first
return ((second ^ (second >>> 14)) >>> 0) / 4294967296
}
}
-13
View File
@@ -1,13 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"include": [
"src",
"script",
"test/fixture",
"test/engine-data.test.ts",
"test/legacy-divergence.test.ts",
"test/sync-engine-laws.test.ts",
"test/sync-engine-sim.test.ts"
]
}
+1 -73
View File
@@ -14,22 +14,11 @@ export interface MapInput {
readonly packageName: string | undefined
readonly settings: Readonly<Record<string, unknown>>
readonly modelID: string
readonly providerID: string
}
export function map(input: MapInput): Mapping | undefined {
const baseSettings = mapBaseSettings(input.settings)
switch (input.packageName) {
case "@ai-sdk/anthropic":
return {
package: "@opencode-ai/ai/providers/anthropic",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.authToken === "string" ? { authToken: input.settings.authToken } : {}),
...mapAnthropicOptions(input.settings),
},
}
case "@ai-sdk/amazon-bedrock":
return {
package: "@opencode-ai/ai/providers/amazon-bedrock",
@@ -62,22 +51,6 @@ export function map(input: MapInput): Mapping | undefined {
...mapGoogleOptions(input.settings),
},
}
case "@ai-sdk/google-vertex":
return {
package: "@opencode-ai/ai/providers/google-vertex",
settings: {
...baseSettings,
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
...mapAPIKey(input.settings),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...mapGoogleOptions(
input.settings,
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/google-vertex/anthropic":
return {
package: "@opencode-ai/ai/providers/google-vertex/messages",
@@ -99,35 +72,6 @@ export function map(input: MapInput): Mapping | undefined {
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/openai":
return {
package: "@opencode-ai/ai/providers/openai",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.organization === "string" ? { organization: input.settings.organization } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
...mapProviderOptions(input.settings, "openai", [
"apiKey",
"baseURL",
"organization",
"project",
"queryParams",
]),
},
}
case "@ai-sdk/openai-compatible":
if (typeof input.settings.baseURL !== "string") return
return {
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
provider: input.providerID,
...mapProviderOptions(input.settings, "openai", ["apiKey", "baseURL"]),
},
}
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/xai":
@@ -142,20 +86,6 @@ export function map(input: MapInput): Mapping | undefined {
}
}
function mapAnthropicOptions(settings: Readonly<Record<string, unknown>>) {
return mapProviderOptions(settings, "anthropic", ["apiKey", "authToken", "baseURL"])
}
function mapProviderOptions(
settings: Readonly<Record<string, unknown>>,
key: string,
excluded: ReadonlyArray<string>,
) {
const options = Object.fromEntries(Object.entries(settings).filter(([name]) => !excluded.includes(name)))
if (Object.keys(options).length === 0) return {}
return { providerOptions: { [key]: options } }
}
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
const settings = input.settings
const chat = input.modelID === "openai.gpt-oss-safeguard-20b" || input.modelID === "openai.gpt-oss-safeguard-120b"
@@ -299,7 +229,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
@@ -310,11 +240,9 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Re
}
const options = {
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
...(isStringRecord(settings.labels) ? { labels: settings.labels } : {}),
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
...extra,
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: { gemini: options } }
+1 -14
View File
@@ -460,20 +460,7 @@ function prompt(request: LLMRequest): LanguageModelV3Prompt {
function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[] {
switch (input.role) {
case "system":
// The initial privileged prompt lives in `request.system` and is prepended above. A system message here is a
// chronological instruction update, but opaque AI SDK providers do not uniformly allow the system role after
// conversation history, so preserve its position using the safe wrapped-user fallback.
return [
{
role: "user",
content: [
{
type: "text",
text: ProviderShared.wrapSystemUpdate(input.content.filter((part) => part.type === "text")),
},
],
},
]
return [{ role: "system", content: input.content.flatMap(text).join("\n\n") }]
case "user":
return [{ role: "user", content: input.content.flatMap(userPart) }]
case "assistant":
+1 -27
View File
@@ -44,21 +44,6 @@ export const reserveSequence = Effect.fn("Bus.reserveSequence")(function* (
.pipe(Effect.orDie)
})
export const retainedCount = Effect.fn("Bus.retainedCount")(function* (
db: Database.Interface["db"],
aggregateID: string,
after: number,
through: number,
) {
const row = yield* db
.select({ count: sql<number>`count(*)` })
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after), lte(EventTable.seq, through)))
.get()
.pipe(Effect.orDie)
return row?.count ?? 0
})
export type SerializedEvent = {
readonly id: Event.ID
readonly type: string
@@ -165,7 +150,6 @@ export interface Interface {
readonly aggregateID: string
readonly after?: number
readonly follow?: boolean
readonly includeLive?: (event: Event.Payload) => boolean
}) => Stream.Stream<LogItem>
/** @deprecated Use `subscribe()` and consume the returned stream. */
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
@@ -784,7 +768,6 @@ export function configured(options?: Options) {
readonly aggregateID: string
readonly after?: number
readonly follow?: boolean
readonly includeLive?: (event: Event.Payload) => boolean
}): Stream.Stream<LogItem> =>
Stream.unwrap(
Effect.gen(function* () {
@@ -808,8 +791,7 @@ export function configured(options?: Options) {
)
// Subscribing before the historical read means events committed during
// replay either appear in the read or arrive through a post-marker wake.
const subscription = input.follow && input.includeLive ? yield* PubSub.subscribe(pubsub.live) : undefined
const wakes = input.follow && !subscription ? yield* subscribeDurable(input.aggregateID) : undefined
const wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined
const target = yield* latestSequence(db, input.aggregateID)
const marker: EventLog.Synced = {
type: "log.synced",
@@ -820,14 +802,6 @@ export function configured(options?: Options) {
Stream.map((event): LogItem => event),
Stream.concat(Stream.make(marker)),
)
if (subscription && input.includeLive) {
const follow: Stream.Stream<LogItem> = Stream.fromSubscription(subscription).pipe(
Stream.filter(input.includeLive),
Stream.filter((event) => !event.durable || event.durable.seq > target),
Stream.map((event): LogItem => event),
)
return Stream.concat(replay, follow)
}
if (!wakes) return replay
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
+28 -11
View File
@@ -8,8 +8,10 @@ import { MCP } from "./mcp/index.js"
import { Bus } from "./bus.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "./config.js"
import { Location } from "./location.js"
import { ShellSelect } from "./shell/select.js"
import { Global } from "@opencode-ai/util/global"
export const Info = Command.Info
export type Info = Command.Info
@@ -51,15 +53,16 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
const layer = () =>
export const layer = (options?: ShellSelect.Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const bus = yield* Bus.Service
const processes = yield* AppProcess.Service
const config = yield* Config.Service
const location = yield* Location.Service
const shell = yield* ShellSelect.Service
const global = yield* Global.Service
const state = State.create<Data, Draft>({
name: "command",
initial: () => ({ commands: new Map() }),
@@ -106,9 +109,11 @@ const layer = () =>
const command = staticCommand(input.name)
if (command)
return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
config,
location,
processes,
shell,
shell: options,
bin: global.bin,
})
const prompt = (yield* mcp.prompts()).find(
@@ -158,9 +163,11 @@ function evaluateTemplate(
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
readonly shell?: ShellSelect.Options
readonly bin: string
},
) {
return Effect.gen(function* () {
@@ -190,14 +197,20 @@ const evaluateShell = Effect.fnUntraced(function* (
command: string,
text: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
readonly shell?: ShellSelect.Options
readonly bin: string
},
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.preferred()
const shell = ShellSelect.preferred(
Config.latest(yield* services.config.entries(), "shell"),
services.shell,
services.bin,
)
const outputs = yield* Effect.forEach(
matches,
(match) => {
@@ -254,8 +267,12 @@ const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [MCP.node, Bus.node, AppProcess.node, Location.node, ShellSelect.node],
})
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
})
}
export const node = configured()
@@ -1,37 +0,0 @@
export * as ConfigCompactionPlugin from "./compaction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { SessionCompaction } from "../../session/compaction.js"
export const Plugin = define({
id: "opencode.config.compaction",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const compaction = yield* SessionCompaction.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(compaction.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* compaction.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.compaction) continue
draft.configure({
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
...(entry.info.compaction.keep?.tokens === undefined
? {}
: { tokens: entry.info.compaction.keep.tokens }),
})
}
})
}),
})
@@ -1,31 +0,0 @@
export * as ConfigLocationWatcherPlugin from "./location-watcher.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { LocationWatcherPolicy } from "../../filesystem/location-watcher-policy.js"
export const Plugin = define({
id: "opencode.config.location-watcher",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const policy = yield* LocationWatcherPolicy.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(policy.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* policy.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.watcher?.ignore) continue
draft.add(entry.info.watcher.ignore)
}
})
}),
})
-29
View File
@@ -1,29 +0,0 @@
export * as ConfigShellPlugin from "./shell.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { ShellSelect } from "../../shell/select.js"
export const Plugin = define({
id: "opencode.config.shell",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const shell = yield* ShellSelect.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(shell.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* shell.transform((draft) => {
const configured = Config.latest(loaded.entries, "shell")
if (configured) draft.configure(configured)
})
}),
})
@@ -1,30 +0,0 @@
export * as ConfigSnapshotPlugin from "./snapshot.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Snapshot } from "../../snapshot.js"
export const Plugin = define({
id: "opencode.config.snapshot",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const snapshot = yield* Snapshot.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(snapshot.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* snapshot.transform((draft) => {
const configured = Config.latest(loaded.entries, "snapshots")
if (configured === undefined) return
draft.configure(configured)
})
}),
})
@@ -1,33 +0,0 @@
export * as ConfigToolOutputPlugin from "./tool-output.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { ToolOutput } from "../../tool-output.js"
export const Plugin = define({
id: "opencode.config.tool-output",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const output = yield* ToolOutput.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(output.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* output.transform((draft) => {
const configured = Config.latest(loaded.entries, "tool_output")
if (!configured) return
draft.configure({
...(configured.max_lines === undefined ? {} : { maxLines: configured.max_lines }),
...(configured.max_bytes === undefined ? {} : { maxBytes: configured.max_bytes }),
})
})
}),
})
@@ -1,65 +0,0 @@
export * as LocationWatcherPolicy from "./location-watcher-policy.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Scope } from "effect"
import { State } from "../state.js"
type Data = {
ignore: string[]
}
export type Draft = {
add: (ignore: readonly string[]) => void
list: () => readonly string[]
}
export interface Interface extends State.Transformable<Draft> {
readonly current: () => readonly string[]
readonly observe: (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
) => Effect.Effect<State.Registration, never, Scope.Scope>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcherPolicy") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let current: readonly string[] = []
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
const state = State.create<Data, Draft>({
name: "location-watcher-policy",
initial: () => ({ ignore: [] }),
draft: (draft) => ({
add: (ignore) => draft.ignore.push(...ignore),
list: () => draft.ignore,
}),
finalize: (draft) =>
Effect.sync(() => {
current = [...draft.list()]
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
})
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
) {
const scope = yield* Scope.Scope
let active = true
const dispose = Effect.sync(() => {
if (!active) return
active = false
listeners.delete(listener)
})
listeners.add(listener)
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
})
return Service.of({
transform: state.transform,
reload: state.reload,
current: () => current,
observe,
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
@@ -1,15 +1,15 @@
export * as LocationWatcher from "./location-watcher.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Cause, Context, Effect, Exit, Layer, Scope, Semaphore, Stream } from "effect"
import { Context, Effect, Layer, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Document } from "@opencode-ai/schema/config"
import path from "path"
import { Config } from "../config.js"
import { Bus } from "../bus.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git.js"
import { Location } from "../location.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { LocationWatcherPolicy } from "./location-watcher-policy.js"
import { Watcher } from "./watcher.js"
export interface Interface {}
@@ -24,86 +24,42 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const plugins = yield* PluginSupervisor.Service
const policy = yield* LocationWatcherPolicy.Service
const configService = yield* Config.Service
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
bus.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
const target = yield* Effect.cached(
Effect.gen(function* () {
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs) return { path: path.join(vcs, "HEAD"), aliases: [".git", vcs, ...(resolved ? [resolved] : [])] }
}
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
return { path: path.join(vcs, "branch"), aliases: [".hg", vcs] }
}
}).pipe(
Effect.withSpan("LocationWatcher.target", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) =>
Effect.logError("failed to resolve location watcher target", { cause }).pipe(Effect.as(undefined)),
),
),
)
const lock = Semaphore.makeUnsafe(1)
let requested = 0
let stopped = false
let active: { path: string; scope: Scope.Closeable } | undefined
const reconcile = (ignore: readonly string[]) => {
const request = ++requested
return lock.withPermit(
Effect.gen(function* () {
if (stopped || request !== requested) return
const resolved = yield* target
if (stopped || request !== requested) return
const next = resolved && !resolved.aliases.some((alias) => ignore.includes(alias)) ? resolved.path : undefined
if (active?.path === next) return
if (active) yield* Scope.close(active.scope, Exit.void)
active = undefined
if (!next) return
const scope = yield* Scope.make()
active = { path: next, scope }
yield* Effect.gen(function* () {
const updates = yield* watcher.subscribe({ path: next, type: "file" })
yield* Stream.runForEach(updates, publish)
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) => Effect.logError("location watcher subscription failed", { path: next, cause }),
),
Effect.forkIn(scope, { startImmediately: true }),
)
}).pipe(Effect.withSpan("LocationWatcher.reconcile", { attributes: { directory: location.directory } })),
)
}
yield* Effect.addFinalizer(() =>
lock.withPermit(
Effect.gen(function* () {
stopped = true
requested++
if (active) yield* Scope.close(active.scope, Exit.void)
active = undefined
}),
),
)
yield* policy.observe(reconcile)
yield* Effect.gen(function* () {
yield* plugins.flush
yield* reconcile(policy.current())
const config = (yield* configService.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
if (!config.includes(".hg") && !config.includes(vcs)) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) => Effect.logError("failed to start location watcher", { cause }),
),
Effect.forkScoped({ startImmediately: true }),
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
Effect.forkScoped,
)
return Service.of({})
}),
)
@@ -111,13 +67,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [
Watcher.node,
FSUtil.node,
Location.node,
Git.node,
Bus.node,
PluginSupervisor.node,
LocationWatcherPolicy.node,
],
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, Bus.node],
})
-2
View File
@@ -29,7 +29,6 @@ import { PluginSupervisor } from "./plugin/supervisor.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Shell } from "./shell.js"
import { ShellSelect } from "./shell/select.js"
import { Reference } from "./reference.js"
import { WebSearch } from "./websearch.js"
import { ReferenceInstructions } from "./reference/instructions.js"
@@ -72,7 +71,6 @@ const locationServiceNodes = [
Worktree.refreshNode,
FileSystemSearch.node,
FileSystem.node,
ShellSelect.node,
Pty.node,
Shell.node,
Skill.node,
+85 -2
View File
@@ -2,7 +2,13 @@ export * as ModelResolver from "./model-resolver.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LanguageModel } from "@opencode-ai/ai"
import { Auth } from "@opencode-ai/ai/route"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
// ast-grep-ignore: no-star-import
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "./aisdk.js"
@@ -77,6 +83,47 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelResolver") {}
const apiKey = (model: Info, credential?: Credential.Value) => {
if (credential?.type === "key") return Auth.value(credential.key)
if (credential?.type === "oauth") return Auth.value(credential.access)
const value = model.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
return undefined
}
const withDefaults = (model: Info, route: AnyRoute) =>
route.with({
provider: model.providerID,
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
})
const providerHeaders = (model: Info) => {
const packageName = Provider.packageName(model.package)
const generated = new Map<string, string>()
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
generated.set("OpenAI-Organization", model.settings.organization)
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
generated.set("OpenAI-Project", model.settings.project)
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
return Provider.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
}
const providerOptions = (model: Info): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
if (!Provider.isAISDK(model.package) || model.settings === undefined) return undefined
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
if (Object.keys(settings).length === 0) return undefined
const packageName = Provider.packageName(model.package)
if (packageName === "@ai-sdk/openai") return { openai: settings }
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
return undefined
}
export const withVariant = (
model: Info,
variantID: VariantID | undefined,
@@ -123,14 +170,37 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
) {
const resolved = prepareRuntimeModel(model, credential)
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
const configuration = credential?.type === "key" ? credential.configuration : undefined
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
}
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
}
if (
Provider.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
}
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
const mapping = Provider.isAISDK(resolved.package)
? AISDKNative.map({
packageName,
settings: configured,
modelID: resolved.modelID ?? resolved.id,
providerID: resolved.providerID,
})
: undefined
const native = mapping?.package ?? resolved.package
@@ -197,6 +267,19 @@ function validateProviderVariables(
return failure ? Effect.fail(failure) : Effect.succeed(resolved)
}
function prepareProviderModel(model: Info): Effect.Effect<Info, UnresolvedProviderVariablesError> {
if (!model.settings) return Effect.succeed(model)
return prepareProviderSettings(model, model.settings).pipe(
Effect.map((settings) =>
settings === model.settings
? model
: produce(model, (draft) => {
draft.settings = settings
}),
),
)
}
function prepareProviderSettings(
model: Info,
settings: Readonly<Record<string, unknown>>,
-30
View File
@@ -13,19 +13,14 @@ import { Config } from "../config.js"
import { Credential } from "../credential.js"
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
import { ConfigCommandPlugin } from "../config/plugin/command.js"
import { ConfigCompactionPlugin } from "../config/plugin/compaction.js"
import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
import { ConfigImagePlugin } from "../config/plugin/image.js"
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
import { ConfigLocationWatcherPlugin } from "../config/plugin/location-watcher.js"
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
import { ConfigShellPlugin } from "../config/plugin/shell.js"
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { Bus } from "../bus.js"
@@ -34,7 +29,6 @@ import { FileMutation } from "../file-mutation.js"
import { Formatter } from "../formatter.js"
import { Form } from "../form.js"
import { FileSystem } from "../filesystem.js"
import { LocationWatcherPolicy } from "../filesystem/location-watcher-policy.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image.js"
@@ -50,11 +44,8 @@ import { Permission } from "../permission.js"
import { Reference } from "../reference.js"
import { WebSearch } from "../websearch.js"
import { Ripgrep } from "../ripgrep.js"
import { SessionCompaction } from "../session/compaction.js"
import { SessionInstructions } from "../session/instructions.js"
import { Shell } from "../shell.js"
import { ShellSelect } from "../shell/select.js"
import { Snapshot } from "../snapshot.js"
import { Skill } from "../skill.js"
import { SkillDiscovery } from "../skill/discovery.js"
import { Watcher } from "../filesystem/watcher.js"
@@ -69,7 +60,6 @@ import { ShellTool } from "../tool/plugin/shell.js"
import { SkillTool } from "../tool/plugin/skill.js"
import { SubagentTool } from "../tool/plugin/subagent.js"
import { Tool } from "../tool.js"
import { ToolOutput } from "../tool-output.js"
import { WebFetchTool } from "../tool/plugin/webfetch.js"
import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
@@ -100,7 +90,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const locationWatcherPolicy = yield* LocationWatcherPolicy.Service
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
@@ -121,15 +110,11 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const compaction = yield* SessionCompaction.Service
const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service
const shellSelect = yield* ShellSelect.Service
const snapshot = yield* Snapshot.Service
const skill = yield* Skill.Service
const skillDiscovery = yield* SkillDiscovery.Service
const tools = yield* Tool.Service
const toolOutput = yield* ToolOutput.Service
const watcher = yield* Watcher.Service
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
@@ -144,7 +129,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter),
Context.make(LocationWatcherPolicy.Service, locationWatcherPolicy),
Context.make(FileSystem.Service, filesystem),
Context.make(FSUtil.Service, fs),
Context.make(Global.Service, global),
@@ -165,15 +149,11 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionCompaction.Service, compaction),
Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell),
Context.make(ShellSelect.Service, shellSelect),
Context.make(Snapshot.Service, snapshot),
Context.make(Skill.Service, skill),
Context.make(SkillDiscovery.Service, skillDiscovery),
Context.make(Tool.Service, tools),
Context.make(ToolOutput.Service, toolOutput),
Context.make(Watcher.Service, watcher),
Context.make(WellKnown.Service, wellknown),
)
@@ -195,7 +175,6 @@ export const requirements = LayerNode.group([
Environment.node,
FileMutation.node,
Formatter.node,
LocationWatcherPolicy.node,
FileSystem.node,
FSUtil.node,
Global.node,
@@ -216,15 +195,11 @@ export const requirements = LayerNode.group([
Reference.node,
WebSearch.node,
Ripgrep.node,
SessionCompaction.node,
SessionInstructions.node,
Shell.node,
ShellSelect.node,
Snapshot.node,
Skill.node,
SkillDiscovery.node,
Tool.node,
ToolOutput.node,
Watcher.node,
WellKnown.node,
])
@@ -263,13 +238,8 @@ const post = [
ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
ConfigCompactionPlugin.Plugin,
ConfigFormatterPlugin.Plugin,
ConfigImagePlugin.Plugin,
ConfigLocationWatcherPlugin.Plugin,
ConfigShellPlugin.Plugin,
ConfigSnapshotPlugin.Plugin,
ConfigToolOutputPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
@@ -190,6 +190,13 @@ export const OpenAIPlugin = define({
})
yield* load()
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
evt.provider.update(item.provider.id, (provider) => {
provider.package = "@opencode-ai/ai/providers/openai"
})
}
if (!chatgpt) return
const item = evt.provider.get(Provider.ID.openai)
if (!item) return
+16 -8
View File
@@ -4,10 +4,12 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Disp, Proc } from "#pty"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Pty } from "@opencode-ai/schema/pty"
import { Config } from "./config.js"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { PtyID } from "./pty/schema.js"
import { ShellSelect } from "./shell/select.js"
import { Global } from "@opencode-ai/util/global"
import { lazy } from "./util/lazy.js"
const BUFFER_LIMIT = 1024 * 1024 * 2
@@ -88,13 +90,14 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
const layer = () =>
export const layer = (options?: ShellSelect.Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const shell = yield* ShellSelect.Service
const config = yield* Config.Service
const global = yield* Global.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
@@ -164,7 +167,8 @@ const layer = () =>
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command = input.command || (yield* shell.preferred())
const command =
input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin)
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
@@ -313,8 +317,12 @@ const layer = () =>
}),
)
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [Bus.node, Location.node, ShellSelect.node],
})
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node],
})
}
export const node = configured()
+25 -132
View File
@@ -141,11 +141,6 @@ export class InboxConflictError extends Schema.TaggedError<InboxConflictError>()
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
}) {}
export class SeqUnavailableError extends Schema.TaggedError<SeqUnavailableError>()("Session.SeqUnavailableError", {
sessionID: SessionSchema.ID,
after: Event.Seq,
head: Schema.optional(Event.Seq),
}) {}
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedError<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
@@ -199,33 +194,13 @@ export interface Interface {
* unhandled compaction barriers.
*/
readonly inbox: (sessionID: SessionSchema.ID) => Effect.Effect<SessionInbox.Info[], NotFoundError>
readonly snapshot: (input: {
sessionID: SessionSchema.ID
recent?: number
}) => Effect.Effect<
{
readonly session: SessionSchema.Info
readonly children: SessionSchema.Info[]
readonly inbox: SessionInbox.Info[]
readonly messages: SessionMessage.Info[]
readonly seq: Event.Seq
},
NotFoundError | MessageDecodeError
>
readonly cancelInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
readonly steerInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
readonly queueInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
readonly openLog: (input: {
sessionID: SessionSchema.ID
after?: number
follow?: boolean
ephemeral?: boolean
}) => Effect.Effect<Stream.Stream<SessionEvent.Event | EventLog.Synced>, NotFoundError | SeqUnavailableError>
/**
* Ordered session log read. Replays durable session events after the
* exclusive `after` cursor, emits a `Synced` marker at the captured replay
* watermark, then continues live when `follow` is set. Ephemeral events are
* included only in the live phase when explicitly requested.
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
* replay watermark, then continues live when `follow` is set.
* The marker's seq may exceed the last emitted event because other durable
* bus share the aggregate's sequence space.
*/
@@ -233,8 +208,7 @@ export interface Interface {
sessionID: SessionSchema.ID
after?: number
follow?: boolean
ephemeral?: boolean
}) => Stream.Stream<SessionEvent.Event | EventLog.Synced, NotFoundError | SeqUnavailableError>
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
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>
@@ -352,7 +326,6 @@ const layer = Layer.effect(
})
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const isSessionEvent = Schema.is(SessionEvent.All)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
@@ -584,90 +557,20 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* SessionInbox.list(db, sessionID)
}),
snapshot: Effect.fn("Session.snapshot")(function* (input) {
return yield* db
.transaction(() =>
Effect.gen(function* () {
const row = yield* db
.select()
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
const children = yield* db
.select()
.from(SessionTable)
.where(eq(SessionTable.parent_id, input.sessionID))
.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id))
.all()
.pipe(Effect.orDie)
const inbox = yield* SessionInbox.list(db, input.sessionID)
const messages = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, input.sessionID))
.orderBy(desc(SessionMessageTable.seq))
.limit(input.recent ?? 200)
.all()
.pipe(Effect.orDie)
const seq = yield* Bus.latestSequence(db, input.sessionID)
if (seq < 0) return yield* Effect.die(new Error(`Session ${input.sessionID} has no event sequence`))
return {
session: fromRow(row),
children: children.map(fromRow),
inbox,
messages: yield* Effect.forEach(messages.toReversed(), decode),
seq: Event.Seq.make(seq),
}
}),
)
.pipe(Effect.catchTag("SqlError", Effect.die))
}),
cancelInbox: Effect.fn("Session.cancelInbox")((input) => mutatePending(input, SessionInbox.cancel)),
steerInbox: Effect.fn("Session.steerInbox")((input) => mutatePending(input, SessionInbox.steer, true)),
queueInbox: Effect.fn("Session.queueInbox")((input) => mutatePending(input, SessionInbox.queue)),
openLog: Effect.fn("Session.openLog")(function* (input) {
yield* result.get(input.sessionID)
if (input.after !== undefined) {
const head = yield* Bus.latestSequence(db, input.sessionID)
if (input.after > head)
return yield* new SeqUnavailableError({
sessionID: input.sessionID,
after: Event.Seq.make(input.after),
head: head >= 0 ? Event.Seq.make(head) : undefined,
})
// A cursor claims the caller already holds everything through `after`, so
// replay of (after, head] must be provably complete. Without retained rows
// covering the range (events.persist off, or pruned history) replaying
// nothing would silently desync the caller; fail so it re-snapshots instead.
if (input.after < head) {
const retained = yield* Bus.retainedCount(db, input.sessionID, input.after, head)
if (retained < head - input.after)
return yield* new SeqUnavailableError({
sessionID: input.sessionID,
after: Event.Seq.make(input.after),
head: Event.Seq.make(head),
})
}
}
return bus
.log({
aggregateID: input.sessionID,
after: input.after,
follow: input.follow,
includeLive: input.ephemeral
? (event) => isSessionEvent(event) && event.data.sessionID === input.sessionID
: undefined,
})
.pipe(
Stream.filter(
(item): item is SessionEvent.Event | EventLog.Synced =>
Bus.isSynced(item) || (input.ephemeral ? isSessionEvent(item) : isDurableSessionEvent(item)),
),
)
}),
log: (input) => Stream.unwrap(result.openLog(input)),
log: (input) =>
Stream.unwrap(
result
.get(input.sessionID)
.pipe(Effect.as(bus.log({ aggregateID: input.sessionID, after: input.after, follow: input.follow }))),
).pipe(
Stream.filter(
(item): item is SessionEvent.DurableEvent | EventLog.Synced =>
Bus.isSynced(item) || isDurableSessionEvent(item),
),
),
prompt: Effect.fn("Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
@@ -725,11 +628,7 @@ const layer = Layer.effect(
}),
command: Effect.fn("Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
const commands = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Command.Service
}).pipe(Effect.provide(locations.get(session.location)))
const commands = yield* Command.Service.pipe(Effect.provide(locations.get(session.location)))
const command = yield* commands.get(input.command)
if (!command)
return yield* new Command.NotFoundError({
@@ -768,8 +667,6 @@ const layer = Layer.effect(
activeShells.add(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const shell = yield* Shell.Service
return yield* shell
.create({
@@ -1008,23 +905,19 @@ const layer = Layer.effect(
const session = yield* result.get(input.sessionID)
if ((yield* execution.active).has(input.sessionID))
return yield* new BusyError({ sessionID: input.sessionID })
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
)
}).pipe(Effect.provide(locations.get(session.location)))
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
Effect.provide(locations.get(session.location)),
)
}),
clear: Effect.fn("Session.revert.clear")(function* (sessionID) {
const session = yield* result.get(sessionID)
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
const revert = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
}).pipe(Effect.provide(locations.get(session.location)))
const revert = yield* SessionRevert.clear(session).pipe(
Effect.provideService(Bus.Service, bus),
Effect.provide(locations.get(session.location)),
)
yield* execution.wake(sessionID)
return revert
}),
+25 -28
View File
@@ -3,7 +3,9 @@ export * as SessionCompaction from "./compaction.js"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config.js"
import { Bus } from "../bus.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform.js"
@@ -22,7 +24,6 @@ import type { Info, Ref } from "../model.js"
import { SessionUsage } from "./usage.js"
import { PluginHooks } from "../plugin/hooks.js"
import { Agent } from "../agent.js"
import { State } from "../state.js"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
@@ -60,14 +61,10 @@ Rules:
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
- Do not mention the summary process or that context was compacted.`
export type Settings = {
auto: boolean
buffer: number
tokens: number
}
export type Draft = {
configure: (settings: Partial<Settings>) => void
type Settings = {
readonly auto: boolean
readonly buffer: number
readonly tokens: number
}
type Dependencies = {
@@ -77,6 +74,7 @@ type Dependencies = {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly models: SessionRunnerModel.Interface
readonly config: Settings
readonly hooks: PluginHooks.Interface
}
@@ -113,7 +111,7 @@ export type Outcome =
| Pick<SessionMessage.CompactionCompleted, "status">
| Pick<SessionMessage.CompactionFailed, "status" | "error">
export interface Interface extends State.Transformable<Draft> {
export interface Interface {
readonly required: (input: RequiredInput) => boolean
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
@@ -167,6 +165,17 @@ const serialize = (message: SessionMessage.Info) => {
return ""
}
const settings = (documents: readonly Entry[]) => {
const configured = documents
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
return {
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER,
tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS,
}
}
const select = (
messages: readonly SessionMessage.Info[],
tokens: number,
@@ -231,17 +240,7 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
}
const make = (dependencies: Dependencies) => {
const state = State.create<Settings, Draft>({
name: "session-compaction",
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
draft: (draft) => ({
configure: (settings) => {
if (settings.auto !== undefined) draft.auto = settings.auto
if (settings.buffer !== undefined) draft.buffer = settings.buffer
if (settings.tokens !== undefined) draft.tokens = settings.tokens
},
}),
})
const config = dependencies.config
const failed = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly reason: SessionMessage.Compaction["reason"]
@@ -351,7 +350,7 @@ const make = (dependencies: Dependencies) => {
return { status: "completed" as const }
})
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
const content = planContent(input.messages, state.get().tokens)
const content = planContent(input.messages, config.tokens)
if (content)
return yield* execute({
session: input.session,
@@ -369,7 +368,6 @@ const make = (dependencies: Dependencies) => {
})
})
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
@@ -390,7 +388,7 @@ const make = (dependencies: Dependencies) => {
return used >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, state.get().tokens)
const content = planContent(input.messages, config.tokens)
if (!content)
return yield* failed({
sessionID: input.session.id,
@@ -421,8 +419,6 @@ const make = (dependencies: Dependencies) => {
})
})
return Service.of({
transform: state.transform,
reload: state.reload,
required,
compact,
compactManual,
@@ -434,15 +430,16 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
const app = yield* App.Metadata
const hooks = yield* PluginHooks.Service
return make({ bus, llm, models, app, hooks })
return make({ bus, llm, models, config: settings(yield* config.entries()), app, hooks })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, SessionRunnerModel.node, App.node, PluginHooks.node],
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node, PluginHooks.node],
})
+1 -7
View File
@@ -195,8 +195,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.retry = undefined
draft.error = undefined
draft.finish = undefined
draft.rawFinish = undefined
draft.providerState = undefined
draft.time.completed = undefined
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
}),
@@ -230,8 +228,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = created
draft.finish = event.data.finish
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.cost = event.data.cost
draft.tokens = event.data.tokens
if (event.data.snapshot || event.data.files)
@@ -245,9 +241,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.step.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = created
draft.finish = event.data.finish ?? "error"
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.finish = "error"
draft.error = castDraft(event.data.error)
draft.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
-6
View File
@@ -34,7 +34,6 @@ import { toSessionError } from "../to-session-error.js"
import { SessionRunnerRetry } from "./retry.js"
import { SessionUsage } from "../usage.js"
import { ToolOutput } from "../../tool-output.js"
import { PluginSupervisor } from "../../plugin/supervisor.js"
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
type CallOutcome = Data.TaggedEnum<{
@@ -115,7 +114,6 @@ const layer = Layer.effect(
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const plugins = yield* PluginSupervisor.Service
const title = yield* SessionTitle.Service
const toolOutput = yield* ToolOutput.Service
// Title generation starts once input is visible and must not delay model execution.
@@ -137,7 +135,6 @@ const layer = Layer.effect(
const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const }
yield* plugins.flush
yield* settleStaleToolCalls(input.sessionID)
while (true) {
if (yield* runPendingCompaction(input.sessionID, promotable)) {
@@ -329,8 +326,6 @@ const layer = Layer.effect(
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: finish.finish,
rawFinish: finish.rawFinish,
providerState: finish.providerState,
...stepUsage(finish),
...end,
})
@@ -649,7 +644,6 @@ export const node = makeLocationNode({
SessionModelTransport.node,
SessionStore.node,
SessionCompaction.node,
PluginSupervisor.node,
SessionTitle.node,
Snapshot.node,
ToolOutput.node,
@@ -35,8 +35,6 @@ export interface StepRecord {
/** Present once the provider finished the step normally. */
readonly finish?: {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly rawFinish?: string
readonly providerState?: SessionMessage.ProviderState
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
readonly calls: ReadonlyArray<{
@@ -366,9 +364,6 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
sessionID: input.sessionID,
assistantMessageID,
error: stepFailure,
finish: stepSettlement?.finish === "content-filter" ? stepSettlement.finish : undefined,
rawFinish: stepSettlement?.rawFinish,
providerState: stepSettlement?.providerState,
...details,
})
})
@@ -522,12 +517,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
case "step-finish":
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = {
finish: event.reason.normalized,
rawFinish: event.reason.raw,
providerState: providerState(event.providerMetadata),
tokens: SessionUsage.tokens(event.usage),
}
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
if (event.reason.normalized === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
+27 -17
View File
@@ -7,6 +7,7 @@ import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
import { AppProcess } from "@opencode-ai/util/process"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Config } from "./config.js"
import { Bus } from "./bus.js"
import { Environment } from "./environment/index.js"
import { Location } from "./location.js"
@@ -67,14 +68,14 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
const layer = () =>
export const layer = (options?: ShellSelect.Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const shell = yield* ShellSelect.Service
const environment = yield* Environment.Service
const hooks = yield* PluginHooks.Service
const environments = yield* SessionEnvironment.Service
@@ -145,7 +146,12 @@ const layer = () =>
return session.info
})
const name = () => shell.preferred().pipe(Effect.map(ShellSelect.name))
const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
@@ -190,7 +196,7 @@ const layer = () =>
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* shell.preferred(),
shell: yield* resolve(),
env: {
...(sessionEnvironment ?? process.env),
TERM: "xterm-256color",
@@ -347,16 +353,20 @@ const layer = () =>
}),
)
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [
Bus.node,
Location.node,
Global.node,
ShellSelect.node,
Environment.node,
PluginHooks.node,
SessionEnvironment.node,
],
})
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [
Bus.node,
Location.node,
Config.node,
Global.node,
Environment.node,
PluginHooks.node,
SessionEnvironment.node,
],
})
}
export const node = configured()
+1 -46
View File
@@ -3,11 +3,8 @@ export * as ShellSelect from "./select.js"
import path from "path"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { State } from "../state.js"
import { which } from "../util/which.js"
const META: Record<string, { deny?: boolean; login?: boolean; ps?: boolean }> = {
@@ -33,20 +30,6 @@ export const Options = Schema.Struct({
})
export type Options = typeof Options.Type
type Data = {
shell?: string
}
export type Draft = {
configure: (shell: string) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly preferred: () => Effect.Effect<string>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellSelect") {}
function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
@@ -198,31 +181,3 @@ export async function list(options?: Options, bin?: string): Promise<Item[]> {
const shells = process.platform === "win32" ? win(options, bin) : await unix()
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
}
const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const global = yield* Global.Service
const state = State.create<Data, Draft>({
name: "shell-select",
initial: () => ({}),
draft: (draft) => ({
configure: (shell) => {
draft.shell = shell
},
}),
})
return Service.of({
transform: state.transform,
reload: state.reload,
preferred: () => Effect.sync(() => preferred(state.get().shell, options, global.bin)),
})
}),
)
export function configured(options?: Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [Global.node] })
}
export const node = configured()
+11 -22
View File
@@ -3,6 +3,7 @@ export * as Snapshot from "./snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, Fiber, Layer, Schema, Scope } from "effect"
import { Config } from "./config.js"
import { File } from "./file.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
@@ -11,7 +12,6 @@ import { Location } from "./location.js"
import { AbsolutePath, RelativePath } from "./schema.js"
import { ID } from "@opencode-ai/schema/snapshot"
import { Hash } from "@opencode-ai/util/hash"
import { State } from "./state.js"
export { ID }
@@ -36,11 +36,7 @@ export interface RestoreInput {
readonly files: ReadonlyMap<RelativePath, ID>
}
export type Draft = {
configure: (enabled: boolean) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface {
/**
* Capture the current Location-scoped filesystem state as a content-addressed
* tree. Returns `undefined` when snapshots are disabled, unsupported, or the
@@ -72,20 +68,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sn
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const global = yield* Global.Service
const location = yield* Location.Service
const lifetime = yield* Scope.Scope
const state = State.create<{ enabled: boolean }, Draft>({
name: "snapshot",
initial: () => ({ enabled: true }),
draft: (draft) => ({
configure: (enabled) => {
draft.enabled = enabled
},
}),
})
// Cache a scope-owned fiber so caller cancellation stops waiting without poisoning shared initialization.
const repositoryFiber = yield* Effect.cached(
Effect.gen(function* () {
@@ -112,10 +100,13 @@ const layer = Layer.effect(
return RelativePath.make(relative.replaceAll("\\", "/") || ".")
})
const enabled = () => location.vcs?.type === "git" && state.get().enabled
const enabled = Effect.fnUntraced(function* () {
if (location.vcs?.type !== "git") return false
return Config.latest(yield* config.entries(), "snapshots") !== false
})
const capture = Effect.fn("Snapshot.capture")(function* () {
if (!enabled()) return undefined
if (!(yield* enabled())) return undefined
return yield* Effect.gen(function* () {
const repo = yield* repository
return ID.make(
@@ -179,28 +170,26 @@ const layer = Layer.effect(
})
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
if (!enabled()) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
yield* git.tree
.restore({ repository: repo.snapshotRepository, files: yield* plan(repo.worktree, input) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
})
return Service.of({ transform: state.transform, reload: state.reload, capture, files, diff, restore })
return Service.of({ capture, files, diff, restore })
}).pipe(Effect.withSpan("Snapshot.boot")),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Git.node, Global.node, Location.node],
deps: [Config.node, FSUtil.node, Git.node, Global.node, Location.node],
})
export const noopLayer = Layer.succeed(
Service,
Service.of({
transform: () => Effect.succeed({ dispose: Effect.void }),
reload: () => Effect.void,
capture: () => Effect.succeed(undefined),
files: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
+11 -32
View File
@@ -6,8 +6,8 @@ import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Config } from "./config.js"
import { Identifier } from "./id/id.js"
import { State } from "./state.js"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024 // 50 KiB
@@ -16,16 +16,7 @@ export const DIRECTORY = "tool-output"
type Result = Tool.Result
type Limits = {
maxLines: number
maxBytes: number
}
export type Draft = {
configure: (limits: Partial<Limits>) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface {
readonly truncate: (result: Result) => Effect.Effect<Result>
readonly cleanup: () => Effect.Effect<void>
}
@@ -55,38 +46,31 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface,
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const directory = path.join(global.data, DIRECTORY)
const state = State.create<Limits, Draft>({
name: "tool-output",
initial: () => ({ maxLines: MAX_LINES, maxBytes: MAX_BYTES }),
draft: (draft) => ({
configure: (limits) => {
if (limits.maxLines !== undefined) draft.maxLines = limits.maxLines
if (limits.maxBytes !== undefined) draft.maxBytes = limits.maxBytes
},
}),
})
const truncate = Effect.fnUntraced(function* (result: Result) {
if (result.metadata?.truncated !== undefined) return result
const content =
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
const limits = state.get()
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? MAX_LINES
const maxBytes = configured?.max_bytes ?? MAX_BYTES
const lines = text.split("\n")
if (text.endsWith("\n")) lines.pop()
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= limits.maxLines && totalBytes <= limits.maxBytes)
if (lines.length <= maxLines && totalBytes <= maxBytes)
return { ...result, metadata: { ...result.metadata, truncated: false } }
const kept: string[] = []
let bytes = 0
let hitBytes = false
for (const line of lines.slice(0, limits.maxLines)) {
for (const line of lines.slice(0, maxLines)) {
const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0)
if (bytes + size > limits.maxBytes) {
if (bytes + size > maxBytes) {
hitBytes = true
break
}
@@ -129,12 +113,7 @@ const layer = Layer.effect(
}
})
return Service.of({
transform: state.transform,
reload: state.reload,
truncate,
cleanup: () => cleanup(fs, directory),
})
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
}),
)
@@ -158,5 +137,5 @@ const cleanupNode = makeGlobalNode({
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, cleanupNode],
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
})
+4 -4
View File
@@ -56,8 +56,8 @@ const headers = (format: Format, userAgent: string) => ({
"Accept-Language": "en-US,en;q=0.9",
})
const openCodeUserAgent =
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OpenCode-User/1.0; +https://opencode.ai"
const browserUserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
const isCloudflareChallenge = (error: unknown) => {
if (!error || typeof error !== "object" || !("reason" in error)) return false
@@ -74,14 +74,14 @@ const isCloudflareChallenge = (error: unknown) => {
return response.status === 403 && response.headers["cf-mitigated"] === "challenge"
}
const request = (url: string, format: Format, userAgent = openCodeUserAgent) =>
const request = (url: string, format: Format, userAgent = browserUserAgent) =>
HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers(format, userAgent)))
const assertHttpUrl = (url: URL) => {
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("URL must use http:// or https://")
}
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = openCodeUserAgent) =>
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = browserUserAgent) =>
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
+1 -112
View File
@@ -2,91 +2,9 @@ import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
const map = (packageName: string, settings: Readonly<Record<string, unknown>>, modelID = "test-model") =>
AISDKNative.map({ packageName, settings, modelID, providerID: "test-provider" })
AISDKNative.map({ packageName, settings, modelID })
describe("AISDKNative", () => {
test("maps OpenAI-family packages and request options to native providers", () => {
expect(
map("@ai-sdk/openai", {
apiKey: "secret",
baseURL: "https://api.meta.ai/v1",
organization: "org",
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
instructions: "Follow the repository instructions.",
truncation: "auto",
}),
).toEqual({
package: "@opencode-ai/ai/providers/openai",
settings: {
apiKey: "secret",
baseURL: "https://api.meta.ai/v1",
organization: "org",
providerOptions: {
openai: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
instructions: "Follow the repository instructions.",
truncation: "auto",
},
},
},
})
expect(map("@ai-sdk/openai-compatible", { baseURL: "https://example.com/v1", reasoningEffort: "high" })).toEqual({
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
baseURL: "https://example.com/v1",
provider: "test-provider",
providerOptions: { openai: { reasoningEffort: "high" } },
},
})
})
test("maps Anthropic settings and request options to the native provider", () => {
expect(
map("@ai-sdk/anthropic", {
authToken: "token",
baseURL: "https://anthropic.example/v1",
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
}),
).toEqual({
package: "@opencode-ai/ai/providers/anthropic",
settings: {
authToken: "token",
baseURL: "https://anthropic.example/v1",
providerOptions: {
anthropic: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
},
},
})
})
test("maps Google Vertex settings to the native provider", () => {
expect(
map("@ai-sdk/google-vertex", {
project: "project",
location: "us-central1",
labels: { environment: "test" },
thinkingConfig: { thinkingLevel: "high" },
}),
).toEqual({
package: "@opencode-ai/ai/providers/google-vertex",
settings: {
project: "project",
location: "us-central1",
providerOptions: {
gemini: { labels: { environment: "test" }, thinkingConfig: { thinkingLevel: "high" } },
},
},
})
})
test("maps both models.dev Bedrock packages to native providers", () => {
expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({
package: "@opencode-ai/ai/providers/amazon-bedrock",
@@ -355,35 +273,6 @@ describe("AISDKNative", () => {
})
})
test("maps Vertex Gemini settings to the native Gemini route", () => {
expect(
map("@ai-sdk/google-vertex", {
accessToken: "vertex-token",
baseURL: "https://vertex.example/v1",
headers: { "x-test": "value" },
labels: { component: "opencode", environment: "test" },
location: "eu",
project: "vertex-project",
thinkingConfig: { thinkingLevel: "high" },
}),
).toEqual({
package: "@opencode-ai/ai/providers/google-vertex",
settings: {
accessToken: "vertex-token",
baseURL: "https://vertex.example/v1",
location: "eu",
project: "vertex-project",
providerOptions: {
gemini: {
labels: { component: "opencode", environment: "test" },
thinkingConfig: { thinkingLevel: "high" },
},
},
},
headers: { "x-test": "value" },
})
})
test("maps Vertex Anthropic settings to native Messages", () => {
expect(
map("@ai-sdk/google-vertex/anthropic", {
-37
View File
@@ -104,43 +104,6 @@ it.effect("projects request settings, headers, and body overlays", () =>
}),
)
it.effect("lowers chronological system updates to wrapped user messages", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("opaque-provider"))
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
system: "Initial instructions.",
messages: [
Message.user("Before."),
Message.system("Updated <rules> & constraints."),
Message.assistant("After."),
],
}),
)
expect(prepared.body.prompt).toEqual([
{ role: "system", content: "Initial instructions." },
{ role: "user", content: [{ type: "text", text: "Before." }] },
{
role: "user",
content: [
{
type: "text",
text: "<system-update>\nUpdated &lt;rules&gt; &amp; constraints.\n</system-update>",
},
],
},
{ role: "assistant", content: [{ type: "text", text: "After." }] },
])
}),
)
it.effect("leaves max output tokens unset when the request omits them", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
+3 -1
View File
@@ -1,17 +1,19 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Command } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(Command.node, [
[MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
]),
)
@@ -1,168 +0,0 @@
import { describe, expect } from "bun:test"
import { LanguageModel, LLMClient, LLMEvent } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigCompactionPlugin } from "@opencode-ai/core/config/plugin/compaction"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Session } from "@opencode-ai/core/session"
import { Agent } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Effect, Fiber, Layer, Option, Schema, Stream } from "effect"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const model = LanguageModel.make({
id: "test-model",
provider: "test-provider",
route: OpenAIChat.route.with({ limits: { context: 100_000, output: 1_000 } }),
})
const config = Config.testLayer()
const it = testEffect(
Layer.merge(
config,
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, Config.node, Bus.node]), [
[
llmClient,
Layer.mock(LLMClient.Service)({
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
}),
],
[
SessionRunnerModel.node,
Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
}),
),
}),
],
[Config.node, config],
]),
),
)
describe("ConfigCompactionPlugin.Plugin", () => {
it.live("merges settings and reloads changed config", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const config = yield* Config.Test
const bus = yield* Bus.Service
yield* config.setEntries([
new Document({
type: "document",
info: new Info({ compaction: new ConfigCompaction.Info({ auto: false, buffer: 20_000 }) }),
}),
new Document({
type: "document",
info: new Info({
compaction: new ConfigCompaction.Info({
buffer: 10_000,
keep: new ConfigCompaction.Keep({ tokens: 0 }),
}),
}),
}),
])
yield* ConfigCompactionPlugin.Plugin.effect(host({ event: { subscribe: () => bus.subscribe(Event.Updated) } }))
expect(compaction.required(nearInput)).toBe(false)
const started = yield* bus
.subscribe(SessionEvent.Compaction.Started)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
expect(
yield* compaction.compactManual({
session,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Older context",
time: { created: DateTime.makeUnsafe(0) },
},
{
id: SessionMessage.ID.create(),
type: "user",
text: "Recent context",
time: { created: DateTime.makeUnsafe(1) },
},
],
inputID: SessionMessage.ID.make("msg_compaction_manual"),
}),
).toEqual({ status: "completed" })
expect(Option.getOrThrow(yield* Fiber.join(started)).data.recent).toContain("Recent context")
yield* config.setEntries([
new Document({
type: "document",
info: new Info({ compaction: new ConfigCompaction.Info({ auto: true, buffer: 20_000 }) }),
}),
new Document({
type: "document",
info: new Info({ compaction: new ConfigCompaction.Info({ buffer: 10_000 }) }),
}),
])
yield* bus.publish(Event.Updated, {})
yield* Effect.gen(function* () {
for (let attempt = 0; attempt < 200; attempt++) {
if (compaction.required(nearInput)) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for compaction config reload"))
})
expect(compaction.required(bufferedInput)).toBe(false)
yield* config.setEntries([
new Document({
type: "document",
info: new Info({ compaction: new ConfigCompaction.Info({ auto: true, buffer: 20_000 }) }),
}),
])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if (compaction.required(bufferedInput)) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for compaction config reload"))
}),
)
})
const session = Session.Info.make({
id: Session.ID.make("ses_compaction_config"),
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number) => ({
session,
model,
cost: [],
messages: [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_compaction_config"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "test-model", providerID: "test-provider" },
content: [],
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
})
const bufferedInput = input(85_000)
const nearInput = input(95_000)
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigShellPlugin } from "@opencode-ai/core/config/plugin/shell"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Layer } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(Layer.merge(PluginTestLayer, AppNodeBuilder.build(ShellSelect.node)))
describe("ConfigShellPlugin.Plugin", () => {
it.live("applies the preferred shell and reloads changed config", () =>
Effect.gen(function* () {
const shell = yield* ShellSelect.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const plugins = yield* Plugin.Service
yield* ConfigShellPlugin.Plugin.effect(yield* PluginHost.make(plugins))
const configured = process.platform === "win32" ? FSUtil.windowsPath(process.execPath) : process.execPath
expect(yield* shell.preferred()).toBe(configured)
yield* config.setEntries([])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if ((yield* shell.preferred()) !== configured) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for shell config reload"))
}).pipe(
Effect.provide(
Config.testLayer([new Document({ type: "document", info: new Info({ shell: process.execPath }) })]),
),
),
)
})
@@ -1,66 +0,0 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigSnapshotPlugin } from "@opencode-ai/core/config/plugin/snapshot"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { tmpdir } from "../fixture/tmpdir"
import { it } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
describe("ConfigSnapshotPlugin.Plugin", () => {
it.live("applies availability and reloads changed config", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await $`git init`.cwd(project).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(project).quiet()
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const plugins = yield* Plugin.Service
yield* ConfigSnapshotPlugin.Plugin.effect(yield* PluginHost.make(plugins))
expect(yield* snapshot.capture()).toBeUndefined()
yield* config.setEntries([new Document({ type: "document", info: new Info({ snapshots: true }) })])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if ((yield* snapshot.capture()) !== undefined) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for snapshot config reload"))
}).pipe(
Effect.provide(
AppNodeBuilder.build(Snapshot.node, [
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))],
[Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
]),
),
)
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.provide(PluginTestLayer),
Effect.provide(Config.testLayer([new Document({ type: "document", info: new Info({ snapshots: false }) })])),
),
)
})
@@ -1,62 +0,0 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigToolOutputPlugin } from "@opencode-ai/core/config/plugin/tool-output"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { tmpdir } from "../fixture/tmpdir"
import { it } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
describe("ConfigToolOutputPlugin.Plugin", () => {
it.live("applies limits and reloads changed config", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const output = yield* ToolOutput.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const plugins = yield* Plugin.Service
yield* ConfigToolOutputPlugin.Plugin.effect(yield* PluginHost.make(plugins))
expect((yield* output.truncate({ content: "one\ntwo" })).metadata?.truncated).toBe(true)
yield* config.setEntries([
new Document({
type: "document",
info: new Info({
tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }),
}),
}),
])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
const result = yield* output.truncate({ content: "one\ntwo" })
if (result.metadata?.truncated === false) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for tool output config reload"))
}).pipe(
Effect.provide(AppNodeBuilder.build(ToolOutput.node, [[Global.node, Global.layerWith({ data: tmp.path })]])),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.provide(PluginTestLayer),
Effect.provide(
Config.testLayer([
new Document({
type: "document",
info: new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 1 }) }),
}),
]),
),
),
)
})
+7 -135
View File
@@ -4,24 +4,18 @@ import fs from "fs/promises"
import path from "path"
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigLocationWatcherPlugin } from "@opencode-ai/core/config/plugin/location-watcher"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode, type LocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
import { LocationWatcherPolicy } from "@opencode-ai/core/filesystem/location-watcher-policy"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config"
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
@@ -29,11 +23,6 @@ const describeNative = process.env.CI ? describe.skip : describe
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
const configLayer = Config.testLayer()
const pluginNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
deps: [],
})
describe("Watcher.testLayer", () => {
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
@@ -146,26 +135,16 @@ describe("Watcher lifecycle", () => {
})
})
function provide(
directory: string,
vcs?: Location.Interface["vcs"],
watcher?: Layer.Layer<Watcher.Service>,
config: Layer.Layer<Config.Service> = configLayer,
plugins: LocationNode<PluginSupervisor.Service> = pluginNode,
) {
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
const built = AppNodeBuilder.build(
LayerNode.group([LocationWatcher.node, LocationWatcherPolicy.node, Bus.node, Config.node]),
[
[Config.node, config],
[Location.node, locationLayer],
[PluginSupervisor.node, plugins],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
],
)
const built = AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
return Effect.provide(built)
}
@@ -175,8 +154,6 @@ function withTmp<A, E, R>(
vcs?: "git" | "hg"
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
config?: Layer.Layer<Config.Service>
plugins?: LocationNode<PluginSupervisor.Service>
},
) {
return Effect.acquireRelease(
@@ -197,11 +174,7 @@ function withTmp<A, E, R>(
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
}),
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap(({ tmp, vcs }) =>
f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher, options?.config ?? configLayer, options?.plugins)),
),
)
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
}
describe("LocationWatcher subscriptions", () => {
@@ -250,107 +223,6 @@ describe("LocationWatcher subscriptions", () => {
{ vcs: "hg", watcher },
)
})
it.live("reconciles config without duplicate subscriptions", () => {
const entries = { current: [] as Entry[] }
const subscriptions: Watcher.WatchInput[] = []
const counts = { active: 0, released: 0 }
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) =>
Effect.sync(() => {
subscriptions.push(input)
counts.active++
return Stream.never.pipe(
Stream.ensuring(
Effect.sync(() => {
counts.active--
counts.released++
}),
),
)
}),
}),
)
const config = Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.sync(() => entries.current),
update: () => Effect.die("unused config.update"),
changes: () => Stream.never,
}),
)
return Effect.gen(function* () {
yield* withTmp(
() =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const bus = yield* Bus.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count === 1),
Effect.retry(Schedule.spaced("10 millis")),
)
expect(counts.active).toBe(1)
entries.current = [new Document({ type: "document", info: new Info({ watcher: { ignore: [".git"] } }) })]
yield* ConfigLocationWatcherPlugin.Plugin.effect(
host({ event: { subscribe: () => bus.subscribe(Event.Updated) } }),
)
yield* Effect.sync(() => counts.active).pipe(
Effect.filterOrFail((count) => count === 0),
Effect.retry(Schedule.spaced("10 millis")),
)
expect(counts.released).toBe(1)
entries.current = []
yield* bus.publish(Event.Updated, {})
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count === 2),
Effect.retry(Schedule.spaced("10 millis")),
)
expect(counts.active).toBe(1)
yield* policy.reload()
expect(subscriptions).toHaveLength(2)
}),
{ vcs: "git", watcher, config },
)
expect(counts.active).toBe(0)
expect(counts.released).toBe(2)
})
})
it.live("does not start before configured policy is ready", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.never)),
}),
)
const plugins = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* policy.transform((draft) => draft.add([".git"]))
return PluginSupervisor.Service.of({ flush: Effect.void })
}),
),
deps: [LocationWatcherPolicy.node],
})
return withTmp(
() =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sleep("50 millis")
expect(subscriptions).toEqual([])
}),
{ vcs: "git", watcher, plugins },
)
})
})
function wait(check: (event: WatcherEvent) => boolean) {
+20 -118
View File
@@ -219,10 +219,7 @@ describe("ModelResolver", () => {
settings: { baseURL: "https://openai.example/v1" },
limit: { context: 100, input: 80, output: 20 },
})
const resolved = yield* ModelResolver.fromCatalogModel(
catalog,
Credential.Key.make({ type: "key", key: "secret" }),
)
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
expect(catalog.id).toBe(ID.make("test-model"))
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
@@ -257,24 +254,22 @@ describe("ModelResolver", () => {
)
it.effect("treats an empty configured API key as omitted", () =>
withEnv({ OPENAI_API_KEY: "environment-key" }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
}),
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
}),
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBe("Bearer environment-key")
}),
),
expect(headers.authorization).toBeUndefined()
}),
)
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
@@ -462,12 +457,8 @@ describe("ModelResolver", () => {
settings: { baseURL: "https://openai.example/v1" },
variants: [
{
id: VariantID.make("xhigh"),
settings: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
id: VariantID.make("high"),
settings: { reasoningEffort: "high" },
headers: { "x-variant": "high" },
body: {
store: false,
@@ -477,7 +468,7 @@ describe("ModelResolver", () => {
},
],
})
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("xhigh"))
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("high"))
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
expect(resolved.route.defaults.http?.body).toEqual({
@@ -487,17 +478,7 @@ describe("ModelResolver", () => {
temperature: 0.2,
})
expect(resolved.route.defaults.providerOptions).toEqual({
openai: {
store: false,
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body).toMatchObject({
include: ["reasoning.encrypted_content"],
reasoning: { effort: "xhigh", summary: "auto" },
openai: { store: false, reasoningEffort: "high" },
})
}),
)
@@ -834,46 +815,12 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
const packages = [
[
"@ai-sdk/openai",
"@opencode-ai/ai/providers/openai",
{
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
{
openai: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
],
[
"@ai-sdk/anthropic",
"@opencode-ai/ai/providers/anthropic",
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
{ anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" } },
],
[
"@ai-sdk/openai-compatible",
"@opencode-ai/ai/providers/openai-compatible",
{ reasoningEffort: "high" },
{ openai: { reasoningEffort: "high" } },
],
[
"@ai-sdk/google",
"@opencode-ai/ai/providers/google",
{ thinkingConfig: { thinkingLevel: "high" } },
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
],
[
"@ai-sdk/google-vertex",
"@opencode-ai/ai/providers/google-vertex",
{ thinkingConfig: { thinkingLevel: "high" } },
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
],
[
"@openrouter/ai-sdk-provider",
"@opencode-ai/ai/providers/openrouter",
@@ -922,51 +869,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("never loads the AI SDK for packages with native implementations", () =>
Effect.gen(function* () {
const packages = [
["@ai-sdk/anthropic", "@opencode-ai/ai/providers/anthropic", "api-model"],
["@ai-sdk/amazon-bedrock", "@opencode-ai/ai/providers/amazon-bedrock", "api-model"],
[
"@ai-sdk/amazon-bedrock/mantle",
"@opencode-ai/ai/providers/amazon-bedrock/mantle/responses",
"openai.gpt-oss-120b",
],
["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"],
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
[
"@ai-sdk/google-vertex/anthropic",
"@opencode-ai/ai/providers/google-vertex/messages",
"claude-sonnet-4-6",
],
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "api-model"],
] as const
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, modelID]) =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk(catalogPackage), {
modelID,
settings: { baseURL: "https://provider.example/v1", region: "us-east-1" },
}),
undefined,
{
loadPackage: (specifier) => {
expect(specifier).toBe(nativePackage)
return Effect.succeed({
model: (id) => LanguageModel.make({ id, provider: "native-provider", route: OpenAIChat.route }),
})
},
loadAISDK: () => Effect.die(`AI SDK loader called for ${catalogPackage}`),
},
),
)
}),
)
it.effect("routes Vertex Anthropic catalog models through native Messages", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
-39
View File
@@ -35,17 +35,6 @@ describe("Npm.sanitize", () => {
})
})
describe("Npm.isRegistryPackage", () => {
test("accepts registry packages and rejects unsupported install targets", async () => {
expect(await Npm.isRegistryPackage("plugin")).toBe(true)
expect(await Npm.isRegistryPackage("@acme/plugin@beta")).toBe(true)
expect(await Npm.isRegistryPackage("plugin@^1.2.0")).toBe(true)
expect(await Npm.isRegistryPackage("./plugin")).toBe(false)
expect(await Npm.isRegistryPackage("github:acme/plugin")).toBe(false)
expect(await Npm.isRegistryPackage("alias@npm:plugin@1.0.0")).toBe(false)
})
})
describe("Npm.add", () => {
test("resolves cached scoped package specs without reifying", async () => {
await using tmp = await tmpdir()
@@ -117,31 +106,3 @@ describe("Npm.add", () => {
expect(entries.fallback.entrypoint).toEndWith("/index.js")
})
})
describe("Npm.resolve", () => {
test("resolves a TUI entrypoint only when the package is already cached", async () => {
await using tmp = await tmpdir()
const cache = path.join(tmp.path, "cache")
const spec = "fixture-plugin@1.0.0"
const directory = path.join(cache, "packages", Npm.sanitize(spec), "node_modules", "fixture-plugin")
const missing = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(missing.entrypoint).toBeUndefined()
await fs.mkdir(directory, { recursive: true })
await writePackage(directory, {
name: "fixture-plugin",
exports: { ".": "./index.js", "./tui": "./tui.js" },
})
await Bun.write(path.join(directory, "index.js"), "export default {}\n")
await Bun.write(path.join(directory, "tui.js"), "export default {}\n")
const resolved = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(resolved.entrypoint).toEndWith("/tui.js")
})
})
-1
View File
@@ -31,7 +31,6 @@ const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.succeed(undefined),
}),
)
@@ -23,7 +23,6 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.succeed(undefined),
})
}
@@ -137,7 +137,7 @@ describe("OpenAIPlugin", () => {
const proxy = yield* request(Provider.ID.openai, "https://proxy.example/v1?region=us")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex")
@@ -147,7 +147,7 @@ describe("OpenAIPlugin", () => {
expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
@@ -194,7 +194,7 @@ describe("OpenAIPlugin", () => {
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(direct.headers).not.toHaveProperty("originator")
@@ -14,7 +14,6 @@ const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.ur
const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.succeed(undefined),
})
+22 -6
View File
@@ -1,5 +1,7 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
@@ -7,7 +9,6 @@ import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import type { PtyID } from "@opencode-ai/core/pty/schema"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
@@ -17,7 +18,13 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [[Location.node, locationLayer]]))
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [
[Config.node, configLayer],
[Location.node, locationLayer],
]),
)
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
@@ -200,17 +207,26 @@ describe("pty", () => {
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
const configuredIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [[Location.node, locationLayer]]),
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [
[
Config.node,
Layer.mock(Config.Service)({
entries: () =>
Effect.succeed(
configuredShell ? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })] : [],
),
}),
],
[Location.node, locationLayer],
]),
)
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
describe("pty create defaults", () => {
configuredTest("defaults command, login args, and cwd from shell selection and location", () =>
configuredTest("defaults command, login args, and cwd from config and location", () =>
Effect.gen(function* () {
if (!configuredShell) return
const pty = yield* Pty.Service
const shell = yield* ShellSelect.Service
yield* shell.transform((draft) => draft.configure(configuredShell))
const info = yield* Effect.acquireRelease(pty.create({ title: "configured" }), (created) =>
pty.remove(created.id).pipe(Effect.ignore),
)
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
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 { llmClient } from "@opencode-ai/core/effect/app-node-platform"
@@ -66,6 +67,7 @@ const client = Layer.mock(LLMClient.Service)({
},
generate: () => Effect.die("unused"),
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
@@ -81,6 +83,7 @@ const it = testEffect(
[
[Bus.node, Bus.configured({ persist: true })],
[llmClient, client],
[Config.node, config],
[SessionRunnerModel.node, models],
],
),
+1 -219
View File
@@ -4,10 +4,8 @@ import { Database } from "@opencode-ai/core/database/database"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { and, eq } from "drizzle-orm"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -17,8 +15,6 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
@@ -32,16 +28,6 @@ const it = testEffect(
],
),
)
// Default bus: durable payloads are not retained (`events.persist` off).
const itVolatile = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("Session.log", () => {
@@ -74,43 +60,6 @@ describe("Session.log", () => {
}),
)
it.effect("accepts a cursor exactly at the aggregate head", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const items = Array.from(
yield* Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(0) })),
)
expect(items).toEqual([{ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(0) }])
}),
)
it.effect("fails with SeqUnavailable when the cursor is beyond the aggregate head", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const errors = yield* Effect.forEach([1, 10], (after) =>
Effect.flip(Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(after) }))),
)
expect(errors.map((error) => error._tag)).toEqual([
"Session.SeqUnavailableError",
"Session.SeqUnavailableError",
])
expect(errors.map((error) => (error._tag === "Session.SeqUnavailableError" ? error.after : undefined))).toEqual([
Event.Seq.make(1),
Event.Seq.make(10),
])
expect(errors.map((error) => (error._tag === "Session.SeqUnavailableError" ? error.head : undefined))).toEqual([
Event.Seq.make(0),
Event.Seq.make(0),
])
}),
)
it.effect("fails with NotFound for an unknown session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -119,109 +68,6 @@ describe("Session.log", () => {
}),
)
it.effect("orders live ephemeral deltas after their durable start", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const assistantMessageID = SessionMessage.ID.create()
const fiber = yield* session
.log({ sessionID: created.id, after: Event.Seq.make(0), follow: true, ephemeral: true })
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* bus.publish(SessionEvent.Text.Started, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
})
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
delta: "hello",
})
expect(Array.from(yield* Fiber.join(fiber)).map((item) => item.type)).toEqual([
"log.synced",
"session.text.started",
"session.text.delta",
])
}),
)
it.effect("never includes ephemeral events in replay", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Text.Started, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
})
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
delta: "not retained",
})
yield* bus.publish(SessionEvent.Text.Ended, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
text: "complete",
})
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, ephemeral: true })))
expect(items.map((item) => item.type)).toEqual([
"session.created",
"session.text.started",
"session.text.ended",
"log.synced",
])
}),
)
it.effect("keeps the default follow stream durable-only", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const assistantMessageID = SessionMessage.ID.create()
const fiber = yield* session
.log({ sessionID: created.id, after: Event.Seq.make(0), follow: true })
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* bus.publish(SessionEvent.Text.Started, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
})
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
delta: "filtered",
})
yield* bus.publish(SessionEvent.Text.Ended, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
text: "complete",
})
expect(Array.from(yield* Fiber.join(fiber)).map((item) => item.type)).toEqual([
"log.synced",
"session.text.started",
"session.text.ended",
])
}),
)
it.effect("reads across undecodable gaps in aggregate order and marks the true log position", () =>
Effect.gen(function* () {
const GapEvent = Bus.durable({
@@ -241,33 +87,12 @@ describe("Session.log", () => {
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 })))
expect(
items.map((item): number | string | undefined =>
Bus.isSynced(item) ? item.type : "durable" in item ? item.durable.seq : undefined,
),
items.map((item): number | string | undefined => (Bus.isSynced(item) ? item.type : item.durable?.seq)),
).toEqual([3, 4, "log.synced"])
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(4) })
}),
)
it.effect("fails with SeqUnavailable when the replay range is only partially retained", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const session = yield* Session.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "pruned" })
yield* db
.delete(EventTable)
.where(and(eq(EventTable.aggregate_id, created.id), eq(EventTable.seq, 1)))
.run()
const error = yield* Effect.flip(
Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(0) })),
)
expect(error._tag).toBe("Session.SeqUnavailableError")
}),
)
it.effect("completes with a bare synced marker for a migrated Session with no event sequence", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
@@ -296,46 +121,3 @@ describe("Session.log", () => {
}),
)
})
describe("Session.log without retained events", () => {
itVolatile.effect("accepts a cursor exactly at the head", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "at head" })
const items = Array.from(
yield* Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(1) })),
)
expect(items).toEqual([{ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) }])
}),
)
itVolatile.effect("fails with SeqUnavailable for a cursor behind the head", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "behind head" })
const error = yield* Effect.flip(
Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(0) })),
)
expect(error._tag).toBe("Session.SeqUnavailableError")
expect(error._tag === "Session.SeqUnavailableError" ? error.head : undefined).toEqual(Event.Seq.make(1))
}),
)
itVolatile.effect("replays nothing but stays live for a cursorless read", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "cursorless" })
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
expect(items).toEqual([{ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) }])
}),
)
})
+1 -33
View File
@@ -28,7 +28,6 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Image } from "@opencode-ai/core/image"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { testEffect } from "./lib/effect"
const executionCalls: Session.ID[] = []
@@ -61,7 +60,7 @@ const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// These operations resolve Location services lazily and must wait for plugin-projected state.
// Attachment admission only needs image normalization and plugin readiness.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.unwrap(
Effect.sync(() => {
@@ -73,12 +72,6 @@ const locations = Layer.effect(
? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content)
: Effect.die(new Error("Image service used before plugins were ready")),
}),
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.succeed(undefined) : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
@@ -1058,31 +1051,6 @@ describe("Session.prompt", () => {
)
})
describe("Session.revert", () => {
it.effect("waits for location plugins before staging", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* Session.Service
yield* db.insert(SessionMessageTable).values(assistantRow(messageID, 0)).run().pipe(Effect.orDie)
yield* session.revert.stage({ sessionID, messageID })
}),
)
it.effect("waits for location plugins before clearing", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID, snapshot: Snapshot.ID.make("tree"), files: [] },
})
yield* session.revert.clear(sessionID)
}),
)
})
describe("Session.inbox", () => {
it.effect("fails for an unknown session", () =>
Effect.gen(function* () {
@@ -353,12 +353,7 @@ test("content-filter finish retains failure evidence until step closeout", async
publisher.publish(
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "content-filter", raw: "refusal" },
providerMetadata: {
anthropic: {
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
},
},
reason: { normalized: "content-filter" },
usage: {
nonCachedInputTokens: 8,
outputTokens: 3,
@@ -372,10 +367,6 @@ test("content-filter finish retains failure evidence until step closeout", async
const settlement = publisher.record().finish
expect(settlement).toMatchObject({
finish: "content-filter",
rawFinish: "refusal",
providerState: {
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
},
tokens: { input: 8, output: 2, reasoning: 1 },
})
if (!settlement) throw new Error("Expected content-filter settlement")
@@ -390,11 +381,6 @@ test("content-filter finish retains failure evidence until step closeout", async
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
expect(published.at(-1)?.data).toMatchObject({
error: { type: "provider.content-filter", message: "Provider blocked the response" },
finish: "content-filter",
rawFinish: "refusal",
providerState: {
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
},
cost: 1.25,
tokens: { input: 8, output: 2, reasoning: 1 },
snapshot: "tree-end",
+2 -43
View File
@@ -4161,49 +4161,13 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("persists raw finish reasons and provider state", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(
TestLLM.complete(
{
reason: { normalized: "stop", raw: "end_turn" },
providerMetadata: { openai: { responseId: "response-1", serviceTier: "priority" } },
},
LLMEvent.textStart({ id: "answer" }),
LLMEvent.textDelta({ id: "answer", text: "Complete" }),
LLMEvent.textEnd({ id: "answer" }),
),
)
yield* runPrompt(session, "Keep provider finish details")
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{
type: "assistant",
finish: "stop",
rawFinish: "end_turn",
providerState: { responseId: "response-1", serviceTier: "priority" },
content: [{ type: "text", text: "Complete" }],
},
])
}),
)
it.effect("projects content-filter finishes as visible terminal failures", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(
TestLLM.complete(
{
reason: { normalized: "content-filter", raw: "SAFETY" },
providerMetadata: {
openai: {
responseId: "response-blocked",
refusal: { category: "safety", explanation: "Prompt blocked" },
},
},
reason: { normalized: "content-filter" },
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
},
LLMEvent.textStart({ id: "partial" }),
@@ -4218,12 +4182,7 @@ describe("SessionRunnerLLM", () => {
{ type: "user" },
{
type: "assistant",
finish: "content-filter",
rawFinish: "SAFETY",
providerState: {
responseId: "response-blocked",
refusal: { category: "safety", explanation: "Prompt blocked" },
},
finish: "error",
error: { type: "provider.content-filter" },
cost: 0,
tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } },
@@ -1,87 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { Event } from "@opencode-ai/schema/event"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("Session.snapshot", () => {
it.effect("returns an empty projected session at its aggregate watermark", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const created = yield* sessions.create({ location })
expect(yield* sessions.snapshot({ sessionID: created.id })).toEqual({
session: created,
children: [],
inbox: [],
messages: [],
seq: Event.Seq.make(0),
})
}),
)
it.effect("returns the most recent messages in aggregate order", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* sessions.create({ location })
yield* Effect.forEach(["first", "second", "third"], (text) =>
bus.publish(SessionEvent.Synthetic, { sessionID: created.id, text }),
)
const snapshot = yield* sessions.snapshot({ sessionID: created.id, recent: 2 })
expect(snapshot.messages.map((message) => (message.type === "synthetic" ? message.text : message.type))).toEqual([
"second",
"third",
])
expect(snapshot.seq).toBe(Event.Seq.make(3))
}),
)
it.effect("keeps rows and watermark consistent during concurrent publication", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* sessions.create({ location })
const publish = Effect.forEach(
Array.from({ length: 40 }, (_, index) => index + 1),
(index) => bus.publish(SessionEvent.Synthetic, { sessionID: created.id, text: String(index) }),
)
const read = Effect.forEach(Array.from({ length: 40 }), () => sessions.snapshot({ sessionID: created.id }))
const [, snapshots] = yield* Effect.all([publish, read], { concurrency: "unbounded" })
snapshots.forEach((snapshot) => {
expect(snapshot.messages).toHaveLength(snapshot.seq)
expect(
snapshot.messages.map((message) => (message.type === "synthetic" ? Number(message.text) : -1)),
).toEqual(Array.from({ length: snapshot.seq }, (_, index) => index + 1))
})
}),
)
})
-25
View File
@@ -127,31 +127,6 @@ describe("Snapshot", () => {
),
)
testEffect(Layer.empty).live("applies availability transforms", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await initGit(project)
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const registration = yield* snapshot.transform((draft) => draft.configure(false))
expect(yield* snapshot.capture()).toBeUndefined()
yield* registration.dispose
expect(yield* snapshot.capture()).toBeDefined()
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),

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