mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 06:53:27 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8db5d60d66 | |||
| f971097e3e |
@@ -6,7 +6,7 @@ permissions:
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [dev, beta, v2]
|
||||
branches: [dev, beta]
|
||||
paths:
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
|
||||
@@ -185,33 +185,6 @@ pmap -x <pid> | sort -k3 -nr | head -25
|
||||
|
||||
Heap serialization itself can temporarily increase RSS and allocator high-water marks, so record `ps`/`smaps_rollup` both before and after capture. Large anonymous mappings with a comparatively small live heap require native-allocation or allocator investigation; they cannot be explained from JavaScript retainer paths alone.
|
||||
|
||||
## CPU profiles
|
||||
|
||||
The CLI installs a `SIGPROF` listener on non-Windows processes in `packages/cli/src/cpu-profile.ts`. One signal starts a ten-second CPU profile and stops it automatically; additional signals are ignored while a profile is active. There is no CPU profile CLI flag or environment variable.
|
||||
|
||||
1. Get the PID from the health endpoint. For shared-service performance, target the server PID returned here rather than the short wrapper or TUI process:
|
||||
|
||||
```bash
|
||||
opencode2 api get /api/health
|
||||
```
|
||||
|
||||
Use `bun dev api get /api/health` instead when targeting the local/dev channel.
|
||||
|
||||
2. Start the capture:
|
||||
|
||||
```bash
|
||||
kill -PROF <server-pid>
|
||||
```
|
||||
|
||||
3. Wait for `CPU profile written` in the channel's log before opening the file. Profiles are written to the same log directory as `cpu-<pid>-<timestamp>.cpuprofile`; the log's `path=` field is authoritative:
|
||||
|
||||
```bash
|
||||
grep 'CPU profile' ~/.local/share/opencode/log/opencode.log | tail
|
||||
find ~/.local/share/opencode/log -maxdepth 1 -name 'cpu-<server-pid>-*.cpuprofile' -printf '%T@ %s %p\n' | sort -nr | head
|
||||
```
|
||||
|
||||
Use `opencode-local.log` for a local/dev process. Load the completed `.cpuprofile` in Chrome DevTools or another V8 CPU profile viewer and inspect the hottest functions, call stacks, and self time during the controlled workload.
|
||||
|
||||
## Debugger
|
||||
|
||||
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-IxkSw0gK/qkMHZGVHqjwgM9BKhzbQX6hyF9SWUNtpzg=",
|
||||
"aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=",
|
||||
"aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=",
|
||||
"x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo="
|
||||
"x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
|
||||
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
|
||||
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
|
||||
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
]).nodeModules.${stdenvNoCC.hostPlatform.system},
|
||||
}:
|
||||
let
|
||||
packageJson = lib.pipe ../packages/cli/package.json [
|
||||
packageJson = lib.pipe ../packages/opencode/package.json [
|
||||
builtins.readFile
|
||||
builtins.fromJSON
|
||||
];
|
||||
@@ -52,7 +52,7 @@ stdenvNoCC.mkDerivation {
|
||||
--cpu="${bunCpu}" \
|
||||
--os="${bunOs}" \
|
||||
--filter '!./' \
|
||||
--filter './packages/cli' \
|
||||
--filter './packages/opencode' \
|
||||
--filter './packages/desktop' \
|
||||
--filter './packages/app' \
|
||||
--frozen-lockfile \
|
||||
|
||||
+10
-8
@@ -48,13 +48,13 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
env.OPENCODE_DISABLE_MODELS_FETCH = true;
|
||||
env.OPENCODE_VERSION = finalAttrs.version;
|
||||
env.OPENCODE_CHANNEL = "prod";
|
||||
env.NODE_OPTIONS = "--max-old-space-size=4096";
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
cd ./packages/cli
|
||||
cd ./packages/opencode
|
||||
bun --bun ./script/build.ts --single --skip-install
|
||||
bun --bun ./script/schema.ts schema.json
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
@@ -62,9 +62,10 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
install -Dm755 dist/cli-*/bin/opencode2 $out/bin/opencode2
|
||||
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
|
||||
install -Dm644 schema.json $out/share/opencode/schema.json
|
||||
|
||||
wrapProgram $out/bin/opencode2 \
|
||||
wrapProgram $out/bin/opencode \
|
||||
--prefix PATH : ${
|
||||
lib.makeBinPath (
|
||||
[
|
||||
@@ -80,9 +81,9 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
|
||||
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
|
||||
# trick yargs into also generating zsh completions
|
||||
installShellCompletion --cmd opencode2 \
|
||||
--bash <($out/bin/opencode2 completion) \
|
||||
--zsh <(SHELL=/bin/zsh $out/bin/opencode2 completion)
|
||||
installShellCompletion --cmd opencode \
|
||||
--bash <($out/bin/opencode completion) \
|
||||
--zsh <(SHELL=/bin/zsh $out/bin/opencode completion)
|
||||
'';
|
||||
|
||||
nativeInstallCheckInputs = [
|
||||
@@ -94,6 +95,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
versionCheckProgramArg = "--version";
|
||||
|
||||
passthru = {
|
||||
jsonschema = "${placeholder "out"}/share/opencode/schema.json";
|
||||
env = finalAttrs.env;
|
||||
};
|
||||
|
||||
@@ -101,7 +103,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
description = "The open source coding agent";
|
||||
homepage = "https://opencode.ai";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "opencode2";
|
||||
mainProgram = "opencode";
|
||||
inherit (node_modules.meta) platforms;
|
||||
};
|
||||
})
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
@@ -76,7 +75,6 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export const baseten = define(profiles.baseten)
|
||||
|
||||
@@ -62,16 +62,13 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
})
|
||||
|
||||
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
const animation = controlledAnimationFrames(targetWindow)
|
||||
const route = targetWindow.document.createElement("section")
|
||||
const viewport = targetWindow.document.createElement("div")
|
||||
const route = document.createElement("section")
|
||||
const viewport = document.createElement("div")
|
||||
route.append(viewport)
|
||||
targetWindow.document.body.append(route)
|
||||
document.body.append(route)
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow,
|
||||
targetWindow: window,
|
||||
scrollOffset: 79_400,
|
||||
options: {
|
||||
horizontal: false,
|
||||
@@ -86,23 +83,20 @@ test("keeps checking until stale reset-delay callbacks can no longer win", async
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
try {
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
animation.run(16)
|
||||
expect(instance.scrollOffset).toBe(0)
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(1)
|
||||
expect(instance.scrollOffset).toBe(0)
|
||||
|
||||
instance.scrollOffset = 79_400
|
||||
animation.run(32)
|
||||
animation.run(48)
|
||||
instance.scrollOffset = 79_400
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
await frames(3)
|
||||
|
||||
expect(instance.scrollOffset).toBe(0)
|
||||
expect(calls).toEqual([0, 0])
|
||||
expect(animation.pending()).toBe(0)
|
||||
} finally {
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
}
|
||||
expect(instance.scrollOffset).toBe(0)
|
||||
expect(calls).toEqual([0, 0])
|
||||
cleanup?.()
|
||||
route.remove()
|
||||
})
|
||||
|
||||
test.each([
|
||||
@@ -241,29 +235,3 @@ function controlledMutations(targetWindow: Window) {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function controlledAnimationFrames(targetWindow: Window) {
|
||||
let time = 0
|
||||
let id = 0
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
Object.defineProperty(targetWindow.performance, "now", { value: () => time })
|
||||
Object.defineProperty(targetWindow, "requestAnimationFrame", {
|
||||
value: (callback: FrameRequestCallback) => {
|
||||
id += 1
|
||||
callbacks.set(id, callback)
|
||||
return id
|
||||
},
|
||||
})
|
||||
Object.defineProperty(targetWindow, "cancelAnimationFrame", {
|
||||
value: (frame: number) => callbacks.delete(frame),
|
||||
})
|
||||
return {
|
||||
run(at: number) {
|
||||
time = at
|
||||
const pending = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
pending.forEach((callback) => callback(at))
|
||||
},
|
||||
pending: () => callbacks.size,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Argument, Flag } from "effect/unstable/cli"
|
||||
import { Argument, Command, Flag } from "effect/unstable/cli"
|
||||
import { Spec } from "../framework/spec"
|
||||
import { GlobalFlags } from "./global-flags"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
@@ -342,4 +343,4 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
],
|
||||
})
|
||||
|
||||
export const Commands = Root
|
||||
export const Commands = { ...Root, spec: Root.spec.pipe(Command.withGlobalFlags(GlobalFlags.all)) }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export * as GlobalFlags from "./global-flags"
|
||||
|
||||
import { Flag, GlobalFlag } from "effect/unstable/cli"
|
||||
|
||||
export const CpuProfile = GlobalFlag.setting("cpu-profile")({
|
||||
flag: Flag.string("cpu-profile").pipe(
|
||||
Flag.withDescription("Write a CPU profile to this path when the process stops"),
|
||||
Flag.optional,
|
||||
),
|
||||
})
|
||||
|
||||
export const all = [CpuProfile] as const
|
||||
@@ -1,36 +1,10 @@
|
||||
export * as CpuProfile from "./cpu-profile"
|
||||
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, FileSystem, Queue } from "effect"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { Session } from "node:inspector"
|
||||
import path from "node:path"
|
||||
|
||||
export const listen = Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
if (process.platform === "win32") return
|
||||
const signals = yield* Queue.dropping<void>(1)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const handler = () => Queue.offerUnsafe(signals, undefined)
|
||||
process.on("SIGPROF", handler)
|
||||
return handler
|
||||
}),
|
||||
(handler) => Effect.sync(() => process.off("SIGPROF", handler)),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Queue.take(signals)
|
||||
const file = path.join(
|
||||
global.log,
|
||||
`cpu-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.cpuprofile`,
|
||||
)
|
||||
yield* run(file, Effect.sleep("10 seconds")).pipe(
|
||||
Effect.catchCause((cause) => Effect.logError("Failed to capture CPU profile", { path: file, cause })),
|
||||
)
|
||||
yield* Queue.poll(signals)
|
||||
}).pipe(Effect.forever, Effect.forkScoped({ startImmediately: true }))
|
||||
})
|
||||
|
||||
function run<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
|
||||
export function run<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
|
||||
const target = path.resolve(file)
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Effect, FileSystem, Scope } from "effect"
|
||||
import { Effect, FileSystem, Option, Scope } from "effect"
|
||||
import { Command } from "effect/unstable/cli"
|
||||
import { Spec } from "./spec"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Updater } from "../services/updater"
|
||||
import { Config } from "../config"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { GlobalFlags } from "../commands/global-flags"
|
||||
import { CpuProfile } from "../cpu-profile"
|
||||
import path from "node:path"
|
||||
|
||||
export type Input<Value> =
|
||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||
@@ -87,7 +90,21 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
const module = yield* Effect.promise(handler.load)
|
||||
return yield* module.default(input)
|
||||
const cpuProfile = Option.getOrUndefined(yield* GlobalFlags.CpuProfile)
|
||||
if (!cpuProfile) return yield* module.default(input)
|
||||
const target = path.resolve(cpuProfile)
|
||||
const previous = process.env.OPENCODE_CPU_PROFILE
|
||||
process.env.OPENCODE_CPU_PROFILE = target
|
||||
return yield* (
|
||||
node.name === "serve" ? CpuProfile.run(target, module.default(input)) : module.default(input)
|
||||
).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
|
||||
else process.env.OPENCODE_CPU_PROFILE = previous
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Config } from "./config"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Heap } from "./heap"
|
||||
import { CpuProfile } from "./cpu-profile"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
@@ -62,7 +61,6 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
|
||||
Effect.gen(function* () {
|
||||
yield* Heap.listen
|
||||
yield* CpuProfile.listen
|
||||
const runFork = Effect.runForkWith(yield* Effect.context<never>())
|
||||
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
|
||||
runFork(Effect.logError("uncaught exception", { cause, origin }))
|
||||
|
||||
@@ -110,6 +110,7 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
|
||||
...selfCommand(),
|
||||
"serve",
|
||||
"--service",
|
||||
...(process.env.OPENCODE_CPU_PROFILE ? ["--cpu-profile", process.env.OPENCODE_CPU_PROFILE] : []),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CpuProfile } from "../src/cpu-profile"
|
||||
|
||||
test("subscribes and unsubscribes SIGPROF with the CLI scope", async () => {
|
||||
const listeners = process.listenerCount("SIGPROF")
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* CpuProfile.listen
|
||||
expect(process.listenerCount("SIGPROF")).toBe(listeners + (process.platform === "win32" ? 0 : 1))
|
||||
}),
|
||||
).pipe(Effect.provideService(Global.Service, Global.make()), Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
expect(process.listenerCount("SIGPROF")).toBe(listeners)
|
||||
})
|
||||
@@ -19,6 +19,29 @@ test("managed service ports are stable per installation channel", () => {
|
||||
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
|
||||
})
|
||||
|
||||
test("managed service forwards the CPU profile path to the server", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-profile-"))
|
||||
const profile = path.join(root, "server.cpuprofile")
|
||||
try {
|
||||
const previous = process.env.OPENCODE_CPU_PROFILE
|
||||
process.env.OPENCODE_CPU_PROFILE = profile
|
||||
try {
|
||||
const options = await Effect.runPromise(
|
||||
ServiceConfig.options().pipe(
|
||||
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
expect(options.command.slice(-2)).toEqual(["--cpu-profile", profile])
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
|
||||
else process.env.OPENCODE_CPU_PROFILE = previous
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("local channel stores service config with the local service filename", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
|
||||
try {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"generate": "bun run script/build.ts",
|
||||
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api",
|
||||
"test": "bun test --timeout 5000",
|
||||
"typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.test.json"
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
|
||||
@@ -12,10 +12,10 @@ import type { Brand } from "effect"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import type { Event } from "@opencode-ai/schema/event"
|
||||
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
|
||||
import type { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { Event } from "@opencode-ai/schema/event"
|
||||
import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||
import type { Schema } from "effect"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
@@ -139,46 +139,36 @@ export type Endpoint5_5Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_5Output = Session.Info
|
||||
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
||||
|
||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly recent?: number | undefined }
|
||||
export type Endpoint5_6Output = {
|
||||
readonly session: Session.Info
|
||||
readonly children: ReadonlyArray<Session.Info>
|
||||
readonly inbox: ReadonlyArray<SessionInbox.Info>
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
readonly seq: Event.Seq
|
||||
}
|
||||
export type SessionSnapshotOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_6Output = void
|
||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||
|
||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_7Output = void
|
||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||
export type Endpoint5_7Output = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||
|
||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||
export type Endpoint5_8Output = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type Endpoint5_8Output = void
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
|
||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
export type Endpoint5_9Output = void
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
|
||||
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||
export type Endpoint5_10Output = void
|
||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
|
||||
export type Endpoint5_11Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||
export type Endpoint5_11Output = void
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
|
||||
export type Endpoint5_12Input = {
|
||||
export type Endpoint5_11Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: Workspace.ID | undefined
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
}
|
||||
export type Endpoint5_12Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
export type Endpoint5_11Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
|
||||
export type Endpoint5_13Input = {
|
||||
export type Endpoint5_12Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -189,10 +179,10 @@ export type Endpoint5_13Input = {
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_13Output = SessionInbox.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
export type Endpoint5_12Output = SessionInbox.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
|
||||
export type Endpoint5_14Input = {
|
||||
export type Endpoint5_13Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly command: string
|
||||
@@ -205,19 +195,19 @@ export type Endpoint5_14Input = {
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_14Output = SessionInbox.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
export type Endpoint5_13Output = SessionInbox.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
|
||||
export type Endpoint5_15Input = {
|
||||
export type Endpoint5_14Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly skill: Skill.ID
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_15Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
export type Endpoint5_14Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
|
||||
export type Endpoint5_16Input = {
|
||||
export type Endpoint5_15Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -226,98 +216,97 @@ export type Endpoint5_16Input = {
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_16Output = SessionInbox.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
export type Endpoint5_15Output = SessionInbox.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
|
||||
export type Endpoint5_17Input = {
|
||||
export type Endpoint5_16Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: Event.ID | undefined
|
||||
readonly command: string
|
||||
}
|
||||
export type Endpoint5_17Output = void
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||
export type Endpoint5_16Output = void
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
|
||||
export type Endpoint5_18Input = {
|
||||
export type Endpoint5_17Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
}
|
||||
export type Endpoint5_18Output = SessionInbox.Compaction
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||
export type Endpoint5_17Output = SessionInbox.Compaction
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||
|
||||
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_19Output = void
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_18Output = void
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||
|
||||
export type Endpoint5_20Input = {
|
||||
export type Endpoint5_19Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly files?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_20Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
export type Endpoint5_19Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
|
||||
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_20Output = void
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
|
||||
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_21Output = void
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
|
||||
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_22Output = void
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
||||
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
||||
|
||||
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_23Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_24Output = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_24Output = void
|
||||
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
|
||||
|
||||
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_25Output = void
|
||||
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
|
||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_26Output = void
|
||||
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_27Output = void
|
||||
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||
|
||||
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_28Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_28Input,
|
||||
) => Effect.Effect<Endpoint5_28Output, E>
|
||||
input: Endpoint5_27Input,
|
||||
) => Effect.Effect<Endpoint5_27Output, E>
|
||||
|
||||
export type Endpoint5_29Input = {
|
||||
export type Endpoint5_28Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly key: InstructionEntry.Key
|
||||
readonly value: Schema.Json
|
||||
}
|
||||
export type Endpoint5_29Output = void
|
||||
export type Endpoint5_28Output = void
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_28Input,
|
||||
) => Effect.Effect<Endpoint5_28Output, E>
|
||||
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_29Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_29Input,
|
||||
) => Effect.Effect<Endpoint5_29Output, E>
|
||||
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_30Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_30Input,
|
||||
) => Effect.Effect<Endpoint5_30Output, E>
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_30Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||
|
||||
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_31Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
||||
|
||||
export type Endpoint5_32Input = {
|
||||
export type Endpoint5_31Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly after?: Event.Seq | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
readonly ephemeral?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_32Output =
|
||||
export type Endpoint5_31Output =
|
||||
| (
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -910,101 +899,24 @@ export type Endpoint5_32Output =
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.usage.updated"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly cost: number & Brand.Brand<"Money.USD">
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.text.delta"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly ordinal: number
|
||||
readonly delta: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.reasoning.delta"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly ordinal: number
|
||||
readonly delta: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.tool.input.delta"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly delta: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.tool.progress"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly metadata: { readonly [x: string]: Schema.Json }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.compaction.delta"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly text: string }
|
||||
}
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_32Input) => Stream.Stream<Endpoint5_32Output, E>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
|
||||
|
||||
export type Endpoint5_33Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
|
||||
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
|
||||
export type Endpoint5_32Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
|
||||
|
||||
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_33Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||
|
||||
export type Endpoint5_34Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_34Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_34Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||
|
||||
export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_35Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
|
||||
|
||||
export type Endpoint5_36Input = { readonly sessionID: Session.ID; readonly variables: { readonly [x: string]: string } }
|
||||
export type Endpoint5_36Output = void
|
||||
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_36Input) => Effect.Effect<Endpoint5_36Output, E>
|
||||
export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly variables: { readonly [x: string]: string } }
|
||||
export type Endpoint5_35Output = void
|
||||
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
@@ -1013,7 +925,6 @@ export interface SessionApi<E = never> {
|
||||
readonly export: SessionExportOperation<E>
|
||||
readonly active: SessionActiveOperation<E>
|
||||
readonly get: SessionGetOperation<E>
|
||||
readonly snapshot: SessionSnapshotOperation<E>
|
||||
readonly remove: SessionRemoveOperation<E>
|
||||
readonly fork: SessionForkOperation<E>
|
||||
readonly switchAgent: SessionSwitchAgentOperation<E>
|
||||
|
||||
@@ -86,8 +86,6 @@ import type {
|
||||
Endpoint5_34Output,
|
||||
Endpoint5_35Input,
|
||||
Endpoint5_35Output,
|
||||
Endpoint5_36Input,
|
||||
Endpoint5_36Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
Endpoint7_0Input,
|
||||
@@ -352,56 +350,48 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
|
||||
|
||||
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
|
||||
preserveEffect<Endpoint5_6Output>()(
|
||||
raw["session.snapshot"]({ params: { sessionID: input["sessionID"] }, query: { recent: input["recent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
|
||||
preserveEffect<Endpoint5_7Output>()(
|
||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
||||
preserveEffect<Endpoint5_8Output>()(
|
||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
||||
preserveEffect<Endpoint5_8Output>()(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
||||
preserveEffect<Endpoint5_9Output>()(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
||||
preserveEffect<Endpoint5_10Output>()(
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
||||
preserveEffect<Endpoint5_11Output>()(
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
raw["session.move"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -420,8 +410,8 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
raw["session.command"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -442,16 +432,16 @@ const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
raw["session.skill"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
raw["session.synthetic"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -468,16 +458,16 @@ const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
raw["session.shell"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], command: input["command"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
raw["session.compact"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], delivery: input["delivery"] },
|
||||
@@ -487,13 +477,13 @@ const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||
preserveEffect<Endpoint5_19Output>()(
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||
preserveEffect<Endpoint5_20Output>()(
|
||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||
preserveEffect<Endpoint5_19Output>()(
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
@@ -503,19 +493,27 @@ const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||
preserveEffect<Endpoint5_20Output>()(
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
||||
preserveEffect<Endpoint5_21Output>()(
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
||||
preserveEffect<Endpoint5_22Output>()(
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||
preserveEffect<Endpoint5_23Output>()(
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -523,70 +521,62 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
|
||||
|
||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||
preserveEffect<Endpoint5_24Output>()(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||
preserveEffect<Endpoint5_25Output>()(
|
||||
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveEffect<Endpoint5_26Output>()(
|
||||
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveEffect<Endpoint5_31Output>()(
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveStream<Endpoint5_32Output>()(
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveStream<Endpoint5_31Output>()(
|
||||
Stream.unwrap(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
|
||||
query: { after: input["after"], follow: input["follow"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||
@@ -594,29 +584,29 @@ const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
preserveEffect<Endpoint5_33Output>()(
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveEffect<Endpoint5_32Output>()(
|
||||
raw["session.interrupt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { continue: input["continue"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||
preserveEffect<Endpoint5_34Output>()(
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
preserveEffect<Endpoint5_33Output>()(
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
|
||||
preserveEffect<Endpoint5_35Output>()(
|
||||
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||
preserveEffect<Endpoint5_34Output>()(
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_36 = (raw: RawClient["server.session"]) => (input: Endpoint5_36Input) =>
|
||||
preserveEffect<Endpoint5_36Output>()(
|
||||
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
|
||||
preserveEffect<Endpoint5_35Output>()(
|
||||
raw["session.environment"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { variables: input["variables"] },
|
||||
@@ -630,30 +620,29 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
export: Endpoint5_3(raw),
|
||||
active: Endpoint5_4(raw),
|
||||
get: Endpoint5_5(raw),
|
||||
snapshot: Endpoint5_6(raw),
|
||||
remove: Endpoint5_7(raw),
|
||||
fork: Endpoint5_8(raw),
|
||||
switchAgent: Endpoint5_9(raw),
|
||||
switchModel: Endpoint5_10(raw),
|
||||
rename: Endpoint5_11(raw),
|
||||
move: Endpoint5_12(raw),
|
||||
prompt: Endpoint5_13(raw),
|
||||
command: Endpoint5_14(raw),
|
||||
skill: Endpoint5_15(raw),
|
||||
synthetic: Endpoint5_16(raw),
|
||||
shell: Endpoint5_17(raw),
|
||||
compact: Endpoint5_18(raw),
|
||||
wait: Endpoint5_19(raw),
|
||||
revert: { stage: Endpoint5_20(raw), clear: Endpoint5_21(raw), commit: Endpoint5_22(raw) },
|
||||
context: Endpoint5_23(raw),
|
||||
inbox: { list: Endpoint5_24(raw), cancel: Endpoint5_25(raw), steer: Endpoint5_26(raw), queue: Endpoint5_27(raw) },
|
||||
instructions: { entry: { list: Endpoint5_28(raw), put: Endpoint5_29(raw), remove: Endpoint5_30(raw) } },
|
||||
generate: Endpoint5_31(raw),
|
||||
log: Endpoint5_32(raw),
|
||||
interrupt: Endpoint5_33(raw),
|
||||
background: Endpoint5_34(raw),
|
||||
message: Endpoint5_35(raw),
|
||||
environment: Endpoint5_36(raw),
|
||||
remove: Endpoint5_6(raw),
|
||||
fork: Endpoint5_7(raw),
|
||||
switchAgent: Endpoint5_8(raw),
|
||||
switchModel: Endpoint5_9(raw),
|
||||
rename: Endpoint5_10(raw),
|
||||
move: Endpoint5_11(raw),
|
||||
prompt: Endpoint5_12(raw),
|
||||
command: Endpoint5_13(raw),
|
||||
skill: Endpoint5_14(raw),
|
||||
synthetic: Endpoint5_15(raw),
|
||||
shell: Endpoint5_16(raw),
|
||||
compact: Endpoint5_17(raw),
|
||||
wait: Endpoint5_18(raw),
|
||||
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
||||
context: Endpoint5_22(raw),
|
||||
inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
|
||||
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
|
||||
generate: Endpoint5_30(raw),
|
||||
log: Endpoint5_31(raw),
|
||||
interrupt: Endpoint5_32(raw),
|
||||
background: Endpoint5_33(raw),
|
||||
message: Endpoint5_34(raw),
|
||||
environment: Endpoint5_35(raw),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
|
||||
@@ -20,8 +20,6 @@ import type {
|
||||
SessionActiveOutput,
|
||||
SessionGetInput,
|
||||
SessionGetOutput,
|
||||
SessionSnapshotInput,
|
||||
SessionSnapshotOutput,
|
||||
SessionRemoveInput,
|
||||
SessionRemoveOutput,
|
||||
SessionForkInput,
|
||||
@@ -516,18 +514,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
snapshot: (input: SessionSnapshotInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionSnapshotOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/snapshot`,
|
||||
query: { recent: input["recent"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 500, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
remove: (input: SessionRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionRemoveOutput>(
|
||||
{
|
||||
@@ -857,9 +843,9 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`,
|
||||
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
|
||||
query: { after: input["after"], follow: input["follow"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 409, 401, 400],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -512,51 +512,6 @@ export type SessionRevertCommitted = {
|
||||
data: { sessionID: string; to: string }
|
||||
}
|
||||
|
||||
export type SessionTextDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.text.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
|
||||
}
|
||||
|
||||
export type SessionReasoningDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.reasoning.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
|
||||
}
|
||||
|
||||
export type SessionToolInputDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.input.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; id: string; delta: string }
|
||||
}
|
||||
|
||||
export type SessionToolProgress = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.progress"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; id: string; metadata: { [x: string]: JsonValue } }
|
||||
}
|
||||
|
||||
export type SessionCompactionDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; text: string }
|
||||
}
|
||||
|
||||
export type ModelsDevRefreshed = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -602,6 +557,51 @@ export type AgentUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type SessionTextDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.text.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
|
||||
}
|
||||
|
||||
export type SessionReasoningDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.reasoning.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
|
||||
}
|
||||
|
||||
export type SessionToolInputDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.input.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; id: string; delta: string }
|
||||
}
|
||||
|
||||
export type SessionToolProgress = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.progress"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; id: string; metadata: { [x: string]: JsonValue } }
|
||||
}
|
||||
|
||||
export type SessionCompactionDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; text: string }
|
||||
}
|
||||
|
||||
export type FilesystemChanged = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2001,15 +2001,7 @@ export type FormCreated = {
|
||||
data: { form: FormInfo1 }
|
||||
}
|
||||
|
||||
export type SessionLogItem =
|
||||
| SessionEventDurable
|
||||
| SessionUsageUpdated
|
||||
| SessionTextDelta
|
||||
| SessionReasoningDelta
|
||||
| SessionToolInputDelta
|
||||
| SessionToolProgress
|
||||
| SessionCompactionDelta
|
||||
| EventLogSynced
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
|
||||
export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields }
|
||||
|
||||
@@ -2019,16 +2011,6 @@ export type FormInfo = { id: string; sessionID: string; title: string; metadata?
|
||||
|
||||
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
|
||||
|
||||
export type SessionSnapshotResponse = {
|
||||
data: {
|
||||
session: SessionInfo
|
||||
children: Array<SessionInfo>
|
||||
inbox: Array<SessionInboxInfo>
|
||||
messages: Array<SessionMessageInfo>
|
||||
seq: number
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessagesResponse = {
|
||||
data: Array<SessionMessageInfo>
|
||||
cursor: { previous?: string | null; next?: string | null }
|
||||
@@ -2243,16 +2225,6 @@ export const isInstructionEntryValueTooLargeError = (value: unknown): value is I
|
||||
"_tag" in value &&
|
||||
value["_tag"] === "InstructionEntryValueTooLargeError"
|
||||
|
||||
export type SeqUnavailableError = {
|
||||
readonly _tag: "SeqUnavailableError"
|
||||
readonly sessionID: string
|
||||
readonly after: number
|
||||
readonly head?: number | undefined
|
||||
readonly message: string
|
||||
}
|
||||
export const isSeqUnavailableError = (value: unknown): value is SeqUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SeqUnavailableError"
|
||||
|
||||
export type ProviderNotFoundError = {
|
||||
readonly _tag: "ProviderNotFoundError"
|
||||
readonly providerID: string
|
||||
@@ -3331,13 +3303,6 @@ export type SessionGetInput = { readonly sessionID: { readonly sessionID: string
|
||||
|
||||
export type SessionGetOutput = { data: SessionInfo }["data"]
|
||||
|
||||
export type SessionSnapshotInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly recent?: { readonly recent?: number | undefined }["recent"]
|
||||
}
|
||||
|
||||
export type SessionSnapshotOutput = SessionSnapshotResponse["data"]
|
||||
|
||||
export type SessionRemoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionRemoveOutput = void
|
||||
@@ -3976,21 +3941,8 @@ export type SessionGenerateOutput = SessionGenerateResponse["data"]
|
||||
|
||||
export type SessionLogInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly after?: {
|
||||
readonly after?: number | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
readonly ephemeral?: boolean | undefined
|
||||
}["after"]
|
||||
readonly follow?: {
|
||||
readonly after?: number | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
readonly ephemeral?: boolean | undefined
|
||||
}["follow"]
|
||||
readonly ephemeral?: {
|
||||
readonly after?: number | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
readonly ephemeral?: boolean | undefined
|
||||
}["ephemeral"]
|
||||
readonly after?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["after"]
|
||||
readonly follow?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["follow"]
|
||||
}
|
||||
|
||||
export type SessionLogOutput = SessionLogItem
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { batch, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import type { Signal } from "solid-js"
|
||||
import type { OpenCodeClient, OpenCodeEvent, SessionPromptInput } from "../promise"
|
||||
import { isSeqUnavailableError } from "../promise"
|
||||
import { createData } from "./data"
|
||||
import type { CreateDataInput } from "./data"
|
||||
import { Engine } from "./engine/engine"
|
||||
|
||||
type SessionApi = Pick<OpenCodeClient["session"], "snapshot" | "log" | "prompt">
|
||||
|
||||
// The legacy layer reconciles handed-off values into its own store, mutating
|
||||
// them in place — so anything shared with it must be a copy, never engine
|
||||
// state. Engine data is plain JSON, so a recursive copy suffices.
|
||||
function clone<T>(value: T): T {
|
||||
if (value === null || typeof value !== "object") return value
|
||||
if (Array.isArray(value)) return value.map(clone) as T
|
||||
const copy: Record<string, unknown> = {}
|
||||
for (const key in value) copy[key] = clone(value[key as keyof T])
|
||||
return copy as T
|
||||
}
|
||||
|
||||
const ambientSessionEvents = new Set<OpenCodeEvent["type"]>([
|
||||
"session.created",
|
||||
"session.deleted",
|
||||
"session.renamed",
|
||||
"session.execution.started",
|
||||
"session.execution.succeeded",
|
||||
"session.execution.failed",
|
||||
"session.execution.interrupted",
|
||||
])
|
||||
|
||||
/** How many recent messages a session snapshot fetch requests. */
|
||||
export const SNAPSHOT_RECENT = 200
|
||||
|
||||
export function createEngineTransport(api: () => SessionApi): Engine.SessionTransport {
|
||||
return {
|
||||
snapshot(sessionID) {
|
||||
return api().snapshot({ sessionID, recent: SNAPSHOT_RECENT })
|
||||
},
|
||||
async *stream(sessionID, after, signal) {
|
||||
try {
|
||||
for await (const item of api().log(
|
||||
{ sessionID, after, follow: true, ephemeral: true },
|
||||
signal ? { signal } : undefined,
|
||||
)) {
|
||||
if (item.type !== "session.forked") yield item
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSeqUnavailableError(error)) throw new Engine.SeqUnavailable()
|
||||
throw error
|
||||
}
|
||||
},
|
||||
async submit(input) {
|
||||
try {
|
||||
await api().prompt({ ...input.request, sessionID: input.sessionID, id: input.id })
|
||||
} catch (error) {
|
||||
if (isTypedError(error)) throw new Engine.SubmitRejected(error.message)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createEngineData(config: CreateDataInput) {
|
||||
const legacy = createData({
|
||||
...config,
|
||||
event: {
|
||||
on: config.event.on,
|
||||
listen(handler) {
|
||||
return config.event.listen((event) => {
|
||||
if (event.name.startsWith("session.") && !ambientSessionEvents.has(event.name)) return
|
||||
handler(event)
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
const engines = new Map<string, Promise<Engine.SessionEngine>>()
|
||||
const families = new Set<string>()
|
||||
const invalidated = new Set<string>()
|
||||
const failures = new Set<(failure: Engine.IntentFailure) => void>()
|
||||
const cleanups = new Set<() => void>()
|
||||
const transport = createEngineTransport(() => config.api().session)
|
||||
let connected = false
|
||||
|
||||
// One signal per session holding the engine's immutable view. The fold is a
|
||||
// persistent structure — unchanged subtrees keep their object identity
|
||||
// across publishes — so keyed consumers get row stability from reference
|
||||
// equality, and the engine's publish guard already drops identity-unchanged
|
||||
// views. Reactivity is per session: any change to a session's view re-runs
|
||||
// that session's readers.
|
||||
const signals = new Map<string, Signal<Engine.SessionView | undefined>>()
|
||||
const viewSignal = (sessionID: string) => {
|
||||
const existing = signals.get(sessionID)
|
||||
if (existing) return existing
|
||||
const created = createSignal<Engine.SessionView | undefined>(undefined)
|
||||
signals.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
const view = (sessionID: string) => viewSignal(sessionID)[0]()
|
||||
|
||||
const update = (sessionID: string, next: Engine.SessionView) => {
|
||||
const [read, write] = viewSignal(sessionID)
|
||||
const previous = untrack(read)
|
||||
batch(() => {
|
||||
write(next)
|
||||
if (next.session !== previous?.session) {
|
||||
const current = legacy.session.get(sessionID)
|
||||
if (!current || current.time.updated <= next.session.time.updated) {
|
||||
legacy.session.remember(clone(next.session))
|
||||
}
|
||||
}
|
||||
if (families.has(sessionID) && next.children !== previous?.children) {
|
||||
next.children.forEach((child) => legacy.session.remember(clone(child)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const ensure = (sessionID: string) => {
|
||||
const existing = engines.get(sessionID)
|
||||
if (existing) return existing
|
||||
const created = Engine.createSessionEngine(sessionID, transport).then((engine) => {
|
||||
update(sessionID, engine.view())
|
||||
cleanups.add(engine.subscribe((view) => update(sessionID, view)))
|
||||
cleanups.add(engine.subscribeFailures((failure) => failures.forEach((listener) => listener(failure))))
|
||||
return engine
|
||||
})
|
||||
engines.set(sessionID, created)
|
||||
void created.catch(() => engines.delete(sessionID))
|
||||
return created
|
||||
}
|
||||
|
||||
const sync = async (sessionID: string) => {
|
||||
const engine = await ensure(sessionID)
|
||||
if (invalidated.delete(sessionID)) await engine.refresh()
|
||||
await engine.ready()
|
||||
}
|
||||
|
||||
cleanups.add(
|
||||
config.event.on("server.connected", () => {
|
||||
if (!connected) {
|
||||
connected = true
|
||||
return
|
||||
}
|
||||
engines.forEach((engine) => void engine.then((handle) => handle.refresh()).catch(() => undefined))
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
cleanups.forEach((cleanup) => cleanup())
|
||||
engines.forEach((engine) => void engine.then((handle) => handle.stop()))
|
||||
})
|
||||
|
||||
return {
|
||||
...legacy,
|
||||
on: config.event.on,
|
||||
listen: config.event.listen,
|
||||
session: {
|
||||
...legacy.session,
|
||||
async sync(sessionID: string, options?: { readonly children?: boolean }) {
|
||||
if (options?.children) families.add(sessionID)
|
||||
await sync(sessionID)
|
||||
if (!options?.children) return
|
||||
view(sessionID)?.children.forEach((child) => legacy.session.remember(clone(child)))
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
invalidated.add(sessionID)
|
||||
},
|
||||
status(sessionID: string) {
|
||||
if (view(sessionID)?.active === "running") return "running"
|
||||
return legacy.session.status(sessionID)
|
||||
},
|
||||
input: {
|
||||
list(sessionID: string) {
|
||||
return (
|
||||
view(sessionID)
|
||||
?.pending.filter((item) => item.type !== "compaction")
|
||||
.map((item) => item.id) ?? legacy.session.input.list(sessionID)
|
||||
)
|
||||
},
|
||||
has(sessionID: string, inboxID: string) {
|
||||
return (
|
||||
view(sessionID)?.pending.some((item) => item.type !== "compaction" && item.id === inboxID) ??
|
||||
legacy.session.input.has(sessionID, inboxID)
|
||||
)
|
||||
},
|
||||
},
|
||||
pending: {
|
||||
list(sessionID: string) {
|
||||
void ensure(sessionID)
|
||||
return [...(view(sessionID)?.pending ?? [])]
|
||||
},
|
||||
sync(sessionID: string) {
|
||||
return sync(sessionID)
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
invalidated.add(sessionID)
|
||||
},
|
||||
},
|
||||
message: {
|
||||
list(sessionID: string) {
|
||||
void ensure(sessionID)
|
||||
return [...(view(sessionID)?.messages ?? [])]
|
||||
},
|
||||
get(sessionID: string, messageID: string) {
|
||||
void ensure(sessionID)
|
||||
return view(sessionID)?.messages.find((message) => message.id === messageID)
|
||||
},
|
||||
sync(sessionID: string) {
|
||||
return sync(sessionID)
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
invalidated.add(sessionID)
|
||||
},
|
||||
},
|
||||
async prompt(input: SessionPromptInput) {
|
||||
return (await ensure(input.sessionID)).submit({
|
||||
id: input.id ?? undefined,
|
||||
text: input.text,
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
skills: input.skills,
|
||||
metadata: input.metadata,
|
||||
delivery: input.delivery,
|
||||
resume: input.resume,
|
||||
})
|
||||
},
|
||||
failures: {
|
||||
listen(listener: (failure: Engine.IntentFailure) => void) {
|
||||
failures.add(listener)
|
||||
return () => failures.delete(listener)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function isTypedError(error: unknown): error is { readonly _tag: string; readonly message: string } {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"_tag" in error &&
|
||||
typeof error._tag === "string" &&
|
||||
"message" in error &&
|
||||
typeof error.message === "string"
|
||||
)
|
||||
}
|
||||
|
||||
export type EngineData = ReturnType<typeof createEngineData>
|
||||
@@ -1,498 +0,0 @@
|
||||
import type {
|
||||
EventLogSynced,
|
||||
SessionCompactionDelta,
|
||||
SessionInboxInfo,
|
||||
SessionInboxItem,
|
||||
SessionMessageInfo,
|
||||
SessionPromptInput,
|
||||
SessionReasoningDelta,
|
||||
SessionTextDelta,
|
||||
SessionToolInputDelta,
|
||||
SessionToolProgress,
|
||||
SessionUsageUpdated,
|
||||
} from "../../promise"
|
||||
import { SessionFold } from "./fold"
|
||||
import type { DurableSessionEvent, SessionFoldState, SessionSnapshot } from "./fold"
|
||||
|
||||
export type EphemeralSessionEvent =
|
||||
| SessionTextDelta
|
||||
| SessionReasoningDelta
|
||||
| SessionToolInputDelta
|
||||
| SessionToolProgress
|
||||
| SessionCompactionDelta
|
||||
| SessionUsageUpdated
|
||||
|
||||
export type SessionStreamItem = DurableSessionEvent | EphemeralSessionEvent | EventLogSynced
|
||||
|
||||
export type Intent = {
|
||||
readonly id: string
|
||||
readonly item: Extract<SessionInboxItem, { readonly type: "user" | "synthetic" }>
|
||||
readonly request: Omit<SessionPromptInput, "sessionID" | "id">
|
||||
readonly created: number
|
||||
}
|
||||
|
||||
export type SubmitInput = {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly request: Intent["request"]
|
||||
}
|
||||
|
||||
export type IntentFailure = {
|
||||
readonly intent: Intent
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
export class SubmitRejected extends Error {
|
||||
readonly _tag = "SubmitRejected"
|
||||
|
||||
constructor(readonly reason: string) {
|
||||
super(reason)
|
||||
}
|
||||
}
|
||||
|
||||
export class SeqUnavailable extends Error {
|
||||
readonly _tag = "SeqUnavailable"
|
||||
}
|
||||
|
||||
export interface SessionTransport {
|
||||
readonly snapshot: (sessionID: string) => Promise<SessionSnapshot>
|
||||
readonly stream: (sessionID: string, after: number, signal?: AbortSignal) => AsyncIterable<SessionStreamItem>
|
||||
readonly submit: (input: SubmitInput) => Promise<void>
|
||||
}
|
||||
|
||||
export type SessionView = SessionFoldState & {
|
||||
readonly pending: ReadonlyArray<SessionInboxInfo>
|
||||
}
|
||||
|
||||
export interface SessionEngine {
|
||||
readonly sessionID: string
|
||||
readonly view: () => SessionView
|
||||
readonly submit: (input: Intent["request"] & { readonly id?: string }) => Intent
|
||||
readonly subscribe: (listener: (view: SessionView) => void) => () => void
|
||||
readonly subscribeFailures: (listener: (failure: IntentFailure) => void) => () => void
|
||||
readonly ready: () => Promise<void>
|
||||
readonly refresh: () => Promise<void>
|
||||
readonly settled: () => Promise<void>
|
||||
readonly stop: () => void
|
||||
}
|
||||
|
||||
export type SessionEngineOptions = {
|
||||
readonly makeID?: () => string
|
||||
readonly now?: () => number
|
||||
readonly reconnect?: () => Promise<void>
|
||||
}
|
||||
|
||||
type Overlay = ReadonlyMap<string, OverlayEntry>
|
||||
|
||||
type OverlayEntry =
|
||||
| { readonly type: "text"; readonly value: string }
|
||||
| { readonly type: "reasoning"; readonly value: string }
|
||||
| { readonly type: "tool-input"; readonly value: string }
|
||||
| { readonly type: "tool-progress"; readonly metadata: SessionToolProgress["data"]["metadata"] }
|
||||
| { readonly type: "compaction"; readonly value: string }
|
||||
| { readonly type: "usage"; readonly value: SessionUsageUpdated["data"] }
|
||||
|
||||
type EngineState = {
|
||||
readonly folded: SessionFoldState
|
||||
readonly outbox: ReadonlyArray<Intent>
|
||||
readonly overlay: Overlay
|
||||
readonly synced: boolean
|
||||
}
|
||||
|
||||
export async function createSessionEngine(
|
||||
sessionID: string,
|
||||
transport: SessionTransport,
|
||||
options: SessionEngineOptions = {},
|
||||
): Promise<SessionEngine> {
|
||||
let counter = 0
|
||||
const makeID = options.makeID ?? (() => `msg_${Date.now().toString(36)}_${++counter}`)
|
||||
const now = options.now ?? Date.now
|
||||
const reconnect = options.reconnect ?? (() => new Promise<void>((resolve) => setTimeout(resolve, 100)))
|
||||
let state: EngineState = {
|
||||
folded: SessionFold.fromSnapshot(await transport.snapshot(sessionID)),
|
||||
outbox: [],
|
||||
overlay: new Map(),
|
||||
synced: false,
|
||||
}
|
||||
const listeners = new Set<(view: SessionView) => void>()
|
||||
const failureListeners = new Set<(failure: IntentFailure) => void>()
|
||||
const settled = new Set<() => void>()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
let sent: string | undefined
|
||||
let stopped = false
|
||||
let sending = false
|
||||
let refreshing: Promise<void> | undefined
|
||||
const abort = new AbortController()
|
||||
|
||||
const publish = (next: EngineState) => {
|
||||
const previous = state
|
||||
state = next
|
||||
// Views derive from folded/outbox/overlay only, so synced flips and stale
|
||||
// replays (where the fold returns its input) need no render or notify.
|
||||
if (next.folded !== previous.folded || next.outbox !== previous.outbox || next.overlay !== previous.overlay) {
|
||||
const view = render(state)
|
||||
listeners.forEach((listener) => listener(view))
|
||||
}
|
||||
if (state.outbox.length > 0) return
|
||||
settled.forEach((resolve) => resolve())
|
||||
settled.clear()
|
||||
}
|
||||
|
||||
const applySnapshot = (snapshot: SessionSnapshot, synced = false) => {
|
||||
const folded = SessionFold.fromSnapshot(snapshot)
|
||||
const acknowledged = new Set([
|
||||
...folded.messages.map((message) => message.id),
|
||||
...folded.inbox.map((item) => item.id),
|
||||
])
|
||||
publish({
|
||||
folded,
|
||||
outbox: state.outbox.filter((intent) => !acknowledged.has(intent.id)),
|
||||
overlay: new Map(),
|
||||
synced,
|
||||
})
|
||||
}
|
||||
|
||||
const applyDurable = (event: DurableSessionEvent) => {
|
||||
if (event.type === "session.inbox.enqueued" && sent === event.data.inboxID) sent = undefined
|
||||
publish({
|
||||
folded: SessionFold.apply(state.folded, event),
|
||||
outbox:
|
||||
event.type === "session.inbox.enqueued"
|
||||
? state.outbox.filter((intent) => intent.id !== event.data.inboxID)
|
||||
: state.outbox,
|
||||
overlay: clearOverlay(state.overlay, event),
|
||||
synced: state.synced,
|
||||
})
|
||||
send()
|
||||
}
|
||||
|
||||
const reject = (intent: Intent, reason: string) => {
|
||||
publish({ ...state, outbox: state.outbox.filter((item) => item.id !== intent.id) })
|
||||
failureListeners.forEach((listener) => listener({ intent, reason }))
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
if (!state.synced || sending || stopped) return
|
||||
const intent = state.outbox[0]
|
||||
if (!intent || sent === intent.id) return
|
||||
sending = true
|
||||
sent = intent.id
|
||||
void (async () => {
|
||||
try {
|
||||
await transport.submit({ id: intent.id, sessionID, request: intent.request })
|
||||
} catch (error) {
|
||||
if (!(error instanceof SubmitRejected)) return
|
||||
sent = undefined
|
||||
reject(intent, error.reason)
|
||||
}
|
||||
})().finally(() => {
|
||||
sending = false
|
||||
send()
|
||||
})
|
||||
}
|
||||
|
||||
const sync = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
for await (const item of transport.stream(sessionID, state.folded.seq, abort.signal)) {
|
||||
if (stopped) return
|
||||
if (item.type === "log.synced") {
|
||||
// A marker past the fold means the server skipped events it could not
|
||||
// replay for this cursor; recover through a fresh snapshot.
|
||||
if (item.seq !== undefined && item.seq > state.folded.seq) throw new SeqUnavailable()
|
||||
sent = undefined
|
||||
publish({ ...state, synced: true })
|
||||
ready.resolve()
|
||||
send()
|
||||
continue
|
||||
}
|
||||
if ("durable" in item) {
|
||||
applyDurable(item)
|
||||
continue
|
||||
}
|
||||
publish({ ...state, overlay: applyOverlay(state.overlay, item) })
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SeqUnavailable) {
|
||||
try {
|
||||
applySnapshot(await transport.snapshot(sessionID))
|
||||
} catch {
|
||||
await reconnect()
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (stopped) return
|
||||
publish({ ...state, synced: false })
|
||||
await reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
void sync()
|
||||
|
||||
return {
|
||||
sessionID,
|
||||
view: () => render(state),
|
||||
submit(input) {
|
||||
const intent: Intent = {
|
||||
id: input.id ?? makeID(),
|
||||
created: now(),
|
||||
request: input,
|
||||
item: {
|
||||
type: "user",
|
||||
delivery: input.delivery ?? "steer",
|
||||
payload: {
|
||||
text: input.text,
|
||||
agents: input.agents?.map((agent) => ({ ...agent })),
|
||||
metadata: input.metadata,
|
||||
},
|
||||
},
|
||||
}
|
||||
publish({ ...state, outbox: [...state.outbox, intent] })
|
||||
send()
|
||||
return intent
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
subscribeFailures(listener) {
|
||||
failureListeners.add(listener)
|
||||
return () => failureListeners.delete(listener)
|
||||
},
|
||||
ready: () => ready.promise,
|
||||
refresh() {
|
||||
if (refreshing) return refreshing
|
||||
refreshing = transport
|
||||
.snapshot(sessionID)
|
||||
.then((snapshot) => {
|
||||
if (snapshot.seq < state.folded.seq) return
|
||||
applySnapshot(snapshot, state.synced)
|
||||
send()
|
||||
})
|
||||
.finally(() => {
|
||||
refreshing = undefined
|
||||
})
|
||||
return refreshing
|
||||
},
|
||||
settled() {
|
||||
if (state.outbox.length === 0) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => settled.add(resolve))
|
||||
},
|
||||
stop() {
|
||||
stopped = true
|
||||
abort.abort()
|
||||
publish({ ...state, synced: false })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Render runs per ephemeral event, so everything an event did not touch must
|
||||
// keep its reference: the adapter diffs consecutive views by identity to
|
||||
// decide what to write into the reactive store. Both caches key on persistent
|
||||
// inputs (a fold, outbox, or usage entry keeps its identity until it actually
|
||||
// changes), so per-delta renders only reapply the overlay.
|
||||
export function render(state: Pick<EngineState, "folded" | "outbox" | "overlay">): SessionView {
|
||||
const base = renderBase(state.folded, state.outbox)
|
||||
return {
|
||||
...state.folded,
|
||||
session: usageSession(state.folded, state.overlay.get("usage")),
|
||||
messages: applyOverlayToMessages(base.messages, state.overlay),
|
||||
pending: base.pending,
|
||||
}
|
||||
}
|
||||
|
||||
const bases = new WeakMap<SessionFoldState, ReturnType<typeof buildBase>>()
|
||||
|
||||
function renderBase(folded: SessionFoldState, outbox: EngineState["outbox"]) {
|
||||
const hit = bases.get(folded)
|
||||
if (hit && hit.outbox === outbox) return hit
|
||||
const base = buildBase(folded, outbox)
|
||||
bases.set(folded, base)
|
||||
return base
|
||||
}
|
||||
|
||||
function buildBase(folded: SessionFoldState, outbox: EngineState["outbox"]) {
|
||||
const pending =
|
||||
outbox.length === 0
|
||||
? folded.inbox
|
||||
: [
|
||||
...folded.inbox,
|
||||
...outbox.map(
|
||||
(intent): SessionInboxInfo => ({
|
||||
id: intent.id,
|
||||
sessionID: folded.session.id,
|
||||
timeCreated: intent.created,
|
||||
...intent.item,
|
||||
}),
|
||||
),
|
||||
]
|
||||
const appended = pendingMessages(folded, pending)
|
||||
return {
|
||||
outbox,
|
||||
pending,
|
||||
messages: appended.length === 0 ? folded.messages : [...folded.messages, ...appended],
|
||||
}
|
||||
}
|
||||
|
||||
function pendingMessages(folded: SessionFoldState, pending: ReadonlyArray<SessionInboxInfo>) {
|
||||
if (pending.length === 0) return []
|
||||
const messageIDs = new Set(folded.messages.map((message) => message.id))
|
||||
return pending.flatMap((item): ReadonlyArray<SessionMessageInfo> => {
|
||||
if (item.type !== "compaction" && item.delivery === "queue") return []
|
||||
if (messageIDs.has(item.id)) return []
|
||||
const message = SessionFold.messageFromInbox(item)
|
||||
return message ? [message] : []
|
||||
})
|
||||
}
|
||||
|
||||
const usageSessions = new WeakMap<
|
||||
Extract<OverlayEntry, { type: "usage" }>,
|
||||
{ base: SessionFoldState["session"]; session: SessionFoldState["session"] }
|
||||
>()
|
||||
|
||||
function usageSession(folded: SessionFoldState, entry: OverlayEntry | undefined) {
|
||||
if (entry?.type !== "usage") return folded.session
|
||||
const hit = usageSessions.get(entry)
|
||||
if (hit && hit.base === folded.session) return hit.session
|
||||
const session = { ...folded.session, cost: entry.value.cost, tokens: entry.value.tokens }
|
||||
usageSessions.set(entry, { base: folded.session, session })
|
||||
return session
|
||||
}
|
||||
|
||||
function applyOverlay(overlay: Overlay, event: EphemeralSessionEvent): Overlay {
|
||||
const next = new Map(overlay)
|
||||
switch (event.type) {
|
||||
case "session.text.delta": {
|
||||
const key = partKey("text", event.data.assistantMessageID, event.data.ordinal)
|
||||
const current = next.get(key)
|
||||
next.set(key, {
|
||||
type: "text",
|
||||
value: (current?.type === "text" ? current.value : "") + event.data.delta,
|
||||
})
|
||||
return next
|
||||
}
|
||||
case "session.reasoning.delta": {
|
||||
const key = partKey("reasoning", event.data.assistantMessageID, event.data.ordinal)
|
||||
const current = next.get(key)
|
||||
next.set(key, {
|
||||
type: "reasoning",
|
||||
value: (current?.type === "reasoning" ? current.value : "") + event.data.delta,
|
||||
})
|
||||
return next
|
||||
}
|
||||
case "session.tool.input.delta": {
|
||||
const key = toolKey("tool-input", event.data.assistantMessageID, event.data.id)
|
||||
const current = next.get(key)
|
||||
next.set(key, {
|
||||
type: "tool-input",
|
||||
value: (current?.type === "tool-input" ? current.value : "") + event.data.delta,
|
||||
})
|
||||
return next
|
||||
}
|
||||
case "session.tool.progress":
|
||||
next.set(toolKey("tool-progress", event.data.assistantMessageID, event.data.id), {
|
||||
type: "tool-progress",
|
||||
metadata: event.data.metadata,
|
||||
})
|
||||
return next
|
||||
case "session.compaction.delta": {
|
||||
const current = next.get("compaction")
|
||||
next.set("compaction", {
|
||||
type: "compaction",
|
||||
value: (current?.type === "compaction" ? current.value : "") + event.data.text,
|
||||
})
|
||||
return next
|
||||
}
|
||||
case "session.usage.updated":
|
||||
next.set("usage", { type: "usage", value: event.data })
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
function clearOverlay(overlay: Overlay, event: DurableSessionEvent): Overlay {
|
||||
switch (event.type) {
|
||||
case "session.text.ended":
|
||||
return removeOverlay(overlay, partKey("text", event.data.assistantMessageID, event.data.ordinal))
|
||||
case "session.reasoning.ended":
|
||||
return removeOverlay(overlay, partKey("reasoning", event.data.assistantMessageID, event.data.ordinal))
|
||||
case "session.tool.input.ended":
|
||||
case "session.tool.called":
|
||||
return removeOverlay(overlay, toolKey("tool-input", event.data.assistantMessageID, event.data.id))
|
||||
case "session.tool.success":
|
||||
case "session.tool.failed":
|
||||
return removeOverlay(overlay, toolKey("tool-progress", event.data.assistantMessageID, event.data.id))
|
||||
case "session.compaction.ended":
|
||||
case "session.compaction.failed":
|
||||
return removeOverlay(overlay, "compaction")
|
||||
case "session.step.ended":
|
||||
case "session.step.failed":
|
||||
case "session.usage.recorded":
|
||||
return removeOverlay(overlay, "usage")
|
||||
default:
|
||||
return overlay
|
||||
}
|
||||
}
|
||||
|
||||
function removeOverlay(overlay: Overlay, key: string): Overlay {
|
||||
if (!overlay.has(key)) return overlay
|
||||
const next = new Map(overlay)
|
||||
next.delete(key)
|
||||
return next
|
||||
}
|
||||
|
||||
function applyOverlayToMessages(messages: ReadonlyArray<SessionMessageInfo>, overlay: Overlay) {
|
||||
if (overlay.size === 0) return messages
|
||||
// Remap only the messages the overlay actually touches so everything else
|
||||
// keeps its identity.
|
||||
const compacting = overlay.has("compaction")
|
||||
const touched = new Set<string>()
|
||||
overlay.forEach((_, key) => {
|
||||
const id = keyMessageID(key)
|
||||
if (id) touched.add(id)
|
||||
})
|
||||
if (touched.size === 0 && !compacting) return messages
|
||||
return messages.map((message): SessionMessageInfo => {
|
||||
if (message.type === "compaction" && message.status === "running") {
|
||||
if (!compacting) return message
|
||||
const entry = overlay.get("compaction")
|
||||
return entry?.type === "compaction" ? { ...message, summary: message.summary + entry.value } : message
|
||||
}
|
||||
if (message.type !== "assistant" || !touched.has(message.id)) return message
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
const content = message.content.map((part) => {
|
||||
if (part.type === "text") {
|
||||
const entry = overlay.get(partKey("text", message.id, ordinals.text++))
|
||||
return entry?.type === "text" ? { ...part, text: part.text + entry.value } : part
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
const entry = overlay.get(partKey("reasoning", message.id, ordinals.reasoning++))
|
||||
return entry?.type === "reasoning" ? { ...part, text: part.text + entry.value } : part
|
||||
}
|
||||
const input = overlay.get(toolKey("tool-input", message.id, part.id))
|
||||
if (input?.type === "tool-input" && part.state.status === "streaming")
|
||||
return { ...part, state: { ...part.state, input: part.state.input + input.value } }
|
||||
const progress = overlay.get(toolKey("tool-progress", message.id, part.id))
|
||||
if (progress?.type === "tool-progress" && part.state.status === "running")
|
||||
return { ...part, state: { ...part.state, metadata: progress.metadata } }
|
||||
return part
|
||||
})
|
||||
return content.some((part, index) => part !== message.content[index]) ? { ...message, content } : message
|
||||
})
|
||||
}
|
||||
|
||||
function partKey(type: "text" | "reasoning", messageID: string, ordinal: number) {
|
||||
return `${type}:${messageID}:${ordinal}`
|
||||
}
|
||||
|
||||
function toolKey(type: "tool-input" | "tool-progress", messageID: string, toolID: string) {
|
||||
return `${type}:${messageID}:${toolID}`
|
||||
}
|
||||
|
||||
// Second segment of a part or tool key; undefined for the segmentless
|
||||
// "compaction" and "usage" keys.
|
||||
function keyMessageID(key: string) {
|
||||
return key.split(":")[1]
|
||||
}
|
||||
|
||||
export * as Engine from "./engine"
|
||||
@@ -1,583 +0,0 @@
|
||||
import type {
|
||||
SessionEventDurable,
|
||||
SessionInboxInfo,
|
||||
SessionInfo,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
TokenUsageInfo,
|
||||
} from "../../promise"
|
||||
|
||||
export type SessionFoldState = {
|
||||
readonly session: SessionInfo
|
||||
readonly children: ReadonlyArray<SessionInfo>
|
||||
readonly inbox: ReadonlyArray<SessionInboxInfo>
|
||||
readonly messages: ReadonlyArray<SessionMessageInfo>
|
||||
readonly active: "idle" | "running"
|
||||
readonly deleted: boolean
|
||||
readonly seq: number
|
||||
}
|
||||
|
||||
export type SessionSnapshot = Omit<SessionFoldState, "active" | "deleted"> & {
|
||||
readonly active?: SessionFoldState["active"]
|
||||
}
|
||||
|
||||
export type DurableSessionEvent = Exclude<SessionEventDurable, { readonly type: "session.forked" }>
|
||||
|
||||
export function fromSnapshot(snapshot: SessionSnapshot): SessionFoldState {
|
||||
return { ...snapshot, active: snapshot.active ?? "idle", deleted: false }
|
||||
}
|
||||
|
||||
export function apply(state: SessionFoldState, event: DurableSessionEvent): SessionFoldState {
|
||||
if (event.durable.seq <= state.seq) return state
|
||||
const current = { ...state, seq: event.durable.seq }
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
return current
|
||||
case "session.deleted":
|
||||
return { ...current, deleted: true }
|
||||
case "session.usage.recorded":
|
||||
return { ...current, session: addUsage(state.session, event.data.cost, event.data.tokens, event.created) }
|
||||
case "session.agent.selected":
|
||||
return append(
|
||||
{
|
||||
...current,
|
||||
session: {
|
||||
...state.session,
|
||||
agent: event.data.agent,
|
||||
time: { ...state.session.time, updated: event.created },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: messageID(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous: event.data.previous ?? state.session.agent,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
case "session.model.selected":
|
||||
return append(
|
||||
{
|
||||
...current,
|
||||
session: {
|
||||
...state.session,
|
||||
model: event.data.model,
|
||||
time: { ...state.session.time, updated: event.created },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: messageID(event.id),
|
||||
type: "model-switched",
|
||||
model: event.data.model,
|
||||
previous: event.data.previous ?? state.session.model,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
case "session.moved":
|
||||
return append(
|
||||
{
|
||||
...current,
|
||||
session: {
|
||||
...state.session,
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
time: { ...state.session.time, updated: event.created },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: messageID(event.id),
|
||||
type: "location-switched",
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
previous: {
|
||||
location: state.session.location,
|
||||
projectID: state.session.projectID,
|
||||
subpath: state.session.subpath,
|
||||
},
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
case "session.renamed":
|
||||
return {
|
||||
...current,
|
||||
session: { ...state.session, title: event.data.title, time: { ...state.session.time, updated: event.created } },
|
||||
}
|
||||
case "session.inbox.enqueued":
|
||||
return {
|
||||
...current,
|
||||
session: { ...state.session, time: { ...state.session.time, updated: event.created } },
|
||||
inbox: state.inbox.some((item) => item.id === event.data.inboxID)
|
||||
? state.inbox
|
||||
: [
|
||||
...state.inbox,
|
||||
{
|
||||
id: event.data.inboxID,
|
||||
sessionID: event.data.sessionID,
|
||||
timeCreated: event.created,
|
||||
...event.data.item,
|
||||
},
|
||||
],
|
||||
}
|
||||
case "session.inbox.delivered": {
|
||||
const item = state.inbox.find((item) => item.id === event.data.inboxID)
|
||||
const next = { ...current, inbox: state.inbox.filter((item) => item.id !== event.data.inboxID) }
|
||||
if (!item) return next
|
||||
const delivered = messageFromInbox(item, event.created)
|
||||
return delivered ? append(next, delivered) : next
|
||||
}
|
||||
case "session.inbox.cancelled":
|
||||
return { ...current, inbox: state.inbox.filter((item) => item.id !== event.data.inboxID) }
|
||||
case "session.inbox.delivery.changed":
|
||||
return {
|
||||
...current,
|
||||
inbox: state.inbox.map((item) =>
|
||||
item.id === event.data.inboxID ? { ...item, delivery: event.data.delivery } : item,
|
||||
),
|
||||
}
|
||||
case "session.execution.started":
|
||||
return { ...current, active: "running" }
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
case "session.execution.interrupted":
|
||||
return { ...updateActiveAssistant(current, (message) => without(message, "retry")), active: "idle" }
|
||||
case "session.instructions.updated":
|
||||
if (event.data.text === undefined) return current
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.synthetic":
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "synthetic",
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.skill.activated":
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "skill",
|
||||
skill: event.data.id,
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.started":
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "shell",
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.ended":
|
||||
return updateMessage(
|
||||
current,
|
||||
(message) => message.type === "shell" && message.shellID === event.data.shell.id,
|
||||
(message) => {
|
||||
if (message.type !== "shell") return message
|
||||
return {
|
||||
...message,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
output: event.data.output,
|
||||
time: { ...message.time, completed: event.created },
|
||||
}
|
||||
},
|
||||
true,
|
||||
)
|
||||
case "session.step.started": {
|
||||
const existing = state.messages.some((message) => message.id === event.data.assistantMessageID)
|
||||
if (existing)
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...without(message, "retry", "error", "finish"),
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
time: without(message.time, "completed"),
|
||||
snapshot: event.data.snapshot ? { ...message.snapshot, start: event.data.snapshot } : message.snapshot,
|
||||
}))
|
||||
return append(
|
||||
updateActiveAssistant(current, (message) => ({
|
||||
...without(message, "retry"),
|
||||
time: { ...message.time, completed: event.created },
|
||||
})),
|
||||
{
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
metadata: event.metadata,
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
}
|
||||
case "session.step.ended":
|
||||
return withUsage(
|
||||
updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
finish: event.data.finish,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
time: { ...message.time, completed: event.created },
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...message.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: message.snapshot,
|
||||
})),
|
||||
event.data.cost,
|
||||
event.data.tokens,
|
||||
event.created,
|
||||
)
|
||||
case "session.step.failed": {
|
||||
const failed = updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...without(message, "retry"),
|
||||
finish: "error",
|
||||
error: event.data.error,
|
||||
cost: event.data.cost ?? message.cost,
|
||||
tokens: event.data.tokens ?? message.tokens,
|
||||
time: { ...message.time, completed: event.created },
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...message.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: message.snapshot,
|
||||
}))
|
||||
if (event.data.cost === undefined || event.data.tokens === undefined) return failed
|
||||
return withUsage(failed, event.data.cost, event.data.tokens, event.created)
|
||||
}
|
||||
case "session.text.started":
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
content: insertOrdinal(message.content, "text", event.data.ordinal, { type: "text", text: "" }),
|
||||
}))
|
||||
case "session.text.ended":
|
||||
return updateContent(current, event.data.assistantMessageID, "text", event.data.ordinal, (part) => {
|
||||
const next = { ...part, text: event.data.text }
|
||||
return event.data.state === undefined ? without(next, "state") : { ...next, state: event.data.state }
|
||||
})
|
||||
case "session.reasoning.started":
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
content: insertOrdinal(message.content, "reasoning", event.data.ordinal, {
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
}))
|
||||
case "session.reasoning.ended":
|
||||
return updateContent(current, event.data.assistantMessageID, "reasoning", event.data.ordinal, (part) => ({
|
||||
...part,
|
||||
text: event.data.text,
|
||||
state: event.data.state ?? part.state,
|
||||
time: { created: part.time?.created ?? event.created, completed: event.created },
|
||||
}))
|
||||
case "session.tool.input.started":
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
content: [
|
||||
...message.content,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
}))
|
||||
case "session.tool.input.ended":
|
||||
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) =>
|
||||
part.state.status === "streaming" ? { ...part, state: { ...part.state, input: event.data.text } } : part,
|
||||
)
|
||||
case "session.tool.called":
|
||||
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => ({
|
||||
...part,
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
state: { status: "running", input: event.data.input, metadata: {} },
|
||||
time: { ...part.time, ran: event.created },
|
||||
}))
|
||||
case "session.tool.success":
|
||||
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => {
|
||||
if (part.state.status !== "running") return part
|
||||
return {
|
||||
...part,
|
||||
executed: event.data.executed || part.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: part.state.input,
|
||||
content: event.data.content,
|
||||
metadata: event.data.metadata,
|
||||
},
|
||||
time: { ...part.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.tool.failed":
|
||||
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => {
|
||||
if (part.state.status !== "streaming" && part.state.status !== "running") return part
|
||||
return {
|
||||
...part,
|
||||
executed: event.data.executed || part.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof part.state.input === "string" ? {} : part.state.input,
|
||||
content: event.data.content,
|
||||
metadata: event.data.metadata,
|
||||
},
|
||||
time: { ...part.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.retry.scheduled":
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error },
|
||||
}))
|
||||
case "session.compaction.started":
|
||||
return append(
|
||||
{
|
||||
...current,
|
||||
inbox: event.data.inputID ? state.inbox.filter((item) => item.id !== event.data.inputID) : state.inbox,
|
||||
},
|
||||
{
|
||||
id: event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
case "session.compaction.ended": {
|
||||
const running = state.messages.findLast(
|
||||
(message) => message.type === "compaction" && message.status === "running",
|
||||
)
|
||||
if (!running)
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return updateMessage(
|
||||
current,
|
||||
(message) => message.id === running.id,
|
||||
(message) => ({
|
||||
...message,
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case "session.compaction.failed": {
|
||||
const running = state.messages.findLast(
|
||||
(message) => message.type === "compaction" && message.status === "running",
|
||||
)
|
||||
const failed = {
|
||||
id: running?.id ?? event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction" as const,
|
||||
status: "failed" as const,
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
metadata: running?.metadata ?? event.metadata,
|
||||
time: running?.time ?? { created: event.created },
|
||||
}
|
||||
const next = {
|
||||
...current,
|
||||
inbox: event.data.inputID ? state.inbox.filter((item) => item.id !== event.data.inputID) : state.inbox,
|
||||
}
|
||||
return running
|
||||
? updateMessage(
|
||||
next,
|
||||
(message) => message.id === running.id,
|
||||
() => failed,
|
||||
)
|
||||
: append(next, failed)
|
||||
}
|
||||
case "session.revert.staged":
|
||||
return {
|
||||
...current,
|
||||
session: {
|
||||
...state.session,
|
||||
revert: event.data.revert,
|
||||
time: { ...state.session.time, updated: event.created },
|
||||
},
|
||||
}
|
||||
case "session.revert.cleared":
|
||||
return {
|
||||
...current,
|
||||
session: without({ ...state.session, time: { ...state.session.time, updated: event.created } }, "revert"),
|
||||
}
|
||||
case "session.revert.committed":
|
||||
return {
|
||||
...current,
|
||||
session: without({ ...state.session, time: { ...state.session.time, updated: event.created } }, "revert"),
|
||||
messages: state.messages.filter((message) => message.id < event.data.to),
|
||||
inbox: state.inbox.filter((item) => item.id < event.data.to),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function messageID(eventID: string) {
|
||||
return eventID.replace(/^evt_/, "msg_")
|
||||
}
|
||||
|
||||
// Clearing a field must delete the key, not assign undefined: fold state
|
||||
// round-trips through JSON snapshots, which cannot represent undefined-valued
|
||||
// keys, so replay and fromSnapshot must agree on key presence.
|
||||
function without<T extends object, K extends keyof T>(value: T, ...keys: ReadonlyArray<K>): Omit<T, K> {
|
||||
const next = { ...value }
|
||||
for (const key of keys) delete next[key]
|
||||
return next
|
||||
}
|
||||
|
||||
export function messageFromInbox(item: SessionInboxInfo, created = item.timeCreated): SessionMessageInfo | undefined {
|
||||
if (item.type === "user") return { id: item.id, type: "user", ...item.payload, time: { created } }
|
||||
if (item.type === "synthetic") return { id: item.id, type: "synthetic", ...item.payload, time: { created } }
|
||||
}
|
||||
|
||||
function append(state: SessionFoldState, item: SessionMessageInfo) {
|
||||
if (state.messages.some((message) => message.id === item.id)) return state
|
||||
return { ...state, messages: [...state.messages, item] }
|
||||
}
|
||||
|
||||
function updateMessage(
|
||||
state: SessionFoldState,
|
||||
predicate: (message: SessionMessageInfo) => boolean,
|
||||
update: (message: SessionMessageInfo) => SessionMessageInfo,
|
||||
last = false,
|
||||
) {
|
||||
const index = last ? state.messages.findLastIndex(predicate) : state.messages.findIndex(predicate)
|
||||
if (index < 0) return state
|
||||
return {
|
||||
...state,
|
||||
messages: state.messages.map((message, position) => (position === index ? update(message) : message)),
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssistant(
|
||||
state: SessionFoldState,
|
||||
messageID: string,
|
||||
update: (message: SessionMessageAssistant) => SessionMessageAssistant,
|
||||
) {
|
||||
return updateMessage(
|
||||
state,
|
||||
(message) => message.id === messageID && message.type === "assistant",
|
||||
(message) => (message.type === "assistant" ? update(message) : message),
|
||||
)
|
||||
}
|
||||
|
||||
function updateActiveAssistant(
|
||||
state: SessionFoldState,
|
||||
update: (message: SessionMessageAssistant) => SessionMessageAssistant,
|
||||
) {
|
||||
return updateMessage(
|
||||
state,
|
||||
(message) => message.type === "assistant" && message.time.completed === undefined,
|
||||
(message) => (message.type === "assistant" ? update(message) : message),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
function updateContent<Type extends "text" | "reasoning">(
|
||||
state: SessionFoldState,
|
||||
messageID: string,
|
||||
type: Type,
|
||||
ordinal: number,
|
||||
update: (
|
||||
part: Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
|
||||
) => Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
|
||||
) {
|
||||
return updateAssistant(state, messageID, (message) => {
|
||||
const position = message.content.flatMap((part, index) => (part.type === type ? [index] : []))[ordinal]
|
||||
const part = position === undefined ? undefined : message.content[position]
|
||||
if (!part || part.type !== type) return message
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((item, index) =>
|
||||
index === position
|
||||
? update(part as Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>)
|
||||
: item,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateTool(
|
||||
state: SessionFoldState,
|
||||
messageID: string,
|
||||
toolID: string,
|
||||
update: (part: SessionMessageAssistantTool) => SessionMessageAssistantTool,
|
||||
) {
|
||||
return updateAssistant(state, messageID, (message) => {
|
||||
const index = message.content.findLastIndex((part) => part.type === "tool" && part.id === toolID)
|
||||
if (index < 0) return message
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((part, position) =>
|
||||
position === index && part.type === "tool" ? update(part) : part,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function insertOrdinal<Type extends SessionMessageAssistant["content"][number]["type"]>(
|
||||
content: SessionMessageAssistant["content"],
|
||||
type: Type,
|
||||
ordinal: number,
|
||||
part: Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
|
||||
) {
|
||||
if (content.filter((item) => item.type === type)[ordinal]) return content
|
||||
return [...content, part]
|
||||
}
|
||||
|
||||
function addUsage(session: SessionInfo, cost: number, tokens: TokenUsageInfo, updated: number): SessionInfo {
|
||||
return {
|
||||
...session,
|
||||
cost: session.cost + cost,
|
||||
tokens: {
|
||||
input: session.tokens.input + tokens.input,
|
||||
output: session.tokens.output + tokens.output,
|
||||
reasoning: session.tokens.reasoning + tokens.reasoning,
|
||||
cache: {
|
||||
read: session.tokens.cache.read + tokens.cache.read,
|
||||
write: session.tokens.cache.write + tokens.cache.write,
|
||||
},
|
||||
},
|
||||
time: { ...session.time, updated },
|
||||
}
|
||||
}
|
||||
|
||||
function withUsage(state: SessionFoldState, cost: number, tokens: TokenUsageInfo, updated: number) {
|
||||
return { ...state, session: addUsage(state.session, cost, tokens, updated) }
|
||||
}
|
||||
|
||||
export * as SessionFold from "./fold"
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./data"
|
||||
export * from "./connection"
|
||||
export * from "./engine-data"
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
// Proves the generated-client wiring the engine laws take for granted: the
|
||||
// adapter in src/solid/engine-data.ts must speak the real snapshot/log/prompt
|
||||
// API shapes and translate the generated typed errors into the engine's own
|
||||
// (the SeqUnavailable path is what laws 7-9 in test/sync-engine-laws.test.ts
|
||||
// rely on in production).
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { Engine } from "../src/solid/engine/engine"
|
||||
import { SNAPSHOT_RECENT, createEngineData, createEngineTransport } from "../src/solid/engine-data"
|
||||
import { FakeSessionServer } from "./fixture/sync-engine"
|
||||
|
||||
describe("engine data transport", () => {
|
||||
test("uses snapshot and ephemeral follow log contracts", async () => {
|
||||
const server = new FakeSessionServer("ses_transport")
|
||||
const calls: Array<unknown> = []
|
||||
const transport = createEngineTransport(() => ({
|
||||
async snapshot(input) {
|
||||
calls.push(input)
|
||||
// The generated client returns mutable arrays; the fixture's snapshot
|
||||
// is readonly, so mirror the wire shape here.
|
||||
const value = server.snapshotValue()
|
||||
return { ...value, children: [...value.children], inbox: [...value.inbox], messages: [...value.messages] }
|
||||
},
|
||||
async *log(input) {
|
||||
calls.push(input)
|
||||
yield { type: "log.synced" as const, aggregateID: input.sessionID, seq: 0 }
|
||||
},
|
||||
async prompt() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
}))
|
||||
|
||||
expect(await transport.snapshot(server.sessionID)).toEqual(server.snapshotValue())
|
||||
const items: Array<Engine.SessionStreamItem> = []
|
||||
for await (const item of transport.stream(server.sessionID, 0)) items.push(item)
|
||||
|
||||
expect(items).toEqual([{ type: "log.synced", aggregateID: server.sessionID, seq: 0 }])
|
||||
expect(calls).toEqual([
|
||||
{ sessionID: server.sessionID, recent: SNAPSHOT_RECENT },
|
||||
{ sessionID: server.sessionID, after: 0, follow: true, ephemeral: true },
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves the prompt request and client-minted ID", async () => {
|
||||
const requests: Array<unknown> = []
|
||||
const transport = createEngineTransport(() => ({
|
||||
async snapshot() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
async *log() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
async prompt(input) {
|
||||
requests.push(input)
|
||||
return {
|
||||
id: input.id!,
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
payload: { text: input.text },
|
||||
delivery: input.delivery ?? "steer",
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
await transport.submit({
|
||||
id: "msg_client",
|
||||
sessionID: "ses_submit",
|
||||
request: {
|
||||
text: "hello",
|
||||
files: [{ uri: "file:///tmp/example.txt", name: "example.txt" }],
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
id: "msg_client",
|
||||
sessionID: "ses_submit",
|
||||
text: "hello",
|
||||
files: [{ uri: "file:///tmp/example.txt", name: "example.txt" }],
|
||||
delivery: "queue",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("a failed initial attach does not poison the session cache", async () => {
|
||||
const server = new FakeSessionServer("ses_attach_retry")
|
||||
server.faults.loseSnapshots = 1
|
||||
const api = {
|
||||
session: {
|
||||
snapshot: (input: { sessionID: string }) => server.snapshot(input.sessionID),
|
||||
log: (input: { sessionID: string; after: number }) => server.stream(input.sessionID, input.after),
|
||||
prompt: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
}
|
||||
await createRoot(async (dispose) => {
|
||||
const data = createEngineData({
|
||||
api: () => api as never,
|
||||
directory: "/workspace",
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
})
|
||||
|
||||
// The server is down when the session first opens…
|
||||
await expect(data.session.sync(server.sessionID)).rejects.toThrow("snapshot lost")
|
||||
// …and the next sync attaches with a fresh engine instead of a cached rejection.
|
||||
await data.session.sync(server.sessionID)
|
||||
|
||||
expect(data.session.get(server.sessionID)?.id).toBe(server.sessionID)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("translates generated typed failures", async () => {
|
||||
// These literals mirror the generated client's error DTO shapes
|
||||
// (SeqUnavailableError / InvalidRequestError in src/promise/generated);
|
||||
// they must change if the generated error schema does.
|
||||
const transport = createEngineTransport(() => ({
|
||||
async snapshot() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
async *log() {
|
||||
throw { _tag: "SeqUnavailableError", sessionID: "ses_errors", after: 2, head: 1, message: "gone" }
|
||||
},
|
||||
async prompt() {
|
||||
throw { _tag: "InvalidRequestError", message: "invalid" }
|
||||
},
|
||||
}))
|
||||
|
||||
const streamError = await collectError(transport.stream("ses_errors", 2))
|
||||
expect(streamError).toBeInstanceOf(Engine.SeqUnavailable)
|
||||
await expect(
|
||||
transport.submit({ id: "msg_client", sessionID: "ses_errors", request: { text: "invalid" } }),
|
||||
).rejects.toEqual(new Engine.SubmitRejected("invalid"))
|
||||
})
|
||||
})
|
||||
|
||||
async function collectError(iterable: AsyncIterable<unknown>) {
|
||||
try {
|
||||
for await (const item of iterable) void item
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
throw new Error("stream did not fail")
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
// In-memory model of the server's session log, used by the engine laws
|
||||
// (sync-engine-laws.test.ts), the chaos simulation (sync-engine-sim.test.ts),
|
||||
// and the legacy bug catalog (legacy-divergence.test.ts). It folds with the
|
||||
// REAL SessionFold, so `truth()` is the same interpretation of events a
|
||||
// converged client must reach, and its admission dedupes by inbox ID exactly
|
||||
// like the server's inbox projector. Faults are injected per call through the
|
||||
// `faults` record; `cutConnections` and `prune` model disconnects and lost
|
||||
// retention.
|
||||
import type { SessionInfo, SessionMessageInfo } from "../../src/promise"
|
||||
import { Engine } from "../../src/solid/engine/engine"
|
||||
import type { DurableSessionEvent, SessionFoldState, SessionSnapshot } from "../../src/solid/engine/fold"
|
||||
import { SessionFold } from "../../src/solid/engine/fold"
|
||||
|
||||
export class FakeSessionServer implements Engine.SessionTransport {
|
||||
readonly events: Array<DurableSessionEvent> = []
|
||||
readonly admitted: Array<string> = []
|
||||
readonly faults = {
|
||||
loseRequests: 0,
|
||||
loseResponses: 0,
|
||||
loseSnapshots: 0,
|
||||
reject: 0,
|
||||
latency: 0,
|
||||
}
|
||||
|
||||
private folded: SessionFoldState
|
||||
private readonly tails = new Set<AsyncQueue<Engine.SessionStreamItem>>()
|
||||
private eventCounter = 0
|
||||
|
||||
constructor(
|
||||
readonly sessionID: string,
|
||||
readonly time = 1_717_171_717_000,
|
||||
) {
|
||||
this.folded = SessionFold.fromSnapshot(emptySnapshot(sessionID, time))
|
||||
}
|
||||
|
||||
async snapshot(sessionID: string) {
|
||||
await this.pause()
|
||||
this.assertSession(sessionID)
|
||||
if (this.faults.loseSnapshots > 0) {
|
||||
this.faults.loseSnapshots--
|
||||
throw new Error("snapshot lost")
|
||||
}
|
||||
return this.snapshotValue()
|
||||
}
|
||||
|
||||
async *stream(sessionID: string, after: number, signal?: AbortSignal): AsyncIterable<Engine.SessionStreamItem> {
|
||||
await this.pause()
|
||||
this.assertSession(sessionID)
|
||||
if (after > this.folded.seq) throw new Engine.SeqUnavailable()
|
||||
const replay = this.events.filter((event) => event.durable.seq > after)
|
||||
// Honest replay contract: a cursor is only admitted when retained events fully cover (after, seq].
|
||||
if (replay.length < this.folded.seq - after) throw new Engine.SeqUnavailable()
|
||||
const queue = new AsyncQueue<Engine.SessionStreamItem>()
|
||||
const abort = () => queue.fail(new Error("stream aborted"))
|
||||
signal?.addEventListener("abort", abort, { once: true })
|
||||
this.tails.add(queue)
|
||||
try {
|
||||
for (const event of replay) yield event
|
||||
yield { type: "log.synced", aggregateID: sessionID, seq: this.folded.seq }
|
||||
while (true) yield await queue.take()
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort)
|
||||
this.tails.delete(queue)
|
||||
}
|
||||
}
|
||||
|
||||
async submit(input: Engine.SubmitInput) {
|
||||
await this.pause()
|
||||
this.assertSession(input.sessionID)
|
||||
const existing = this.events.find(
|
||||
(event) => event.type === "session.inbox.enqueued" && event.data.inboxID === input.id,
|
||||
)
|
||||
if (existing) return
|
||||
if (this.faults.loseRequests > 0) {
|
||||
this.faults.loseRequests--
|
||||
throw new Error("request lost")
|
||||
}
|
||||
if (this.faults.reject > 0) {
|
||||
this.faults.reject--
|
||||
throw new Engine.SubmitRejected("rejected")
|
||||
}
|
||||
this.admitted.push(input.id)
|
||||
this.publish({
|
||||
id: `evt_${String(++this.eventCounter).padStart(8, "0")}`,
|
||||
created: this.time,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: this.sessionID, seq: this.folded.seq + 1, version: 1 },
|
||||
data: {
|
||||
sessionID: this.sessionID,
|
||||
inboxID: input.id,
|
||||
item: {
|
||||
type: "user",
|
||||
delivery: input.request.delivery ?? "steer",
|
||||
payload: {
|
||||
text: input.request.text,
|
||||
agents: input.request.agents?.map((agent) => ({ ...agent })),
|
||||
metadata: input.request.metadata,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (this.faults.loseResponses > 0) {
|
||||
this.faults.loseResponses--
|
||||
throw new Error("response lost")
|
||||
}
|
||||
}
|
||||
|
||||
cutConnections() {
|
||||
this.tails.forEach((tail) => tail.fail(new Error("connection cut")))
|
||||
}
|
||||
|
||||
/** Drop retained event history, simulating `events.persist` off or pruned retention. */
|
||||
prune() {
|
||||
this.events.length = 0
|
||||
}
|
||||
|
||||
/** Clear every injected fault. */
|
||||
heal() {
|
||||
for (const fault of Object.keys(this.faults) as Array<keyof FakeSessionServer["faults"]>) this.faults[fault] = 0
|
||||
}
|
||||
|
||||
seq() {
|
||||
return this.folded.seq
|
||||
}
|
||||
|
||||
truth() {
|
||||
return Engine.render({ folded: this.folded, outbox: [], overlay: new Map() })
|
||||
}
|
||||
|
||||
snapshotValue(): SessionSnapshot {
|
||||
return {
|
||||
session: this.folded.session,
|
||||
children: this.folded.children,
|
||||
inbox: this.folded.inbox,
|
||||
messages: this.folded.messages,
|
||||
seq: this.folded.seq,
|
||||
active: this.folded.active,
|
||||
}
|
||||
}
|
||||
|
||||
private publish(event: DurableSessionEvent) {
|
||||
this.events.push(event)
|
||||
this.folded = SessionFold.apply(this.folded, event)
|
||||
this.tails.forEach((tail) => tail.offer(event))
|
||||
}
|
||||
|
||||
private assertSession(sessionID: string) {
|
||||
if (sessionID !== this.sessionID) throw new Error(`unknown session: ${sessionID}`)
|
||||
}
|
||||
|
||||
private async pause() {
|
||||
for (let step = 0; step < this.faults.latency; step++) await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect option that holds the engine's first reconnect until released,
|
||||
* so a test can advance the server "while disconnected". Later reconnects
|
||||
* pass through instantly.
|
||||
*/
|
||||
export function reconnectGate() {
|
||||
let open = false
|
||||
let release: (() => void) | undefined
|
||||
return {
|
||||
reconnect: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (open) return resolve()
|
||||
release = () => {
|
||||
open = true
|
||||
resolve()
|
||||
}
|
||||
}),
|
||||
holding: () => release !== undefined,
|
||||
release: () => release!(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function until(check: () => boolean, message = "condition did not become true") {
|
||||
for (let attempt = 0; attempt < 500; attempt++) {
|
||||
if (check()) return
|
||||
await Bun.sleep(1)
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
export function userMessages(messages: ReadonlyArray<SessionMessageInfo>) {
|
||||
return messages.filter(
|
||||
(message): message is Extract<SessionMessageInfo, { readonly type: "user" }> => message.type === "user",
|
||||
)
|
||||
}
|
||||
|
||||
function emptySnapshot(sessionID: string, time: number): SessionSnapshot {
|
||||
const session: SessionInfo = {
|
||||
id: sessionID,
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: time, updated: time },
|
||||
location: { directory: "/workspace" },
|
||||
}
|
||||
return { session, children: [], inbox: [], messages: [], seq: 0 }
|
||||
}
|
||||
|
||||
class AsyncQueue<Value> {
|
||||
private readonly values: Array<Value> = []
|
||||
private readonly waiting: Array<{
|
||||
readonly resolve: (value: Value) => void
|
||||
readonly reject: (error: Error) => void
|
||||
}> = []
|
||||
private error?: Error
|
||||
|
||||
offer(value: Value) {
|
||||
const waiter = this.waiting.shift()
|
||||
if (waiter) {
|
||||
waiter.resolve(value)
|
||||
return
|
||||
}
|
||||
this.values.push(value)
|
||||
}
|
||||
|
||||
fail(error: Error) {
|
||||
this.error = error
|
||||
this.waiting.splice(0).forEach((waiter) => waiter.reject(error))
|
||||
}
|
||||
|
||||
take() {
|
||||
if (this.values.length) return Promise.resolve(this.values.shift()!)
|
||||
if (this.error) return Promise.reject(this.error)
|
||||
return new Promise<Value>((resolve, reject) => this.waiting.push({ resolve, reject }))
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// Divergence catalog: weird states the legacy data layer (createData) can get
|
||||
// into that the sync engine cannot. Each test drives the REAL legacy layer —
|
||||
// or, for the retry test, its raw ID-less prompt protocol — and PASSES by
|
||||
// demonstrating the bug, with a pointer to the engine law or mechanism that
|
||||
// rules the same state out. If a test here starts failing, the legacy layer
|
||||
// got fixed — celebrate and delete the test.
|
||||
//
|
||||
// Companion clean-behavior proofs: test/sync-engine-laws.test.ts.
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData } from "../src/solid/data"
|
||||
import type { CreateDataInput } from "../src/solid/data"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "../src/promise"
|
||||
import { FakeSessionServer } from "./fixture/sync-engine"
|
||||
|
||||
const sessionID = "ses_legacy"
|
||||
const assistantID = "msg_assistant"
|
||||
|
||||
describe("legacy data layer divergence catalog", () => {
|
||||
test("a dropped durable event desyncs the transcript silently and forever", async () => {
|
||||
// Server truth: the assistant message finished with text "FINAL".
|
||||
// The client misses only the `session.text.ended` event (blip mid-stream).
|
||||
const legacy = await hydrated()
|
||||
legacy.dispatch(textStarted())
|
||||
for (let index = 0; index < 5; index++) legacy.dispatch(textDelta("x"))
|
||||
// ...the `ended` event with the durable final text never arrives.
|
||||
|
||||
// The transcript is stuck on accumulated deltas, disagreeing with the
|
||||
// server, and nothing in the layer can ever notice: there is no sequence
|
||||
// cursor, no gap check, no recovery path. Only a manual refetch heals it.
|
||||
expect(legacy.text()).toBe("xxxxx")
|
||||
legacy.dispose()
|
||||
// Engine: durable events carry seqs; a gap surfaces as SeqUnavailable or a
|
||||
// marker past the fold, forcing snapshot recovery (laws 7 and 8).
|
||||
})
|
||||
|
||||
test("a late delta corrupts a completed message", async () => {
|
||||
// Events delivered slightly out of order: the final text lands, then a
|
||||
// straggling delta from the finished stream arrives.
|
||||
const legacy = await hydrated()
|
||||
legacy.dispatch(textStarted())
|
||||
legacy.dispatch(textDelta("Hel"))
|
||||
legacy.dispatch(textEnded("Hello"))
|
||||
legacy.dispatch(textDelta("lo"))
|
||||
|
||||
// The handler appends onto whatever text part it finds — including a
|
||||
// completed one. The final message is permanently corrupted.
|
||||
expect(legacy.text()).toBe("Hellolo")
|
||||
legacy.dispose()
|
||||
// Engine: deltas are ephemeral overlay entries cleared by the durable
|
||||
// lifecycle events, and the ordered log cannot deliver a delta after its
|
||||
// own `ended` — there is no durable state for a straggler to corrupt.
|
||||
})
|
||||
|
||||
test("a slow fetch rewinds the store past already-rendered live events", async () => {
|
||||
// The initial message fetch is in flight when a live prompt admission
|
||||
// arrives. The user's message renders... then the stale fetch resolves.
|
||||
let resolveFetch: ((messages: SessionMessageInfo[]) => void) | undefined
|
||||
const legacy = makeLegacy({
|
||||
list: () => new Promise<SessionMessageInfo[]>((resolve) => (resolveFetch = resolve)),
|
||||
})
|
||||
const syncing = legacy.data.session.message.sync(sessionID)
|
||||
legacy.dispatch(inboxEnqueued("msg_user"))
|
||||
expect(legacy.data.session.message.get(sessionID, "msg_user")).toBeDefined()
|
||||
|
||||
resolveFetch!([]) // the fetch was served before the admission — stale
|
||||
await syncing
|
||||
|
||||
// The message the user just watched appear is gone. It returns only if
|
||||
// some later event or refetch happens to bring it back.
|
||||
expect(legacy.data.session.message.get(sessionID, "msg_user")).toBeUndefined()
|
||||
legacy.dispose()
|
||||
// Engine: hydration is a seq-stamped snapshot, and a stale refresh cannot
|
||||
// move the fold behind the live log (law 10, refresh monotonicity).
|
||||
})
|
||||
|
||||
test("delivered-before-enqueued leaves a phantom pending row forever", async () => {
|
||||
// Reordered delivery: the `delivered` event arrives before its `enqueued`.
|
||||
const legacy = await hydrated()
|
||||
legacy.dispatch(inboxDelivered("msg_user")) // no-op: nothing to deliver yet
|
||||
legacy.dispatch(inboxEnqueued("msg_user")) // adds the pending row
|
||||
|
||||
// The delivered event was already consumed, so the row the server has
|
||||
// long since promoted sits in "pending" until a manual refetch.
|
||||
expect(legacy.data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_user"])
|
||||
legacy.dispose()
|
||||
// Engine: the transport is a single ordered log, so this ordering cannot
|
||||
// be observed live; a reconnect replays from the seq cursor, and any gap
|
||||
// fails the cursor check and recovers via snapshot (laws 7 and 8).
|
||||
})
|
||||
|
||||
test("a retry after a lost response admits the prompt twice", async () => {
|
||||
// Both protocols drive the same server admission logic (FakeSessionServer
|
||||
// dedupes by inbox ID exactly like the real projector). The only
|
||||
// difference is who mints the ID.
|
||||
|
||||
// Legacy protocol: the request carries no ID, so the server mints a fresh
|
||||
// one per attempt and cannot recognize a retry. The response to the first
|
||||
// send is lost, the user presses enter again — the transcript now has the
|
||||
// prompt twice.
|
||||
const legacyServer = new FakeSessionServer(sessionID)
|
||||
legacyServer.faults.loseResponses = 1
|
||||
let minted = 0
|
||||
const legacySend = (text: string) =>
|
||||
legacyServer.submit({ id: `msg_minted_${++minted}`, sessionID, request: { text } })
|
||||
await legacySend("hello").catch(() => {})
|
||||
await legacySend("hello")
|
||||
expect(legacyServer.admitted).toHaveLength(2)
|
||||
|
||||
// Engine protocol: the retry reuses the client-minted ID and the same
|
||||
// server admits exactly once. (Law 1 proves this end-to-end through the
|
||||
// real engine retry loop; this is the raw protocol contrast.)
|
||||
const engineServer = new FakeSessionServer(sessionID)
|
||||
engineServer.faults.loseResponses = 1
|
||||
const engineSend = () => engineServer.submit({ id: "msg_client", sessionID, request: { text: "hello" } })
|
||||
await engineSend().catch(() => {})
|
||||
await engineSend()
|
||||
expect(engineServer.admitted).toEqual(["msg_client"])
|
||||
})
|
||||
|
||||
test("a dropped execution event leaves an interrupted session spinning forever", async () => {
|
||||
// The user hits interrupt; the server stops the run; the terminal
|
||||
// `session.execution.interrupted` event is lost in a reconnect blip.
|
||||
const legacy = await hydrated()
|
||||
legacy.dispatch(executionStarted())
|
||||
|
||||
// Status only ever changes on the terminal event (lost) or a full
|
||||
// reconnect's active-session refetch — until one of those happens the
|
||||
// spinner spins over a session the server already stopped.
|
||||
expect(legacy.data.session.status(sessionID)).toBe("running")
|
||||
legacy.dispose()
|
||||
// Engine: activity is folded durable state behind the seq cursor, so the
|
||||
// gap itself is detected and snapshot recovery resyncs activity with the
|
||||
// server (laws 7 and 8 pin the mechanism).
|
||||
})
|
||||
})
|
||||
|
||||
// Also part of the catalog, straight from the legacy source: the layer
|
||||
// documents its own event-vs-fetch race — see the session.created "band-aid"
|
||||
// comment in src/solid/data.ts (skipping racy initial reads so live events
|
||||
// are not overwritten by stale fetches).
|
||||
|
||||
function makeLegacy(overrides: { list?: () => Promise<SessionMessageInfo[]> } = {}) {
|
||||
let handler: ((event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void) | undefined
|
||||
const api = {
|
||||
session: {
|
||||
get: async () => ({
|
||||
id: sessionID,
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
location: { directory: "/workspace" },
|
||||
}),
|
||||
},
|
||||
message: {
|
||||
list: async () => ({
|
||||
data: overrides.list ? await overrides.list() : transcript().toReversed(),
|
||||
cursor: {},
|
||||
}),
|
||||
},
|
||||
} as unknown as ReturnType<CreateDataInput["api"]>
|
||||
return createRoot((dispose) => {
|
||||
const data = createData({
|
||||
api: () => api,
|
||||
directory: "/workspace",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(next) {
|
||||
handler = next
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
return {
|
||||
data,
|
||||
dispose,
|
||||
dispatch(event: { type: OpenCodeEvent["type"] } & Record<string, unknown>) {
|
||||
handler?.({ name: event.type, details: event as unknown as OpenCodeEvent })
|
||||
},
|
||||
text() {
|
||||
const message = data.session.message.get(sessionID, assistantID)
|
||||
const part =
|
||||
message?.type === "assistant" ? message.content.findLast((item) => item.type === "text") : undefined
|
||||
return part?.type === "text" ? part.text : undefined
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function hydrated() {
|
||||
const legacy = makeLegacy()
|
||||
await legacy.data.session.sync(sessionID)
|
||||
await legacy.data.session.message.sync(sessionID)
|
||||
return legacy
|
||||
}
|
||||
|
||||
function transcript(): SessionMessageInfo[] {
|
||||
return [
|
||||
{ id: "msg_earlier", type: "user", text: "earlier", time: { created: 1 } },
|
||||
{
|
||||
id: assistantID,
|
||||
type: "assistant",
|
||||
time: { created: 2 },
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const textStarted = () => ({
|
||||
id: "evt_start",
|
||||
created: 3,
|
||||
type: "session.text.started" as const,
|
||||
data: { sessionID, assistantMessageID: assistantID, ordinal: 0 },
|
||||
})
|
||||
|
||||
let deltaCount = 0
|
||||
const textDelta = (delta: string) => ({
|
||||
id: `evt_delta_${++deltaCount}`,
|
||||
created: 4,
|
||||
type: "session.text.delta" as const,
|
||||
data: { sessionID, assistantMessageID: assistantID, ordinal: 0, delta },
|
||||
})
|
||||
|
||||
const textEnded = (text: string) => ({
|
||||
id: "evt_end",
|
||||
created: 5,
|
||||
type: "session.text.ended" as const,
|
||||
data: { sessionID, assistantMessageID: assistantID, ordinal: 0, text },
|
||||
})
|
||||
|
||||
const inboxEnqueued = (inboxID: string) => ({
|
||||
id: "evt_enqueued",
|
||||
created: 6,
|
||||
type: "session.inbox.enqueued" as const,
|
||||
data: {
|
||||
sessionID,
|
||||
inboxID,
|
||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||
},
|
||||
})
|
||||
|
||||
const inboxDelivered = (inboxID: string) => ({
|
||||
id: "evt_delivered",
|
||||
created: 7,
|
||||
type: "session.inbox.delivered" as const,
|
||||
data: { sessionID, inboxID },
|
||||
})
|
||||
|
||||
const executionStarted = () => ({
|
||||
id: "evt_execution",
|
||||
created: 8,
|
||||
type: "session.execution.started" as const,
|
||||
data: { sessionID },
|
||||
})
|
||||
@@ -1,201 +0,0 @@
|
||||
// Laws of the session sync engine: each test pins one property the engine
|
||||
// must hold under transport faults. Cited by number from
|
||||
// test/legacy-divergence.test.ts (the legacy bug catalog these laws rule out)
|
||||
// and stress-tested together by test/sync-engine-sim.test.ts. The server
|
||||
// model lives in test/fixture/sync-engine.ts and folds with the real
|
||||
// SessionFold, so `server.truth()` is the state a converged client must show.
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { Engine } from "../src/solid/engine/engine"
|
||||
import { FakeSessionServer, reconnectGate, until, userMessages } from "./fixture/sync-engine"
|
||||
|
||||
describe("session sync engine laws", () => {
|
||||
test("1. idempotency: lost responses converge to one admitted message", async () => {
|
||||
const server = new FakeSessionServer("ses_idempotency")
|
||||
server.faults.loseResponses = 1
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server, {
|
||||
now: () => server.time,
|
||||
reconnect: async () => {},
|
||||
})
|
||||
|
||||
engine.submit({ id: "msg_1", text: "hello" })
|
||||
await until(() => engine.view().seq === 1)
|
||||
// The admit landed but the response was lost; the reconnect makes the
|
||||
// engine resend the same client-minted ID — that resend is what
|
||||
// idempotency must absorb.
|
||||
server.cutConnections()
|
||||
await engine.settled()
|
||||
|
||||
expect(server.admitted).toEqual(["msg_1"])
|
||||
expect(userMessages(engine.view().messages)).toHaveLength(1)
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("2. echo determinism: folding the echo does not change rendered messages", async () => {
|
||||
const server = new FakeSessionServer("ses_echo")
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server, { now: () => server.time })
|
||||
await engine.ready()
|
||||
|
||||
// The "echo" is the server's inbox.enqueued event for our own submit:
|
||||
// folding it over the optimistic render must be invisible — no flicker.
|
||||
engine.submit({ id: "msg_1", text: "instant" })
|
||||
const before = engine.view().messages
|
||||
await engine.settled()
|
||||
|
||||
expect(engine.view().messages).toEqual(before)
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("3. sync opacity: the fold cannot see intents or the engine", () => {
|
||||
const source = readFileSync(new URL("../src/solid/engine/fold.ts", import.meta.url), "utf8")
|
||||
const code = source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
|
||||
expect(code).not.toMatch(/\boutbox\b/)
|
||||
expect(code).not.toContain("./engine")
|
||||
expect(code).not.toMatch(/\bintents?\b/i)
|
||||
})
|
||||
|
||||
test("4. ordering: a burst admits in submission order", async () => {
|
||||
const server = new FakeSessionServer("ses_ordering")
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server)
|
||||
|
||||
for (const value of [1, 2, 3, 4, 5]) engine.submit({ id: `msg_${value}`, text: `m${value}` })
|
||||
await engine.settled()
|
||||
|
||||
expect(server.admitted).toEqual(["msg_1", "msg_2", "msg_3", "msg_4", "msg_5"])
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("5. convergence: drained clients equal the server fold", async () => {
|
||||
const server = new FakeSessionServer("ses_convergence")
|
||||
const a = await Engine.createSessionEngine(server.sessionID, server, { makeID: () => "msg_a" })
|
||||
const b = await Engine.createSessionEngine(server.sessionID, server, { makeID: () => "msg_b" })
|
||||
|
||||
a.submit({ text: "from a" })
|
||||
b.submit({ text: "from b" })
|
||||
await Promise.all([a.settled(), b.settled()])
|
||||
await until(() => a.view().seq === server.seq() && b.view().seq === server.seq())
|
||||
|
||||
expect(a.view()).toEqual(server.truth())
|
||||
expect(b.view()).toEqual(server.truth())
|
||||
a.stop()
|
||||
b.stop()
|
||||
})
|
||||
|
||||
test("6. failure atomicity: typed rejection removes and surfaces the intent", async () => {
|
||||
const server = new FakeSessionServer("ses_failure")
|
||||
server.faults.reject = 1
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server)
|
||||
const failures: Array<Engine.IntentFailure> = []
|
||||
engine.subscribeFailures((failure) => failures.push(failure))
|
||||
const before = engine.view()
|
||||
|
||||
const intent = engine.submit({ id: "msg_1", text: "doomed" })
|
||||
expect(userMessages(engine.view().messages)).toHaveLength(1)
|
||||
await until(() => failures.length === 1)
|
||||
|
||||
expect(engine.view()).toEqual(before)
|
||||
expect(failures).toEqual([{ intent, reason: "rejected" }])
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("7. lossy history: reconnect without retained events recovers via snapshot", async () => {
|
||||
const server = new FakeSessionServer("ses_lossy")
|
||||
const gate = reconnectGate()
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server, {
|
||||
now: () => server.time,
|
||||
reconnect: gate.reconnect,
|
||||
})
|
||||
engine.submit({ id: "msg_1", text: "first" })
|
||||
await engine.settled()
|
||||
|
||||
server.cutConnections()
|
||||
await until(gate.holding)
|
||||
// While disconnected the session advances, then history is dropped: the
|
||||
// reconnect cursor cannot be replayed and must recover via snapshot.
|
||||
await server.submit({ id: "msg_2", sessionID: server.sessionID, request: { text: "second" } })
|
||||
server.prune()
|
||||
gate.release()
|
||||
|
||||
await until(() => engine.view().seq === 2)
|
||||
expect(engine.view()).toEqual(server.truth())
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("8. attach gaps: a synced marker past the fold forces snapshot recovery", async () => {
|
||||
const server = new FakeSessionServer("ses_marker_gap")
|
||||
await server.submit({ id: "msg_1", sessionID: server.sessionID, request: { text: "hello" } })
|
||||
const stale = { ...server.snapshotValue(), messages: [], inbox: [], seq: 0 }
|
||||
let attempts = 0
|
||||
const engine = await Engine.createSessionEngine(
|
||||
server.sessionID,
|
||||
{
|
||||
snapshot: (sessionID) => (attempts === 0 ? Promise.resolve(stale) : server.snapshot(sessionID)),
|
||||
async *stream(sessionID, after, signal) {
|
||||
attempts++
|
||||
if (attempts === 1) {
|
||||
// Dishonest attach: the marker admits the cursor but skips the replay range.
|
||||
yield { type: "log.synced" as const, aggregateID: sessionID, seq: server.snapshotValue().seq }
|
||||
return
|
||||
}
|
||||
yield* server.stream(sessionID, after, signal)
|
||||
},
|
||||
submit: (input) => server.submit(input),
|
||||
},
|
||||
{ reconnect: async () => {} },
|
||||
)
|
||||
await engine.ready()
|
||||
|
||||
expect(attempts).toBe(2)
|
||||
expect(engine.view()).toEqual(server.truth())
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("9. outage recovery: failed recovery snapshots retry until the server returns", async () => {
|
||||
const server = new FakeSessionServer("ses_outage")
|
||||
const gate = reconnectGate()
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server, {
|
||||
now: () => server.time,
|
||||
reconnect: gate.reconnect,
|
||||
})
|
||||
engine.submit({ id: "msg_1", text: "first" })
|
||||
await engine.settled()
|
||||
|
||||
server.cutConnections()
|
||||
await until(gate.holding)
|
||||
// A server restart while disconnected: history is gone, and the server
|
||||
// stays unreachable for the first snapshot attempts of the recovery.
|
||||
await server.submit({ id: "msg_2", sessionID: server.sessionID, request: { text: "second" } })
|
||||
server.prune()
|
||||
server.faults.loseSnapshots = 3
|
||||
gate.release()
|
||||
|
||||
await until(() => engine.view().seq === 2)
|
||||
expect(server.faults.loseSnapshots).toBe(0)
|
||||
expect(engine.view()).toEqual(server.truth())
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("10. refresh monotonicity: a stale snapshot refresh cannot move the fold behind the live log", async () => {
|
||||
const server = new FakeSessionServer("ses_refresh_race")
|
||||
const stale = server.snapshotValue()
|
||||
let refresh = false
|
||||
const transport: Engine.SessionTransport = {
|
||||
snapshot: (sessionID) => (refresh ? Promise.resolve(stale) : server.snapshot(sessionID)),
|
||||
stream: (sessionID, after) => server.stream(sessionID, after),
|
||||
submit: (input) => server.submit(input),
|
||||
}
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, transport)
|
||||
await engine.ready()
|
||||
|
||||
engine.submit({ id: "msg_1", text: "newer than snapshot" })
|
||||
await until(() => engine.view().seq === 1)
|
||||
refresh = true
|
||||
await engine.refresh()
|
||||
|
||||
expect(engine.view().seq).toBe(1)
|
||||
// ...and the un-echoed intent survives the rejected refresh.
|
||||
expect(engine.view().pending.map((item) => item.id)).toEqual(["msg_1"])
|
||||
engine.stop()
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
// Seeded chaos simulation: two engine clients share one FakeSessionServer
|
||||
// while every fault the fixture can inject is thrown at them at random, then
|
||||
// all faults heal and both clients must converge exactly to the server's
|
||||
// truth. This stress-tests the laws of test/sync-engine-laws.test.ts in
|
||||
// combination; failures reproduce deterministically from the seed.
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Engine } from "../src/solid/engine/engine"
|
||||
import { FakeSessionServer, until, userMessages } from "./fixture/sync-engine"
|
||||
|
||||
type Client = {
|
||||
readonly name: string
|
||||
readonly engine: Engine.SessionEngine
|
||||
readonly submitted: Array<string>
|
||||
readonly rejected: Set<string>
|
||||
readonly views: Array<Engine.SessionView>
|
||||
}
|
||||
|
||||
describe("session sync engine simulation", () => {
|
||||
for (const seed of [1, 2, 3, 42, 1337, 90210]) {
|
||||
test(`seed ${seed}: two clients converge through chaotic transport faults`, async () => {
|
||||
const random = mulberry32(seed)
|
||||
const server = new FakeSessionServer(`ses_sim_${seed}`)
|
||||
const clients = await Promise.all([makeClient("a", server), makeClient("b", server)])
|
||||
|
||||
// Chaos phase. Per step: 45% submit from a random client, 10% cut all
|
||||
// connections, 10% lose a response, 8% lose a burst of requests,
|
||||
// 7% reject an admission, 7% lose a snapshot fetch, 13% shift latency.
|
||||
for (let step = 0; step < 80; step++) {
|
||||
const roll = random()
|
||||
if (roll < 0.45) {
|
||||
const client = pick(clients, random)
|
||||
const intent = client.engine.submit({ text: `step-${step}` })
|
||||
client.submitted.push(intent.id)
|
||||
} else if (roll < 0.55) {
|
||||
server.cutConnections()
|
||||
} else if (roll < 0.65) {
|
||||
server.faults.loseResponses++
|
||||
} else if (roll < 0.73) {
|
||||
server.faults.loseRequests += 1 + Math.floor(random() * 2)
|
||||
} else if (roll < 0.8) {
|
||||
server.faults.reject++
|
||||
} else if (roll < 0.87) {
|
||||
server.faults.loseSnapshots++
|
||||
} else {
|
||||
server.faults.latency = Math.floor(random() * 6)
|
||||
}
|
||||
await advance(2 + Math.floor(random() * 8))
|
||||
}
|
||||
|
||||
// Drain phase: heal all faults, then repeatedly cut connections —
|
||||
// reconnecting is what makes the engine resend intents whose responses
|
||||
// were lost, so every submitted ID ends up admitted or rejected.
|
||||
server.heal()
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
server.cutConnections()
|
||||
await advance(4)
|
||||
const accounted = clients.every(
|
||||
(client) =>
|
||||
client.submitted.filter((id) => server.admitted.includes(id) || client.rejected.has(id)).length ===
|
||||
client.submitted.length,
|
||||
)
|
||||
if (accounted) break
|
||||
}
|
||||
await until(
|
||||
() => clients.every((client) => client.engine.view().seq === server.seq()),
|
||||
`seed ${seed} did not converge`,
|
||||
)
|
||||
|
||||
expect(new Set(server.admitted).size).toBe(server.admitted.length)
|
||||
for (const client of clients) {
|
||||
const expected = client.submitted.filter((id) => !client.rejected.has(id))
|
||||
const observed = server.admitted.filter((id) => client.submitted.includes(id))
|
||||
expect(observed).toEqual(expected)
|
||||
expect(client.engine.view()).toEqual(server.truth())
|
||||
assertNoFlicker(client.views, server.admitted, `${seed}/${client.name}`)
|
||||
client.engine.stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function makeClient(name: string, server: FakeSessionServer): Promise<Client> {
|
||||
let counter = 0
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server, {
|
||||
makeID: () => `msg_${name}${String(++counter).padStart(4, "0")}`,
|
||||
now: () => server.time,
|
||||
reconnect: async () => {},
|
||||
})
|
||||
const client: Client = { name, engine, submitted: [], rejected: new Set(), views: [engine.view()] }
|
||||
engine.subscribe((view) => client.views.push(view))
|
||||
engine.subscribeFailures((failure) => client.rejected.add(failure.intent.id))
|
||||
return client
|
||||
}
|
||||
|
||||
// Once an admitted message first renders, it appears exactly once in every
|
||||
// subsequent view — it never disappears or duplicates.
|
||||
function assertNoFlicker(views: ReadonlyArray<Engine.SessionView>, admitted: ReadonlyArray<string>, label: string) {
|
||||
for (const id of admitted) {
|
||||
const first = views.findIndex((view) => userMessages(view.messages).some((message) => message.id === id))
|
||||
expect(first, `${label}: ${id} never rendered`).toBeGreaterThanOrEqual(0)
|
||||
for (const view of views.slice(first)) {
|
||||
const rows = userMessages(view.messages).filter((message) => message.id === id)
|
||||
expect(rows, `${label}: ${id} disappeared or duplicated`).toHaveLength(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One "step" is one microtask turn — each fixture `pause()` under
|
||||
// `faults.latency` consumes one — followed by a macrotask flush.
|
||||
async function advance(steps: number) {
|
||||
for (let step = 0; step < steps; step++) await Promise.resolve()
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
|
||||
function pick<Value>(values: ReadonlyArray<Value>, random: () => number) {
|
||||
return values[Math.floor(random() * values.length)]!
|
||||
}
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
return () => {
|
||||
seed |= 0
|
||||
seed = (seed + 0x6d2b79f5) | 0
|
||||
const first = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
||||
const second = (first + Math.imul(first ^ (first >>> 7), 61 | first)) ^ first
|
||||
return ((second ^ (second >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src",
|
||||
"script",
|
||||
"test/fixture",
|
||||
"test/engine-data.test.ts",
|
||||
"test/legacy-divergence.test.ts",
|
||||
"test/sync-engine-laws.test.ts",
|
||||
"test/sync-engine-sim.test.ts"
|
||||
]
|
||||
}
|
||||
@@ -14,22 +14,11 @@ export interface MapInput {
|
||||
readonly packageName: string | undefined
|
||||
readonly settings: Readonly<Record<string, unknown>>
|
||||
readonly modelID: string
|
||||
readonly providerID: string
|
||||
}
|
||||
|
||||
export function map(input: MapInput): Mapping | undefined {
|
||||
const baseSettings = mapBaseSettings(input.settings)
|
||||
switch (input.packageName) {
|
||||
case "@ai-sdk/anthropic":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/anthropic",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.authToken === "string" ? { authToken: input.settings.authToken } : {}),
|
||||
...mapAnthropicOptions(input.settings),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/amazon-bedrock":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/amazon-bedrock",
|
||||
@@ -62,22 +51,6 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
...mapGoogleOptions(input.settings),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/google-vertex":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/google-vertex",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...mapGoogleOptions(
|
||||
input.settings,
|
||||
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
|
||||
),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
case "@ai-sdk/google-vertex/anthropic":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/google-vertex/messages",
|
||||
@@ -99,35 +72,6 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
case "@ai-sdk/openai":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openai",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.organization === "string" ? { organization: input.settings.organization } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
|
||||
...mapProviderOptions(input.settings, "openai", [
|
||||
"apiKey",
|
||||
"baseURL",
|
||||
"organization",
|
||||
"project",
|
||||
"queryParams",
|
||||
]),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/openai-compatible":
|
||||
if (typeof input.settings.baseURL !== "string") return
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
provider: input.providerID,
|
||||
...mapProviderOptions(input.settings, "openai", ["apiKey", "baseURL"]),
|
||||
},
|
||||
}
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return mapOpenRouter(input.settings, baseSettings)
|
||||
case "@ai-sdk/xai":
|
||||
@@ -142,20 +86,6 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function mapAnthropicOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
return mapProviderOptions(settings, "anthropic", ["apiKey", "authToken", "baseURL"])
|
||||
}
|
||||
|
||||
function mapProviderOptions(
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
key: string,
|
||||
excluded: ReadonlyArray<string>,
|
||||
) {
|
||||
const options = Object.fromEntries(Object.entries(settings).filter(([name]) => !excluded.includes(name)))
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { [key]: options } }
|
||||
}
|
||||
|
||||
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
|
||||
const settings = input.settings
|
||||
const chat = input.modelID === "openai.gpt-oss-safeguard-20b" || input.modelID === "openai.gpt-oss-safeguard-120b"
|
||||
@@ -299,7 +229,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
|
||||
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
|
||||
}
|
||||
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const input = settings.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
|
||||
@@ -310,11 +240,9 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Re
|
||||
}
|
||||
const options = {
|
||||
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
|
||||
...(isStringRecord(settings.labels) ? { labels: settings.labels } : {}),
|
||||
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
|
||||
...extra,
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { gemini: options } }
|
||||
|
||||
@@ -460,20 +460,7 @@ function prompt(request: LLMRequest): LanguageModelV3Prompt {
|
||||
function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[] {
|
||||
switch (input.role) {
|
||||
case "system":
|
||||
// The initial privileged prompt lives in `request.system` and is prepended above. A system message here is a
|
||||
// chronological instruction update, but opaque AI SDK providers do not uniformly allow the system role after
|
||||
// conversation history, so preserve its position using the safe wrapped-user fallback.
|
||||
return [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: ProviderShared.wrapSystemUpdate(input.content.filter((part) => part.type === "text")),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
return [{ role: "system", content: input.content.flatMap(text).join("\n\n") }]
|
||||
case "user":
|
||||
return [{ role: "user", content: input.content.flatMap(userPart) }]
|
||||
case "assistant":
|
||||
|
||||
@@ -44,21 +44,6 @@ export const reserveSequence = Effect.fn("Bus.reserveSequence")(function* (
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const retainedCount = Effect.fn("Bus.retainedCount")(function* (
|
||||
db: Database.Interface["db"],
|
||||
aggregateID: string,
|
||||
after: number,
|
||||
through: number,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after), lte(EventTable.seq, through)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row?.count ?? 0
|
||||
})
|
||||
|
||||
export type SerializedEvent = {
|
||||
readonly id: Event.ID
|
||||
readonly type: string
|
||||
@@ -165,7 +150,6 @@ export interface Interface {
|
||||
readonly aggregateID: string
|
||||
readonly after?: number
|
||||
readonly follow?: boolean
|
||||
readonly includeLive?: (event: Event.Payload) => boolean
|
||||
}) => Stream.Stream<LogItem>
|
||||
/** @deprecated Use `subscribe()` and consume the returned stream. */
|
||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||
@@ -784,7 +768,6 @@ export function configured(options?: Options) {
|
||||
readonly aggregateID: string
|
||||
readonly after?: number
|
||||
readonly follow?: boolean
|
||||
readonly includeLive?: (event: Event.Payload) => boolean
|
||||
}): Stream.Stream<LogItem> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
@@ -808,8 +791,7 @@ export function configured(options?: Options) {
|
||||
)
|
||||
// Subscribing before the historical read means events committed during
|
||||
// replay either appear in the read or arrive through a post-marker wake.
|
||||
const subscription = input.follow && input.includeLive ? yield* PubSub.subscribe(pubsub.live) : undefined
|
||||
const wakes = input.follow && !subscription ? yield* subscribeDurable(input.aggregateID) : undefined
|
||||
const wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined
|
||||
const target = yield* latestSequence(db, input.aggregateID)
|
||||
const marker: EventLog.Synced = {
|
||||
type: "log.synced",
|
||||
@@ -820,14 +802,6 @@ export function configured(options?: Options) {
|
||||
Stream.map((event): LogItem => event),
|
||||
Stream.concat(Stream.make(marker)),
|
||||
)
|
||||
if (subscription && input.includeLive) {
|
||||
const follow: Stream.Stream<LogItem> = Stream.fromSubscription(subscription).pipe(
|
||||
Stream.filter(input.includeLive),
|
||||
Stream.filter((event) => !event.durable || event.durable.seq > target),
|
||||
Stream.map((event): LogItem => event),
|
||||
)
|
||||
return Stream.concat(replay, follow)
|
||||
}
|
||||
if (!wakes) return replay
|
||||
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
|
||||
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
|
||||
|
||||
@@ -8,8 +8,10 @@ import { MCP } from "./mcp/index.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "./config.js"
|
||||
import { Location } from "./location.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export const Info = Command.Info
|
||||
export type Info = Command.Info
|
||||
@@ -51,15 +53,16 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
||||
|
||||
const layer = () =>
|
||||
export const layer = (options?: ShellSelect.Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const bus = yield* Bus.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const global = yield* Global.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "command",
|
||||
initial: () => ({ commands: new Map() }),
|
||||
@@ -106,9 +109,11 @@ const layer = () =>
|
||||
const command = staticCommand(input.name)
|
||||
if (command)
|
||||
return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
|
||||
config,
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
shell: options,
|
||||
bin: global.bin,
|
||||
})
|
||||
|
||||
const prompt = (yield* mcp.prompts()).find(
|
||||
@@ -158,9 +163,11 @@ function evaluateTemplate(
|
||||
template: string,
|
||||
input: string,
|
||||
services: {
|
||||
readonly config: Config.Interface
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
readonly shell?: ShellSelect.Options
|
||||
readonly bin: string
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
@@ -190,14 +197,20 @@ const evaluateShell = Effect.fnUntraced(function* (
|
||||
command: string,
|
||||
text: string,
|
||||
services: {
|
||||
readonly config: Config.Interface
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
readonly shell?: ShellSelect.Options
|
||||
readonly bin: string
|
||||
},
|
||||
) {
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.preferred()
|
||||
const shell = ShellSelect.preferred(
|
||||
Config.latest(yield* services.config.entries(), "shell"),
|
||||
services.shell,
|
||||
services.bin,
|
||||
)
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
@@ -254,8 +267,12 @@ const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
const shellRegex = /!`([^`]+)`/g
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Location.node, ShellSelect.node],
|
||||
})
|
||||
export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
|
||||
})
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
export * as ConfigCompactionPlugin from "./compaction.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { SessionCompaction } from "../../session/compaction.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.compaction",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
const reload = config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(compaction.reload()),
|
||||
)
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* compaction.transform((draft) => {
|
||||
for (const entry of loaded.entries) {
|
||||
if (entry.type !== "document" || !entry.info.compaction) continue
|
||||
draft.configure({
|
||||
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
|
||||
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
|
||||
...(entry.info.compaction.keep?.tokens === undefined
|
||||
? {}
|
||||
: { tokens: entry.info.compaction.keep.tokens }),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
export * as ConfigLocationWatcherPlugin from "./location-watcher.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { LocationWatcherPolicy } from "../../filesystem/location-watcher-policy.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.location-watcher",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
const reload = config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(policy.reload()),
|
||||
)
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* policy.transform((draft) => {
|
||||
for (const entry of loaded.entries) {
|
||||
if (entry.type !== "document" || !entry.info.watcher?.ignore) continue
|
||||
draft.add(entry.info.watcher.ignore)
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
export * as ConfigShellPlugin from "./shell.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.shell",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
const reload = config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(shell.reload()),
|
||||
)
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* shell.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "shell")
|
||||
if (configured) draft.configure(configured)
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
export * as ConfigSnapshotPlugin from "./snapshot.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.snapshot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
const reload = config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(snapshot.reload()),
|
||||
)
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* snapshot.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "snapshots")
|
||||
if (configured === undefined) return
|
||||
draft.configure(configured)
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
export * as ConfigToolOutputPlugin from "./tool-output.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.tool-output",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const output = yield* ToolOutput.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
const reload = config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(output.reload()),
|
||||
)
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* output.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "tool_output")
|
||||
if (!configured) return
|
||||
draft.configure({
|
||||
...(configured.max_lines === undefined ? {} : { maxLines: configured.max_lines }),
|
||||
...(configured.max_bytes === undefined ? {} : { maxBytes: configured.max_bytes }),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
export * as LocationWatcherPolicy from "./location-watcher-policy.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { State } from "../state.js"
|
||||
|
||||
type Data = {
|
||||
ignore: string[]
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
add: (ignore: readonly string[]) => void
|
||||
list: () => readonly string[]
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly current: () => readonly string[]
|
||||
readonly observe: (
|
||||
listener: (ignore: readonly string[]) => Effect.Effect<void>,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcherPolicy") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let current: readonly string[] = []
|
||||
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "location-watcher-policy",
|
||||
initial: () => ({ ignore: [] }),
|
||||
draft: (draft) => ({
|
||||
add: (ignore) => draft.ignore.push(...ignore),
|
||||
list: () => draft.ignore,
|
||||
}),
|
||||
finalize: (draft) =>
|
||||
Effect.sync(() => {
|
||||
current = [...draft.list()]
|
||||
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
|
||||
})
|
||||
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
|
||||
listener: (ignore: readonly string[]) => Effect.Effect<void>,
|
||||
) {
|
||||
const scope = yield* Scope.Scope
|
||||
let active = true
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
listeners.delete(listener)
|
||||
})
|
||||
listeners.add(listener)
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
})
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
current: () => current,
|
||||
observe,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
@@ -1,15 +1,15 @@
|
||||
export * as LocationWatcher from "./location-watcher.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Cause, Context, Effect, Exit, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import path from "path"
|
||||
import { Config } from "../config.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git.js"
|
||||
import { Location } from "../location.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { LocationWatcherPolicy } from "./location-watcher-policy.js"
|
||||
import { Watcher } from "./watcher.js"
|
||||
|
||||
export interface Interface {}
|
||||
@@ -24,86 +24,42 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const configService = yield* Config.Service
|
||||
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
|
||||
bus.publish(FileSystem.Event.Changed, {
|
||||
file: update.path,
|
||||
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
|
||||
})
|
||||
const target = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
if (location.vcs?.type === "git") {
|
||||
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
||||
const vcs = resolved
|
||||
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
|
||||
: undefined
|
||||
if (vcs) return { path: path.join(vcs, "HEAD"), aliases: [".git", vcs, ...(resolved ? [resolved] : [])] }
|
||||
}
|
||||
if (location.vcs?.type === "hg") {
|
||||
const store = location.vcs.store
|
||||
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
|
||||
return { path: path.join(vcs, "branch"), aliases: [".hg", vcs] }
|
||||
}
|
||||
}).pipe(
|
||||
Effect.withSpan("LocationWatcher.target", { attributes: { directory: location.directory } }),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("failed to resolve location watcher target", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
),
|
||||
)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let requested = 0
|
||||
let stopped = false
|
||||
let active: { path: string; scope: Scope.Closeable } | undefined
|
||||
const reconcile = (ignore: readonly string[]) => {
|
||||
const request = ++requested
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (stopped || request !== requested) return
|
||||
const resolved = yield* target
|
||||
if (stopped || request !== requested) return
|
||||
const next = resolved && !resolved.aliases.some((alias) => ignore.includes(alias)) ? resolved.path : undefined
|
||||
if (active?.path === next) return
|
||||
if (active) yield* Scope.close(active.scope, Exit.void)
|
||||
active = undefined
|
||||
if (!next) return
|
||||
const scope = yield* Scope.make()
|
||||
active = { path: next, scope }
|
||||
yield* Effect.gen(function* () {
|
||||
const updates = yield* watcher.subscribe({ path: next, type: "file" })
|
||||
yield* Stream.runForEach(updates, publish)
|
||||
}).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) => Effect.logError("location watcher subscription failed", { path: next, cause }),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
}).pipe(Effect.withSpan("LocationWatcher.reconcile", { attributes: { directory: location.directory } })),
|
||||
)
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
stopped = true
|
||||
requested++
|
||||
if (active) yield* Scope.close(active.scope, Exit.void)
|
||||
active = undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* policy.observe(reconcile)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
yield* plugins.flush
|
||||
yield* reconcile(policy.current())
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
|
||||
if (location.vcs?.type === "git") {
|
||||
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
||||
const vcs = resolved
|
||||
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
|
||||
: undefined
|
||||
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
||||
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
}
|
||||
if (location.vcs?.type === "hg") {
|
||||
const store = location.vcs.store
|
||||
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
|
||||
if (!config.includes(".hg") && !config.includes(vcs)) {
|
||||
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) => Effect.logError("failed to start location watcher", { cause }),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
|
||||
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({})
|
||||
}),
|
||||
)
|
||||
@@ -111,13 +67,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
Watcher.node,
|
||||
FSUtil.node,
|
||||
Location.node,
|
||||
Git.node,
|
||||
Bus.node,
|
||||
PluginSupervisor.node,
|
||||
LocationWatcherPolicy.node,
|
||||
],
|
||||
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, Bus.node],
|
||||
})
|
||||
|
||||
@@ -29,7 +29,6 @@ import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
import { ReferenceInstructions } from "./reference/instructions.js"
|
||||
@@ -72,7 +71,6 @@ const locationServiceNodes = [
|
||||
Worktree.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
ShellSelect.node,
|
||||
Pty.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
|
||||
@@ -2,7 +2,13 @@ export * as ModelResolver from "./model-resolver.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { Auth } from "@opencode-ai/ai/route"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
|
||||
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
@@ -77,6 +83,47 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelResolver") {}
|
||||
|
||||
const apiKey = (model: Info, credential?: Credential.Value) => {
|
||||
if (credential?.type === "key") return Auth.value(credential.key)
|
||||
if (credential?.type === "oauth") return Auth.value(credential.access)
|
||||
const value = model.settings?.apiKey
|
||||
if (typeof value === "string") return Auth.value(value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const withDefaults = (model: Info, route: AnyRoute) =>
|
||||
route.with({
|
||||
provider: model.providerID,
|
||||
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
|
||||
headers: providerHeaders(model),
|
||||
providerOptions: providerOptions(model),
|
||||
http: model.body === undefined ? undefined : { body: model.body },
|
||||
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
|
||||
})
|
||||
|
||||
const providerHeaders = (model: Info) => {
|
||||
const packageName = Provider.packageName(model.package)
|
||||
const generated = new Map<string, string>()
|
||||
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
|
||||
generated.set("OpenAI-Organization", model.settings.organization)
|
||||
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
|
||||
generated.set("OpenAI-Project", model.settings.project)
|
||||
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
|
||||
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
|
||||
return Provider.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
|
||||
}
|
||||
|
||||
const providerOptions = (model: Info): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
if (!Provider.isAISDK(model.package) || model.settings === undefined) return undefined
|
||||
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
|
||||
if (Object.keys(settings).length === 0) return undefined
|
||||
const packageName = Provider.packageName(model.package)
|
||||
if (packageName === "@ai-sdk/openai") return { openai: settings }
|
||||
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
|
||||
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const withVariant = (
|
||||
model: Info,
|
||||
variantID: VariantID | undefined,
|
||||
@@ -123,14 +170,37 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
) {
|
||||
const resolved = prepareRuntimeModel(model, credential)
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
const configuration = credential?.type === "key" ? credential.configuration : undefined
|
||||
|
||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||
const runtime = yield* prepareProviderModel(resolved)
|
||||
return withDefaults(runtime, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
|
||||
}
|
||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
|
||||
const runtime = yield* prepareProviderModel(resolved)
|
||||
return withDefaults(runtime, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
|
||||
}
|
||||
if (
|
||||
Provider.isAISDK(resolved.package) &&
|
||||
packageName === "@ai-sdk/openai-compatible" &&
|
||||
typeof resolved.settings?.baseURL === "string"
|
||||
) {
|
||||
const runtime = yield* prepareProviderModel(resolved)
|
||||
return withDefaults(runtime, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
|
||||
}
|
||||
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
|
||||
const mapping = Provider.isAISDK(resolved.package)
|
||||
? AISDKNative.map({
|
||||
packageName,
|
||||
settings: configured,
|
||||
modelID: resolved.modelID ?? resolved.id,
|
||||
providerID: resolved.providerID,
|
||||
})
|
||||
: undefined
|
||||
const native = mapping?.package ?? resolved.package
|
||||
@@ -197,6 +267,19 @@ function validateProviderVariables(
|
||||
return failure ? Effect.fail(failure) : Effect.succeed(resolved)
|
||||
}
|
||||
|
||||
function prepareProviderModel(model: Info): Effect.Effect<Info, UnresolvedProviderVariablesError> {
|
||||
if (!model.settings) return Effect.succeed(model)
|
||||
return prepareProviderSettings(model, model.settings).pipe(
|
||||
Effect.map((settings) =>
|
||||
settings === model.settings
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
draft.settings = settings
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function prepareProviderSettings(
|
||||
model: Info,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
|
||||
@@ -13,19 +13,14 @@ import { Config } from "../config.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||
import { ConfigCompactionPlugin } from "../config/plugin/compaction.js"
|
||||
import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
|
||||
import { ConfigImagePlugin } from "../config/plugin/image.js"
|
||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
||||
import { ConfigLocationWatcherPlugin } from "../config/plugin/location-watcher.js"
|
||||
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
||||
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
|
||||
import { ConfigShellPlugin } from "../config/plugin/shell.js"
|
||||
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
|
||||
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -34,7 +29,6 @@ import { FileMutation } from "../file-mutation.js"
|
||||
import { Formatter } from "../formatter.js"
|
||||
import { Form } from "../form.js"
|
||||
import { FileSystem } from "../filesystem.js"
|
||||
import { LocationWatcherPolicy } from "../filesystem/location-watcher-policy.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image.js"
|
||||
@@ -50,11 +44,8 @@ import { Permission } from "../permission.js"
|
||||
import { Reference } from "../reference.js"
|
||||
import { WebSearch } from "../websearch.js"
|
||||
import { Ripgrep } from "../ripgrep.js"
|
||||
import { SessionCompaction } from "../session/compaction.js"
|
||||
import { SessionInstructions } from "../session/instructions.js"
|
||||
import { Shell } from "../shell.js"
|
||||
import { ShellSelect } from "../shell/select.js"
|
||||
import { Snapshot } from "../snapshot.js"
|
||||
import { Skill } from "../skill.js"
|
||||
import { SkillDiscovery } from "../skill/discovery.js"
|
||||
import { Watcher } from "../filesystem/watcher.js"
|
||||
@@ -69,7 +60,6 @@ import { ShellTool } from "../tool/plugin/shell.js"
|
||||
import { SkillTool } from "../tool/plugin/skill.js"
|
||||
import { SubagentTool } from "../tool/plugin/subagent.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { ToolOutput } from "../tool-output.js"
|
||||
import { WebFetchTool } from "../tool/plugin/webfetch.js"
|
||||
import { WebSearchTool } from "../tool/plugin/websearch.js"
|
||||
import { WellKnown } from "../wellknown.js"
|
||||
@@ -100,7 +90,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const locationWatcherPolicy = yield* LocationWatcherPolicy.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
@@ -121,15 +110,11 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const reference = yield* Reference.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const shell = yield* Shell.Service
|
||||
const shellSelect = yield* ShellSelect.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const skill = yield* Skill.Service
|
||||
const skillDiscovery = yield* SkillDiscovery.Service
|
||||
const tools = yield* Tool.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const wellknown = yield* WellKnown.Service
|
||||
return Context.mergeAll(
|
||||
@@ -144,7 +129,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Environment.Service, environment),
|
||||
Context.make(FileMutation.Service, mutation),
|
||||
Context.make(Formatter.Service, formatter),
|
||||
Context.make(LocationWatcherPolicy.Service, locationWatcherPolicy),
|
||||
Context.make(FileSystem.Service, filesystem),
|
||||
Context.make(FSUtil.Service, fs),
|
||||
Context.make(Global.Service, global),
|
||||
@@ -165,15 +149,11 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Reference.Service, reference),
|
||||
Context.make(WebSearch.Service, websearch),
|
||||
Context.make(Ripgrep.Service, ripgrep),
|
||||
Context.make(SessionCompaction.Service, compaction),
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(ShellSelect.Service, shellSelect),
|
||||
Context.make(Snapshot.Service, snapshot),
|
||||
Context.make(Skill.Service, skill),
|
||||
Context.make(SkillDiscovery.Service, skillDiscovery),
|
||||
Context.make(Tool.Service, tools),
|
||||
Context.make(ToolOutput.Service, toolOutput),
|
||||
Context.make(Watcher.Service, watcher),
|
||||
Context.make(WellKnown.Service, wellknown),
|
||||
)
|
||||
@@ -195,7 +175,6 @@ export const requirements = LayerNode.group([
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
LocationWatcherPolicy.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
@@ -216,15 +195,11 @@ export const requirements = LayerNode.group([
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Ripgrep.node,
|
||||
SessionCompaction.node,
|
||||
SessionInstructions.node,
|
||||
Shell.node,
|
||||
ShellSelect.node,
|
||||
Snapshot.node,
|
||||
Skill.node,
|
||||
SkillDiscovery.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
Watcher.node,
|
||||
WellKnown.node,
|
||||
])
|
||||
@@ -263,13 +238,8 @@ const post = [
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
ConfigCompactionPlugin.Plugin,
|
||||
ConfigFormatterPlugin.Plugin,
|
||||
ConfigImagePlugin.Plugin,
|
||||
ConfigLocationWatcherPlugin.Plugin,
|
||||
ConfigShellPlugin.Plugin,
|
||||
ConfigSnapshotPlugin.Plugin,
|
||||
ConfigToolOutputPlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
|
||||
@@ -190,6 +190,13 @@ export const OpenAIPlugin = define({
|
||||
})
|
||||
yield* load()
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (!Provider.isAISDK(item.provider.package)) continue
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.package = "@opencode-ai/ai/providers/openai"
|
||||
})
|
||||
}
|
||||
if (!chatgpt) return
|
||||
const item = evt.provider.get(Provider.ID.openai)
|
||||
if (!item) return
|
||||
|
||||
@@ -4,10 +4,12 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { Disp, Proc } from "#pty"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { Config } from "./config.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Location } from "./location.js"
|
||||
import { PtyID } from "./pty/schema.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { lazy } from "./util/lazy.js"
|
||||
|
||||
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||
@@ -88,13 +90,14 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
|
||||
|
||||
const layer = () =>
|
||||
export const layer = (options?: ShellSelect.Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<PtyID, Active>()
|
||||
@@ -164,7 +167,8 @@ const layer = () =>
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || (yield* shell.preferred())
|
||||
const command =
|
||||
input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin)
|
||||
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||
const cwd = input.cwd || location.directory
|
||||
const env = {
|
||||
@@ -313,8 +317,12 @@ const layer = () =>
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [Bus.node, Location.node, ShellSelect.node],
|
||||
})
|
||||
export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node],
|
||||
})
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
|
||||
+25
-132
@@ -141,11 +141,6 @@ export class InboxConflictError extends Schema.TaggedError<InboxConflictError>()
|
||||
sessionID: SessionSchema.ID,
|
||||
inboxID: SessionMessage.ID,
|
||||
}) {}
|
||||
export class SeqUnavailableError extends Schema.TaggedError<SeqUnavailableError>()("Session.SeqUnavailableError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
after: Event.Seq,
|
||||
head: Schema.optional(Event.Seq),
|
||||
}) {}
|
||||
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
|
||||
export class SkillNotFoundError extends Schema.TaggedError<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||
skill: Skill.ID,
|
||||
@@ -199,33 +194,13 @@ export interface Interface {
|
||||
* unhandled compaction barriers.
|
||||
*/
|
||||
readonly inbox: (sessionID: SessionSchema.ID) => Effect.Effect<SessionInbox.Info[], NotFoundError>
|
||||
readonly snapshot: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
recent?: number
|
||||
}) => Effect.Effect<
|
||||
{
|
||||
readonly session: SessionSchema.Info
|
||||
readonly children: SessionSchema.Info[]
|
||||
readonly inbox: SessionInbox.Info[]
|
||||
readonly messages: SessionMessage.Info[]
|
||||
readonly seq: Event.Seq
|
||||
},
|
||||
NotFoundError | MessageDecodeError
|
||||
>
|
||||
readonly cancelInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
|
||||
readonly steerInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
|
||||
readonly queueInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
|
||||
readonly openLog: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
follow?: boolean
|
||||
ephemeral?: boolean
|
||||
}) => Effect.Effect<Stream.Stream<SessionEvent.Event | EventLog.Synced>, NotFoundError | SeqUnavailableError>
|
||||
/**
|
||||
* Ordered session log read. Replays durable session events after the
|
||||
* exclusive `after` cursor, emits a `Synced` marker at the captured replay
|
||||
* watermark, then continues live when `follow` is set. Ephemeral events are
|
||||
* included only in the live phase when explicitly requested.
|
||||
* Durable, ordered session log read. Replays durable session bus after
|
||||
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
||||
* replay watermark, then continues live when `follow` is set.
|
||||
* The marker's seq may exceed the last emitted event because other durable
|
||||
* bus share the aggregate's sequence space.
|
||||
*/
|
||||
@@ -233,8 +208,7 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
follow?: boolean
|
||||
ephemeral?: boolean
|
||||
}) => Stream.Stream<SessionEvent.Event | EventLog.Synced, NotFoundError | SeqUnavailableError>
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
@@ -352,7 +326,6 @@ const layer = Layer.effect(
|
||||
})
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const isSessionEvent = Schema.is(SessionEvent.All)
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
@@ -584,90 +557,20 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* SessionInbox.list(db, sessionID)
|
||||
}),
|
||||
snapshot: Effect.fn("Session.snapshot")(function* (input) {
|
||||
return yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
|
||||
const children = yield* db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.parent_id, input.sessionID))
|
||||
.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const inbox = yield* SessionInbox.list(db, input.sessionID)
|
||||
const messages = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, input.sessionID))
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(input.recent ?? 200)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const seq = yield* Bus.latestSequence(db, input.sessionID)
|
||||
if (seq < 0) return yield* Effect.die(new Error(`Session ${input.sessionID} has no event sequence`))
|
||||
return {
|
||||
session: fromRow(row),
|
||||
children: children.map(fromRow),
|
||||
inbox,
|
||||
messages: yield* Effect.forEach(messages.toReversed(), decode),
|
||||
seq: Event.Seq.make(seq),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.catchTag("SqlError", Effect.die))
|
||||
}),
|
||||
cancelInbox: Effect.fn("Session.cancelInbox")((input) => mutatePending(input, SessionInbox.cancel)),
|
||||
steerInbox: Effect.fn("Session.steerInbox")((input) => mutatePending(input, SessionInbox.steer, true)),
|
||||
queueInbox: Effect.fn("Session.queueInbox")((input) => mutatePending(input, SessionInbox.queue)),
|
||||
openLog: Effect.fn("Session.openLog")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
if (input.after !== undefined) {
|
||||
const head = yield* Bus.latestSequence(db, input.sessionID)
|
||||
if (input.after > head)
|
||||
return yield* new SeqUnavailableError({
|
||||
sessionID: input.sessionID,
|
||||
after: Event.Seq.make(input.after),
|
||||
head: head >= 0 ? Event.Seq.make(head) : undefined,
|
||||
})
|
||||
// A cursor claims the caller already holds everything through `after`, so
|
||||
// replay of (after, head] must be provably complete. Without retained rows
|
||||
// covering the range (events.persist off, or pruned history) replaying
|
||||
// nothing would silently desync the caller; fail so it re-snapshots instead.
|
||||
if (input.after < head) {
|
||||
const retained = yield* Bus.retainedCount(db, input.sessionID, input.after, head)
|
||||
if (retained < head - input.after)
|
||||
return yield* new SeqUnavailableError({
|
||||
sessionID: input.sessionID,
|
||||
after: Event.Seq.make(input.after),
|
||||
head: Event.Seq.make(head),
|
||||
})
|
||||
}
|
||||
}
|
||||
return bus
|
||||
.log({
|
||||
aggregateID: input.sessionID,
|
||||
after: input.after,
|
||||
follow: input.follow,
|
||||
includeLive: input.ephemeral
|
||||
? (event) => isSessionEvent(event) && event.data.sessionID === input.sessionID
|
||||
: undefined,
|
||||
})
|
||||
.pipe(
|
||||
Stream.filter(
|
||||
(item): item is SessionEvent.Event | EventLog.Synced =>
|
||||
Bus.isSynced(item) || (input.ephemeral ? isSessionEvent(item) : isDurableSessionEvent(item)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
log: (input) => Stream.unwrap(result.openLog(input)),
|
||||
log: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.as(bus.log({ aggregateID: input.sessionID, after: input.after, follow: input.follow }))),
|
||||
).pipe(
|
||||
Stream.filter(
|
||||
(item): item is SessionEvent.DurableEvent | EventLog.Synced =>
|
||||
Bus.isSynced(item) || isDurableSessionEvent(item),
|
||||
),
|
||||
),
|
||||
prompt: Effect.fn("Session.prompt")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
@@ -725,11 +628,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
command: Effect.fn("Session.command")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const commands = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* Command.Service
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const commands = yield* Command.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const command = yield* commands.get(input.command)
|
||||
if (!command)
|
||||
return yield* new Command.NotFoundError({
|
||||
@@ -768,8 +667,6 @@ const layer = Layer.effect(
|
||||
activeShells.add(input.sessionID)
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
const started = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
const shell = yield* Shell.Service
|
||||
return yield* shell
|
||||
.create({
|
||||
@@ -1008,23 +905,19 @@ const layer = Layer.effect(
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if ((yield* execution.active).has(input.sessionID))
|
||||
return yield* new BusyError({ sessionID: input.sessionID })
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
)
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
clear: Effect.fn("Session.revert.clear")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
||||
const revert = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const revert = yield* SessionRevert.clear(session).pipe(
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
yield* execution.wake(sessionID)
|
||||
return revert
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,9 @@ export * as SessionCompaction from "./compaction.js"
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "../config.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
@@ -22,7 +24,6 @@ import type { Info, Ref } from "../model.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { Agent } from "../agent.js"
|
||||
import { State } from "../state.js"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_000
|
||||
@@ -60,14 +61,10 @@ Rules:
|
||||
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
|
||||
- Do not mention the summary process or that context was compacted.`
|
||||
|
||||
export type Settings = {
|
||||
auto: boolean
|
||||
buffer: number
|
||||
tokens: number
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
configure: (settings: Partial<Settings>) => void
|
||||
type Settings = {
|
||||
readonly auto: boolean
|
||||
readonly buffer: number
|
||||
readonly tokens: number
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
@@ -77,6 +74,7 @@ type Dependencies = {
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly config: Settings
|
||||
readonly hooks: PluginHooks.Interface
|
||||
}
|
||||
|
||||
@@ -113,7 +111,7 @@ export type Outcome =
|
||||
| Pick<SessionMessage.CompactionCompleted, "status">
|
||||
| Pick<SessionMessage.CompactionFailed, "status" | "error">
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
export interface Interface {
|
||||
readonly required: (input: RequiredInput) => boolean
|
||||
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
|
||||
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
|
||||
@@ -167,6 +165,17 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
return ""
|
||||
}
|
||||
|
||||
const settings = (documents: readonly Entry[]) => {
|
||||
const configured = documents
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
||||
return {
|
||||
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
|
||||
buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER,
|
||||
tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS,
|
||||
}
|
||||
}
|
||||
|
||||
const select = (
|
||||
messages: readonly SessionMessage.Info[],
|
||||
tokens: number,
|
||||
@@ -231,17 +240,7 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
|
||||
}
|
||||
|
||||
const make = (dependencies: Dependencies) => {
|
||||
const state = State.create<Settings, Draft>({
|
||||
name: "session-compaction",
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
|
||||
draft: (draft) => ({
|
||||
configure: (settings) => {
|
||||
if (settings.auto !== undefined) draft.auto = settings.auto
|
||||
if (settings.buffer !== undefined) draft.buffer = settings.buffer
|
||||
if (settings.tokens !== undefined) draft.tokens = settings.tokens
|
||||
},
|
||||
}),
|
||||
})
|
||||
const config = dependencies.config
|
||||
const failed = Effect.fnUntraced(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
@@ -351,7 +350,7 @@ const make = (dependencies: Dependencies) => {
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
const content = planContent(input.messages, config.tokens)
|
||||
if (content)
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
@@ -369,7 +368,6 @@ const make = (dependencies: Dependencies) => {
|
||||
})
|
||||
})
|
||||
const required = (input: RequiredInput) => {
|
||||
const config = state.get()
|
||||
if (!config.auto) return false
|
||||
const context = input.model.route.defaults.limits?.context
|
||||
if (context === undefined || context <= 0) return false
|
||||
@@ -390,7 +388,7 @@ const make = (dependencies: Dependencies) => {
|
||||
return used >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
const content = planContent(input.messages, config.tokens)
|
||||
if (!content)
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
@@ -421,8 +419,6 @@ const make = (dependencies: Dependencies) => {
|
||||
})
|
||||
})
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
required,
|
||||
compact,
|
||||
compactManual,
|
||||
@@ -434,15 +430,16 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const config = yield* Config.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const app = yield* App.Metadata
|
||||
const hooks = yield* PluginHooks.Service
|
||||
return make({ bus, llm, models, app, hooks })
|
||||
return make({ bus, llm, models, config: settings(yield* config.entries()), app, hooks })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, llmClient, SessionRunnerModel.node, App.node, PluginHooks.node],
|
||||
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node, PluginHooks.node],
|
||||
})
|
||||
|
||||
@@ -34,7 +34,6 @@ import { toSessionError } from "../to-session-error.js"
|
||||
import { SessionRunnerRetry } from "./retry.js"
|
||||
import { SessionUsage } from "../usage.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
import { PluginSupervisor } from "../../plugin/supervisor.js"
|
||||
|
||||
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
|
||||
type CallOutcome = Data.TaggedEnum<{
|
||||
@@ -115,7 +114,6 @@ const layer = Layer.effect(
|
||||
const snapshots = yield* Snapshot.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
// Title generation starts once input is visible and must not delay model execution.
|
||||
@@ -137,7 +135,6 @@ const layer = Layer.effect(
|
||||
const promotable = input.promotable ?? "input"
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
return { type: "complete" as const }
|
||||
yield* plugins.flush
|
||||
yield* settleStaleToolCalls(input.sessionID)
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(input.sessionID, promotable)) {
|
||||
@@ -649,7 +646,6 @@ export const node = makeLocationNode({
|
||||
SessionModelTransport.node,
|
||||
SessionStore.node,
|
||||
SessionCompaction.node,
|
||||
PluginSupervisor.node,
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
ToolOutput.node,
|
||||
|
||||
+27
-17
@@ -7,6 +7,7 @@ import { produce } from "immer"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Config } from "./config.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Environment } from "./environment/index.js"
|
||||
import { Location } from "./location.js"
|
||||
@@ -67,14 +68,14 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
|
||||
|
||||
const layer = () =>
|
||||
export const layer = (options?: ShellSelect.Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const environment = yield* Environment.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
@@ -145,7 +146,12 @@ const layer = () =>
|
||||
return session.info
|
||||
})
|
||||
|
||||
const name = () => shell.preferred().pipe(Effect.map(ShellSelect.name))
|
||||
const resolve = () =>
|
||||
config
|
||||
.entries()
|
||||
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin)))
|
||||
|
||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
@@ -190,7 +196,7 @@ const layer = () =>
|
||||
command: input.command,
|
||||
cwd: input.cwd ?? location.directory,
|
||||
timeout: input.timeout,
|
||||
shell: yield* shell.preferred(),
|
||||
shell: yield* resolve(),
|
||||
env: {
|
||||
...(sessionEnvironment ?? process.env),
|
||||
TERM: "xterm-256color",
|
||||
@@ -347,16 +353,20 @@ const layer = () =>
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [
|
||||
Bus.node,
|
||||
Location.node,
|
||||
Global.node,
|
||||
ShellSelect.node,
|
||||
Environment.node,
|
||||
PluginHooks.node,
|
||||
SessionEnvironment.node,
|
||||
],
|
||||
})
|
||||
export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [
|
||||
Bus.node,
|
||||
Location.node,
|
||||
Config.node,
|
||||
Global.node,
|
||||
Environment.node,
|
||||
PluginHooks.node,
|
||||
SessionEnvironment.node,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
|
||||
@@ -3,11 +3,8 @@ export * as ShellSelect from "./select.js"
|
||||
import path from "path"
|
||||
import { readFile } from "fs/promises"
|
||||
import { statSync } from "fs"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { State } from "../state.js"
|
||||
import { which } from "../util/which.js"
|
||||
|
||||
const META: Record<string, { deny?: boolean; login?: boolean; ps?: boolean }> = {
|
||||
@@ -33,20 +30,6 @@ export const Options = Schema.Struct({
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
type Data = {
|
||||
shell?: string
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
configure: (shell: string) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly preferred: () => Effect.Effect<string>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellSelect") {}
|
||||
|
||||
function stat(file: string) {
|
||||
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
||||
}
|
||||
@@ -198,31 +181,3 @@ export async function list(options?: Options, bin?: string): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win(options, bin) : await unix()
|
||||
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
|
||||
}
|
||||
|
||||
const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "shell-select",
|
||||
initial: () => ({}),
|
||||
draft: (draft) => ({
|
||||
configure: (shell) => {
|
||||
draft.shell = shell
|
||||
},
|
||||
}),
|
||||
})
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
preferred: () => Effect.sync(() => preferred(state.get().shell, options, global.bin)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export function configured(options?: Options) {
|
||||
return makeLocationNode({ service: Service, layer: layer(options), deps: [Global.node] })
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as Snapshot from "./snapshot.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Fiber, Layer, Schema, Scope } from "effect"
|
||||
import { Config } from "./config.js"
|
||||
import { File } from "./file.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
@@ -11,7 +12,6 @@ import { Location } from "./location.js"
|
||||
import { AbsolutePath, RelativePath } from "./schema.js"
|
||||
import { ID } from "@opencode-ai/schema/snapshot"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export { ID }
|
||||
|
||||
@@ -36,11 +36,7 @@ export interface RestoreInput {
|
||||
readonly files: ReadonlyMap<RelativePath, ID>
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
configure: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
export interface Interface {
|
||||
/**
|
||||
* Capture the current Location-scoped filesystem state as a content-addressed
|
||||
* tree. Returns `undefined` when snapshots are disabled, unsupported, or the
|
||||
@@ -72,20 +68,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sn
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const lifetime = yield* Scope.Scope
|
||||
const state = State.create<{ enabled: boolean }, Draft>({
|
||||
name: "snapshot",
|
||||
initial: () => ({ enabled: true }),
|
||||
draft: (draft) => ({
|
||||
configure: (enabled) => {
|
||||
draft.enabled = enabled
|
||||
},
|
||||
}),
|
||||
})
|
||||
// Cache a scope-owned fiber so caller cancellation stops waiting without poisoning shared initialization.
|
||||
const repositoryFiber = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
@@ -112,10 +100,13 @@ const layer = Layer.effect(
|
||||
return RelativePath.make(relative.replaceAll("\\", "/") || ".")
|
||||
})
|
||||
|
||||
const enabled = () => location.vcs?.type === "git" && state.get().enabled
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (location.vcs?.type !== "git") return false
|
||||
return Config.latest(yield* config.entries(), "snapshots") !== false
|
||||
})
|
||||
|
||||
const capture = Effect.fn("Snapshot.capture")(function* () {
|
||||
if (!enabled()) return undefined
|
||||
if (!(yield* enabled())) return undefined
|
||||
return yield* Effect.gen(function* () {
|
||||
const repo = yield* repository
|
||||
return ID.make(
|
||||
@@ -179,28 +170,26 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
|
||||
if (!enabled()) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
|
||||
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
yield* git.tree
|
||||
.restore({ repository: repo.snapshotRepository, files: yield* plan(repo.worktree, input) })
|
||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
})
|
||||
|
||||
return Service.of({ transform: state.transform, reload: state.reload, capture, files, diff, restore })
|
||||
return Service.of({ capture, files, diff, restore })
|
||||
}).pipe(Effect.withSpan("Snapshot.boot")),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Git.node, Global.node, Location.node],
|
||||
deps: [Config.node, FSUtil.node, Git.node, Global.node, Location.node],
|
||||
})
|
||||
|
||||
export const noopLayer = Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
transform: () => Effect.succeed({ dispose: Effect.void }),
|
||||
reload: () => Effect.void,
|
||||
capture: () => Effect.succeed(undefined),
|
||||
files: () => Effect.succeed([]),
|
||||
diff: () => Effect.succeed([]),
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config.js"
|
||||
import { Identifier } from "./id/id.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export const MAX_LINES = 2_000
|
||||
export const MAX_BYTES = 50 * 1024 // 50 KiB
|
||||
@@ -16,16 +16,7 @@ export const DIRECTORY = "tool-output"
|
||||
|
||||
type Result = Tool.Result
|
||||
|
||||
type Limits = {
|
||||
maxLines: number
|
||||
maxBytes: number
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
configure: (limits: Partial<Limits>) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
export interface Interface {
|
||||
readonly truncate: (result: Result) => Effect.Effect<Result>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -55,38 +46,31 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface,
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.data, DIRECTORY)
|
||||
const state = State.create<Limits, Draft>({
|
||||
name: "tool-output",
|
||||
initial: () => ({ maxLines: MAX_LINES, maxBytes: MAX_BYTES }),
|
||||
draft: (draft) => ({
|
||||
configure: (limits) => {
|
||||
if (limits.maxLines !== undefined) draft.maxLines = limits.maxLines
|
||||
if (limits.maxBytes !== undefined) draft.maxBytes = limits.maxBytes
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const truncate = Effect.fnUntraced(function* (result: Result) {
|
||||
if (result.metadata?.truncated !== undefined) return result
|
||||
const content =
|
||||
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
|
||||
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
|
||||
const limits = state.get()
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? MAX_BYTES
|
||||
const lines = text.split("\n")
|
||||
if (text.endsWith("\n")) lines.pop()
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
if (lines.length <= limits.maxLines && totalBytes <= limits.maxBytes)
|
||||
if (lines.length <= maxLines && totalBytes <= maxBytes)
|
||||
return { ...result, metadata: { ...result.metadata, truncated: false } }
|
||||
|
||||
const kept: string[] = []
|
||||
let bytes = 0
|
||||
let hitBytes = false
|
||||
for (const line of lines.slice(0, limits.maxLines)) {
|
||||
for (const line of lines.slice(0, maxLines)) {
|
||||
const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0)
|
||||
if (bytes + size > limits.maxBytes) {
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
@@ -129,12 +113,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
truncate,
|
||||
cleanup: () => cleanup(fs, directory),
|
||||
})
|
||||
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -158,5 +137,5 @@ const cleanupNode = makeGlobalNode({
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Global.node, cleanupNode],
|
||||
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
|
||||
})
|
||||
|
||||
@@ -2,91 +2,9 @@ import { describe, expect, test } from "bun:test"
|
||||
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
|
||||
|
||||
const map = (packageName: string, settings: Readonly<Record<string, unknown>>, modelID = "test-model") =>
|
||||
AISDKNative.map({ packageName, settings, modelID, providerID: "test-provider" })
|
||||
AISDKNative.map({ packageName, settings, modelID })
|
||||
|
||||
describe("AISDKNative", () => {
|
||||
test("maps OpenAI-family packages and request options to native providers", () => {
|
||||
expect(
|
||||
map("@ai-sdk/openai", {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://api.meta.ai/v1",
|
||||
organization: "org",
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
instructions: "Follow the repository instructions.",
|
||||
truncation: "auto",
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/openai",
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://api.meta.ai/v1",
|
||||
organization: "org",
|
||||
providerOptions: {
|
||||
openai: {
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
instructions: "Follow the repository instructions.",
|
||||
truncation: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(map("@ai-sdk/openai-compatible", { baseURL: "https://example.com/v1", reasoningEffort: "high" })).toEqual({
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: {
|
||||
baseURL: "https://example.com/v1",
|
||||
provider: "test-provider",
|
||||
providerOptions: { openai: { reasoningEffort: "high" } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Anthropic settings and request options to the native provider", () => {
|
||||
expect(
|
||||
map("@ai-sdk/anthropic", {
|
||||
authToken: "token",
|
||||
baseURL: "https://anthropic.example/v1",
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/anthropic",
|
||||
settings: {
|
||||
authToken: "token",
|
||||
baseURL: "https://anthropic.example/v1",
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Google Vertex settings to the native provider", () => {
|
||||
expect(
|
||||
map("@ai-sdk/google-vertex", {
|
||||
project: "project",
|
||||
location: "us-central1",
|
||||
labels: { environment: "test" },
|
||||
thinkingConfig: { thinkingLevel: "high" },
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/google-vertex",
|
||||
settings: {
|
||||
project: "project",
|
||||
location: "us-central1",
|
||||
providerOptions: {
|
||||
gemini: { labels: { environment: "test" }, thinkingConfig: { thinkingLevel: "high" } },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("maps both models.dev Bedrock packages to native providers", () => {
|
||||
expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({
|
||||
package: "@opencode-ai/ai/providers/amazon-bedrock",
|
||||
@@ -355,35 +273,6 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Vertex Gemini settings to the native Gemini route", () => {
|
||||
expect(
|
||||
map("@ai-sdk/google-vertex", {
|
||||
accessToken: "vertex-token",
|
||||
baseURL: "https://vertex.example/v1",
|
||||
headers: { "x-test": "value" },
|
||||
labels: { component: "opencode", environment: "test" },
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
thinkingConfig: { thinkingLevel: "high" },
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/google-vertex",
|
||||
settings: {
|
||||
accessToken: "vertex-token",
|
||||
baseURL: "https://vertex.example/v1",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
labels: { component: "opencode", environment: "test" },
|
||||
thinkingConfig: { thinkingLevel: "high" },
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: { "x-test": "value" },
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Vertex Anthropic settings to native Messages", () => {
|
||||
expect(
|
||||
map("@ai-sdk/google-vertex/anthropic", {
|
||||
|
||||
@@ -104,43 +104,6 @@ it.effect("projects request settings, headers, and body overlays", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("opaque-provider"))
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
system: "Initial instructions.",
|
||||
messages: [
|
||||
Message.user("Before."),
|
||||
Message.system("Updated <rules> & constraints."),
|
||||
Message.assistant("After."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt).toEqual([
|
||||
{ role: "system", content: "Initial instructions." },
|
||||
{ role: "user", content: [{ type: "text", text: "Before." }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<system-update>\nUpdated <rules> & 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,17 +1,19 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
|
||||
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(Command.node, [
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LanguageModel, LLMClient, LLMEvent } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCompactionPlugin } from "@opencode-ai/core/config/plugin/compaction"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { DateTime, Effect, Fiber, Layer, Option, Schema, Stream } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const model = LanguageModel.make({
|
||||
id: "test-model",
|
||||
provider: "test-provider",
|
||||
route: OpenAIChat.route.with({ limits: { context: 100_000, output: 1_000 } }),
|
||||
})
|
||||
const config = Config.testLayer()
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
config,
|
||||
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, Config.node, Bus.node]), [
|
||||
[
|
||||
llmClient,
|
||||
Layer.mock(LLMClient.Service)({
|
||||
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
|
||||
}),
|
||||
],
|
||||
[
|
||||
SessionRunnerModel.node,
|
||||
Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
],
|
||||
[Config.node, config],
|
||||
]),
|
||||
),
|
||||
)
|
||||
describe("ConfigCompactionPlugin.Plugin", () => {
|
||||
it.live("merges settings and reloads changed config", () =>
|
||||
Effect.gen(function* () {
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const config = yield* Config.Test
|
||||
const bus = yield* Bus.Service
|
||||
yield* config.setEntries([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ compaction: new ConfigCompaction.Info({ auto: false, buffer: 20_000 }) }),
|
||||
}),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
compaction: new ConfigCompaction.Info({
|
||||
buffer: 10_000,
|
||||
keep: new ConfigCompaction.Keep({ tokens: 0 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
yield* ConfigCompactionPlugin.Plugin.effect(host({ event: { subscribe: () => bus.subscribe(Event.Updated) } }))
|
||||
|
||||
expect(compaction.required(nearInput)).toBe(false)
|
||||
const started = yield* bus
|
||||
.subscribe(SessionEvent.Compaction.Started)
|
||||
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Older context",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Recent context",
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
},
|
||||
],
|
||||
inputID: SessionMessage.ID.make("msg_compaction_manual"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
expect(Option.getOrThrow(yield* Fiber.join(started)).data.recent).toContain("Recent context")
|
||||
|
||||
yield* config.setEntries([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ compaction: new ConfigCompaction.Info({ auto: true, buffer: 20_000 }) }),
|
||||
}),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ compaction: new ConfigCompaction.Info({ buffer: 10_000 }) }),
|
||||
}),
|
||||
])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* Effect.gen(function* () {
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if (compaction.required(nearInput)) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
yield* Effect.die(new Error("Timed out waiting for compaction config reload"))
|
||||
})
|
||||
expect(compaction.required(bufferedInput)).toBe(false)
|
||||
|
||||
yield* config.setEntries([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ compaction: new ConfigCompaction.Info({ auto: true, buffer: 20_000 }) }),
|
||||
}),
|
||||
])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if (compaction.required(bufferedInput)) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
yield* Effect.die(new Error("Timed out waiting for compaction config reload"))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const session = Session.Info.make({
|
||||
id: Session.ID.make("ses_compaction_config"),
|
||||
projectID: Project.ID.global,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
|
||||
})
|
||||
const input = (tokens: number) => ({
|
||||
session,
|
||||
model,
|
||||
cost: [],
|
||||
messages: [
|
||||
Schema.decodeUnknownSync(SessionMessage.Assistant)({
|
||||
id: SessionMessage.ID.make("msg_compaction_config"),
|
||||
type: "assistant",
|
||||
agent: Agent.defaultID,
|
||||
model: { id: "test-model", providerID: "test-provider" },
|
||||
content: [],
|
||||
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, completed: 0 },
|
||||
}),
|
||||
],
|
||||
})
|
||||
const bufferedInput = input(85_000)
|
||||
const nearInput = input(95_000)
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigShellPlugin } from "@opencode-ai/core/config/plugin/shell"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(Layer.merge(PluginTestLayer, AppNodeBuilder.build(ShellSelect.node)))
|
||||
|
||||
describe("ConfigShellPlugin.Plugin", () => {
|
||||
it.live("applies the preferred shell and reloads changed config", () =>
|
||||
Effect.gen(function* () {
|
||||
const shell = yield* ShellSelect.Service
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Test
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* ConfigShellPlugin.Plugin.effect(yield* PluginHost.make(plugins))
|
||||
|
||||
const configured = process.platform === "win32" ? FSUtil.windowsPath(process.execPath) : process.execPath
|
||||
expect(yield* shell.preferred()).toBe(configured)
|
||||
|
||||
yield* config.setEntries([])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if ((yield* shell.preferred()) !== configured) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
yield* Effect.die(new Error("Timed out waiting for shell config reload"))
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Config.testLayer([new Document({ type: "document", info: new Info({ shell: process.execPath }) })]),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigSnapshotPlugin } from "@opencode-ai/core/config/plugin/snapshot"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect } from "effect"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { it } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
describe("ConfigSnapshotPlugin.Plugin", () => {
|
||||
it.live("applies availability and reloads changed config", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
|
||||
await $`git init`.cwd(project).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(project).quiet()
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Test
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* ConfigSnapshotPlugin.Plugin.effect(yield* PluginHost.make(plugins))
|
||||
|
||||
expect(yield* snapshot.capture()).toBeUndefined()
|
||||
|
||||
yield* config.setEntries([new Document({ type: "document", info: new Info({ snapshots: true }) })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if ((yield* snapshot.capture()) !== undefined) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
yield* Effect.die(new Error("Timed out waiting for snapshot config reload"))
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Snapshot.node, [
|
||||
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))],
|
||||
[Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
|
||||
]),
|
||||
),
|
||||
)
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.provide(PluginTestLayer),
|
||||
Effect.provide(Config.testLayer([new Document({ type: "document", info: new Info({ snapshots: false }) })])),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigToolOutputPlugin } from "@opencode-ai/core/config/plugin/tool-output"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect } from "effect"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { it } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
describe("ConfigToolOutputPlugin.Plugin", () => {
|
||||
it.live("applies limits and reloads changed config", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const output = yield* ToolOutput.Service
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Test
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* ConfigToolOutputPlugin.Plugin.effect(yield* PluginHost.make(plugins))
|
||||
|
||||
expect((yield* output.truncate({ content: "one\ntwo" })).metadata?.truncated).toBe(true)
|
||||
|
||||
yield* config.setEntries([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
const result = yield* output.truncate({ content: "one\ntwo" })
|
||||
if (result.metadata?.truncated === false) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
yield* Effect.die(new Error("Timed out waiting for tool output config reload"))
|
||||
}).pipe(
|
||||
Effect.provide(AppNodeBuilder.build(ToolOutput.node, [[Global.node, Global.layerWith({ data: tmp.path })]])),
|
||||
),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.provide(PluginTestLayer),
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 1 }) }),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -4,24 +4,18 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigLocationWatcherPlugin } from "@opencode-ai/core/config/plugin/location-watcher"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeLocationNode, type LocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
|
||||
import { LocationWatcherPolicy } from "@opencode-ai/core/filesystem/location-watcher-policy"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
const describeNative = process.env.CI ? describe.skip : describe
|
||||
@@ -29,11 +23,6 @@ const describeNative = process.env.CI ? describe.skip : describe
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
|
||||
|
||||
const configLayer = Config.testLayer()
|
||||
const pluginNode = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
describe("Watcher.testLayer", () => {
|
||||
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
|
||||
@@ -146,26 +135,16 @@ describe("Watcher lifecycle", () => {
|
||||
})
|
||||
})
|
||||
|
||||
function provide(
|
||||
directory: string,
|
||||
vcs?: Location.Interface["vcs"],
|
||||
watcher?: Layer.Layer<Watcher.Service>,
|
||||
config: Layer.Layer<Config.Service> = configLayer,
|
||||
plugins: LocationNode<PluginSupervisor.Service> = pluginNode,
|
||||
) {
|
||||
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||
)
|
||||
const built = AppNodeBuilder.build(
|
||||
LayerNode.group([LocationWatcher.node, LocationWatcherPolicy.node, Bus.node, Config.node]),
|
||||
[
|
||||
[Config.node, config],
|
||||
[Location.node, locationLayer],
|
||||
[PluginSupervisor.node, plugins],
|
||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||
],
|
||||
)
|
||||
const built = AppNodeBuilder.build(LocationWatcher.node, [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||
])
|
||||
return Effect.provide(built)
|
||||
}
|
||||
|
||||
@@ -175,8 +154,6 @@ function withTmp<A, E, R>(
|
||||
vcs?: "git" | "hg"
|
||||
init?: (directory: string) => Promise<void>
|
||||
watcher?: Layer.Layer<Watcher.Service>
|
||||
config?: Layer.Layer<Config.Service>
|
||||
plugins?: LocationNode<PluginSupervisor.Service>
|
||||
},
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
@@ -197,11 +174,7 @@ function withTmp<A, E, R>(
|
||||
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
|
||||
}),
|
||||
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap(({ tmp, vcs }) =>
|
||||
f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher, options?.config ?? configLayer, options?.plugins)),
|
||||
),
|
||||
)
|
||||
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
|
||||
}
|
||||
|
||||
describe("LocationWatcher subscriptions", () => {
|
||||
@@ -250,107 +223,6 @@ describe("LocationWatcher subscriptions", () => {
|
||||
{ vcs: "hg", watcher },
|
||||
)
|
||||
})
|
||||
|
||||
it.live("reconciles config without duplicate subscriptions", () => {
|
||||
const entries = { current: [] as Entry[] }
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
const counts = { active: 0, released: 0 }
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: (input) =>
|
||||
Effect.sync(() => {
|
||||
subscriptions.push(input)
|
||||
counts.active++
|
||||
return Stream.never.pipe(
|
||||
Stream.ensuring(
|
||||
Effect.sync(() => {
|
||||
counts.active--
|
||||
counts.released++
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.sync(() => entries.current),
|
||||
update: () => Effect.die("unused config.update"),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
yield* withTmp(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* Effect.sync(() => subscriptions.length).pipe(
|
||||
Effect.filterOrFail((count) => count === 1),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
)
|
||||
expect(counts.active).toBe(1)
|
||||
|
||||
entries.current = [new Document({ type: "document", info: new Info({ watcher: { ignore: [".git"] } }) })]
|
||||
yield* ConfigLocationWatcherPlugin.Plugin.effect(
|
||||
host({ event: { subscribe: () => bus.subscribe(Event.Updated) } }),
|
||||
)
|
||||
yield* Effect.sync(() => counts.active).pipe(
|
||||
Effect.filterOrFail((count) => count === 0),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
)
|
||||
expect(counts.released).toBe(1)
|
||||
|
||||
entries.current = []
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* Effect.sync(() => subscriptions.length).pipe(
|
||||
Effect.filterOrFail((count) => count === 2),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
)
|
||||
expect(counts.active).toBe(1)
|
||||
|
||||
yield* policy.reload()
|
||||
expect(subscriptions).toHaveLength(2)
|
||||
}),
|
||||
{ vcs: "git", watcher, config },
|
||||
)
|
||||
expect(counts.active).toBe(0)
|
||||
expect(counts.released).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
it.live("does not start before configured policy is ready", () => {
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.never)),
|
||||
}),
|
||||
)
|
||||
const plugins = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
yield* policy.transform((draft) => draft.add([".git"]))
|
||||
return PluginSupervisor.Service.of({ flush: Effect.void })
|
||||
}),
|
||||
),
|
||||
deps: [LocationWatcherPolicy.node],
|
||||
})
|
||||
return withTmp(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* LocationWatcher.Service
|
||||
yield* Effect.sleep("50 millis")
|
||||
expect(subscriptions).toEqual([])
|
||||
}),
|
||||
{ vcs: "git", watcher, plugins },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function wait(check: (event: WatcherEvent) => boolean) {
|
||||
|
||||
@@ -219,10 +219,7 @@ describe("ModelResolver", () => {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
limit: { context: 100, input: 80, output: 20 },
|
||||
})
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
catalog,
|
||||
Credential.Key.make({ type: "key", key: "secret" }),
|
||||
)
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
|
||||
|
||||
expect(catalog.id).toBe(ID.make("test-model"))
|
||||
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
|
||||
@@ -257,24 +254,22 @@ describe("ModelResolver", () => {
|
||||
)
|
||||
|
||||
it.effect("treats an empty configured API key as omitted", () =>
|
||||
withEnv({ OPENAI_API_KEY: "environment-key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
)
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://openai.example/v1/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
)
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://openai.example/v1/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(headers.authorization).toBe("Bearer environment-key")
|
||||
}),
|
||||
),
|
||||
expect(headers.authorization).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
|
||||
@@ -462,12 +457,8 @@ describe("ModelResolver", () => {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
variants: [
|
||||
{
|
||||
id: VariantID.make("xhigh"),
|
||||
settings: {
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
id: VariantID.make("high"),
|
||||
settings: { reasoningEffort: "high" },
|
||||
headers: { "x-variant": "high" },
|
||||
body: {
|
||||
store: false,
|
||||
@@ -477,7 +468,7 @@ describe("ModelResolver", () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("xhigh"))
|
||||
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("high"))
|
||||
|
||||
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
|
||||
expect(resolved.route.defaults.http?.body).toEqual({
|
||||
@@ -487,17 +478,7 @@ describe("ModelResolver", () => {
|
||||
temperature: 0.2,
|
||||
})
|
||||
expect(resolved.route.defaults.providerOptions).toEqual({
|
||||
openai: {
|
||||
store: false,
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
})
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
expect(prepared.body).toMatchObject({
|
||||
include: ["reasoning.encrypted_content"],
|
||||
reasoning: { effort: "xhigh", summary: "auto" },
|
||||
openai: { store: false, reasoningEffort: "high" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -834,46 +815,12 @@ describe("ModelResolver", () => {
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
|
||||
const packages = [
|
||||
[
|
||||
"@ai-sdk/openai",
|
||||
"@opencode-ai/ai/providers/openai",
|
||||
{
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
{
|
||||
openai: {
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ai-sdk/anthropic",
|
||||
"@opencode-ai/ai/providers/anthropic",
|
||||
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
|
||||
{ anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/openai-compatible",
|
||||
"@opencode-ai/ai/providers/openai-compatible",
|
||||
{ reasoningEffort: "high" },
|
||||
{ openai: { reasoningEffort: "high" } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/google",
|
||||
"@opencode-ai/ai/providers/google",
|
||||
{ thinkingConfig: { thinkingLevel: "high" } },
|
||||
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/google-vertex",
|
||||
"@opencode-ai/ai/providers/google-vertex",
|
||||
{ thinkingConfig: { thinkingLevel: "high" } },
|
||||
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
|
||||
],
|
||||
[
|
||||
"@openrouter/ai-sdk-provider",
|
||||
"@opencode-ai/ai/providers/openrouter",
|
||||
@@ -922,51 +869,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("never loads the AI SDK for packages with native implementations", () =>
|
||||
Effect.gen(function* () {
|
||||
const packages = [
|
||||
["@ai-sdk/anthropic", "@opencode-ai/ai/providers/anthropic", "api-model"],
|
||||
["@ai-sdk/amazon-bedrock", "@opencode-ai/ai/providers/amazon-bedrock", "api-model"],
|
||||
[
|
||||
"@ai-sdk/amazon-bedrock/mantle",
|
||||
"@opencode-ai/ai/providers/amazon-bedrock/mantle/responses",
|
||||
"openai.gpt-oss-120b",
|
||||
],
|
||||
["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"],
|
||||
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
|
||||
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
|
||||
[
|
||||
"@ai-sdk/google-vertex/anthropic",
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
"claude-sonnet-4-6",
|
||||
],
|
||||
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
|
||||
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
|
||||
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
|
||||
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "api-model"],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, modelID]) =>
|
||||
ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk(catalogPackage), {
|
||||
modelID,
|
||||
settings: { baseURL: "https://provider.example/v1", region: "us-east-1" },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe(nativePackage)
|
||||
return Effect.succeed({
|
||||
model: (id) => LanguageModel.make({ id, provider: "native-provider", route: OpenAIChat.route }),
|
||||
})
|
||||
},
|
||||
loadAISDK: () => Effect.die(`AI SDK loader called for ${catalogPackage}`),
|
||||
},
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes Vertex Anthropic catalog models through native Messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("OpenAIPlugin", () => {
|
||||
const proxy = yield* request(Provider.ID.openai, "https://proxy.example/v1?region=us")
|
||||
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||
expect(provider.package).toBe(Provider.aisdk("@ai-sdk/openai"))
|
||||
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
|
||||
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
|
||||
expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||
@@ -147,7 +147,7 @@ describe("OpenAIPlugin", () => {
|
||||
expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us")
|
||||
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(eligible.package).toBe(Provider.aisdk("@ai-sdk/openai"))
|
||||
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(eligible.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
|
||||
expect(eligible.cost).toEqual([])
|
||||
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
@@ -194,7 +194,7 @@ describe("OpenAIPlugin", () => {
|
||||
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(model.package).toBe(Provider.aisdk("@ai-sdk/openai"))
|
||||
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(model.enabled).toBe(true)
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(direct.headers).not.toHaveProperty("originator")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
@@ -7,7 +9,6 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import type { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -17,7 +18,13 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [[Location.node, locationLayer]]))
|
||||
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
|
||||
@@ -200,17 +207,26 @@ describe("pty", () => {
|
||||
|
||||
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
|
||||
const configuredIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [[Location.node, locationLayer]]),
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [
|
||||
[
|
||||
Config.node,
|
||||
Layer.mock(Config.Service)({
|
||||
entries: () =>
|
||||
Effect.succeed(
|
||||
configuredShell ? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })] : [],
|
||||
),
|
||||
}),
|
||||
],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
|
||||
|
||||
describe("pty create defaults", () => {
|
||||
configuredTest("defaults command, login args, and cwd from shell selection and location", () =>
|
||||
configuredTest("defaults command, login args, and cwd from config and location", () =>
|
||||
Effect.gen(function* () {
|
||||
if (!configuredShell) return
|
||||
const pty = yield* Pty.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
yield* shell.transform((draft) => draft.configure(configuredShell))
|
||||
const info = yield* Effect.acquireRelease(pty.create({ title: "configured" }), (created) =>
|
||||
pty.remove(created.id).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||
@@ -66,6 +67,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
},
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
@@ -81,6 +83,7 @@ const it = testEffect(
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[llmClient, client],
|
||||
[Config.node, config],
|
||||
[SessionRunnerModel.node, models],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,10 +4,8 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
@@ -17,8 +15,6 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
@@ -32,16 +28,6 @@ const it = testEffect(
|
||||
],
|
||||
),
|
||||
)
|
||||
// Default bus: durable payloads are not retained (`events.persist` off).
|
||||
const itVolatile = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
describe("Session.log", () => {
|
||||
@@ -74,43 +60,6 @@ describe("Session.log", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts a cursor exactly at the aggregate head", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
const items = Array.from(
|
||||
yield* Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(0) })),
|
||||
)
|
||||
|
||||
expect(items).toEqual([{ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(0) }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with SeqUnavailable when the cursor is beyond the aggregate head", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
const errors = yield* Effect.forEach([1, 10], (after) =>
|
||||
Effect.flip(Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(after) }))),
|
||||
)
|
||||
|
||||
expect(errors.map((error) => error._tag)).toEqual([
|
||||
"Session.SeqUnavailableError",
|
||||
"Session.SeqUnavailableError",
|
||||
])
|
||||
expect(errors.map((error) => (error._tag === "Session.SeqUnavailableError" ? error.after : undefined))).toEqual([
|
||||
Event.Seq.make(1),
|
||||
Event.Seq.make(10),
|
||||
])
|
||||
expect(errors.map((error) => (error._tag === "Session.SeqUnavailableError" ? error.head : undefined))).toEqual([
|
||||
Event.Seq.make(0),
|
||||
Event.Seq.make(0),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with NotFound for an unknown session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -119,109 +68,6 @@ describe("Session.log", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("orders live ephemeral deltas after their durable start", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const fiber = yield* session
|
||||
.log({ sessionID: created.id, after: Event.Seq.make(0), follow: true, ephemeral: true })
|
||||
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* bus.publish(SessionEvent.Text.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: "hello",
|
||||
})
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((item) => item.type)).toEqual([
|
||||
"log.synced",
|
||||
"session.text.started",
|
||||
"session.text.delta",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("never includes ephemeral events in replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Text.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: "not retained",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
text: "complete",
|
||||
})
|
||||
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, ephemeral: true })))
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual([
|
||||
"session.created",
|
||||
"session.text.started",
|
||||
"session.text.ended",
|
||||
"log.synced",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the default follow stream durable-only", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const fiber = yield* session
|
||||
.log({ sessionID: created.id, after: Event.Seq.make(0), follow: true })
|
||||
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* bus.publish(SessionEvent.Text.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: "filtered",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
text: "complete",
|
||||
})
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((item) => item.type)).toEqual([
|
||||
"log.synced",
|
||||
"session.text.started",
|
||||
"session.text.ended",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads across undecodable gaps in aggregate order and marks the true log position", () =>
|
||||
Effect.gen(function* () {
|
||||
const GapEvent = Bus.durable({
|
||||
@@ -241,33 +87,12 @@ describe("Session.log", () => {
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 })))
|
||||
|
||||
expect(
|
||||
items.map((item): number | string | undefined =>
|
||||
Bus.isSynced(item) ? item.type : "durable" in item ? item.durable.seq : undefined,
|
||||
),
|
||||
items.map((item): number | string | undefined => (Bus.isSynced(item) ? item.type : item.durable?.seq)),
|
||||
).toEqual([3, 4, "log.synced"])
|
||||
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(4) })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with SeqUnavailable when the replay range is only partially retained", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: created.id, title: "pruned" })
|
||||
yield* db
|
||||
.delete(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, created.id), eq(EventTable.seq, 1)))
|
||||
.run()
|
||||
|
||||
const error = yield* Effect.flip(
|
||||
Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(0) })),
|
||||
)
|
||||
|
||||
expect(error._tag).toBe("Session.SeqUnavailableError")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("completes with a bare synced marker for a migrated Session with no event sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -296,46 +121,3 @@ describe("Session.log", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Session.log without retained events", () => {
|
||||
itVolatile.effect("accepts a cursor exactly at the head", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: created.id, title: "at head" })
|
||||
|
||||
const items = Array.from(
|
||||
yield* Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(1) })),
|
||||
)
|
||||
|
||||
expect(items).toEqual([{ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) }])
|
||||
}),
|
||||
)
|
||||
|
||||
itVolatile.effect("fails with SeqUnavailable for a cursor behind the head", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: created.id, title: "behind head" })
|
||||
|
||||
const error = yield* Effect.flip(
|
||||
Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(0) })),
|
||||
)
|
||||
|
||||
expect(error._tag).toBe("Session.SeqUnavailableError")
|
||||
expect(error._tag === "Session.SeqUnavailableError" ? error.head : undefined).toEqual(Event.Seq.make(1))
|
||||
}),
|
||||
)
|
||||
|
||||
itVolatile.effect("replays nothing but stays live for a cursorless read", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: created.id, title: "cursorless" })
|
||||
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
||||
|
||||
expect(items).toEqual([{ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -28,7 +28,6 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const executionCalls: Session.ID[] = []
|
||||
@@ -61,7 +60,7 @@ const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() =>
|
||||
// These operations resolve Location services lazily and must wait for plugin-projected state.
|
||||
// Attachment admission only needs image normalization and plugin readiness.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
Layer.unwrap(
|
||||
Effect.sync(() => {
|
||||
@@ -73,12 +72,6 @@ const locations = Layer.effect(
|
||||
? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content)
|
||||
: Effect.die(new Error("Image service used before plugins were ready")),
|
||||
}),
|
||||
Layer.mock(Snapshot.Service, {
|
||||
capture: () =>
|
||||
ready ? Effect.succeed(undefined) : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
restore: () =>
|
||||
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
}),
|
||||
Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
|
||||
@@ -1058,31 +1051,6 @@ describe("Session.prompt", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("Session.revert", () => {
|
||||
it.effect("waits for location plugins before staging", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const session = yield* Session.Service
|
||||
yield* db.insert(SessionMessageTable).values(assistantRow(messageID, 0)).run().pipe(Effect.orDie)
|
||||
yield* session.revert.stage({ sessionID, messageID })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for location plugins before clearing", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID,
|
||||
revert: { messageID, snapshot: Snapshot.ID.make("tree"), files: [] },
|
||||
})
|
||||
yield* session.revert.clear(sessionID)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Session.inbox", () => {
|
||||
it.effect("fails for an unknown session", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
describe("Session.snapshot", () => {
|
||||
it.effect("returns an empty projected session at its aggregate watermark", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const created = yield* sessions.create({ location })
|
||||
|
||||
expect(yield* sessions.snapshot({ sessionID: created.id })).toEqual({
|
||||
session: created,
|
||||
children: [],
|
||||
inbox: [],
|
||||
messages: [],
|
||||
seq: Event.Seq.make(0),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the most recent messages in aggregate order", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* sessions.create({ location })
|
||||
yield* Effect.forEach(["first", "second", "third"], (text) =>
|
||||
bus.publish(SessionEvent.Synthetic, { sessionID: created.id, text }),
|
||||
)
|
||||
|
||||
const snapshot = yield* sessions.snapshot({ sessionID: created.id, recent: 2 })
|
||||
|
||||
expect(snapshot.messages.map((message) => (message.type === "synthetic" ? message.text : message.type))).toEqual([
|
||||
"second",
|
||||
"third",
|
||||
])
|
||||
expect(snapshot.seq).toBe(Event.Seq.make(3))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps rows and watermark consistent during concurrent publication", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* sessions.create({ location })
|
||||
const publish = Effect.forEach(
|
||||
Array.from({ length: 40 }, (_, index) => index + 1),
|
||||
(index) => bus.publish(SessionEvent.Synthetic, { sessionID: created.id, text: String(index) }),
|
||||
)
|
||||
const read = Effect.forEach(Array.from({ length: 40 }), () => sessions.snapshot({ sessionID: created.id }))
|
||||
|
||||
const [, snapshots] = yield* Effect.all([publish, read], { concurrency: "unbounded" })
|
||||
|
||||
snapshots.forEach((snapshot) => {
|
||||
expect(snapshot.messages).toHaveLength(snapshot.seq)
|
||||
expect(
|
||||
snapshot.messages.map((message) => (message.type === "synthetic" ? Number(message.text) : -1)),
|
||||
).toEqual(Array.from({ length: snapshot.seq }, (_, index) => index + 1))
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -127,31 +127,6 @@ describe("Snapshot", () => {
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("applies availability transforms", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
|
||||
await initGit(project)
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const registration = yield* snapshot.transform((draft) => draft.configure(false))
|
||||
expect(yield* snapshot.capture()).toBeUndefined()
|
||||
|
||||
yield* registration.dispose
|
||||
expect(yield* snapshot.capture()).toBeDefined()
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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"
|
||||
@@ -12,18 +15,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>,
|
||||
limits?: { maxLines?: number; maxBytes?: number },
|
||||
info = new Info(),
|
||||
) =>
|
||||
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* () {
|
||||
const output = yield* ToolOutput.Service
|
||||
if (limits) yield* output.transform((draft) => draft.configure(limits))
|
||||
return yield* body(output, yield* FSUtil.Service, tmp.path)
|
||||
return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
|
||||
}).pipe(Effect.provide(layer))
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
@@ -47,7 +50,7 @@ describe("ToolOutput", () => {
|
||||
{ type: "text", text: `... 1 line truncated; full content saved to ${outputPath} ...` },
|
||||
])
|
||||
}),
|
||||
{ maxLines: 2, maxBytes: 1_000 },
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -64,7 +67,7 @@ describe("ToolOutput", () => {
|
||||
},
|
||||
])
|
||||
}),
|
||||
{ maxLines: 100, maxBytes: 5 },
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 100, max_bytes: 5 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -83,7 +86,7 @@ describe("ToolOutput", () => {
|
||||
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 line truncated; full content saved to /) },
|
||||
])
|
||||
}),
|
||||
{ maxLines: 2, maxBytes: 1_000 },
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -116,7 +119,7 @@ describe("ToolOutput", () => {
|
||||
metadata: { truncated: false },
|
||||
})
|
||||
}),
|
||||
{ maxLines: 2, maxBytes: 1_000 },
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -130,7 +133,7 @@ describe("ToolOutput", () => {
|
||||
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
|
||||
])
|
||||
}),
|
||||
{ maxLines: 2, maxBytes: 3 },
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 3 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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",
|
||||
@@ -36,17 +35,6 @@ 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",
|
||||
{
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
InvalidRequestError,
|
||||
MessageNotFoundError,
|
||||
ServiceUnavailableError,
|
||||
SeqUnavailableError,
|
||||
SessionBusyError,
|
||||
SessionNotFoundError,
|
||||
SkillNotFoundError,
|
||||
@@ -220,30 +219,6 @@ 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 },
|
||||
@@ -658,18 +633,17 @@ 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.All, EventLog.Synced]).annotate({ identifier: "SessionLogItem" }),
|
||||
data: Schema.Union([SessionEvent.Durable, EventLog.Synced]).annotate({ identifier: "SessionLogItem" }),
|
||||
}),
|
||||
error: [SessionNotFoundError, SeqUnavailableError],
|
||||
error: SessionNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.log",
|
||||
summary: "Read the session log",
|
||||
description:
|
||||
"Experimental session event log. Replay is durable-only; follow mode can opt into live ephemeral events.",
|
||||
"Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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"
|
||||
@@ -40,8 +39,6 @@ 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
|
||||
|
||||
@@ -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 } from "effect"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
InvalidCursorError,
|
||||
MessageNotFoundError,
|
||||
ServiceUnavailableError,
|
||||
SeqUnavailableError,
|
||||
SessionBusyError,
|
||||
SessionNotFoundError,
|
||||
SkillNotFoundError,
|
||||
@@ -27,23 +26,16 @@ 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", sessionNotFound),
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
(error) =>
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag(
|
||||
"Session.InboxConflictError",
|
||||
(error) => new ConflictError({ resource: error.inboxID, message: `${conflict}: ${error.inboxID}` }),
|
||||
@@ -139,8 +131,25 @@ 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", sessionNotFound),
|
||||
Effect.catchTag("Session.MessageDecodeError", messageDecodeFailed),
|
||||
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 }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
@@ -171,19 +180,6 @@ 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) {
|
||||
@@ -668,8 +664,25 @@ 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", sessionNotFound),
|
||||
Effect.catchTag("Session.MessageDecodeError", messageDecodeFailed),
|
||||
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 }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
@@ -760,25 +773,19 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.log",
|
||||
Effect.fn(function* (ctx) {
|
||||
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}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
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)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
@@ -20,8 +19,6 @@ 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(
|
||||
|
||||
@@ -9,12 +9,14 @@ 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 { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -113,7 +115,9 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
}),
|
||||
],
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
|
||||
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
|
||||
[Command.node, Command.configured({ gitbash: options.windows?.gitbash })],
|
||||
[Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })],
|
||||
[Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })],
|
||||
[
|
||||
MCP.node,
|
||||
MCP.configured({
|
||||
|
||||
@@ -111,7 +111,6 @@ 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: {
|
||||
@@ -143,7 +142,6 @@ export const DEFAULT_THEME = {
|
||||
$selected: "$hue.interactive.700",
|
||||
$disabled: "$hue.neutral.300",
|
||||
},
|
||||
secondary: { default: "transparent" },
|
||||
destructive: {
|
||||
default: "$hue.red.600",
|
||||
$hovered: "$hue.red.700",
|
||||
@@ -326,7 +324,6 @@ 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: {
|
||||
@@ -358,7 +355,6 @@ export const DEFAULT_THEME = {
|
||||
$selected: "$hue.interactive.600",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
secondary: { default: "transparent" },
|
||||
destructive: {
|
||||
default: "$hue.red.600",
|
||||
$hovered: "$hue.red.700",
|
||||
|
||||
@@ -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", "secondary", "destructive"])
|
||||
export const ActionVariant = Schema.Literals(["primary", "destructive"])
|
||||
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
|
||||
|
||||
export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "selected", "hovered"])
|
||||
@@ -90,7 +90,6 @@ export type FormfieldColorDefinition = StatefulColorDefinition
|
||||
|
||||
const ActionColorDefinition = Schema.Struct({
|
||||
primary: Schema.optional(StatefulColorDefinition),
|
||||
secondary: Schema.optional(StatefulColorDefinition),
|
||||
destructive: Schema.optional(StatefulColorDefinition),
|
||||
})
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
$focused: selected,
|
||||
$selected: primary,
|
||||
},
|
||||
secondary: { default: "$text.subdued", $hovered: "$text.default" },
|
||||
destructive: { default: destructive, $disabled: textMuted },
|
||||
},
|
||||
formfield: {
|
||||
@@ -108,7 +107,6 @@ 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: {
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { For, type JSX } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { NativeImage, type RGBA, TextAttributes, type TerminalCapabilities } from "@opentui/core"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createSignal, For, type JSX, onCleanup, Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { go, logo } from "../logo"
|
||||
|
||||
const halves: Record<string, readonly [number, number]> = {
|
||||
"█": [1, 1],
|
||||
"▀": [1, 0],
|
||||
"▄": [0, 1],
|
||||
_: [2, 2],
|
||||
"^": [1, 2],
|
||||
"~": [2, 0],
|
||||
",": [0, 2],
|
||||
}
|
||||
|
||||
function wordmarkProtocol(capabilities: TerminalCapabilities | null | undefined) {
|
||||
if (capabilities?.sixel) return "sixel" as const
|
||||
if (capabilities?.kitty_graphics) return "kitty" as const
|
||||
}
|
||||
|
||||
export function Logo() {
|
||||
const theme = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [protocol, setProtocol] = createSignal(wordmarkProtocol(renderer.capabilities))
|
||||
const updateCapabilities = (capabilities: TerminalCapabilities) => setProtocol(wordmarkProtocol(capabilities))
|
||||
renderer.on("capabilities", updateCapabilities)
|
||||
onCleanup(() => renderer.off("capabilities", updateCapabilities))
|
||||
|
||||
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
|
||||
const shadow = tint(theme.background.default, fg, 0.25)
|
||||
@@ -49,31 +69,103 @@ export function Logo() {
|
||||
})
|
||||
}
|
||||
|
||||
const art = createMemo(() => {
|
||||
const imageProtocol = protocol()
|
||||
if (!imageProtocol) return
|
||||
const rows =
|
||||
dimensions().height < 12
|
||||
? []
|
||||
: dimensions().width < 22
|
||||
? go.right.slice(1).map((line) => [{ line, color: theme.text.default }])
|
||||
: dimensions().width < 44
|
||||
? [
|
||||
...logo.left.slice(1).map((line) => [{ line, color: theme.text.subdued }]),
|
||||
...logo.right.map((line) => [{ line, color: theme.text.default }]),
|
||||
]
|
||||
: logo.left.map((line, index) => [
|
||||
{ line, color: theme.text.subdued },
|
||||
{ line: " ", color: theme.background.default },
|
||||
{ line: logo.right[index], color: theme.text.default },
|
||||
])
|
||||
if (rows.length === 0) return
|
||||
|
||||
const width = Math.max(...rows.map((row) => row.reduce((total, segment) => total + segment.line.length, 0)))
|
||||
const pixels = new Uint8Array(width * rows.length * 2 * 4)
|
||||
const background = theme.background.default.toInts()
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
pixels[offset] = background[0]
|
||||
pixels[offset + 1] = background[1]
|
||||
pixels[offset + 2] = background[2]
|
||||
pixels[offset + 3] = 255
|
||||
}
|
||||
|
||||
rows.forEach((row, y) => {
|
||||
let start = 0
|
||||
row.forEach((segment) => {
|
||||
const foreground = segment.color.toInts()
|
||||
const shadow = tint(theme.background.default, segment.color, 0.25).toInts()
|
||||
const palette = [background, foreground, shadow]
|
||||
Array.from(segment.line).forEach((char, x) => {
|
||||
const fill = halves[char] ?? [0, 0]
|
||||
fill.forEach((color, half) => {
|
||||
const offset = ((y * 2 + half) * width + start + x) * 4
|
||||
pixels[offset] = palette[color][0]
|
||||
pixels[offset + 1] = palette[color][1]
|
||||
pixels[offset + 2] = palette[color][2]
|
||||
pixels[offset + 3] = 255
|
||||
})
|
||||
})
|
||||
start += segment.line.length
|
||||
})
|
||||
})
|
||||
|
||||
const image = NativeImage.fromRgba(pixels, width, rows.length * 2)
|
||||
const source = image.resize({ width: width * 16, height: rows.length * 32, kernel: "nearest" })
|
||||
image.dispose()
|
||||
onCleanup(() => source.dispose())
|
||||
return { source, width, height: rows.length, protocol: imageProtocol }
|
||||
})
|
||||
|
||||
return (
|
||||
<box>
|
||||
{dimensions().height < 12 ? null : dimensions().width < 22 ? (
|
||||
<For each={go.right.slice(1)}>
|
||||
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
|
||||
</For>
|
||||
) : dimensions().width < 44 ? (
|
||||
<>
|
||||
<For each={logo.left.slice(1)}>
|
||||
{(line) => <box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>}
|
||||
</For>
|
||||
<For each={logo.right}>
|
||||
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
|
||||
</For>
|
||||
</>
|
||||
) : (
|
||||
<For each={logo.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
|
||||
</box>
|
||||
<Show
|
||||
when={art()}
|
||||
fallback={
|
||||
<box>
|
||||
{dimensions().height < 12 ? null : dimensions().width < 22 ? (
|
||||
<For each={go.right.slice(1)}>
|
||||
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
|
||||
</For>
|
||||
) : dimensions().width < 44 ? (
|
||||
<>
|
||||
<For each={logo.left.slice(1)}>
|
||||
{(line) => <box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>}
|
||||
</For>
|
||||
<For each={logo.right}>
|
||||
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
|
||||
</For>
|
||||
</>
|
||||
) : (
|
||||
<For each={logo.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(value) => (
|
||||
<image
|
||||
source={value().source}
|
||||
width={value().width}
|
||||
height={value().height}
|
||||
fit="fill"
|
||||
protocol={value().protocol}
|
||||
/>
|
||||
)}
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -208,11 +208,6 @@ 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()
|
||||
@@ -1309,7 +1304,7 @@ export function Prompt(props: PromptProps) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const error = await data.session
|
||||
const error = await client.api.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
text: inputText,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEngineData } from "@opencode-ai/client/solid"
|
||||
import { createData } 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 = createEngineData({
|
||||
const data = createData({
|
||||
api: () => client.api,
|
||||
event: client.event,
|
||||
connection: client.connection,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { contextUsage, formatContextUsage } from "../../util/session"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
@@ -48,34 +47,16 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
|
||||
<Match when={props.mode === "normal"}>
|
||||
<Switch>
|
||||
<Match when={live() || status().length > 0}>
|
||||
<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>
|
||||
<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>}
|
||||
</Show>
|
||||
<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>
|
||||
<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>
|
||||
</Match>
|
||||
<Match when={dimensions().width >= 44}>
|
||||
<text fg={props.context.theme.text.default} flexShrink={0}>
|
||||
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import path from "path"
|
||||
import { readFile, stat } from "fs/promises"
|
||||
import { 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"
|
||||
@@ -79,7 +78,6 @@ 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>>>,
|
||||
@@ -109,18 +107,6 @@ 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) =>
|
||||
@@ -128,18 +114,15 @@ 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(() => {
|
||||
clearContributions(id)
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
setStore("registrations", id, "slots", reconcileStore({}))
|
||||
setStore("registrations", id, "markdown", reconcileStore({}))
|
||||
setStore("registrations", id, "cleanups", [])
|
||||
})
|
||||
const owned: Dispose[] = []
|
||||
@@ -167,17 +150,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
},
|
||||
})
|
||||
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
|
||||
clearContributions(id)
|
||||
if (item.target)
|
||||
setupFailures.set(item.target, {
|
||||
version: item.version,
|
||||
options: snapshotOptions(item.options),
|
||||
error: errorMessage(error),
|
||||
})
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
setStore("registrations", id, "slots", reconcileStore({}))
|
||||
setStore("registrations", id, "markdown", reconcileStore({}))
|
||||
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)
|
||||
@@ -201,7 +179,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
await disposeAll(cleanups).finally(() =>
|
||||
batch(() => {
|
||||
if (store.registrations[id]) {
|
||||
clearContributions(id)
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
setStore("registrations", id, "slots", reconcileStore({}))
|
||||
setStore("registrations", id, "markdown", reconcileStore({}))
|
||||
}
|
||||
setStore("states", (items) =>
|
||||
items.map((state) =>
|
||||
@@ -295,12 +275,10 @@ 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, sourceGeneration).catch(
|
||||
(error) => ({
|
||||
status: "failed" as const,
|
||||
error: errorMessage(error),
|
||||
}),
|
||||
)
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install).catch((error) => ({
|
||||
status: "failed" as const,
|
||||
error: errorMessage(error),
|
||||
}))
|
||||
if (resolved.status === "unsupported") {
|
||||
if (source.server) continue
|
||||
failures.push({ target, status: "unsupported" })
|
||||
@@ -314,21 +292,17 @@ 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, toDesired(previous))
|
||||
if (previous)
|
||||
desired.set(previous.plugin.id, {
|
||||
plugin: previous.plugin,
|
||||
source: previous.source,
|
||||
target,
|
||||
version: previous.version,
|
||||
options: previous.options,
|
||||
enabled: previous.active,
|
||||
})
|
||||
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",
|
||||
@@ -361,7 +335,11 @@ 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 !sameGeneration(registration, item) || registration.active !== item.enabled
|
||||
return (
|
||||
registration.version !== item.version ||
|
||||
!sameOptions(registration.options, item.options) ||
|
||||
registration.active !== item.enabled
|
||||
)
|
||||
})
|
||||
|
||||
// Swap: cleanup failures surface as a toast, never propagate, so one
|
||||
@@ -370,11 +348,22 @@ 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 || !sameGeneration(registration, item)
|
||||
const replaced =
|
||||
!registration || registration.version !== item.version || !sameOptions(registration.options, item.options)
|
||||
// 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 = replaced && registration ? toDesired(registration) : undefined
|
||||
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
|
||||
if (replaced) {
|
||||
if (registration) await deactivateNoisily(id)
|
||||
// In-place replacement keeps the registration's key position, which
|
||||
@@ -575,7 +564,6 @@ 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).
|
||||
@@ -583,9 +571,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 }
|
||||
// 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
|
||||
// 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
|
||||
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)
|
||||
@@ -599,7 +587,7 @@ function toRegistration(item: Desired): Registration {
|
||||
source: item.source,
|
||||
target: item.target,
|
||||
version: item.version,
|
||||
options: snapshotOptions(item.options),
|
||||
options: item.options,
|
||||
active: false,
|
||||
routes: {},
|
||||
slots: {},
|
||||
@@ -608,32 +596,10 @@ 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
|
||||
|
||||
@@ -45,13 +45,15 @@ export function localSource(spec: string, directory: string) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${version}`
|
||||
return `${entrypoint}?mtime=${version}`
|
||||
}
|
||||
|
||||
@@ -275,9 +275,6 @@ 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)
|
||||
@@ -1199,15 +1196,16 @@ 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.secondary.hovered : theme.text.action.secondary.default}
|
||||
>
|
||||
<text fg={latestHovered() ? theme.text.action.primary.focused : theme.text.action.primary.default}>
|
||||
Jump to latest ↓
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -213,25 +213,13 @@ 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_"))
|
||||
}),
|
||||
|
||||
@@ -2592,7 +2592,18 @@ 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(undefined, events)
|
||||
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)
|
||||
let sync!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
@@ -2619,7 +2630,6 @@ 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,
|
||||
@@ -2635,7 +2645,6 @@ 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,10 +27,7 @@ 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, 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
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/location") {
|
||||
const directory = url.searchParams.get("location[directory]") ?? process.cwd()
|
||||
const project = directory === active ? "proj_b" : "proj_a"
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA, TextRenderable } from "@opentui/core"
|
||||
import { RGBA } 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,
|
||||
},
|
||||
},
|
||||
theme: { text: { default: color, subdued: color } },
|
||||
keymap: {
|
||||
shortcuts: (id: string) =>
|
||||
id === "session.child.first" ? ["ctrl+j"] : id === "command.palette.show" ? ["ctrl+p"] : [],
|
||||
dispatch: (id: string) => dispatched.push(id),
|
||||
},
|
||||
data: {
|
||||
session: {
|
||||
@@ -47,14 +39,6 @@ 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()
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ 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[],
|
||||
@@ -30,8 +27,7 @@ export function createEventStream() {
|
||||
start(controller) {
|
||||
current = controller
|
||||
controllers.add(controller)
|
||||
const values = Array.isArray(initial) ? initial : initial ? [initial] : []
|
||||
for (const value of values) controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
|
||||
if (initial) controller.enqueue(encoder.encode(`data: ${JSON.stringify(initial)}\n\n`))
|
||||
for (const chunk of queued.splice(0)) controller.enqueue(chunk)
|
||||
},
|
||||
cancel() {
|
||||
@@ -57,47 +53,13 @@ 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()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -106,8 +68,6 @@ 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)
|
||||
@@ -115,47 +75,6 @@ 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 (
|
||||
[
|
||||
|
||||
@@ -271,40 +271,30 @@ 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.
|
||||
const broken = `
|
||||
await writeFile(
|
||||
source,
|
||||
`
|
||||
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,23 +154,6 @@ 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(
|
||||
{
|
||||
@@ -231,8 +214,6 @@ 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,8 +30,6 @@ 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())
|
||||
@@ -44,8 +42,6 @@ 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.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` |
|
||||
| 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` |
|
||||
|
||||
### Contexts
|
||||
|
||||
|
||||
Reference in New Issue
Block a user