Compare commits

...
Author SHA1 Message Date
Kit Langton 0f40b3f053 Merge remote-tracking branch 'origin/v2' into sync-engine-plan 2026-08-19 15:33:47 -04:00
opencode-agent[bot] 173e3b0d48 chore: update nix node_modules hashes 2026-08-19 18:59:43 +00:00
Kit Langton de7d389ed7 refactor(client): replace store adapter with signal of view
The engine's immutable views are now held in one signal per session;
the Data facade keeps its shape, backed by signal reads instead of
store paths. Deletes the identity diff, per-path reconcile, deep
clone into the store, the rendered shadow map, the mutable
StoreSessionView type, and both hand-maintained invariants (new
fields must be diffed; per-index writes cannot shrink). Views alias
engine state directly, which is safe because views are immutable and
consumers receive fresh arrays from the facade. Clones remain only
where values cross into the legacy layer's store.
2026-08-19 14:40:31 -04:00
Kit Langton 96900eb67d fix(client): clear fold fields by deleting keys
Assigning undefined leaves an own key that JSON snapshots cannot
represent, so replay and fromSnapshot disagreed on key presence
(retry, error, finish, time.completed, revert, text state). The Solid
store adapter masked this: reconcile treats undefined as delete. Clear
semantics now delete the key so both hydration paths agree.
2026-08-19 14:40:31 -04:00
ReStranger 1df9092f07 feat(ci): enable nix-hashes ci in v2 branch (#43482)
Signed-off-by: ReStranger <restranger@disroot.org>
2026-08-19 20:37:59 +02:00
Kit Langton d7562b3161 chore: move perf harness and design docs to follow-up
Slims the review surface to runtime code and tests. The bench script,
perf experiment log, and the two root-level design documents are
preserved on the sync-engine-perf-docs branch and will return in a
follow-up PR. Headline numbers stay in the PR description.
2026-08-19 14:06:09 -04:00
Aiden Cline fbf45f2384 fix(core): centralize native provider mapping (#43468) 2026-08-19 12:32:53 -05:00
ReStranger 9292bfbe49 feat(nix): update cli packaging (#43469)
Signed-off-by: ReStranger <restranger@disroot.org>
2026-08-19 11:19:13 -05:00
Shoubhit Dash 5d453752f0 refactor(core): move location watcher config into state (#43464) 2026-08-19 21:19:32 +05:30
Kit Langton 16b77e5b50 fix(tui): soften jump to latest action (#43457) 2026-08-19 11:41:57 -04:00
Dax c1583ddcbb docs: move CPU profiling to dev skill (#43448) 2026-08-19 11:29:06 -04:00
Kit Langton d8debaa449 fix(tui): stabilize plugin reload generations (#43447) 2026-08-19 15:26:54 +00:00
Shoubhit Dash ace822308f refactor(core): move compaction config into state (#43442) 2026-08-19 20:43:01 +05:30
opencode-agent[bot]andnexxeln 728053b645 fix(tui): avoid retrying broken plugin setup (#43441)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-19 20:41:13 +05:30
opencode-agent[bot]andrekram1-node bfe9917ee7 fix(core): lower AI SDK system updates (#43440)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 10:03:03 -05:00
opencode-agent[bot]andrekram1-node 1556b74082 fix(core): preserve Vertex billing labels (#43449)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 09:57:47 -05:00
Dax 7700faad81 feat(cli): capture CPU profiles with SIGPROF (#43446) 2026-08-19 10:45:05 -04:00
Kit Langton 241a88a5d9 fix(tui): open subagent panel from footer (#43325) 2026-08-19 10:33:44 -04:00
Major Hayden 3edcb3ca2b fix(core): route Vertex Gemini through native provider (#43433)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-19 08:59:11 -05:00
opencode-agent[bot]andnexxeln dcc7de2e47 test(app): stabilize reconnect offset timing (#43438)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-19 19:27:34 +05:30
Shoubhit Dash c40c306170 refactor(core): move shell config into state (#43430) 2026-08-19 19:23:22 +05:30
Shoubhit Dash a207253242 fix(core): await snapshot state readiness (#43435) 2026-08-19 19:23:10 +05:30
Shoubhit Dash 1e867c228a refactor(core): move snapshot config into state (#43425) 2026-08-19 18:34:54 +05:30
Shoubhit Dash 8a402d3f03 refactor(core): move tool output config into state (#43422) 2026-08-19 18:25:22 +05:30
Kit Langton 726a11ba4a test(client): review pass on sync-engine test suite
Two parallel reviews (quality + tests-as-spec) over the engine tests,
treating them as first-class code. Accuracy fixes to catalog claims,
fixture honesty, and typecheck coverage:

- typecheck now covers the sync-engine tests and scripts via
  tsconfig.test.json (old test files with pre-existing errors excluded);
  fixes the type errors this surfaced (fixture stream signal param,
  readonly snapshot arrays, invalid assistant literal)
- FakeSessionServer: file header stating the real-fold modeling claim,
  honors the abort signal, heal() so the sim reset cannot drift from the
  faults record, seq() so tests stop leaning on events.length, threads
  the constructor time into the snapshot, fixes AsyncQueue falsy trap
- laws: file header, law 10 numbering for the refresh-race test, vacuous
  law 2 wait replaced with engine.ready(), law 1/2 story comments,
  robust comment stripping in law 3
- sim: header, chaos distribution + drain-phase comments, heal(),
  ses_-prefixed session IDs everywhere
- divergence catalog: corrected engine-side claims (live reordering is
  ruled out by the ordered log, not the cursor check; activity resync
  wording; law 10 citation), header notes the raw-protocol retry test,
  per-call delta event IDs
- engine-data: header linking it to the laws, SNAPSHOT_RECENT named
  constant, error-DTO source-of-truth comment
2026-08-18 23:03:29 -04:00
Kit Langton d31fbab722 test(client): catalog legacy data layer divergence states 2026-08-18 22:42:38 -04:00
Kit Langton 4fcffdd05e docs: record future directions from architecture retrospective 2026-08-18 22:24:13 -04:00
Kit Langton a060459985 docs: clarify engine vs legacy size comparison 2026-08-18 22:14:44 -04:00
Kit Langton 16a45957fd test(client): cover server outages and restarts in sync engine suite 2026-08-18 22:11:51 -04:00
Kit Langton 90832ffe8d refactor(client): simplify engine render and store diff 2026-08-18 21:58:35 -04:00
Kit Langton 6cbf607849 refactor(client): clone only changed subtrees on engine publish 2026-08-18 21:29:12 -04:00
Kit Langton 378d46c076 chore(client): add session sync streaming benchmark 2026-08-18 21:29:11 -04:00
Kit Langton e0d4a0374c chore(client): regenerate on rebased protocol 2026-08-18 20:56:08 -04:00
Kit Langton f6f8606155 docs: record cursor honesty contract 2026-08-18 20:44:57 -04:00
Kit Langton ef31823a6f fix(client): recover from lossy log attach via snapshot 2026-08-18 20:44:57 -04:00
Kit Langton 4a42f4a724 fix(core): refuse log cursors without retained replay 2026-08-18 20:44:57 -04:00
Kit Langton ce27f07f15 fix(client): preserve minted prompt IDs 2026-08-18 20:44:57 -04:00
Kit Langton 80587d936f docs: wiring complete, regression dispositions 2026-08-18 20:44:57 -04:00
Kit Langton 09772d89d5 fix(tui): restore data-layer guarantees under engine wiring 2026-08-18 20:44:57 -04:00
Kit Langton 4f07025067 fix(client): restore engine refresh guarantees 2026-08-18 20:44:57 -04:00
Kit Langton f253bdaeef feat(tui): wire engine data layer 2026-08-18 20:44:57 -04:00
Kit Langton 93c24cafa7 feat(client): engine-backed data layer 2026-08-18 20:44:57 -04:00
Kit Langton 0963826120 docs: record lane status 2026-08-18 20:44:56 -04:00
Kit Langton c5df2e5c28 refactor(core): simplify snapshot and log changes 2026-08-18 20:44:56 -04:00
Kit Langton 13f0cdd0ea feat(core): session.log follow includes ephemeral events 2026-08-18 20:44:32 -04:00
Kit Langton 5887911c26 feat(core): typed seq-unavailable on session.log 2026-08-18 20:44:01 -04:00
Kit Langton 59bc653465 feat(protocol): session.snapshot endpoint 2026-08-18 20:44:01 -04:00
Kit Langton 8a4c14aed5 refactor(client): simplify sync engine 2026-08-18 20:43:47 -04:00
Kit Langton 15aef5f0f2 test(client): seeded chaos sim 2026-08-18 20:43:47 -04:00
Kit Langton 9b87120db6 test(client): six laws 2026-08-18 20:43:47 -04:00
Kit Langton af6407a1b1 feat(client): engine core 2026-08-18 20:43:47 -04:00
Kit Langton e4ff762483 feat(client): session fold 2026-08-18 20:43:47 -04:00
Kit Langton a00822b06e feat(core): Session.snapshot 2026-08-18 20:43:47 -04:00
Kit Langton 58c33f6870 docs: deletion inventory at adoption 2026-08-18 20:43:21 -04:00
Kit Langton 82d13c6715 docs: outbox ack contract, feature-grouped commits, opt-in ephemeral stream 2026-08-18 20:43:21 -04:00
Kit Langton 6c1d066a65 docs: drop adopt-strictness (S2), renumber lanes 2026-08-18 20:43:21 -04:00
Kit Langton 9b8801a3c6 docs: commit-by-commit implementation lanes, snapshot recent param 2026-08-18 20:43:21 -04:00
Kit Langton 8dad59cf99 docs: delivery is a parallel build for local comparison, not a staged migration 2026-08-18 20:43:21 -04:00
Kit Langton a023ece71f docs: snapshot-only entry contract, compaction/revert/fork, ambient bus filter (S4) 2026-08-18 20:43:21 -04:00
Kit Langton 2b5cc0090a docs: single session stream, overlay semantics, mid-stream reconnect 2026-08-18 20:43:21 -04:00
Kit Langton fc6b9e0dde docs: fold prior-art findings into snapshot decision 2026-08-18 20:43:20 -04:00
Kit Langton ab535c3875 docs: decide snapshot endpoint shape (tentative) 2026-08-18 20:43:20 -04:00
Kit Langton 4ca2f9dd83 docs: sync engine plan outline 2026-08-18 20:43:20 -04:00
94 changed files with 5122 additions and 834 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
on:
workflow_dispatch:
push:
branches: [dev, beta]
branches: [dev, beta, v2]
paths:
- "bun.lock"
- "package.json"
+27
View File
@@ -185,6 +185,33 @@ 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-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
"x86_64-linux": "sha256-IxkSw0gK/qkMHZGVHqjwgM9BKhzbQX6hyF9SWUNtpzg=",
"aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=",
"aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=",
"x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo="
}
}
+2 -2
View File
@@ -10,7 +10,7 @@
]).nodeModules.${stdenvNoCC.hostPlatform.system},
}:
let
packageJson = lib.pipe ../packages/opencode/package.json [
packageJson = lib.pipe ../packages/cli/package.json [
builtins.readFile
builtins.fromJSON
];
@@ -52,7 +52,7 @@ stdenvNoCC.mkDerivation {
--cpu="${bunCpu}" \
--os="${bunOs}" \
--filter '!./' \
--filter './packages/opencode' \
--filter './packages/cli' \
--filter './packages/desktop' \
--filter './packages/app' \
--frozen-lockfile \
+8 -10
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/opencode
cd ./packages/cli
bun --bun ./script/build.ts --single --skip-install
bun --bun ./script/schema.ts schema.json
runHook postBuild
'';
@@ -62,10 +62,9 @@ stdenvNoCC.mkDerivation (finalAttrs: {
installPhase = ''
runHook preInstall
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
install -Dm755 dist/cli-*/bin/opencode2 $out/bin/opencode2
wrapProgram $out/bin/opencode \
wrapProgram $out/bin/opencode2 \
--prefix PATH : ${
lib.makeBinPath (
[
@@ -81,9 +80,9 @@ stdenvNoCC.mkDerivation (finalAttrs: {
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions
installShellCompletion --cmd opencode \
--bash <($out/bin/opencode completion) \
--zsh <(SHELL=/bin/zsh $out/bin/opencode completion)
installShellCompletion --cmd opencode2 \
--bash <($out/bin/opencode2 completion) \
--zsh <(SHELL=/bin/zsh $out/bin/opencode2 completion)
'';
nativeInstallCheckInputs = [
@@ -95,7 +94,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
versionCheckProgramArg = "--version";
passthru = {
jsonschema = "${placeholder "out"}/share/opencode/schema.json";
env = finalAttrs.env;
};
@@ -103,7 +101,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
description = "The open source coding agent";
homepage = "https://opencode.ai";
license = lib.licenses.mit;
mainProgram = "opencode";
mainProgram = "opencode2";
inherit (node_modules.meta) platforms;
};
})
@@ -19,6 +19,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
@@ -75,6 +76,7 @@ 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)
@@ -62,13 +62,16 @@ 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 route = document.createElement("section")
const viewport = document.createElement("div")
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
const animation = controlledAnimationFrames(targetWindow)
const route = targetWindow.document.createElement("section")
const viewport = targetWindow.document.createElement("div")
route.append(viewport)
document.body.append(route)
targetWindow.document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
targetWindow,
scrollOffset: 79_400,
options: {
horizontal: false,
@@ -83,20 +86,23 @@ test("keeps checking until stale reset-delay callbacks can no longer win", async
instance.scrollOffset = offset
})
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(1)
expect(instance.scrollOffset).toBe(0)
try {
mutations.remove(route)
mutations.append(targetWindow.document.body, route)
animation.run(16)
expect(instance.scrollOffset).toBe(0)
instance.scrollOffset = 79_400
await new Promise((resolve) => setTimeout(resolve, 25))
await frames(3)
instance.scrollOffset = 79_400
animation.run(32)
animation.run(48)
expect(instance.scrollOffset).toBe(0)
expect(calls).toEqual([0, 0])
cleanup?.()
route.remove()
expect(instance.scrollOffset).toBe(0)
expect(calls).toEqual([0, 0])
expect(animation.pending()).toBe(0)
} finally {
cleanup?.()
await targetWindow.happyDOM.close()
}
})
test.each([
@@ -235,3 +241,29 @@ 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,
}
}
+2 -3
View File
@@ -1,6 +1,5 @@
import { Argument, Command, Flag } from "effect/unstable/cli"
import { Argument, Flag } from "effect/unstable/cli"
import { Spec } from "../framework/spec"
import { GlobalFlags } from "./global-flags"
declare const OPENCODE_CLI_NAME: string | undefined
@@ -343,4 +342,4 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
],
})
export const Commands = { ...Root, spec: Root.spec.pipe(Command.withGlobalFlags(GlobalFlags.all)) }
export const Commands = Root
-12
View File
@@ -1,12 +0,0 @@
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
+28 -2
View File
@@ -1,10 +1,36 @@
export * as CpuProfile from "./cpu-profile"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem, Queue } from "effect"
import { Session } from "node:inspector"
import path from "node:path"
export function run<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
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>) {
const target = path.resolve(file)
return Effect.acquireUseRelease(
Effect.gen(function* () {
+2 -19
View File
@@ -1,13 +1,10 @@
import { Effect, FileSystem, Option, Scope } from "effect"
import { Effect, FileSystem, 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>
@@ -90,21 +87,7 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
Command.withHandler((input) =>
Effect.gen(function* () {
const module = yield* Effect.promise(handler.load)
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
}),
),
)
return yield* module.default(input)
}),
),
)
+2
View File
@@ -13,6 +13,7 @@ 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"),
@@ -61,6 +62,7 @@ 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 }))
@@ -110,7 +110,6 @@ 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
@@ -0,0 +1,18 @@
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)
})
-23
View File
@@ -19,29 +19,6 @@ 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"
"typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.test.json"
},
"dependencies": {
"@opencode-ai/schema": "workspace:*",
+174 -85
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,36 +139,46 @@ 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 }
export type Endpoint5_6Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, 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_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_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_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_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_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_9Output = void
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_10Output = void
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_11Input = {
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 = {
readonly sessionID: Session.ID
readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_11Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_12Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_12Input = {
export type Endpoint5_13Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -179,10 +189,10 @@ export type Endpoint5_12Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_12Output = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_13Output = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_13Input = {
export type Endpoint5_14Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
@@ -195,19 +205,19 @@ export type Endpoint5_13Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_14Output = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_14Input = {
export type Endpoint5_15Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly skill: Skill.ID
readonly resume?: boolean | undefined
}
export type Endpoint5_14Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_15Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_15Input = {
export type Endpoint5_16Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -216,97 +226,98 @@ export type Endpoint5_15Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_15Output = SessionInbox.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Output = SessionInbox.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_16Input = {
export type Endpoint5_17Input = {
readonly sessionID: Session.ID
readonly id?: Event.ID | undefined
readonly command: string
}
export type Endpoint5_16Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_17Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_17Input = {
export type Endpoint5_18Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_17Output = SessionInbox.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Output = SessionInbox.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, 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_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_19Input = {
export type Endpoint5_20Input = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly files?: boolean | undefined
}
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_20Output = Session.Revert
export type SessionRevertStageOperation<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 SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_22Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_23Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, 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_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_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_25Output = void
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type SessionInboxCancelOperation<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 SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
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 SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, E>
input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_28Input = {
export type Endpoint5_29Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
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> = (
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_29Input,
) => Effect.Effect<Endpoint5_29Output, 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_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_31Input = {
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 = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
readonly ephemeral?: boolean | undefined
}
export type Endpoint5_31Output =
export type Endpoint5_32Output =
| (
| {
readonly id: Event.ID
@@ -899,24 +910,101 @@ export type Endpoint5_31Output =
}
}
)
| {
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_31Input) => Stream.Stream<Endpoint5_31Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_32Input) => Stream.Stream<Endpoint5_32Output, E>
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_33Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type Endpoint5_33Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, 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_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_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 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 interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -925,6 +1013,7 @@ 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>
+91 -80
View File
@@ -86,6 +86,8 @@ import type {
Endpoint5_34Output,
Endpoint5_35Input,
Endpoint5_35Output,
Endpoint5_36Input,
Endpoint5_36Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -350,48 +352,56 @@ 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.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.snapshot"]({ params: { sessionID: input["sessionID"] }, query: { recent: input["recent"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
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.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).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_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -410,8 +420,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
),
)
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -432,16 +442,16 @@ 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_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
raw["session.skill"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -458,16 +468,16 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I
),
)
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
raw["session.compact"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], delivery: input["delivery"] },
@@ -477,13 +487,13 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
),
)
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -493,27 +503,19 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I
),
)
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.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -521,62 +523,70 @@ 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.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).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.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.inbox.cancel"]({ 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.queue"]({ 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_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_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_31Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveStream<Endpoint5_31Output>()(
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveStream<Endpoint5_32Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
query: { after: input["after"], follow: input["follow"] },
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
@@ -584,29 +594,29 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
),
)
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveEffect<Endpoint5_32Output>()(
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
raw["session.interrupt"]({
params: { sessionID: input["sessionID"] },
query: { continue: input["continue"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
const Endpoint5_36 = (raw: RawClient["server.session"]) => (input: Endpoint5_36Input) =>
preserveEffect<Endpoint5_36Output>()(
raw["session.environment"]({
params: { sessionID: input["sessionID"] },
payload: { variables: input["variables"] },
@@ -620,29 +630,30 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
export: Endpoint5_3(raw),
active: Endpoint5_4(raw),
get: Endpoint5_5(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),
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),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -20,6 +20,8 @@ import type {
SessionActiveOutput,
SessionGetInput,
SessionGetOutput,
SessionSnapshotInput,
SessionSnapshotOutput,
SessionRemoveInput,
SessionRemoveOutput,
SessionForkInput,
@@ -514,6 +516,18 @@ 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>(
{
@@ -843,9 +857,9 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`,
query: { after: input["after"], follow: input["follow"] },
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [404, 409, 401, 400],
empty: false,
},
requestOptions,
+96 -48
View File
@@ -512,51 +512,6 @@ export type SessionRevertCommitted = {
data: { sessionID: string; to: string }
}
export type ModelsDevRefreshed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "models-dev.refreshed"
location?: LocationRef
data: {}
}
export type IntegrationUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "integration.updated"
location?: LocationRef
data: {}
}
export type IntegrationConnectionUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "integration.connection.updated"
location?: LocationRef
data: { integrationID: string }
}
export type CatalogUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "catalog.updated"
location?: LocationRef
data: {}
}
export type AgentUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "agent.updated"
location?: LocationRef
data: {}
}
export type SessionTextDelta = {
id: string
created: number
@@ -602,6 +557,51 @@ export type SessionCompactionDelta = {
data: { sessionID: string; text: string }
}
export type ModelsDevRefreshed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "models-dev.refreshed"
location?: LocationRef
data: {}
}
export type IntegrationUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "integration.updated"
location?: LocationRef
data: {}
}
export type IntegrationConnectionUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "integration.connection.updated"
location?: LocationRef
data: { integrationID: string }
}
export type CatalogUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "catalog.updated"
location?: LocationRef
data: {}
}
export type AgentUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "agent.updated"
location?: LocationRef
data: {}
}
export type FilesystemChanged = {
id: string
created: number
@@ -2001,7 +2001,15 @@ export type FormCreated = {
data: { form: FormInfo1 }
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionLogItem =
| SessionEventDurable
| SessionUsageUpdated
| SessionTextDelta
| SessionReasoningDelta
| SessionToolInputDelta
| SessionToolProgress
| SessionCompactionDelta
| EventLogSynced
export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields }
@@ -2011,6 +2019,16 @@ 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 }
@@ -2225,6 +2243,16 @@ 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
@@ -3303,6 +3331,13 @@ 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
@@ -3941,8 +3976,21 @@ export type SessionGenerateOutput = SessionGenerateResponse["data"]
export type SessionLogInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly after?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["after"]
readonly follow?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["follow"]
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"]
}
export type SessionLogOutput = SessionLogItem
+248
View File
@@ -0,0 +1,248 @@
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
@@ -0,0 +1,498 @@
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
@@ -0,0 +1,583 @@
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,2 +1,3 @@
export * from "./data"
export * from "./connection"
export * from "./engine-data"
+145
View File
@@ -0,0 +1,145 @@
// 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
@@ -0,0 +1,231 @@
// 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 }))
}
}
@@ -0,0 +1,258 @@
// 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 },
})
@@ -0,0 +1,201 @@
// 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()
})
})
@@ -0,0 +1,127 @@
// 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
@@ -0,0 +1,13 @@
{
"$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"
]
}
+73 -1
View File
@@ -14,11 +14,22 @@ 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",
@@ -51,6 +62,22 @@ 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",
@@ -72,6 +99,35 @@ 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":
@@ -86,6 +142,20 @@ 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"
@@ -229,7 +299,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
@@ -240,9 +310,11 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
}
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 } }
+14 -1
View File
@@ -460,7 +460,20 @@ function prompt(request: LLMRequest): LanguageModelV3Prompt {
function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[] {
switch (input.role) {
case "system":
return [{ role: "system", content: input.content.flatMap(text).join("\n\n") }]
// 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")),
},
],
},
]
case "user":
return [{ role: "user", content: input.content.flatMap(userPart) }]
case "assistant":
+27 -1
View File
@@ -44,6 +44,21 @@ 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
@@ -150,6 +165,7 @@ 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>
@@ -768,6 +784,7 @@ 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* () {
@@ -791,7 +808,8 @@ 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 wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined
const subscription = input.follow && input.includeLive ? yield* PubSub.subscribe(pubsub.live) : undefined
const wakes = input.follow && !subscription ? yield* subscribeDurable(input.aggregateID) : undefined
const target = yield* latestSequence(db, input.aggregateID)
const marker: EventLog.Synced = {
type: "log.synced",
@@ -802,6 +820,14 @@ 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)),
+11 -28
View File
@@ -8,10 +8,8 @@ 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
@@ -53,16 +51,15 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export const layer = (options?: ShellSelect.Options) =>
const layer = () =>
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 global = yield* Global.Service
const shell = yield* ShellSelect.Service
const state = State.create<Data, Draft>({
name: "command",
initial: () => ({ commands: new Map() }),
@@ -109,11 +106,9 @@ export const layer = (options?: ShellSelect.Options) =>
const command = staticCommand(input.name)
if (command)
return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
config,
location,
processes,
shell: options,
bin: global.bin,
shell,
})
const prompt = (yield* mcp.prompts()).find(
@@ -163,11 +158,9 @@ function evaluateTemplate(
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
readonly bin: string
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
@@ -197,20 +190,14 @@ 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.Options
readonly bin: string
readonly shell: ShellSelect.Interface
},
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = ShellSelect.preferred(
Config.latest(yield* services.config.entries(), "shell"),
services.shell,
services.bin,
)
const shell = yield* services.shell.preferred()
const outputs = yield* Effect.forEach(
matches,
(match) => {
@@ -267,12 +254,8 @@ const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
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()
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [MCP.node, Bus.node, AppProcess.node, Location.node, ShellSelect.node],
})
@@ -0,0 +1,37 @@
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 }),
})
}
})
}),
})
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,29 @@
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)
})
}),
})
@@ -0,0 +1,30 @@
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)
})
}),
})
@@ -0,0 +1,33 @@
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 }),
})
})
}),
})
@@ -0,0 +1,65 @@
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 { Context, Effect, Layer, Stream } from "effect"
import { Cause, Context, Effect, Exit, Layer, Scope, Semaphore, 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,42 +24,86 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const plugins = yield* PluginSupervisor.Service
const policy = yield* LocationWatcherPolicy.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",
})
yield* Effect.gen(function* () {
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)
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)))
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)
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())
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
Effect.forkScoped,
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) => Effect.logError("failed to start location watcher", { cause }),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({})
}),
)
@@ -67,5 +111,13 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, Bus.node],
deps: [
Watcher.node,
FSUtil.node,
Location.node,
Git.node,
Bus.node,
PluginSupervisor.node,
LocationWatcherPolicy.node,
],
})
+2
View File
@@ -29,6 +29,7 @@ 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"
@@ -71,6 +72,7 @@ const locationServiceNodes = [
Worktree.refreshNode,
FileSystemSearch.node,
FileSystem.node,
ShellSelect.node,
Pty.node,
Shell.node,
Skill.node,
+2 -85
View File
@@ -2,13 +2,7 @@ export * as ModelResolver from "./model-resolver.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LanguageModel } from "@opencode-ai/ai"
// 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 { Auth } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "./aisdk.js"
@@ -83,47 +77,6 @@ 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,
@@ -170,37 +123,14 @@ 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
@@ -267,19 +197,6 @@ 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,14 +13,19 @@ 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"
@@ -29,6 +34,7 @@ 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"
@@ -44,8 +50,11 @@ 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"
@@ -60,6 +69,7 @@ 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"
@@ -90,6 +100,7 @@ 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
@@ -110,11 +121,15 @@ 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(
@@ -129,6 +144,7 @@ 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),
@@ -149,11 +165,15 @@ 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),
)
@@ -175,6 +195,7 @@ export const requirements = LayerNode.group([
Environment.node,
FileMutation.node,
Formatter.node,
LocationWatcherPolicy.node,
FileSystem.node,
FSUtil.node,
Global.node,
@@ -195,11 +216,15 @@ 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,
])
@@ -238,8 +263,13 @@ 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,13 +190,6 @@ 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
+8 -16
View File
@@ -4,12 +4,10 @@ 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
@@ -90,14 +88,13 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
export const layer = (options?: ShellSelect.Options) =>
const layer = () =>
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 context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
@@ -167,8 +164,7 @@ export const layer = (options?: ShellSelect.Options) =>
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command =
input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin)
const command = input.command || (yield* shell.preferred())
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
@@ -317,12 +313,8 @@ export const layer = (options?: ShellSelect.Options) =>
}),
)
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()
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [Bus.node, Location.node, ShellSelect.node],
})
+132 -25
View File
@@ -141,6 +141,11 @@ 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,
@@ -194,13 +199,33 @@ 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>
/**
* 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.
* 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.
* The marker's seq may exceed the last emitted event because other durable
* bus share the aggregate's sequence space.
*/
@@ -208,7 +233,8 @@ export interface Interface {
sessionID: SessionSchema.ID
after?: number
follow?: boolean
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
ephemeral?: boolean
}) => Stream.Stream<SessionEvent.Event | EventLog.Synced, NotFoundError | SeqUnavailableError>
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>
@@ -326,6 +352,7 @@ 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(
@@ -557,20 +584,90 @@ 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)),
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),
),
),
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)),
prompt: Effect.fn("Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
@@ -628,7 +725,11 @@ const layer = Layer.effect(
}),
command: Effect.fn("Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
const commands = yield* Command.Service.pipe(Effect.provide(locations.get(session.location)))
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 command = yield* commands.get(input.command)
if (!command)
return yield* new Command.NotFoundError({
@@ -667,6 +768,8 @@ 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({
@@ -905,19 +1008,23 @@ 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* 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)),
)
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)))
}),
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* SessionRevert.clear(session).pipe(
Effect.provideService(Bus.Service, bus),
Effect.provide(locations.get(session.location)),
)
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)))
yield* execution.wake(sessionID)
return revert
}),
+28 -25
View File
@@ -3,9 +3,7 @@ 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"
@@ -24,6 +22,7 @@ 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
@@ -61,10 +60,14 @@ 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.`
type Settings = {
readonly auto: boolean
readonly buffer: number
readonly tokens: number
export type Settings = {
auto: boolean
buffer: number
tokens: number
}
export type Draft = {
configure: (settings: Partial<Settings>) => void
}
type Dependencies = {
@@ -74,7 +77,6 @@ type Dependencies = {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly models: SessionRunnerModel.Interface
readonly config: Settings
readonly hooks: PluginHooks.Interface
}
@@ -111,7 +113,7 @@ export type Outcome =
| Pick<SessionMessage.CompactionCompleted, "status">
| Pick<SessionMessage.CompactionFailed, "status" | "error">
export interface Interface {
export interface Interface extends State.Transformable<Draft> {
readonly required: (input: RequiredInput) => boolean
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
@@ -165,17 +167,6 @@ 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,
@@ -240,7 +231,17 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
}
const make = (dependencies: Dependencies) => {
const config = dependencies.config
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 failed = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly reason: SessionMessage.Compaction["reason"]
@@ -350,7 +351,7 @@ const make = (dependencies: Dependencies) => {
return { status: "completed" as const }
})
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
const content = planContent(input.messages, config.tokens)
const content = planContent(input.messages, state.get().tokens)
if (content)
return yield* execute({
session: input.session,
@@ -368,6 +369,7 @@ 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
@@ -388,7 +390,7 @@ const make = (dependencies: Dependencies) => {
return used >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, config.tokens)
const content = planContent(input.messages, state.get().tokens)
if (!content)
return yield* failed({
sessionID: input.session.id,
@@ -419,6 +421,8 @@ const make = (dependencies: Dependencies) => {
})
})
return Service.of({
transform: state.transform,
reload: state.reload,
required,
compact,
compactManual,
@@ -430,16 +434,15 @@ 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, config: settings(yield* config.entries()), app, hooks })
return make({ bus, llm, models, app, hooks })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node, PluginHooks.node],
deps: [Bus.node, llmClient, SessionRunnerModel.node, App.node, PluginHooks.node],
})
+4
View File
@@ -34,6 +34,7 @@ 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<{
@@ -114,6 +115,7 @@ 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.
@@ -135,6 +137,7 @@ 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)) {
@@ -646,6 +649,7 @@ export const node = makeLocationNode({
SessionModelTransport.node,
SessionStore.node,
SessionCompaction.node,
PluginSupervisor.node,
SessionTitle.node,
Snapshot.node,
ToolOutput.node,
+17 -27
View File
@@ -7,7 +7,6 @@ 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"
@@ -68,14 +67,14 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
export const layer = (options?: ShellSelect.Options) =>
const layer = () =>
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
@@ -146,12 +145,7 @@ export const layer = (options?: ShellSelect.Options) =>
return session.info
})
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 name = () => shell.preferred().pipe(Effect.map(ShellSelect.name))
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
@@ -196,7 +190,7 @@ export const layer = (options?: ShellSelect.Options) =>
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
shell: yield* shell.preferred(),
env: {
...(sessionEnvironment ?? process.env),
TERM: "xterm-256color",
@@ -353,20 +347,16 @@ export const layer = (options?: ShellSelect.Options) =>
}),
)
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()
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [
Bus.node,
Location.node,
Global.node,
ShellSelect.node,
Environment.node,
PluginHooks.node,
SessionEnvironment.node,
],
})
+46 -1
View File
@@ -3,8 +3,11 @@ export * as ShellSelect from "./select.js"
import path from "path"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { Schema } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
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 }> = {
@@ -30,6 +33,20 @@ 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
}
@@ -181,3 +198,31 @@ 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()
+22 -11
View File
@@ -3,7 +3,6 @@ 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"
@@ -12,6 +11,7 @@ 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,7 +36,11 @@ export interface RestoreInput {
readonly files: ReadonlyMap<RelativePath, ID>
}
export interface Interface {
export type Draft = {
configure: (enabled: boolean) => void
}
export interface Interface extends State.Transformable<Draft> {
/**
* Capture the current Location-scoped filesystem state as a content-addressed
* tree. Returns `undefined` when snapshots are disabled, unsupported, or the
@@ -68,12 +72,20 @@ 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* () {
@@ -100,13 +112,10 @@ const layer = Layer.effect(
return RelativePath.make(relative.replaceAll("\\", "/") || ".")
})
const enabled = Effect.fnUntraced(function* () {
if (location.vcs?.type !== "git") return false
return Config.latest(yield* config.entries(), "snapshots") !== false
})
const enabled = () => location.vcs?.type === "git" && state.get().enabled
const capture = Effect.fn("Snapshot.capture")(function* () {
if (!(yield* enabled())) return undefined
if (!enabled()) return undefined
return yield* Effect.gen(function* () {
const repo = yield* repository
return ID.make(
@@ -170,26 +179,28 @@ const layer = Layer.effect(
})
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
if (!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({ capture, files, diff, restore })
return Service.of({ transform: state.transform, reload: state.reload, capture, files, diff, restore })
}).pipe(Effect.withSpan("Snapshot.boot")),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Git.node, Global.node, Location.node],
deps: [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([]),
+32 -11
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,7 +16,16 @@ export const DIRECTORY = "tool-output"
type Result = Tool.Result
export interface Interface {
type Limits = {
maxLines: number
maxBytes: number
}
export type Draft = {
configure: (limits: Partial<Limits>) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly truncate: (result: Result) => Effect.Effect<Result>
readonly cleanup: () => Effect.Effect<void>
}
@@ -46,31 +55,38 @@ 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 configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? MAX_LINES
const maxBytes = configured?.max_bytes ?? MAX_BYTES
const limits = state.get()
const lines = text.split("\n")
if (text.endsWith("\n")) lines.pop()
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes)
if (lines.length <= limits.maxLines && totalBytes <= limits.maxBytes)
return { ...result, metadata: { ...result.metadata, truncated: false } }
const kept: string[] = []
let bytes = 0
let hitBytes = false
for (const line of lines.slice(0, maxLines)) {
for (const line of lines.slice(0, limits.maxLines)) {
const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
if (bytes + size > limits.maxBytes) {
hitBytes = true
break
}
@@ -113,7 +129,12 @@ const layer = Layer.effect(
}
})
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
return Service.of({
transform: state.transform,
reload: state.reload,
truncate,
cleanup: () => cleanup(fs, directory),
})
}),
)
@@ -137,5 +158,5 @@ const cleanupNode = makeGlobalNode({
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
deps: [FSUtil.node, Global.node, cleanupNode],
})
+112 -1
View File
@@ -2,9 +2,91 @@ 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 })
AISDKNative.map({ packageName, settings, modelID, providerID: "test-provider" })
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",
@@ -273,6 +355,35 @@ 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,6 +104,43 @@ 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
+1 -3
View File
@@ -1,19 +1,17 @@
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 { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
import { 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],
]),
)
@@ -0,0 +1,168 @@
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
@@ -0,0 +1,42 @@
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 }) })]),
),
),
)
})
@@ -0,0 +1,66 @@
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 }) })])),
),
)
})
@@ -0,0 +1,62 @@
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 }) }),
}),
]),
),
),
)
})
+135 -7
View File
@@ -4,18 +4,24 @@ 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
@@ -23,6 +29,11 @@ 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", () =>
@@ -135,16 +146,26 @@ describe("Watcher lifecycle", () => {
})
})
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
function provide(
directory: string,
vcs?: Location.Interface["vcs"],
watcher?: Layer.Layer<Watcher.Service>,
config: Layer.Layer<Config.Service> = configLayer,
plugins: LocationNode<PluginSupervisor.Service> = pluginNode,
) {
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
const built = AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
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) : []),
],
)
return Effect.provide(built)
}
@@ -154,6 +175,8 @@ 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(
@@ -174,7 +197,11 @@ 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))))
).pipe(
Effect.flatMap(({ tmp, vcs }) =>
f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher, options?.config ?? configLayer, options?.plugins)),
),
)
}
describe("LocationWatcher subscriptions", () => {
@@ -223,6 +250,107 @@ 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) {
+118 -20
View File
@@ -219,7 +219,10 @@ describe("ModelResolver", () => {
settings: { baseURL: "https://openai.example/v1" },
limit: { context: 100, input: 80, output: 20 },
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
const resolved = yield* ModelResolver.fromCatalogModel(
catalog,
Credential.Key.make({ type: "key", key: "secret" }),
)
expect(catalog.id).toBe(ID.make("test-model"))
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
@@ -254,22 +257,24 @@ describe("ModelResolver", () => {
)
it.effect("treats an empty configured API key as omitted", () =>
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,
})
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,
})
expect(headers.authorization).toBeUndefined()
}),
expect(headers.authorization).toBe("Bearer environment-key")
}),
),
)
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
@@ -457,8 +462,12 @@ describe("ModelResolver", () => {
settings: { baseURL: "https://openai.example/v1" },
variants: [
{
id: VariantID.make("high"),
settings: { reasoningEffort: "high" },
id: VariantID.make("xhigh"),
settings: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
headers: { "x-variant": "high" },
body: {
store: false,
@@ -468,7 +477,7 @@ describe("ModelResolver", () => {
},
],
})
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("high"))
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("xhigh"))
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
expect(resolved.route.defaults.http?.body).toEqual({
@@ -478,7 +487,17 @@ describe("ModelResolver", () => {
temperature: 0.2,
})
expect(resolved.route.defaults.providerOptions).toEqual({
openai: { store: false, reasoningEffort: "high" },
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" },
})
}),
)
@@ -815,12 +834,46 @@ 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",
@@ -869,6 +922,51 @@ 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")))
@@ -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("@opencode-ai/ai/providers/openai")
expect(provider.package).toBe(Provider.aisdk("@ai-sdk/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("@opencode-ai/ai/providers/openai")
expect(eligible.package).toBe(Provider.aisdk("@ai-sdk/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("@opencode-ai/ai/providers/openai")
expect(model.package).toBe(Provider.aisdk("@ai-sdk/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")
+6 -22
View File
@@ -1,7 +1,5 @@
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"
@@ -9,6 +7,7 @@ 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"
@@ -18,13 +17,7 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
)
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 it = testEffect(AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [[Location.node, locationLayer]]))
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
@@ -207,26 +200,17 @@ describe("pty", () => {
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
const configuredIt = testEffect(
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],
]),
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [[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 config and location", () =>
configuredTest("defaults command, login args, and cwd from shell selection 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,7 +1,6 @@
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"
@@ -67,7 +66,6 @@ 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(
@@ -83,7 +81,6 @@ const it = testEffect(
[
[Bus.node, Bus.configured({ persist: true })],
[llmClient, client],
[Config.node, config],
[SessionRunnerModel.node, models],
],
),
+219 -1
View File
@@ -4,8 +4,10 @@ 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"
@@ -15,6 +17,8 @@ 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"
@@ -28,6 +32,16 @@ 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", () => {
@@ -60,6 +74,43 @@ 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
@@ -68,6 +119,109 @@ 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({
@@ -87,12 +241,33 @@ 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 : item.durable?.seq)),
items.map((item): number | string | undefined =>
Bus.isSynced(item) ? item.type : "durable" in item ? item.durable.seq : undefined,
),
).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
@@ -121,3 +296,46 @@ 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) }])
}),
)
})
+33 -1
View File
@@ -28,6 +28,7 @@ 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[] = []
@@ -60,7 +61,7 @@ const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// Attachment admission only needs image normalization and plugin readiness.
// These operations resolve Location services lazily and must wait for plugin-projected state.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.unwrap(
Effect.sync(() => {
@@ -72,6 +73,12 @@ 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)) }),
@@ -1051,6 +1058,31 @@ 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* () {
@@ -0,0 +1,87 @@
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,6 +127,31 @@ 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()),
+9 -12
View File
@@ -1,9 +1,6 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ToolOutput } from "@opencode-ai/core/tool-output"
@@ -15,18 +12,18 @@ import { it } from "./lib/effect"
const withStore = <A, E, R>(
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
info = new Info(),
limits?: { maxLines?: number; maxBytes?: number },
) =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
const config = Config.testLayer([new Document({ type: "document", info })])
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
[Config.node, config],
[Global.node, Global.layerWith({ data: tmp.path })],
])
return Effect.gen(function* () {
return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
const output = yield* ToolOutput.Service
if (limits) yield* output.transform((draft) => draft.configure(limits))
return yield* body(output, yield* FSUtil.Service, tmp.path)
}).pipe(Effect.provide(layer))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -50,7 +47,7 @@ describe("ToolOutput", () => {
{ type: "text", text: `... 1 line truncated; full content saved to ${outputPath} ...` },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
{ maxLines: 2, maxBytes: 1_000 },
),
)
@@ -67,7 +64,7 @@ describe("ToolOutput", () => {
},
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 100, max_bytes: 5 }) }),
{ maxLines: 100, maxBytes: 5 },
),
)
@@ -86,7 +83,7 @@ describe("ToolOutput", () => {
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 line truncated; full content saved to /) },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
{ maxLines: 2, maxBytes: 1_000 },
),
)
@@ -119,7 +116,7 @@ describe("ToolOutput", () => {
metadata: { truncated: false },
})
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
{ maxLines: 2, maxBytes: 1_000 },
),
)
@@ -133,7 +130,7 @@ describe("ToolOutput", () => {
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 3 }) }),
{ maxLines: 2, maxBytes: 3 },
),
)
+12
View File
@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
import { Event } from "@opencode-ai/schema/event"
export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>()(
"InvalidRequestError",
@@ -35,6 +36,17 @@ export class SessionBusyError extends Schema.TaggedError<SessionBusyError>()(
{ httpApiStatus: 409 },
) {}
export class SeqUnavailableError extends Schema.TaggedError<SeqUnavailableError>()(
"SeqUnavailableError",
{
sessionID: Schema.String,
after: Event.Seq,
head: Schema.optional(Event.Seq),
message: Schema.String,
},
{ httpApiStatus: 409 },
) {}
export class ServiceUnavailableError extends Schema.TaggedError<ServiceUnavailableError>()(
"ServiceUnavailableError",
{
+29 -3
View File
@@ -18,6 +18,7 @@ import {
InvalidRequestError,
MessageNotFoundError,
ServiceUnavailableError,
SeqUnavailableError,
SessionBusyError,
SessionNotFoundError,
SkillNotFoundError,
@@ -219,6 +220,30 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.get("session.snapshot", "/api/session/:sessionID/snapshot", {
params: { sessionID: Session.ID },
query: {
recent: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional),
},
success: Schema.Struct({
data: Schema.Struct({
session: Session.Info,
children: Schema.Array(Session.Info),
inbox: Schema.Array(SessionInbox.Info),
messages: Schema.Array(SessionMessage.Info),
seq: Event.Seq,
}),
}).annotate({ identifier: "SessionSnapshotResponse" }),
error: [SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.snapshot",
summary: "Snapshot session state",
description: "Retrieve projected session state and its aggregate sequence from one consistent read.",
}),
),
)
.add(
HttpApiEndpoint.delete("session.remove", "/api/session/:sessionID", {
params: { sessionID: Session.ID },
@@ -633,17 +658,18 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
query: {
after: Schema.NumberFromString.pipe(Schema.decodeTo(Event.Seq), Schema.optional),
follow: BooleanFromString.pipe(Schema.optional),
ephemeral: BooleanFromString.pipe(Schema.optional),
},
success: HttpApiSchema.StreamSse({
data: Schema.Union([SessionEvent.Durable, EventLog.Synced]).annotate({ identifier: "SessionLogItem" }),
data: Schema.Union([SessionEvent.All, EventLog.Synced]).annotate({ identifier: "SessionLogItem" }),
}),
error: SessionNotFoundError,
error: [SessionNotFoundError, SeqUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.log",
summary: "Read the session log",
description:
"Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.",
"Experimental session event log. Replay is durable-only; follow mode can opt into live ephemeral events.",
}),
),
)
+3
View File
@@ -1,6 +1,7 @@
import { Pty } from "@opencode-ai/core/pty"
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Location } from "@opencode-ai/core/location"
import { Effect, Queue } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -39,6 +40,8 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
.handle(
"pty.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const pty = yield* Pty.Service
const location = yield* Location.Service
const cwd = ctx.payload.cwd || location.directory
+53 -60
View File
@@ -1,7 +1,7 @@
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { DateTime, Effect, Stream } from "effect"
import { DateTime, Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
@@ -13,6 +13,7 @@ import {
InvalidCursorError,
MessageNotFoundError,
ServiceUnavailableError,
SeqUnavailableError,
SessionBusyError,
SessionNotFoundError,
SkillNotFoundError,
@@ -26,16 +27,23 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const sessionNotFound = (error: Session.NotFoundError) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
})
const messageDecodeFailed = (error: Session.MessageDecodeError) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
),
)
}
const pendingMutation = (effect: ReturnType<typeof session.cancelInbox>, conflict: string) =>
effect.pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag("Session.NotFoundError", sessionNotFound),
Effect.catchTag(
"Session.InboxConflictError",
(error) => new ConflictError({ resource: error.inboxID, message: `${conflict}: ${error.inboxID}` }),
@@ -131,25 +139,8 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.fn(function* (ctx) {
return {
data: yield* transfer.export({ sessionID: ctx.params.sessionID, sanitize: ctx.query.sanitize }).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
),
),
)
}),
Effect.catchTag("Session.NotFoundError", sessionNotFound),
Effect.catchTag("Session.MessageDecodeError", messageDecodeFailed),
),
}
}),
@@ -180,6 +171,19 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.snapshot",
Effect.fn(function* (ctx) {
return {
data: yield* session
.snapshot({ sessionID: ctx.params.sessionID, recent: ctx.query.recent })
.pipe(
Effect.catchTag("Session.NotFoundError", sessionNotFound),
Effect.catchTag("Session.MessageDecodeError", messageDecodeFailed),
),
}
}),
)
.handle(
"session.remove",
Effect.fn(function* (ctx) {
@@ -664,25 +668,8 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.fn(function* (ctx) {
return {
data: yield* session.context(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
),
),
)
}),
Effect.catchTag("Session.NotFoundError", sessionNotFound),
Effect.catchTag("Session.MessageDecodeError", messageDecodeFailed),
),
}
}),
@@ -773,19 +760,25 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.log",
Effect.fn(function* (ctx) {
yield* session.get(ctx.params.sessionID).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
)
return session
.log({ sessionID: ctx.params.sessionID, after: ctx.query.after, follow: ctx.query.follow })
.pipe(Stream.orDie)
return yield* session
.openLog({
sessionID: ctx.params.sessionID,
after: ctx.query.after,
follow: ctx.query.follow,
ephemeral: ctx.query.ephemeral,
})
.pipe(
Effect.mapError((error) =>
error._tag === "Session.NotFoundError"
? sessionNotFound(error)
: new SeqUnavailableError({
sessionID: error.sessionID,
after: error.after,
head: error.head,
message: `Session log is unavailable after sequence ${error.after}`,
}),
),
)
}),
)
.handle(
+3
View File
@@ -1,5 +1,6 @@
import { Shell } from "@opencode-ai/core/shell"
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { ShellNotFoundError } from "@opencode-ai/protocol/errors"
@@ -19,6 +20,8 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
.handle(
"shell.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const shell = yield* Shell.Service
const location = yield* Location.Service
return yield* response(
+2 -6
View File
@@ -9,14 +9,12 @@ import { EventLogger } from "@opencode-ai/core/event-logger"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { Command } from "@opencode-ai/core/command"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Pty } from "@opencode-ai/core/pty"
import { Project } from "@opencode-ai/core/project"
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Global } from "@opencode-ai/util/global"
@@ -115,9 +113,7 @@ function makeRoutes<AuthError, AuthServices>(
}),
],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
[Command.node, Command.configured({ gitbash: options.windows?.gitbash })],
[Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })],
[Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })],
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
[
MCP.node,
MCP.configured({
+4
View File
@@ -111,6 +111,7 @@ export const DEFAULT_THEME = {
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
secondary: { default: "$text.subdued", $hovered: "$text.default" },
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
@@ -142,6 +143,7 @@ export const DEFAULT_THEME = {
$selected: "$hue.interactive.700",
$disabled: "$hue.neutral.300",
},
secondary: { default: "transparent" },
destructive: {
default: "$hue.red.600",
$hovered: "$hue.red.700",
@@ -324,6 +326,7 @@ export const DEFAULT_THEME = {
subdued: "$hue.neutral.400",
action: {
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
secondary: { default: "$text.subdued", $hovered: "$text.default" },
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
@@ -355,6 +358,7 @@ export const DEFAULT_THEME = {
$selected: "$hue.interactive.600",
$disabled: "$hue.neutral.800",
},
secondary: { default: "transparent" },
destructive: {
default: "$hue.red.600",
$hovered: "$hue.red.700",
+2 -1
View File
@@ -9,7 +9,7 @@ export type BaseHue = Schema.Schema.Type<typeof BaseHue>
export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"])
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
export const ActionVariant = Schema.Literals(["primary", "destructive"])
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "selected", "hovered"])
@@ -90,6 +90,7 @@ export type FormfieldColorDefinition = StatefulColorDefinition
const ActionColorDefinition = Schema.Struct({
primary: Schema.optional(StatefulColorDefinition),
secondary: Schema.optional(StatefulColorDefinition),
destructive: Schema.optional(StatefulColorDefinition),
})
+2
View File
@@ -82,6 +82,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
$focused: selected,
$selected: primary,
},
secondary: { default: "$text.subdued", $hovered: "$text.default" },
destructive: { default: destructive, $disabled: textMuted },
},
formfield: {
@@ -107,6 +108,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
},
action: {
primary: { default: "transparent", $hovered: backgroundPanel, $focused: primary, $selected: "transparent" },
secondary: { default: "transparent" },
destructive: { default: color("error") },
},
formfield: {
+6 -1
View File
@@ -208,6 +208,11 @@ export function Prompt(props: PromptProps) {
const config = useConfig().data
const dialog = useDialog()
const toast = useToast()
onCleanup(
data.session.failures.listen((failure) => {
toast.show({ title: "Prompt rejected", message: failure.reason, variant: "error" })
}),
)
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const history = usePromptHistory()
const stash = usePromptStash()
@@ -1304,7 +1309,7 @@ export function Prompt(props: PromptProps) {
return false
}
}
const error = await client.api.session
const error = await data.session
.prompt({
sessionID,
text: inputText,
+2 -2
View File
@@ -1,4 +1,4 @@
import { createData } from "@opencode-ai/client/solid"
import { createEngineData } from "@opencode-ai/client/solid"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
@@ -10,7 +10,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: () => {
const client = useClient()
const data = createData({
const data = createEngineData({
api: () => client.api,
event: client.event,
connection: client.connection,
@@ -1,5 +1,5 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { contextUsage, formatContextUsage } from "../../util/session"
import { useTerminalDimensions } from "@opentui/solid"
@@ -10,6 +10,7 @@ const money = new Intl.NumberFormat("en-US", {
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
const dimensions = useTerminalDimensions()
const [liveHovered, setLiveHovered] = createSignal(false)
const subagents = createMemo(() => {
if (!props.sessionID) return 0
const count = props.context.data.session
@@ -47,16 +48,34 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
<Match when={props.mode === "normal"}>
<Switch>
<Match when={live() || status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live() && shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
<box flexDirection="row" flexShrink={1} minWidth={0}>
<Show when={live()}>
<box
flexShrink={0}
onMouseOver={() => setLiveHovered(true)}
onMouseOut={() => setLiveHovered(false)}
onMouseUp={() => props.context.keymap.dispatch("session.child.first")}
>
<text
fg={liveHovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
wrapMode="none"
>
<Show when={shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
</Show>
<Show when={subagents()}>{(value) => <>{value()}</>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <>{value()}</>}</Show>
</text>
</box>
</Show>
<Show when={subagents()}>{(value) => <span>{value()}</span>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <span>{value()}</span>}</Show>
<Show when={live() && status().length > 0}> · </Show>
<Show when={status().length > 0}>{status().join(" · ")}</Show>
</text>
<Show when={status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live()}> · </Show>
{status().join(" · ")}
</text>
</Show>
</box>
</Match>
<Match when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
+79 -45
View File
@@ -14,9 +14,10 @@ import {
type ParentProps,
} from "solid-js"
import path from "path"
import { stat } from "fs/promises"
import { readFile, stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Page } from "@opencode-ai/plugin/tui/context"
import { Hash } from "@opencode-ai/util/hash"
import { resolveSlots, type Claim } from "./structure"
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
import { isDeepEqual } from "remeda"
@@ -78,6 +79,7 @@ type Registration = {
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
const PluginContext = createContext<Value>()
let sourceVersion = Date.now()
export function combineMarkdownRenderers(
sources: ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
@@ -107,6 +109,18 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
states: [] as ReadonlyArray<State>,
registrations: {} as Record<string, Registration>,
})
// One save can emit several watch events. Remember setup failures so those
// events do not repeatedly tear down and restore the last good generation.
const setupFailures = new Map<string, { version: string; options: Registration["options"]; error: string }>()
const sourceVersions = new Map<string, { digest: string; generation: number }>()
const sourceGeneration = async (entrypoint: string) => {
const digest = Hash.sha256(await readFile(new URL(entrypoint)))
const previous = sourceVersions.get(entrypoint)
if (previous?.digest === digest) return previous.generation
const generation = ++sourceVersion
sourceVersions.set(entrypoint, { digest, generation })
return generation
}
const markdown = createMemo(() =>
combineMarkdownRenderers(
Object.values(store.registrations).flatMap((registration) =>
@@ -114,15 +128,18 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
),
),
)
const clearContributions = (id: string) => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "markdown", reconcileStore({}))
}
const activate = async (id: string) => {
const item = store.registrations[id]
if (!item) return false
await deactivate(id)
batch(() => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "markdown", reconcileStore({}))
clearContributions(id)
setStore("registrations", id, "cleanups", [])
})
const owned: Dispose[] = []
@@ -150,12 +167,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
},
})
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "markdown", reconcileStore({}))
clearContributions(id)
if (item.target)
setupFailures.set(item.target, {
version: item.version,
options: snapshotOptions(item.options),
error: errorMessage(error),
})
throw error
})
if (cleanup) owned.push(async () => cleanup())
if (item.target && sameGeneration(setupFailures.get(item.target), item)) setupFailures.delete(item.target)
batch(() => {
setStore("registrations", id, "cleanups", owned)
setStore("registrations", id, "active", true)
@@ -179,9 +201,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
await disposeAll(cleanups).finally(() =>
batch(() => {
if (store.registrations[id]) {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "markdown", reconcileStore({}))
clearContributions(id)
}
setStore("states", (items) =>
items.map((state) =>
@@ -275,10 +295,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages, source.install).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}))
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sourceGeneration).catch(
(error) => ({
status: "failed" as const,
error: errorMessage(error),
}),
)
if (resolved.status === "unsupported") {
if (source.server) continue
failures.push({ target, status: "unsupported" })
@@ -292,17 +314,21 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
status: "failed",
error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
})
if (previous)
desired.set(previous.plugin.id, {
plugin: previous.plugin,
source: previous.source,
target,
version: previous.version,
options: previous.options,
enabled: previous.active,
})
if (previous) desired.set(previous.plugin.id, toDesired(previous))
continue
}
const setupFailure = setupFailures.get(target)
if (setupFailure && sameGeneration(setupFailure, { version: resolved.version, options }) && previous) {
failures.push({
target,
id: previous.plugin.id,
status: "failed",
error: previous.active ? `${setupFailure.error} (previous version still active)` : setupFailure.error,
})
desired.set(previous.plugin.id, toDesired(previous))
continue
}
setupFailures.delete(target)
desired.set(resolved.plugin.id, {
plugin: resolved.plugin,
source: "external",
@@ -335,11 +361,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// enabled derives from config directives alone, so config wins over
// manual dialog toggles on every reconcile — the same semantics
// config saves had before hot reload existed, just more frequent.
return (
registration.version !== item.version ||
!sameOptions(registration.options, item.options) ||
registration.active !== item.enabled
)
return !sameGeneration(registration, item) || registration.active !== item.enabled
})
// Swap: cleanup failures surface as a toast, never propagate, so one
@@ -348,22 +370,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
for (const id of changed) {
const item = desired.get(id)!
const registration = store.registrations[id]
const replaced =
!registration || registration.version !== item.version || !sameOptions(registration.options, item.options)
const replaced = !registration || !sameGeneration(registration, item)
// Snapshot the running version before it is overwritten: an import
// failure keeps last-good in the resolve phase, and a setup failure
// must not cost the previous version either.
const fallback: Desired | undefined =
replaced && registration
? {
plugin: registration.plugin,
source: registration.source,
target: registration.target,
version: registration.version,
options: registration.options,
enabled: registration.active,
}
: undefined
const fallback = replaced && registration ? toDesired(registration) : undefined
if (replaced) {
if (registration) await deactivateNoisily(id)
// In-place replacement keeps the registration's key position, which
@@ -564,6 +575,7 @@ async function resolvePlugin(
previous: Registration | undefined,
packages: PackageResolver,
install: boolean,
sourceGeneration: (entrypoint: string) => Promise<number>,
) {
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
@@ -571,9 +583,9 @@ async function resolvePlugin(
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
if (!entrypoint) return { status: "unsupported" as const }
// The cache-busted specifier doubles as the version: unique per entrypoint
// and mtime, so equal versions mean an identical module.
const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
// Content remains stable across the several mtimes one save may expose to
// filesystem watchers, while the generation keeps reverted modules fresh.
const version = local ? freshSpecifier(entrypoint, await sourceGeneration(entrypoint)) : entrypoint
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(version)
@@ -587,7 +599,7 @@ function toRegistration(item: Desired): Registration {
source: item.source,
target: item.target,
version: item.version,
options: item.options,
options: snapshotOptions(item.options),
active: false,
routes: {},
slots: {},
@@ -596,10 +608,32 @@ function toRegistration(item: Desired): Registration {
}
}
function toDesired(item: Registration): Desired {
return {
plugin: item.plugin,
source: item.source,
target: item.target,
version: item.version,
options: item.options,
enabled: item.active,
}
}
function sameOptions(a: Registration["options"], b: Registration["options"]) {
return isDeepEqual(a ?? null, b ?? null)
}
function sameGeneration(
a: Pick<Registration, "version" | "options"> | undefined,
b: Pick<Registration, "version" | "options">,
) {
return a?.version === b.version && sameOptions(a.options, b.options)
}
function snapshotOptions(options: Registration["options"]) {
return options ? structuredClone(unwrap(options)) : undefined
}
async function resolveLocal(url: URL) {
const info = await stat(url)
if (info.isFile()) return url.href
+7 -9
View File
@@ -45,15 +45,13 @@ export function localSource(spec: string, directory: string) {
return undefined
}
// Key local plugin imports by mtime so edited sources re-import fresh instead
// of hitting the ESM cache. Bun ignores query params when caching file:// URL
// imports, so bust with a plain path there; Node keys its cache on the full
// URL. Mirrors the core plugin supervisor's loader.
// The mtime is truncated to whole milliseconds: a fractional mtimeMs puts a
// dot in the query, and Bun's compiled binaries then skip runtime plugin
// hooks for the import, breaking JSX/solid rewriting for external plugins.
export function freshSpecifier(entrypoint: string, mtime: number) {
const version = Math.trunc(mtime)
// Key local plugin imports by a numeric source version so edited sources
// re-import fresh instead of hitting the ESM cache. Bun ignores query params
// when caching file:// URL imports, so bust with a plain path there; Node keys
// its cache on the full URL. Fractional versions break Bun's runtime JSX/solid
// plugin hooks, so always truncate them.
export function freshSpecifier(entrypoint: string, sourceVersion: number) {
const version = Math.trunc(sourceVersion)
if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${version}`
return `${entrypoint}?mtime=${version}`
}
+7 -5
View File
@@ -275,6 +275,9 @@ export function Session(props: { verticalTabsWidth: number }) {
const sessionTabs = useSessionTabs()
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
const [latestHovered, setLatestHovered] = createSignal(false)
createEffect(() => {
if (!awayFromBottom()) setLatestHovered(false)
})
const clearMessageNavigation = () => {
setNavigationSlack(0)
@@ -1196,16 +1199,15 @@ export function Session(props: { verticalTabsWidth: number }) {
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
<Show when={awayFromBottom()}>
<box
id="session-jump-to-latest"
paddingLeft={1}
paddingRight={1}
backgroundColor={
latestHovered() ? theme.background.action.primary.focused : theme.background.action.primary.default
}
onMouseOver={() => setLatestHovered(true)}
onMouseOut={() => setLatestHovered(false)}
onMouseUp={toBottom}
>
<text fg={latestHovered() ? theme.text.action.primary.focused : theme.text.action.primary.default}>
<text
fg={latestHovered() ? theme.text.action.secondary.hovered : theme.text.action.secondary.default}
>
Jump to latest
</text>
</box>
+12
View File
@@ -213,13 +213,25 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
const input = (event: SessionInboxEnqueued) => {
if (
event.data.sessionID === sessionID() &&
event.data.item.delivery !== "queue" &&
(event.data.item.type === "user" ||
(event.data.item.type === "synthetic" && event.data.item.payload.description?.trim()))
)
appendMessage(event.data.inboxID)
}
const delivery = (event: { data: { sessionID: string; inboxID: string; delivery: "steer" | "queue" } }) => {
if (event.data.sessionID !== sessionID()) return
if (event.data.delivery === "steer") return appendMessage(event.data.inboxID)
setRows(
produce((draft) => {
const index = draft.findIndex((row) => row.type === "message" && row.messageID === event.data.inboxID)
if (index !== -1) draft.splice(index, 1)
}),
)
}
const subscriptions = [
data.on("session.inbox.enqueued", input),
data.on("session.inbox.delivery.changed", delivery),
data.on("session.compaction.started", (event) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? event.id.replace(/^evt_/, "msg_"))
}),
+3 -12
View File
@@ -2592,18 +2592,7 @@ test("reconciles active session forms when the event stream reconnects", async (
test("settles pending tools when a live failure arrives", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message/msg_model_1")
return json({
data: {
id: "msg_model_1",
type: "model-switched",
previous: { id: "model-1", providerID: "provider-1", variant: "medium" },
model: { id: "model-1", providerID: "provider-1", variant: "high" },
time: { created: 0 },
},
})
}, events)
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
@@ -2630,6 +2619,7 @@ test("settles pending tools when a live failure arrives", async () => {
try {
await mounted
await sync.session.message.sync("session-1")
emitEvent(events, {
id: "evt_agent_1",
created: 0,
@@ -2645,6 +2635,7 @@ test("settles pending tools when a live failure arrives", async () => {
data: {
sessionID: "session-1",
model: { id: "model-1", providerID: "provider-1", variant: "high" },
previous: { id: "model-1", providerID: "provider-1", variant: "medium" },
},
})
emitEvent(events, {
@@ -27,7 +27,10 @@ test("scopes sessions to the active session location", async () => {
const active = "/tmp/opencode/project-b"
const events = createEventStream()
const requestedProjects: string[] = []
const calls = createFetch((url) => {
const calls = createFetch((url, request) => {
// The fixture's snapshot synthesizer reads through this handler; only app
// traffic belongs in the scoping assertions.
if (request.headers.get("x-fixture-synthetic")) return undefined
if (url.pathname === "/api/location") {
const directory = url.searchParams.get("location[directory]") ?? process.cwd()
const project = directory === active ? "proj_b" : "proj_a"
@@ -1,18 +1,26 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { RGBA, TextRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { Context } from "@opencode-ai/plugin/tui/context"
import { PromptFooter } from "../../src/feature-plugins/prompt/footer"
test("prompt footer separates simultaneous subagent, shell, and usage status", async () => {
const color = RGBA.fromInts(200, 200, 200)
const subdued = RGBA.fromInts(100, 100, 100)
const dispatched: string[] = []
const context = {
location: { directory: "/workspace" },
theme: { text: { default: color, subdued: color } },
theme: {
text: {
default: color,
subdued,
},
},
keymap: {
shortcuts: (id: string) =>
id === "session.child.first" ? ["ctrl+j"] : id === "command.palette.show" ? ["ctrl+p"] : [],
dispatch: (id: string) => dispatched.push(id),
},
data: {
session: {
@@ -39,6 +47,14 @@ test("prompt footer separates simultaneous subagent, shell, and usage status", a
await app.renderOnce()
expect(app.captureCharFrame()).toContain("ctrl+j 1 subagent · 1 shell · $1.00")
expect(app.captureCharFrame()).toContain("ctrl+p commands")
await app.mockMouse.moveTo(2, 0)
const live = app.renderer.root.getChildren()[0]?.getChildren()[0]?.getChildren()[0]
expect(live).toBeInstanceOf(TextRenderable)
expect((live as TextRenderable).fg.toInts()).toEqual(color.toInts())
await app.mockMouse.click(2, 0)
expect(dispatched).toEqual(["session.child.first"])
} finally {
app.renderer.destroy()
}
+82 -1
View File
@@ -16,6 +16,9 @@ export function createEventStream() {
const encoder = new TextEncoder()
const v2 = new Set<ReadableStreamDefaultController<Uint8Array>>()
const pending: Uint8Array[] = []
const logs = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>()
const logSeq = new Map<string, number>()
const logHistory = new Map<string, Array<{ readonly seq: number; readonly event: unknown }>>()
const response = (
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
queued: Uint8Array[],
@@ -27,7 +30,8 @@ export function createEventStream() {
start(controller) {
current = controller
controllers.add(controller)
if (initial) controller.enqueue(encoder.encode(`data: ${JSON.stringify(initial)}\n\n`))
const values = Array.isArray(initial) ? initial : initial ? [initial] : []
for (const value of values) controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
for (const chunk of queued.splice(0)) controller.enqueue(chunk)
},
cancel() {
@@ -53,13 +57,47 @@ export function createEventStream() {
return {
emit(event: OpenCodeEvent) {
send(v2, pending, event)
const sessionID =
"durable" in event
? event.durable.aggregateID
: "sessionID" in event.data && typeof event.data.sessionID === "string"
? event.data.sessionID
: undefined
if (!sessionID) return
const seq = (logSeq.get(sessionID) ?? 0) + 1
const item = "durable" in event ? { ...event, durable: { ...event.durable, seq } } : event
if ("durable" in event) {
logSeq.set(sessionID, seq)
logHistory.set(sessionID, [...(logHistory.get(sessionID) ?? []), { seq, event: item }])
}
const controllers = logs.get(sessionID)
if (controllers) send(controllers, [], item)
},
v2() {
return response(v2, pending, { id: "evt_connected", type: "server.connected", data: {} })
},
log(sessionID: string, after: number) {
const controllers = logs.get(sessionID) ?? new Set<ReadableStreamDefaultController<Uint8Array>>()
logs.set(sessionID, controllers)
return response(
controllers,
[],
[
...(logHistory.get(sessionID) ?? []).filter((entry) => entry.seq > after).map((entry) => entry.event),
{ type: "log.synced", aggregateID: sessionID, seq: logSeq.get(sessionID) ?? 0 },
],
)
},
seq(sessionID: string) {
return logSeq.get(sessionID) ?? 0
},
disconnect() {
for (const controller of v2) controller.close()
v2.clear()
for (const controllers of logs.values()) {
for (const controller of controllers) controller.close()
controllers.clear()
}
},
}
}
@@ -68,6 +106,8 @@ export type FetchHandler = (url: URL, request: Request) => Response | undefined
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventStream>) {
const session = [] as URL[]
const sessionEvents = events ?? createEventStream()
const snapshots = new Map<string, number>()
async function fetch(input: RequestInfo | URL, init?: RequestInit) {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
@@ -75,6 +115,47 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
const overridden = await override?.(url, request)
if (overridden) return overridden
if (url.pathname === "/api/event" && events) return events.v2()
const snapshot = url.pathname.match(/^\/api\/session\/([^/]+)\/snapshot$/)
if (snapshot) {
const sessionID = decodeURIComponent(snapshot[1])
const count = snapshots.get(sessionID) ?? 0
snapshots.set(sessionID, count + 1)
// Synthetic sub-reads reuse the test override as the data source but are
// not app traffic; the marker header lets request-observing tests skip them.
const read = async (path: string, fallback: unknown) => {
const target = new URL(path, url)
const response = await override?.(target, new Request(target, { headers: { "x-fixture-synthetic": "snapshot" } }))
if (!response) return fallback
const body = await response.json()
if (typeof body !== "object" || body === null || !("data" in body)) return fallback
return body.data
}
const children = await read(`/api/session?parentID=${encodeURIComponent(sessionID)}`, [])
const messages = await read(`/api/session/${encodeURIComponent(sessionID)}/message`, [])
return json({
data: {
session: await read(`/api/session/${encodeURIComponent(sessionID)}`, {
id: sessionID,
projectID: "proj_test",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}),
children: Array.isArray(children)
? children.filter(
(child) =>
typeof child === "object" && child !== null && "parentID" in child && child.parentID === sessionID,
)
: [],
inbox: await read(`/api/session/${encodeURIComponent(sessionID)}/inbox`, []),
messages: Array.isArray(messages) ? messages.toReversed() : [],
seq: count === 0 ? 0 : sessionEvents.seq(sessionID),
},
})
}
const log = url.pathname.match(/^\/api\/experimental\/session\/([^/]+)\/log$/)
if (log) return sessionEvents.log(decodeURIComponent(log[1]), Number(url.searchParams.get("after") ?? 0))
if (
[
+15 -5
View File
@@ -271,30 +271,40 @@ test("a save whose setup throws restores the previous version", async () => {
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const marker = path.join(tmp.path, "a.txt")
const markerB = path.join(tmp.path, "b.txt")
const source = path.join(directory, "a.ts")
const sourceB = path.join(directory, "b.ts")
await writeFile(source, lifecycleSource(marker, "test.a", "a1"))
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
const readB = () => readFile(markerB, "utf8")
expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
expect(await until(readB, (value) => value === "b1:setup\n")).toBe("b1:setup\n")
// The module imports fine but its setup throws — unlike an import failure,
// the swap has already torn down a1, so keep-last-good means restoring it.
await writeFile(
source,
`
const broken = `
export default {
id: "test.a",
setup: async () => {
throw new Error("setup boom")
},
}
`,
)
`
await writeFile(source, broken)
expect(await until(read, (value) => value === "a1:setup\na1:cleanup\na1:setup\n")).toBe(
"a1:setup\na1:cleanup\na1:setup\n",
)
// Duplicate notifications for unchanged contents must not retry the broken
// generation and cycle the restored plugin again.
await writeFile(source, broken)
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2"))
expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
expect(await read()).toBe("a1:setup\na1:cleanup\na1:setup\n")
// Fixing the file swaps out the restored version normally.
await writeFile(source, lifecycleSource(marker, "test.a", "a2"))
expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe(
@@ -154,6 +154,23 @@ test("merges partial documents with the selected OpenCode defaults", () => {
expect(theme.background.action.destructive.pressed).toBeInstanceOf(RGBA)
})
test("resolves custom secondary actions and falls back per mode", () => {
const document = {
version: 2,
light: {
text: { action: { secondary: { default: "#123456", $hovered: "#234567" } } },
},
dark: {},
} as const
const lightTheme = resolveSource(document, "light")
const darkTheme = resolveSource(document, "dark")
expect(lightTheme.text.action.secondary.default.toInts()).toEqual([18, 52, 86, 255])
expect(lightTheme.text.action.secondary.hovered.toInts()).toEqual([35, 69, 103, 255])
expect(darkTheme.text.action.secondary.default).toBe(darkTheme.text.subdued)
expect(darkTheme.text.action.secondary.hovered).toBe(darkTheme.text.default)
})
test("expands user structural fallbacks before merging defaults", () => {
const expanded = resolveSource(
{
@@ -214,6 +231,8 @@ test("resolves matched action variants and states", () => {
expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.text.action.primary.hovered).toBeInstanceOf(RGBA)
expect(theme.text.action.primary.selected).toBeInstanceOf(RGBA)
expect(theme.text.action.secondary.default).toBe(theme.text.subdued)
expect(theme.text.action.secondary.hovered).toBe(theme.text.default)
expect(theme.background.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.background.action.primary.hovered).toBeInstanceOf(RGBA)
expect(theme.background.action.primary.selected).toBeInstanceOf(RGBA)
@@ -30,6 +30,8 @@ test("migrates resolved V1 modes into V2 tokens", () => {
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.700")
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.600")
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
expect(migrated.light.text?.action?.secondary?.default).toBe("$text.subdued")
expect(migrated.light.text?.action?.secondary?.$hovered).toBe("$text.default")
expect(migrated.light.background?.action?.primary?.$selected).toBe("transparent")
expect(resolved.background.surface.offset.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundElement.toInts())
@@ -42,6 +44,8 @@ test("migrates resolved V1 modes into V2 tokens", () => {
expect(resolved.hue.interactive[800].toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.action.primary.selected.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.text.action.secondary.default.toInts()).toEqual(legacy.textMuted.toInts())
expect(resolved.text.action.secondary.hovered.toInts()).toEqual(legacy.text.toInts())
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contextual.elevated.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.contextual.elevated.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
@@ -52,25 +52,25 @@ Semantic values can reference another token by prefixing its path with `$`,
for example `$text.default`. Stateful tokens inherit their `default`
value when a state is omitted.
| Group | Tokens |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text` | `text.default`<br />`text.subdued` |
| `text.action` | `text.action.primary.default`<br />`text.action.primary.$hovered`<br />`text.action.primary.$focused`<br />`text.action.primary.$pressed`<br />`text.action.primary.$selected`<br />`text.action.primary.$disabled`<br />`text.action.destructive.default`<br />`text.action.destructive.$hovered`<br />`text.action.destructive.$focused`<br />`text.action.destructive.$pressed`<br />`text.action.destructive.$selected`<br />`text.action.destructive.$disabled` |
| `text.formfield` | `text.formfield.default`<br />`text.formfield.$hovered`<br />`text.formfield.$focused`<br />`text.formfield.$pressed`<br />`text.formfield.$selected`<br />`text.formfield.$disabled` |
| `text.feedback` | `text.feedback.error.default`<br />`text.feedback.error.subdued`<br />`text.feedback.warning.default`<br />`text.feedback.warning.subdued`<br />`text.feedback.success.default`<br />`text.feedback.success.subdued`<br />`text.feedback.info.default`<br />`text.feedback.info.subdued` |
| `background` | `background.default` |
| `background.surface` | `background.surface.offset`<br />`background.surface.overlay` |
| `background.action` | `background.action.primary.default`<br />`background.action.primary.$hovered`<br />`background.action.primary.$focused`<br />`background.action.primary.$pressed`<br />`background.action.primary.$selected`<br />`background.action.primary.$disabled`<br />`background.action.destructive.default`<br />`background.action.destructive.$hovered`<br />`background.action.destructive.$focused`<br />`background.action.destructive.$pressed`<br />`background.action.destructive.$selected`<br />`background.action.destructive.$disabled` |
| `background.formfield` | `background.formfield.default`<br />`background.formfield.$hovered`<br />`background.formfield.$focused`<br />`background.formfield.$pressed`<br />`background.formfield.$selected`<br />`background.formfield.$disabled` |
| `background.feedback` | `background.feedback.error.default`<br />`background.feedback.warning.default`<br />`background.feedback.success.default`<br />`background.feedback.info.default` |
| `border` | `border.default` |
| `scrollbar` | `scrollbar.default` |
| `diff.text` | `diff.text.added`<br />`diff.text.removed`<br />`diff.text.context`<br />`diff.text.hunkHeader` |
| `diff.background` | `diff.background.added`<br />`diff.background.removed`<br />`diff.background.context` |
| `diff.highlight` | `diff.highlight.added`<br />`diff.highlight.removed` |
| `diff.lineNumber` | `diff.lineNumber.text`<br />`diff.lineNumber.background.added`<br />`diff.lineNumber.background.removed` |
| `syntax` | `syntax.comment`<br />`syntax.keyword`<br />`syntax.function`<br />`syntax.variable`<br />`syntax.string`<br />`syntax.number`<br />`syntax.type`<br />`syntax.operator`<br />`syntax.punctuation` |
| `markdown` | `markdown.text`<br />`markdown.heading`<br />`markdown.link`<br />`markdown.linkText`<br />`markdown.code`<br />`markdown.blockQuote`<br />`markdown.emphasis`<br />`markdown.strong`<br />`markdown.horizontalRule`<br />`markdown.listItem`<br />`markdown.listEnumeration`<br />`markdown.image`<br />`markdown.imageText`<br />`markdown.codeBlock` |
| Group | Tokens |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text` | `text.default`<br />`text.subdued` |
| `text.action` | `text.action.primary.default`<br />`text.action.primary.$hovered`<br />`text.action.primary.$focused`<br />`text.action.primary.$pressed`<br />`text.action.primary.$selected`<br />`text.action.primary.$disabled`<br />`text.action.secondary.default`<br />`text.action.secondary.$hovered`<br />`text.action.secondary.$focused`<br />`text.action.secondary.$pressed`<br />`text.action.secondary.$selected`<br />`text.action.secondary.$disabled`<br />`text.action.destructive.default`<br />`text.action.destructive.$hovered`<br />`text.action.destructive.$focused`<br />`text.action.destructive.$pressed`<br />`text.action.destructive.$selected`<br />`text.action.destructive.$disabled` |
| `text.formfield` | `text.formfield.default`<br />`text.formfield.$hovered`<br />`text.formfield.$focused`<br />`text.formfield.$pressed`<br />`text.formfield.$selected`<br />`text.formfield.$disabled` |
| `text.feedback` | `text.feedback.error.default`<br />`text.feedback.error.subdued`<br />`text.feedback.warning.default`<br />`text.feedback.warning.subdued`<br />`text.feedback.success.default`<br />`text.feedback.success.subdued`<br />`text.feedback.info.default`<br />`text.feedback.info.subdued` |
| `background` | `background.default` |
| `background.surface` | `background.surface.offset`<br />`background.surface.overlay` |
| `background.action` | `background.action.primary.default`<br />`background.action.primary.$hovered`<br />`background.action.primary.$focused`<br />`background.action.primary.$pressed`<br />`background.action.primary.$selected`<br />`background.action.primary.$disabled`<br />`background.action.secondary.default`<br />`background.action.secondary.$hovered`<br />`background.action.secondary.$focused`<br />`background.action.secondary.$pressed`<br />`background.action.secondary.$selected`<br />`background.action.secondary.$disabled`<br />`background.action.destructive.default`<br />`background.action.destructive.$hovered`<br />`background.action.destructive.$focused`<br />`background.action.destructive.$pressed`<br />`background.action.destructive.$selected`<br />`background.action.destructive.$disabled` |
| `background.formfield` | `background.formfield.default`<br />`background.formfield.$hovered`<br />`background.formfield.$focused`<br />`background.formfield.$pressed`<br />`background.formfield.$selected`<br />`background.formfield.$disabled` |
| `background.feedback` | `background.feedback.error.default`<br />`background.feedback.warning.default`<br />`background.feedback.success.default`<br />`background.feedback.info.default` |
| `border` | `border.default` |
| `scrollbar` | `scrollbar.default` |
| `diff.text` | `diff.text.added`<br />`diff.text.removed`<br />`diff.text.context`<br />`diff.text.hunkHeader` |
| `diff.background` | `diff.background.added`<br />`diff.background.removed`<br />`diff.background.context` |
| `diff.highlight` | `diff.highlight.added`<br />`diff.highlight.removed` |
| `diff.lineNumber` | `diff.lineNumber.text`<br />`diff.lineNumber.background.added`<br />`diff.lineNumber.background.removed` |
| `syntax` | `syntax.comment`<br />`syntax.keyword`<br />`syntax.function`<br />`syntax.variable`<br />`syntax.string`<br />`syntax.number`<br />`syntax.type`<br />`syntax.operator`<br />`syntax.punctuation` |
| `markdown` | `markdown.text`<br />`markdown.heading`<br />`markdown.link`<br />`markdown.linkText`<br />`markdown.code`<br />`markdown.blockQuote`<br />`markdown.emphasis`<br />`markdown.strong`<br />`markdown.horizontalRule`<br />`markdown.listItem`<br />`markdown.listEnumeration`<br />`markdown.image`<br />`markdown.imageText`<br />`markdown.codeBlock` |
### Contexts