From 5b8b874732d7027564e69891561ceb3ebd65b845 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 14:07:59 -0400 Subject: [PATCH 1/7] update effect docs (#22340) --- packages/opencode/specs/effect/facades.md | 238 ++++++++++++++++++++ packages/opencode/specs/effect/migration.md | 4 +- 2 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/specs/effect/facades.md diff --git a/packages/opencode/specs/effect/facades.md b/packages/opencode/specs/effect/facades.md new file mode 100644 index 0000000000..e2d9d3d8a1 --- /dev/null +++ b/packages/opencode/specs/effect/facades.md @@ -0,0 +1,238 @@ +# Facade removal checklist + +Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`. + +As of 2026-04-13, latest `origin/dev`: + +- `src/` still has 15 `makeRuntime(...)` call sites. +- 13 of those are still in scope for facade removal. +- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`. + +Recent progress: + +- Wave 1 is merged: `Pty`, `Skill`, `Vcs`, `ToolRegistry`, `Auth`. +- Wave 2 is merged: `Config`, `Provider`, `File`, `LSP`, `MCP`. + +## Priority hotspots + +- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades. +- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion. + +## Completed Batches + +Low-risk batch, all merged: + +1. `src/pty/index.ts` +2. `src/skill/index.ts` +3. `src/project/vcs.ts` +4. `src/tool/registry.ts` +5. `src/auth/index.ts` + +Caller-heavy batch, all merged: + +1. `src/config/config.ts` +2. `src/provider/provider.ts` +3. `src/file/index.ts` +4. `src/lsp/index.ts` +5. `src/mcp/index.ts` + +Shared pattern: + +- one service file still exports `makeRuntime(...)` + async facades +- one or two route or CLI entrypoints call those facades directly +- tests call the facade directly and need to switch to `yield* svc.method(...)` +- once callers are gone, delete `makeRuntime(...)`, remove async facade exports, and drop the `makeRuntime` import + +## Done means + +For each service in the low-risk batch, the work is complete only when all of these are true: + +1. all production callers stop using `Namespace.method(...)` facade calls +2. all direct test callers stop using the facade and instead yield the service from context +3. the service file no longer has `makeRuntime(...)` +4. the service file no longer exports runtime-backed facade helpers +5. `grep` for the migrated facade methods only finds the service implementation itself or unrelated names + +## Caller templates + +### Route handlers + +Use one `AppRuntime.runPromise(Effect.gen(...))` body and yield the service inside it. + +```ts +const value = await AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* pty.list() + }), +) +``` + +If two service calls are independent, keep them in the same effect body and use `Effect.all(...)`. + +### Plain async CLI or script entrypoints + +If the caller is not itself an Effect service yet, still prefer one contiguous `AppRuntime.runPromise(Effect.gen(...))` block for the whole unit of work. + +```ts +const skills = await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + const skill = yield* Skill.Service + yield* auth.set(key, info) + return yield* skill.all() + }), +) +``` + +Only fall back to `AppRuntime.runPromise(Service.use(...))` for truly isolated one-off calls or awkward callback boundaries. Do not stack multiple tiny `runPromise(...)` calls in the same contiguous workflow. + +This is the right intermediate state. Do not block facade removal on effectifying the whole CLI file. + +### Bootstrap or fire-and-forget startup code + +If the old facade call existed only to kick off initialization, call the service through the existing runtime for that file. + +```ts +void BootstrapRuntime.runPromise(Vcs.Service.use((svc) => svc.init())) +``` + +Do not reintroduce a dedicated runtime in the service just for bootstrap. + +### Tests + +Convert facade tests to full effect style. + +```ts +it.effect("does the thing", () => + Effect.gen(function* () { + const svc = yield* Pty.Service + const info = yield* svc.create({ command: "cat", title: "a" }) + yield* svc.remove(info.id) + }).pipe(Effect.provide(Pty.defaultLayer)), +) +``` + +If the repo test already uses `testEffect(...)`, prefer `testEffect(Service.defaultLayer)` and `yield* Service.Service` inside the test body. + +Do not route tests through `AppRuntime` unless the test is explicitly exercising the app runtime. For facade removal, tests should usually provide the specific service layer they need. + +If the test uses `provideTmpdirInstance(...)`, remember that fixture needs a live `ChildProcessSpawner` layer. For services whose `defaultLayer` does not already provide that infra, prefer the repo-standard cross-spawn layer: + +```ts +const infra = CrossSpawnSpawner.defaultLayer + +const it = testEffect(Layer.mergeAll(MyService.defaultLayer, infra)) +``` + +Without that extra layer, tests fail at runtime with `Service not found: effect/process/ChildProcessSpawner`. + +## Questions already answered + +### Do we need to effectify the whole caller first? + +No. + +- route files: compose the handler with `AppRuntime.runPromise(Effect.gen(...))` +- CLI and scripts: use `AppRuntime.runPromise(Service.use(...))` +- bootstrap: use the existing bootstrap runtime + +Facade removal does not require a bigger refactor than that. + +### Should tests keep calling the namespace from async test bodies? + +No. Convert them now. + +The end state is `yield* svc.method(...)`, not `await Namespace.method(...)` inside `async` tests. + +### Should we keep `runPromise` exported for convenience? + +No. For this batch the goal is to delete the service-local runtime entirely. + +### What if a route has websocket callbacks or nested async handlers? + +Keep the route shape, but replace each facade call with `AppRuntime.runPromise(Service.use(...))` or wrap the surrounding async section in one `Effect.gen(...)` when practical. Do not keep the service facade just because the route has callback-shaped code. + +### Should we use one `runPromise` per service call? + +No. + +Default to one contiguous `AppRuntime.runPromise(Effect.gen(...))` block per handler, command, or workflow. Yield every service you need inside that block. + +Multiple tiny `runPromise(...)` calls are only acceptable when the caller structure forces it, such as websocket lifecycle callbacks, external callback APIs, or genuinely unrelated one-off operations. + +### Should we wrap a single service expression in `Effect.gen(...)`? + +Usually no. + +Prefer the direct form when there is only one expression: + +```ts +await AppRuntime.runPromise(File.Service.use((svc) => svc.read(path))) +``` + +Use `Effect.gen(...)` when the workflow actually needs multiple yielded values or branching. + +## Learnings + +These were the recurring mistakes and useful corrections from the first two batches: + +1. Tests should usually provide the specific service layer, not `AppRuntime`. +2. If a test uses `provideTmpdirInstance(...)` and needs child processes, prefer `CrossSpawnSpawner.defaultLayer`. +3. Instance-scoped services may need both the service layer and the right instance fixture. `File` tests, for example, needed `provideInstance(...)` plus `File.defaultLayer`. +4. Do not wrap a single `Service.use(...)` call in `Effect.gen(...)` just to return it. Use the direct form. +5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline. +6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code. + +## Next batch + +Recommended next five, in order: + +1. `src/permission/index.ts` +2. `src/agent/agent.ts` +3. `src/session/summary.ts` +4. `src/session/revert.ts` +5. `src/mcp/auth.ts` + +Why this batch: + +- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`. +- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`. +- `McpAuth` is small and closely related to the just-landed `MCP` cleanup. + +After that batch, the expected follow-up is the main session cluster: + +1. `src/session/index.ts` +2. `src/session/prompt.ts` +3. `src/session/compaction.ts` + +## Checklist + +- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts` +- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts` +- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts` +- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts` +- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts` +- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts` +- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts` +- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts` +- [x] `src/file/index.ts` (`File`) - facades removed and merged. +- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged. +- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged. +- [x] `src/config/config.ts` (`Config`) - facades removed and merged. +- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged. +- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged. +- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged. +- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged. +- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged. +- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts` +- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged. +- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts` +- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts` +- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts` +- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts` + +## Excluded `makeRuntime(...)` sites + +- `src/bus/index.ts` - core bus plumbing, not a normal facade-removal target. +- `src/effect/cross-spawn-spawner.ts` - runtime helper for `ChildProcessSpawner`, not a service namespace facade. diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 21e2220903..b8d4d12597 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -180,7 +180,7 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow Service-shape migrated (single namespace, traced methods, `InstanceState` where needed). -This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in [Destroying the facades](#destroying-the-facades). +This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in `facades.md`. - [x] `Account` — `account/index.ts` - [x] `Agent` — `agent/agent.ts` @@ -263,7 +263,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`. ## Destroying the facades -This phase is still broadly open. As of 2026-04-11 there are still 31 `makeRuntime(...)` call sites under `src/`, and many service namespaces still export async facade helpers like `export async function read(...) { return runPromise(...) }`. +This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`. These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them. From 14ccff40379b9dabd4e7276881f93939113b8b12 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 14:54:01 -0400 Subject: [PATCH 2/7] refactor(agent): remove async facade exports (#22341) --- packages/opencode/src/acp/agent.ts | 6 +- packages/opencode/src/agent/agent.ts | 19 ---- packages/opencode/src/cli/cmd/agent.ts | 7 +- packages/opencode/src/cli/cmd/debug/agent.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 18 ++-- .../opencode/src/server/instance/index.ts | 2 +- .../opencode/src/server/instance/session.ts | 5 +- packages/opencode/src/tool/registry.ts | 5 +- packages/opencode/test/agent/agent.test.ts | 89 ++++++++++--------- .../opencode/test/config/agent-color.test.ts | 9 +- packages/opencode/test/session/system.test.ts | 8 +- 11 files changed, 87 insertions(+), 83 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 6e87e7642d..73a7ebe48d 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -40,6 +40,7 @@ import type { ACPConfig } from "./types" import { Provider } from "../provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { Agent as AgentModule } from "../agent/agent" +import { AppRuntime } from "@/effect/app-runtime" import { Installation } from "@/installation" import { MessageV2 } from "@/session/message-v2" import { Config } from "@/config/config" @@ -1166,7 +1167,7 @@ export namespace ACP { this.sessionManager.get(sessionId).modeId || (await (async () => { if (!availableModes.length) return undefined - const defaultAgentName = await AgentModule.defaultAgent() + const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id this.sessionManager.setMode(sessionId, resolvedModeId) @@ -1367,7 +1368,8 @@ export namespace ACP { if (!current) { this.sessionManager.setModel(session.id, model) } - const agent = session.modeId ?? (await AgentModule.defaultAgent()) + const agent = + session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))) const parts: Array< | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index fd9ac43e8b..cebcaa048f 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -21,7 +21,6 @@ import { Plugin } from "@/plugin" import { Skill } from "../skill" import { Effect, Context, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" export namespace Agent { export const Info = z @@ -404,22 +403,4 @@ export namespace Agent { Layer.provide(Config.defaultLayer), Layer.provide(Skill.defaultLayer), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function get(agent: string) { - return runPromise((svc) => svc.get(agent)) - } - - export async function list() { - return runPromise((svc) => svc.list()) - } - - export async function defaultAgent() { - return runPromise((svc) => svc.defaultAgent()) - } - - export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) { - return runPromise((svc) => svc.generate(input)) - } } diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 70082c8e2e..60f52e403b 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -1,5 +1,6 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" +import { AppRuntime } from "@/effect/app-runtime" import { UI } from "../ui" import { Global } from "../../global" import { Agent } from "../../agent/agent" @@ -110,7 +111,9 @@ const AgentCreateCommand = cmd({ const spinner = prompts.spinner() spinner.start("Generating agent configuration...") const model = args.model ? Provider.parseModel(args.model) : undefined - const generated = await Agent.generate({ description, model }).catch((error) => { + const generated = await AppRuntime.runPromise( + Agent.Service.use((svc) => svc.generate({ description, model })), + ).catch((error) => { spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) if (isFullyNonInteractive) process.exit(1) throw new UI.CancelledError() @@ -220,7 +223,7 @@ const AgentListCommand = cmd({ await Instance.provide({ directory: process.cwd(), async fn() { - const agents = await Agent.list() + const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) const sortedAgents = agents.sort((a, b) => { if (a.native !== b.native) { return a.native ? -1 : 1 diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index fbaf8d78dc..51de43f671 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -35,7 +35,7 @@ export const AgentCommand = cmd({ async handler(args) { await bootstrap(process.cwd(), async () => { const agentName = args.name as string - const agent = await Agent.get(agentName) + const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName))) if (!agent) { process.stderr.write( `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL, diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 04130aa951..17fc4bc087 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -27,6 +27,7 @@ import { SkillTool } from "../../tool/skill" import { BashTool } from "../../tool/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" +import { AppRuntime } from "@/effect/app-runtime" type ToolProps = { input: Tool.InferParameters @@ -573,6 +574,7 @@ export const RunCommand = cmd({ // Validate agent if specified const agent = await (async () => { if (!args.agent) return undefined + const name = args.agent // When attaching, validate against the running server instead of local Instance state. if (args.attach) { @@ -590,12 +592,12 @@ export const RunCommand = cmd({ return undefined } - const agent = modes.find((a) => a.name === args.agent) + const agent = modes.find((a) => a.name === name) if (!agent) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, - `agent "${args.agent}" not found. Falling back to default agent`, + `agent "${name}" not found. Falling back to default agent`, ) return undefined } @@ -604,20 +606,20 @@ export const RunCommand = cmd({ UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, - `agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, ) return undefined } - return args.agent + return name } - const entry = await Agent.get(args.agent) + const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) if (!entry) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, - `agent "${args.agent}" not found. Falling back to default agent`, + `agent "${name}" not found. Falling back to default agent`, ) return undefined } @@ -625,11 +627,11 @@ export const RunCommand = cmd({ UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, - `agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, ) return undefined } - return args.agent + return name })() const sessionID = await session(sdk) diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 6d383afa7c..86a18dc673 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -207,7 +207,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => }, }), async (c) => { - const modes = await Agent.list() + const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) return c.json(modes) }, ) diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index b28db3a894..6f8feea8e6 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -550,11 +550,12 @@ export const SessionRoutes = lazy(() => const session = await Session.get(sessionID) await SessionRevert.cleanup(session) const msgs = await Session.messages({ sessionID }) - let currentAgent = await Agent.defaultAgent() + const defaultAgent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.defaultAgent())) + let currentAgent = defaultAgent for (let i = msgs.length - 1; i >= 0; i--) { const info = msgs[i].info if (info.role === "user") { - currentAgent = info.agent || (await Agent.defaultAgent()) + currentAgent = info.agent || defaultAgent break } } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 3ed9e4b185..d6daa87f55 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -121,6 +121,7 @@ export namespace ToolRegistry { const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool const skilltool = yield* SkillTool + const agent = yield* Agent.Service const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { @@ -140,8 +141,8 @@ export namespace ToolRegistry { worktree: ctx.worktree, } const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) - const agent = yield* Effect.promise(() => Agent.get(toolCtx.agent)) - const out = yield* truncate.output(result, {}, agent) + const info = yield* agent.get(toolCtx.agent) + const out = yield* truncate.output(result, {}, info) return { title: "", output: out.truncated ? out.content : result, diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 98a0fd4c6e..409a0ed606 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,6 +1,7 @@ import { afterEach, test, expect } from "bun:test" +import { Effect } from "effect" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" @@ -11,6 +12,10 @@ function evalPerm(agent: Agent.Info | undefined, permission: string): Permission return Permission.evaluate(permission, "*", agent.permission).action } +function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { + return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) +} + afterEach(async () => { await Instance.disposeAll() }) @@ -20,7 +25,7 @@ test("returns default native agents when no config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const agents = await Agent.list() + const agents = await load(tmp.path, (svc) => svc.list()) const names = agents.map((a) => a.name) expect(names).toContain("build") expect(names).toContain("plan") @@ -38,7 +43,7 @@ test("build agent has correct default properties", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(build).toBeDefined() expect(build?.mode).toBe("primary") expect(build?.native).toBe(true) @@ -53,7 +58,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const plan = await Agent.get("plan") + const plan = await load(tmp.path, (svc) => svc.get("plan")) expect(plan).toBeDefined() // Wildcard is denied expect(evalPerm(plan, "edit")).toBe("deny") @@ -68,7 +73,7 @@ test("explore agent denies edit and write", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const explore = await Agent.get("explore") + const explore = await load(tmp.path, (svc) => svc.get("explore")) expect(explore).toBeDefined() expect(explore?.mode).toBe("subagent") expect(evalPerm(explore, "edit")).toBe("deny") @@ -84,7 +89,7 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy await Instance.provide({ directory: tmp.path, fn: async () => { - const explore = await Agent.get("explore") + const explore = await load(tmp.path, (svc) => svc.get("explore")) expect(explore).toBeDefined() expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") @@ -97,7 +102,7 @@ test("general agent denies todo tools", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const general = await Agent.get("general") + const general = await load(tmp.path, (svc) => svc.get("general")) expect(general).toBeDefined() expect(general?.mode).toBe("subagent") expect(general?.hidden).toBeUndefined() @@ -111,7 +116,7 @@ test("compaction agent denies all permissions", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const compaction = await Agent.get("compaction") + const compaction = await load(tmp.path, (svc) => svc.get("compaction")) expect(compaction).toBeDefined() expect(compaction?.hidden).toBe(true) expect(evalPerm(compaction, "bash")).toBe("deny") @@ -137,7 +142,7 @@ test("custom agent from config creates new agent", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const custom = await Agent.get("my_custom_agent") + const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent")) expect(custom).toBeDefined() expect(String(custom?.model?.providerID)).toBe("openai") expect(String(custom?.model?.modelID)).toBe("gpt-4") @@ -166,7 +171,7 @@ test("custom agent config overrides native agent properties", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(build).toBeDefined() expect(String(build?.model?.providerID)).toBe("anthropic") expect(String(build?.model?.modelID)).toBe("claude-3") @@ -189,9 +194,9 @@ test("agent disable removes agent from list", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const explore = await Agent.get("explore") + const explore = await load(tmp.path, (svc) => svc.get("explore")) expect(explore).toBeUndefined() - const agents = await Agent.list() + const agents = await load(tmp.path, (svc) => svc.list()) const names = agents.map((a) => a.name) expect(names).not.toContain("explore") }, @@ -215,7 +220,7 @@ test("agent permission config merges with defaults", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(build).toBeDefined() // Specific pattern is denied expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") @@ -236,7 +241,7 @@ test("global permission config applies to all agents", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(build).toBeDefined() expect(evalPerm(build, "bash")).toBe("deny") }, @@ -255,8 +260,8 @@ test("agent steps/maxSteps config sets steps property", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") - const plan = await Agent.get("plan") + const build = await load(tmp.path, (svc) => svc.get("build")) + const plan = await load(tmp.path, (svc) => svc.get("plan")) expect(build?.steps).toBe(50) expect(plan?.steps).toBe(100) }, @@ -274,7 +279,7 @@ test("agent mode can be overridden", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const explore = await Agent.get("explore") + const explore = await load(tmp.path, (svc) => svc.get("explore")) expect(explore?.mode).toBe("primary") }, }) @@ -291,7 +296,7 @@ test("agent name can be overridden", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(build?.name).toBe("Builder") }, }) @@ -308,7 +313,7 @@ test("agent prompt can be set from config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(build?.prompt).toBe("Custom system prompt") }, }) @@ -328,7 +333,7 @@ test("unknown agent properties are placed into options", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(build?.options.random_property).toBe("hello") expect(build?.options.another_random).toBe(123) }, @@ -351,7 +356,7 @@ test("agent options merge correctly", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(build?.options.custom_option).toBe(true) expect(build?.options.another_option).toBe("value") }, @@ -376,8 +381,8 @@ test("multiple custom agents can be defined", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const agentA = await Agent.get("agent_a") - const agentB = await Agent.get("agent_b") + const agentA = await load(tmp.path, (svc) => svc.get("agent_a")) + const agentB = await load(tmp.path, (svc) => svc.get("agent_b")) expect(agentA?.description).toBe("Agent A") expect(agentA?.mode).toBe("subagent") expect(agentB?.description).toBe("Agent B") @@ -405,7 +410,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn await Instance.provide({ directory: tmp.path, fn: async () => { - const names = (await Agent.list()).map((a) => a.name) + const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name) expect(names[0]).toBe("plan") expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b))) }, @@ -417,7 +422,7 @@ test("Agent.get returns undefined for non-existent agent", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nonExistent = await Agent.get("does_not_exist") + const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist")) expect(nonExistent).toBeUndefined() }, }) @@ -428,7 +433,7 @@ test("default permission includes doom_loop and external_directory as ask", asyn await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(evalPerm(build, "doom_loop")).toBe("ask") expect(evalPerm(build, "external_directory")).toBe("ask") }, @@ -440,7 +445,7 @@ test("webfetch is allowed by default", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(evalPerm(build, "webfetch")).toBe("allow") }, }) @@ -462,7 +467,7 @@ test("legacy tools config converts to permissions", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(evalPerm(build, "bash")).toBe("deny") expect(evalPerm(build, "read")).toBe("deny") }, @@ -484,7 +489,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(evalPerm(build, "edit")).toBe("deny") }, }) @@ -502,7 +507,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") @@ -526,7 +531,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") @@ -549,7 +554,7 @@ test("explicit Truncate.GLOB deny is respected", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") }, @@ -581,7 +586,7 @@ description: Permission skill. await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill") const target = path.join(skillDir, "reference", "notes.md") expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow") @@ -597,7 +602,7 @@ test("defaultAgent returns build when no default_agent config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const agent = await Agent.defaultAgent() + const agent = await load(tmp.path, (svc) => svc.defaultAgent()) expect(agent).toBe("build") }, }) @@ -612,7 +617,7 @@ test("defaultAgent respects default_agent config set to plan", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const agent = await Agent.defaultAgent() + const agent = await load(tmp.path, (svc) => svc.defaultAgent()) expect(agent).toBe("plan") }, }) @@ -632,7 +637,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a await Instance.provide({ directory: tmp.path, fn: async () => { - const agent = await Agent.defaultAgent() + const agent = await load(tmp.path, (svc) => svc.defaultAgent()) expect(agent).toBe("my_custom") }, }) @@ -647,7 +652,7 @@ test("defaultAgent throws when default_agent points to subagent", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(Agent.defaultAgent()).rejects.toThrow('default agent "explore" is a subagent') + await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent') }, }) }) @@ -661,7 +666,7 @@ test("defaultAgent throws when default_agent points to hidden agent", async () = await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(Agent.defaultAgent()).rejects.toThrow('default agent "compaction" is hidden') + await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden') }, }) }) @@ -675,7 +680,9 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(Agent.defaultAgent()).rejects.toThrow('default agent "does_not_exist" not found') + await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow( + 'default agent "does_not_exist" not found', + ) }, }) }) @@ -691,7 +698,7 @@ test("defaultAgent returns plan when build is disabled and default_agent not set await Instance.provide({ directory: tmp.path, fn: async () => { - const agent = await Agent.defaultAgent() + const agent = await load(tmp.path, (svc) => svc.defaultAgent()) // build is disabled, so it should return plan (next primary agent) expect(agent).toBe("plan") }, @@ -711,7 +718,7 @@ test("defaultAgent throws when all primary agents are disabled", async () => { directory: tmp.path, fn: async () => { // build and plan are disabled, no primary-capable agents remain - await expect(Agent.defaultAgent()).rejects.toThrow("no primary visible agent found") + await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow("no primary visible agent found") }, }) }) diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index 0dc2653c2d..af9565cba8 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -1,6 +1,7 @@ import { test, expect } from "bun:test" +import { Effect } from "effect" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Config } from "../../src/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" @@ -8,6 +9,8 @@ import { Color } from "../../src/util/color" import { AppRuntime } from "../../src/effect/app-runtime" const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) +const agent = (dir: string, fn: (svc: AgentSvc.Interface) => Effect.Effect) => + Effect.runPromise(provideInstance(dir)(AgentSvc.Service.use(fn)).pipe(Effect.provide(AgentSvc.defaultLayer))) test("agent color parsed from project config", async () => { await using tmp = await tmpdir({ @@ -52,9 +55,9 @@ test("Agent.get includes color from config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const plan = await AgentSvc.get("plan") + const plan = await agent(tmp.path, (svc) => svc.get("plan")) expect(plan?.color).toBe("#A855F7") - const build = await AgentSvc.get("build") + const build = await agent(tmp.path, (svc) => svc.get("build")) expect(build?.color).toBe("accent") }, }) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 6f1047a97d..33123acce6 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -4,7 +4,11 @@ import { Effect } from "effect" import { Agent } from "../../src/agent/agent" import { Instance } from "../../src/project/instance" import { SystemPrompt } from "../../src/session/system" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir } from "../fixture/fixture" + +function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { + return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) +} describe("session.system", () => { test("skills output is sorted by name and stable across calls", async () => { @@ -38,7 +42,7 @@ description: ${description} await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await load(tmp.path, (svc) => svc.get("build")) const runSkills = Effect.gen(function* () { const svc = yield* SystemPrompt.Service return yield* svc.skills(build!) From dcbf11f41a299cf18879a1096584dcaf99fa6b76 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 15:35:38 -0400 Subject: [PATCH 3/7] refactor(session): remove summary async facades (#22337) --- packages/opencode/src/server/instance/session.ts | 12 ++++++++---- packages/opencode/src/session/processor.ts | 16 +++++++++++----- packages/opencode/src/session/prompt.ts | 7 ++++++- packages/opencode/src/session/summary.ts | 10 ---------- .../opencode/test/session/compaction.test.ts | 12 +++++++++++- .../test/session/processor-effect.test.ts | 15 ++++++++++++++- .../opencode/test/session/prompt-effect.test.ts | 13 ++++++++++++- .../test/session/snapshot-tool-race.test.ts | 9 ++++++--- 8 files changed, 68 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 6f8feea8e6..104bdb4f99 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -474,10 +474,14 @@ export const SessionRoutes = lazy(() => async (c) => { const query = c.req.valid("query") const params = c.req.valid("param") - const result = await SessionSummary.diff({ - sessionID: params.sessionID, - messageID: query.messageID, - }) + const result = await AppRuntime.runPromise( + SessionSummary.Service.use((summary) => + summary.diff({ + sessionID: params.sessionID, + messageID: query.messageID, + }), + ), + ) return c.json(result) }, ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index be0977c1dd..a544e4b456 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Layer, Context } from "effect" +import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -89,6 +89,7 @@ export namespace SessionProcessor { | LLM.Service | Permission.Service | Plugin.Service + | SessionSummary.Service | SessionStatus.Service > = Layer.effect( Service, @@ -101,6 +102,8 @@ export namespace SessionProcessor { const llm = yield* LLM.Service const permission = yield* Permission.Service const plugin = yield* Plugin.Service + const summary = yield* SessionSummary.Service + const scope = yield* Scope.Scope const status = yield* SessionStatus.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { @@ -385,10 +388,12 @@ export namespace SessionProcessor { } ctx.snapshot = undefined } - SessionSummary.summarize({ - sessionID: ctx.sessionID, - messageID: ctx.assistantMessage.parentID, - }) + yield* summary + .summarize({ + sessionID: ctx.sessionID, + messageID: ctx.assistantMessage.parentID, + }) + .pipe(Effect.ignore, Effect.forkIn(scope)) if ( !ctx.assistantMessage.summary && isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model }) @@ -603,6 +608,7 @@ export namespace SessionProcessor { Layer.provide(LLM.defaultLayer), Layer.provide(Permission.defaultLayer), Layer.provide(Plugin.defaultLayer), + Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionStatus.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 97a37865df..1d96392b0a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -102,6 +102,7 @@ export namespace SessionPrompt { const instruction = yield* Instruction.Service const state = yield* SessionRunState.Service const revert = yield* SessionRevert.Service + const summary = yield* SessionSummary.Service const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service @@ -1444,7 +1445,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) } - if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser.id }) + if (step === 1) + yield* summary + .summarize({ sessionID, messageID: lastUser.id }) + .pipe(Effect.ignore, Effect.forkIn(scope)) if (step > 1 && lastFinished) { for (const m of msgs) { @@ -1692,6 +1696,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the Layer.provide(Plugin.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(SessionRevert.defaultLayer), + Layer.provide(SessionSummary.defaultLayer), Layer.provide( Layer.mergeAll( Agent.defaultLayer, diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 498288d615..2c973c5df7 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -1,6 +1,5 @@ import z from "zod" import { Effect, Layer, Context } from "effect" -import { makeRuntime } from "@/effect/run-service" import { Bus } from "@/bus" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" @@ -159,17 +158,8 @@ export namespace SessionSummary { ), ) - const { runPromise } = makeRuntime(Service, defaultLayer) - - export const summarize = (input: { sessionID: SessionID; messageID: MessageID }) => - void runPromise((svc) => svc.summarize(input)).catch(() => {}) - export const DiffInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional(), }) - - export async function diff(input: z.infer) { - return runPromise((svc) => svc.diff(input)) - } } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 61b47df34a..2b0908ee9d 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -18,6 +18,7 @@ import { Session } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { SessionSummary } from "../../src/session/summary" import { ModelID, ProviderID } from "../../src/provider/schema" import type { Provider } from "../../src/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" @@ -26,6 +27,15 @@ import { ProviderTest } from "../fake/provider" Log.init({ print: false }) +const summary = Layer.succeed( + SessionSummary.Service, + SessionSummary.Service.of({ + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), +) + const ref = { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model"), @@ -194,7 +204,7 @@ function llm() { function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake()) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer + const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) return ManagedRuntime.make( Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( Layer.provide(provider.layer), diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index a3b335b6da..d384513087 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -16,6 +16,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { SessionProcessor } from "../../src/session/processor" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { SessionSummary } from "../../src/session/summary" import { Snapshot } from "../../src/snapshot" import { Log } from "../../src/util/log" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" @@ -25,6 +26,15 @@ import { raw, reply, TestLLMServer } from "../lib/llm-server" Log.init({ print: false }) +const summary = Layer.succeed( + SessionSummary.Service, + SessionSummary.Service.of({ + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), +) + const ref = { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model"), @@ -156,7 +166,10 @@ const deps = Layer.mergeAll( Provider.defaultLayer, status, ).pipe(Layer.provideMerge(infra)) -const env = Layer.mergeAll(TestLLMServer.layer, SessionProcessor.layer.pipe(Layer.provideMerge(deps))) +const env = Layer.mergeAll( + TestLLMServer.layer, + SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)), +) const it = testEffect(env) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index eafe682067..4e5b39942a 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -23,6 +23,7 @@ import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { AppFileSystem } from "../../src/filesystem" import { SessionCompaction } from "../../src/session/compaction" +import { SessionSummary } from "../../src/session/summary" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" @@ -46,6 +47,15 @@ import { reply, TestLLMServer } from "../lib/llm-server" Log.init({ print: false }) +const summary = Layer.succeed( + SessionSummary.Service, + SessionSummary.Service.of({ + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), +) + const ref = { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model"), @@ -182,12 +192,13 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(summary), Layer.provideMerge(run), Layer.provideMerge(compact), Layer.provideMerge(proc), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 391d9d488c..2723e362dd 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -146,12 +146,14 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(deps)) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, + SessionSummary.defaultLayer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(run), Layer.provideMerge(compact), Layer.provideMerge(proc), @@ -200,6 +202,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => Effect.fnUntraced(function* ({ dir, llm }) { const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service + const summary = yield* SessionSummary.Service const session = yield* sessions.create({ title: "snapshot race test", @@ -244,9 +247,9 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => expect(tool?.state.status).toBe("completed") // Poll for diff — summarize() is fire-and-forget - let diff: Awaited> = [] + let diff: Array<{ file: string }> = [] for (let i = 0; i < 50; i++) { - diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id })) + diff = yield* summary.diff({ sessionID: session.id }) if (diff.length > 0) break yield* Effect.sleep("100 millis") } From 29c202e6ab0b84d3884da5f8ab94f4873597084c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 15:36:12 -0400 Subject: [PATCH 4/7] refactor(mcp): remove mcp auth async facade exports (#22338) --- packages/opencode/src/cli/cmd/mcp.ts | 7 +- packages/opencode/src/mcp/auth.ts | 29 --------- packages/opencode/src/mcp/index.ts | 2 + packages/opencode/src/mcp/oauth-provider.ts | 64 ++++++++++--------- .../test/mcp/oauth-auto-connect.test.ts | 27 ++++++-- 5 files changed, 66 insertions(+), 63 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 4904db1c31..3afedb356d 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -361,7 +361,6 @@ export const McpLogoutCommand = cmd({ UI.empty() prompts.intro("MCP OAuth Logout") - const authPath = path.join(Global.Path.data, "mcp-auth.json") const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all())) const serverNames = Object.keys(credentials) @@ -717,6 +716,11 @@ export const McpDebugCommand = cmd({ // Try to discover OAuth metadata const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + const auth = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* McpAuth.Service + }), + ) const authProvider = new McpOAuthProvider( serverName, serverConfig.url, @@ -729,6 +733,7 @@ export const McpDebugCommand = cmd({ { onRedirect: async () => {}, }, + auth, ) prompts.log.info("Testing OAuth flow (without completing authorization)...") diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 7f33f32b84..6eefc107d9 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -3,7 +3,6 @@ import z from "zod" import { Global } from "../global" import { Effect, Layer, Context } from "effect" import { AppFileSystem } from "@/filesystem" -import { makeRuntime } from "@/effect/run-service" export namespace McpAuth { export const Tokens = z.object({ @@ -142,32 +141,4 @@ export namespace McpAuth { ) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - // Async facades for backward compat (used by McpOAuthProvider, CLI) - - export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName)) - - export const getForUrl = async (mcpName: string, serverUrl: string) => - runPromise((svc) => svc.getForUrl(mcpName, serverUrl)) - - export const all = async () => runPromise((svc) => svc.all()) - - export const set = async (mcpName: string, entry: Entry, serverUrl?: string) => - runPromise((svc) => svc.set(mcpName, entry, serverUrl)) - - export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName)) - - export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) => - runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl)) - - export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => - runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl)) - - export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) => - runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier)) - - export const updateOAuthState = async (mcpName: string, oauthState: string) => - runPromise((svc) => svc.updateOAuthState(mcpName, oauthState)) } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index fc34143a2c..39c7ed98e8 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -293,6 +293,7 @@ export namespace MCP { log.info("oauth redirect requested", { key, url: url.toString() }) }, }, + auth, ) } @@ -744,6 +745,7 @@ export namespace MCP { capturedUrl = url }, }, + auth, ) const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider }) diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index d675fc71e4..4fdc192df7 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -5,6 +5,7 @@ import type { OAuthClientInformation, OAuthClientInformationFull, } from "@modelcontextprotocol/sdk/shared/auth.js" +import { Effect } from "effect" import { McpAuth } from "./auth" import { Log } from "../util/log" @@ -30,6 +31,7 @@ export class McpOAuthProvider implements OAuthClientProvider { private serverUrl: string, private config: McpOAuthConfig, private callbacks: McpOAuthCallbacks, + private auth: McpAuth.Interface, ) {} get redirectUrl(): string { @@ -61,7 +63,7 @@ export class McpOAuthProvider implements OAuthClientProvider { // Check stored client info (from dynamic registration) // Use getForUrl to validate credentials are for the current server URL - const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl) + const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) if (entry?.clientInfo) { // Check if client secret has expired if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) { @@ -79,15 +81,17 @@ export class McpOAuthProvider implements OAuthClientProvider { } async saveClientInformation(info: OAuthClientInformationFull): Promise { - await McpAuth.updateClientInfo( - this.mcpName, - { - clientId: info.client_id, - clientSecret: info.client_secret, - clientIdIssuedAt: info.client_id_issued_at, - clientSecretExpiresAt: info.client_secret_expires_at, - }, - this.serverUrl, + await Effect.runPromise( + this.auth.updateClientInfo( + this.mcpName, + { + clientId: info.client_id, + clientSecret: info.client_secret, + clientIdIssuedAt: info.client_id_issued_at, + clientSecretExpiresAt: info.client_secret_expires_at, + }, + this.serverUrl, + ), ) log.info("saved dynamically registered client", { mcpName: this.mcpName, @@ -97,7 +101,7 @@ export class McpOAuthProvider implements OAuthClientProvider { async tokens(): Promise { // Use getForUrl to validate tokens are for the current server URL - const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl) + const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) if (!entry?.tokens) return undefined return { @@ -112,15 +116,17 @@ export class McpOAuthProvider implements OAuthClientProvider { } async saveTokens(tokens: OAuthTokens): Promise { - await McpAuth.updateTokens( - this.mcpName, - { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined, - scope: tokens.scope, - }, - this.serverUrl, + await Effect.runPromise( + this.auth.updateTokens( + this.mcpName, + { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined, + scope: tokens.scope, + }, + this.serverUrl, + ), ) log.info("saved oauth tokens", { mcpName: this.mcpName }) } @@ -131,11 +137,11 @@ export class McpOAuthProvider implements OAuthClientProvider { } async saveCodeVerifier(codeVerifier: string): Promise { - await McpAuth.updateCodeVerifier(this.mcpName, codeVerifier) + await Effect.runPromise(this.auth.updateCodeVerifier(this.mcpName, codeVerifier)) } async codeVerifier(): Promise { - const entry = await McpAuth.get(this.mcpName) + const entry = await Effect.runPromise(this.auth.get(this.mcpName)) if (!entry?.codeVerifier) { throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`) } @@ -143,11 +149,11 @@ export class McpOAuthProvider implements OAuthClientProvider { } async saveState(state: string): Promise { - await McpAuth.updateOAuthState(this.mcpName, state) + await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, state)) } async state(): Promise { - const entry = await McpAuth.get(this.mcpName) + const entry = await Effect.runPromise(this.auth.get(this.mcpName)) if (entry?.oauthState) { return entry.oauthState } @@ -159,28 +165,28 @@ export class McpOAuthProvider implements OAuthClientProvider { const newState = Array.from(crypto.getRandomValues(new Uint8Array(32))) .map((b) => b.toString(16).padStart(2, "0")) .join("") - await McpAuth.updateOAuthState(this.mcpName, newState) + await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, newState)) return newState } async invalidateCredentials(type: "all" | "client" | "tokens"): Promise { log.info("invalidating credentials", { mcpName: this.mcpName, type }) - const entry = await McpAuth.get(this.mcpName) + const entry = await Effect.runPromise(this.auth.get(this.mcpName)) if (!entry) { return } switch (type) { case "all": - await McpAuth.remove(this.mcpName) + await Effect.runPromise(this.auth.remove(this.mcpName)) break case "client": delete entry.clientInfo - await McpAuth.set(this.mcpName, entry) + await Effect.runPromise(this.auth.set(this.mcpName, entry)) break case "tokens": delete entry.tokens - await McpAuth.set(this.mcpName, entry) + await Effect.runPromise(this.auth.set(this.mcpName, entry)) break } } diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 786f1fb464..7f267e9c3a 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -154,15 +154,22 @@ test("state() generates a new state when none is saved", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + const auth = await Effect.runPromise( + Effect.gen(function* () { + return yield* McpAuth.Service + }).pipe(Effect.provide(McpAuth.defaultLayer)), + ) const provider = new McpOAuthProvider( "test-state-gen", "https://example.com/mcp", {}, { onRedirect: async () => {} }, + auth, ) - // Ensure no state exists - const entryBefore = await McpAuth.get("test-state-gen") + const entryBefore = await Effect.runPromise( + McpAuth.Service.use((auth) => auth.get("test-state-gen")).pipe(Effect.provide(McpAuth.defaultLayer)), + ) expect(entryBefore?.oauthState).toBeUndefined() // state() should generate and return a new state, not throw @@ -171,7 +178,9 @@ test("state() generates a new state when none is saved", async () => { expect(state.length).toBe(64) // 32 bytes as hex // The generated state should be persisted - const entryAfter = await McpAuth.get("test-state-gen") + const entryAfter = await Effect.runPromise( + McpAuth.Service.use((auth) => auth.get("test-state-gen")).pipe(Effect.provide(McpAuth.defaultLayer)), + ) expect(entryAfter?.oauthState).toBe(state) }, }) @@ -186,16 +195,26 @@ test("state() returns existing state when one is saved", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + const auth = await Effect.runPromise( + Effect.gen(function* () { + return yield* McpAuth.Service + }).pipe(Effect.provide(McpAuth.defaultLayer)), + ) const provider = new McpOAuthProvider( "test-state-existing", "https://example.com/mcp", {}, { onRedirect: async () => {} }, + auth, ) // Pre-save a state const existingState = "pre-saved-state-value" - await McpAuth.updateOAuthState("test-state-existing", existingState) + await Effect.runPromise( + McpAuth.Service.use((auth) => auth.updateOAuthState("test-state-existing", existingState)).pipe( + Effect.provide(McpAuth.defaultLayer), + ), + ) // state() should return the existing state const state = await provider.state() From 67aaecacaca91fbb1b7671e28d3f88437aa84fad Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 16:16:13 -0400 Subject: [PATCH 5/7] refactor(session): remove revert async facade exports (#22339) --- .../opencode/src/server/instance/session.ts | 68 +- packages/opencode/src/session/revert.ts | 15 - .../test/session/revert-compact.test.ts | 1106 +++++++++-------- 3 files changed, 603 insertions(+), 586 deletions(-) diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 104bdb4f99..32bd3d9fc8 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -551,28 +551,38 @@ export const SessionRoutes = lazy(() => async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - const session = await Session.get(sessionID) - await SessionRevert.cleanup(session) - const msgs = await Session.messages({ sessionID }) - const defaultAgent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.defaultAgent())) - let currentAgent = defaultAgent - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role === "user") { - currentAgent = info.agent || defaultAgent - break - } - } - await SessionCompaction.create({ - sessionID, - agent: currentAgent, - model: { - providerID: body.providerID, - modelID: body.modelID, - }, - auto: body.auto, - }) - await SessionPrompt.loop({ sessionID }) + await AppRuntime.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const revert = yield* SessionRevert.Service + const compact = yield* SessionCompaction.Service + const prompt = yield* SessionPrompt.Service + const agent = yield* Agent.Service + + yield* revert.cleanup(yield* session.get(sessionID)) + const msgs = yield* session.messages({ sessionID }) + const defaultAgent = yield* agent.defaultAgent() + let currentAgent = defaultAgent + for (let i = msgs.length - 1; i >= 0; i--) { + const info = msgs[i].info + if (info.role === "user") { + currentAgent = info.agent || defaultAgent + break + } + } + + yield* compact.create({ + sessionID, + agent: currentAgent, + model: { + providerID: body.providerID, + modelID: body.modelID, + }, + auto: body.auto, + }) + yield* prompt.loop({ sessionID }) + }), + ) return c.json(true) }, ) @@ -990,10 +1000,14 @@ export const SessionRoutes = lazy(() => async (c) => { const sessionID = c.req.valid("param").sessionID log.info("revert", c.req.valid("json")) - const session = await SessionRevert.revert({ - sessionID, - ...c.req.valid("json"), - }) + const session = await AppRuntime.runPromise( + SessionRevert.Service.use((svc) => + svc.revert({ + sessionID, + ...c.req.valid("json"), + }), + ), + ) return c.json(session) }, ) @@ -1023,7 +1037,7 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - const session = await SessionRevert.unrevert({ sessionID }) + const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID }))) return c.json(session) }, ) diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 416b8555de..a4a7a27d6d 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -1,6 +1,5 @@ import z from "zod" import { Effect, Layer, Context } from "effect" -import { makeRuntime } from "@/effect/run-service" import { Bus } from "../bus" import { Snapshot } from "../snapshot" import { Storage } from "@/storage/storage" @@ -160,18 +159,4 @@ export namespace SessionRevert { Layer.provide(SessionSummary.defaultLayer), ), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function revert(input: RevertInput) { - return runPromise((svc) => svc.revert(input)) - } - - export async function unrevert(input: { sessionID: SessionID }) { - return runPromise((svc) => svc.unrevert(input)) - } - - export async function cleanup(session: Session.Info) { - return runPromise((svc) => svc.cleanup(session)) - } } diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index 95d90325ad..679f6166ff 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -1,35 +1,47 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" +import { Effect, Layer } from "effect" import { Session } from "../../src/session" import { ModelID, ProviderID } from "../../src/provider/schema" import { SessionRevert } from "../../src/session/revert" -import { SessionCompaction } from "../../src/session/compaction" import { MessageV2 } from "../../src/session/message-v2" import { Snapshot } from "../../src/snapshot" import { Log } from "../../src/util/log" -import { Instance } from "../../src/project/instance" -import { MessageID, PartID } from "../../src/session/schema" -import { tmpdir } from "../fixture/fixture" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" Log.init({ print: false }) -function user(sessionID: string, agent = "default") { - return Session.updateMessage({ +const env = Layer.mergeAll( + Session.defaultLayer, + SessionRevert.defaultLayer, + Snapshot.defaultLayer, + CrossSpawnSpawner.defaultLayer, +) + +const it = testEffect(env) + +const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "default") { + const session = yield* Session.Service + return yield* session.updateMessage({ id: MessageID.ascending(), role: "user" as const, - sessionID: sessionID as any, + sessionID, agent, model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4") }, time: { created: Date.now() }, }) -} +}) -function assistant(sessionID: string, parentID: string, dir: string) { - return Session.updateMessage({ +const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, parentID: MessageID, dir: string) { + const session = yield* Session.Service + return yield* session.updateMessage({ id: MessageID.ascending(), role: "assistant" as const, - sessionID: sessionID as any, + sessionID, mode: "default", agent: "default", path: { cwd: dir, root: dir }, @@ -37,27 +49,29 @@ function assistant(sessionID: string, parentID: string, dir: string) { tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: ModelID.make("gpt-4"), providerID: ProviderID.make("openai"), - parentID: parentID as any, + parentID, time: { created: Date.now() }, finish: "end_turn", }) -} +}) -function text(sessionID: string, messageID: string, content: string) { - return Session.updatePart({ +const text = Effect.fn("test.text")(function* (sessionID: SessionID, messageID: MessageID, content: string) { + const session = yield* Session.Service + return yield* session.updatePart({ id: PartID.ascending(), - messageID: messageID as any, - sessionID: sessionID as any, + messageID, + sessionID, type: "text" as const, text: content, }) -} +}) -function tool(sessionID: string, messageID: string) { - return Session.updatePart({ +const tool = Effect.fn("test.tool")(function* (sessionID: SessionID, messageID: MessageID) { + const session = yield* Session.Service + return yield* session.updatePart({ id: PartID.ascending(), - messageID: messageID as any, - sessionID: sessionID as any, + messageID, + sessionID, type: "tool" as const, tool: "bash", callID: "call-1", @@ -70,7 +84,10 @@ function tool(sessionID: string, messageID: string) { time: { start: 0, end: 1 }, }, }) -} +}) + +const read = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8")) +const write = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text)) const tokens = { input: 0, @@ -80,542 +97,543 @@ const tokens = { } describe("revert + compact workflow", () => { - test("should properly handle compact command after revert", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - // Create a session - const session = await Session.create({}) - const sessionID = session.id + it.live( + "should properly handle compact command after revert", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const session = yield* Session.Service + const revert = yield* SessionRevert.Service - // Create a user message - const userMsg1 = await Session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "default", - model: { - providerID: ProviderID.make("openai"), - modelID: ModelID.make("gpt-4"), - }, - time: { - created: Date.now(), - }, - }) + const info = yield* session.create({}) + const sessionID = info.id - // Add a text part to the user message - await Session.updatePart({ - id: PartID.ascending(), - messageID: userMsg1.id, - sessionID, - type: "text", - text: "Hello, please help me", - }) - - // Create an assistant response message - const assistantMsg1: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID, - mode: "default", - agent: "default", - path: { - cwd: tmp.path, - root: tmp.path, - }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ModelID.make("gpt-4"), - providerID: ProviderID.make("openai"), - parentID: userMsg1.id, - time: { - created: Date.now(), - }, - finish: "end_turn", - } - await Session.updateMessage(assistantMsg1) - - // Add a text part to the assistant message - await Session.updatePart({ - id: PartID.ascending(), - messageID: assistantMsg1.id, - sessionID, - type: "text", - text: "Sure, I'll help you!", - }) - - // Create another user message - const userMsg2 = await Session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "default", - model: { - providerID: ProviderID.make("openai"), - modelID: ModelID.make("gpt-4"), - }, - time: { - created: Date.now(), - }, - }) - - await Session.updatePart({ - id: PartID.ascending(), - messageID: userMsg2.id, - sessionID, - type: "text", - text: "What's the capital of France?", - }) - - // Create another assistant response - const assistantMsg2: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID, - mode: "default", - agent: "default", - path: { - cwd: tmp.path, - root: tmp.path, - }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ModelID.make("gpt-4"), - providerID: ProviderID.make("openai"), - parentID: userMsg2.id, - time: { - created: Date.now(), - }, - finish: "end_turn", - } - await Session.updateMessage(assistantMsg2) - - await Session.updatePart({ - id: PartID.ascending(), - messageID: assistantMsg2.id, - sessionID, - type: "text", - text: "The capital of France is Paris.", - }) - - // Verify messages before revert - let messages = await Session.messages({ sessionID }) - expect(messages.length).toBe(4) // 2 user + 2 assistant messages - const messageIds = messages.map((m) => m.info.id) - expect(messageIds).toContain(userMsg1.id) - expect(messageIds).toContain(userMsg2.id) - expect(messageIds).toContain(assistantMsg1.id) - expect(messageIds).toContain(assistantMsg2.id) - - // Revert the last user message (userMsg2) - await SessionRevert.revert({ - sessionID, - messageID: userMsg2.id, - }) - - // Check that revert state is set - let sessionInfo = await Session.get(sessionID) - expect(sessionInfo.revert).toBeDefined() - const revertMessageID = sessionInfo.revert?.messageID - expect(revertMessageID).toBeDefined() - - // Messages should still be in the list (not removed yet, just marked for revert) - messages = await Session.messages({ sessionID }) - expect(messages.length).toBe(4) - - // Now clean up the revert state (this is what the compact endpoint should do) - await SessionRevert.cleanup(sessionInfo) - - // After cleanup, the reverted messages (those after the revert point) should be removed - messages = await Session.messages({ sessionID }) - const remainingIds = messages.map((m) => m.info.id) - // The revert point is somewhere in the message chain, so we should have fewer messages - expect(messages.length).toBeLessThan(4) - // userMsg2 and assistantMsg2 should be removed (they come after the revert point) - expect(remainingIds).not.toContain(userMsg2.id) - expect(remainingIds).not.toContain(assistantMsg2.id) - - // Revert state should be cleared - sessionInfo = await Session.get(sessionID) - expect(sessionInfo.revert).toBeUndefined() - - // Clean up - await Session.remove(sessionID) - }, - }) - }) - - test("should properly clean up revert state before creating compaction message", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - // Create a session - const session = await Session.create({}) - const sessionID = session.id - - // Create initial messages - const userMsg = await Session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "default", - model: { - providerID: ProviderID.make("openai"), - modelID: ModelID.make("gpt-4"), - }, - time: { - created: Date.now(), - }, - }) - - await Session.updatePart({ - id: PartID.ascending(), - messageID: userMsg.id, - sessionID, - type: "text", - text: "Hello", - }) - - const assistantMsg: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID, - mode: "default", - agent: "default", - path: { - cwd: tmp.path, - root: tmp.path, - }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ModelID.make("gpt-4"), - providerID: ProviderID.make("openai"), - parentID: userMsg.id, - time: { - created: Date.now(), - }, - finish: "end_turn", - } - await Session.updateMessage(assistantMsg) - - await Session.updatePart({ - id: PartID.ascending(), - messageID: assistantMsg.id, - sessionID, - type: "text", - text: "Hi there!", - }) - - // Revert the user message - await SessionRevert.revert({ - sessionID, - messageID: userMsg.id, - }) - - // Check that revert state is set - let sessionInfo = await Session.get(sessionID) - expect(sessionInfo.revert).toBeDefined() - - // Simulate what the compact endpoint does: cleanup revert before creating compaction - await SessionRevert.cleanup(sessionInfo) - - // Verify revert state is cleared - sessionInfo = await Session.get(sessionID) - expect(sessionInfo.revert).toBeUndefined() - - // Verify messages are properly cleaned up - const messages = await Session.messages({ sessionID }) - expect(messages.length).toBe(0) // All messages should be reverted - - // Clean up - await Session.remove(sessionID) - }, - }) - }) - - test("cleanup with partID removes parts from the revert point onward", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const sid = session.id - - const u1 = await user(sid) - const p1 = await text(sid, u1.id, "first part") - const p2 = await tool(sid, u1.id) - const p3 = await text(sid, u1.id, "third part") - - // Set revert state pointing at a specific part - await Session.setRevert({ - sessionID: sid, - revert: { messageID: u1.id, partID: p2.id }, - summary: { additions: 0, deletions: 0, files: 0 }, - }) - - const info = await Session.get(sid) - await SessionRevert.cleanup(info) - - const msgs = await Session.messages({ sessionID: sid }) - expect(msgs.length).toBe(1) - // Only the first part should remain (before the revert partID) - expect(msgs[0].parts.length).toBe(1) - expect(msgs[0].parts[0].id).toBe(p1.id) - - const cleared = await Session.get(sid) - expect(cleared.revert).toBeUndefined() - }, - }) - }) - - test("cleanup removes messages after revert point but keeps earlier ones", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const sid = session.id - - const u1 = await user(sid) - await text(sid, u1.id, "hello") - const a1 = await assistant(sid, u1.id, tmp.path) - await text(sid, a1.id, "hi back") - - const u2 = await user(sid) - await text(sid, u2.id, "second question") - const a2 = await assistant(sid, u2.id, tmp.path) - await text(sid, a2.id, "second answer") - - // Revert from u2 onward - await Session.setRevert({ - sessionID: sid, - revert: { messageID: u2.id }, - summary: { additions: 0, deletions: 0, files: 0 }, - }) - - const info = await Session.get(sid) - await SessionRevert.cleanup(info) - - const msgs = await Session.messages({ sessionID: sid }) - const ids = msgs.map((m) => m.info.id) - expect(ids).toContain(u1.id) - expect(ids).toContain(a1.id) - expect(ids).not.toContain(u2.id) - expect(ids).not.toContain(a2.id) - }, - }) - }) - - test("cleanup is a no-op when session has no revert state", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const sid = session.id - - const u1 = await user(sid) - await text(sid, u1.id, "hello") - - const info = await Session.get(sid) - expect(info.revert).toBeUndefined() - await SessionRevert.cleanup(info) - - const msgs = await Session.messages({ sessionID: sid }) - expect(msgs.length).toBe(1) - }, - }) - }) - - test("restore messages in sequential order", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await fs.writeFile(path.join(tmp.path, "a.txt"), "a0") - await fs.writeFile(path.join(tmp.path, "b.txt"), "b0") - await fs.writeFile(path.join(tmp.path, "c.txt"), "c0") - - const session = await Session.create({}) - const sid = session.id - - const turn = async (file: string, next: string) => { - const u = await user(sid) - await text(sid, u.id, `${file}:${next}`) - const a = await assistant(sid, u.id, tmp.path) - const before = await Snapshot.track() - if (!before) throw new Error("expected snapshot") - await fs.writeFile(path.join(tmp.path, file), next) - const after = await Snapshot.track() - if (!after) throw new Error("expected snapshot") - const patch = await Snapshot.patch(before) - await Session.updatePart({ - id: PartID.ascending(), - messageID: a.id, - sessionID: sid, - type: "step-start", - snapshot: before, + const userMsg1 = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "default", + model: { + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-4"), + }, + time: { + created: Date.now(), + }, }) - await Session.updatePart({ + + yield* session.updatePart({ id: PartID.ascending(), - messageID: a.id, - sessionID: sid, - type: "step-finish", - reason: "stop", - snapshot: after, + messageID: userMsg1.id, + sessionID, + type: "text", + text: "Hello, please help me", + }) + + const assistantMsg1: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: dir, + root: dir, + }, cost: 0, - tokens, - }) - await Session.updatePart({ + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ModelID.make("gpt-4"), + providerID: ProviderID.make("openai"), + parentID: userMsg1.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + yield* session.updateMessage(assistantMsg1) + + yield* session.updatePart({ id: PartID.ascending(), - messageID: a.id, - sessionID: sid, - type: "patch", - hash: patch.hash, - files: patch.files, + messageID: assistantMsg1.id, + sessionID, + type: "text", + text: "Sure, I'll help you!", }) - return u.id - } - const first = await turn("a.txt", "a1") - const second = await turn("b.txt", "b2") - const third = await turn("c.txt", "c3") - - await SessionRevert.revert({ - sessionID: sid, - messageID: first, - }) - expect((await Session.get(sid)).revert?.messageID).toBe(first) - expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a0") - expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b0") - expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0") - - await SessionRevert.revert({ - sessionID: sid, - messageID: second, - }) - expect((await Session.get(sid)).revert?.messageID).toBe(second) - expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1") - expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b0") - expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0") - - await SessionRevert.revert({ - sessionID: sid, - messageID: third, - }) - expect((await Session.get(sid)).revert?.messageID).toBe(third) - expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1") - expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b2") - expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0") - - await SessionRevert.unrevert({ - sessionID: sid, - }) - expect((await Session.get(sid)).revert).toBeUndefined() - expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1") - expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b2") - expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c3") - }, - }) - }) - - test("restore same file in sequential order", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await fs.writeFile(path.join(tmp.path, "a.txt"), "a0") - - const session = await Session.create({}) - const sid = session.id - - const turn = async (next: string) => { - const u = await user(sid) - await text(sid, u.id, `a.txt:${next}`) - const a = await assistant(sid, u.id, tmp.path) - const before = await Snapshot.track() - if (!before) throw new Error("expected snapshot") - await fs.writeFile(path.join(tmp.path, "a.txt"), next) - const after = await Snapshot.track() - if (!after) throw new Error("expected snapshot") - const patch = await Snapshot.patch(before) - await Session.updatePart({ - id: PartID.ascending(), - messageID: a.id, - sessionID: sid, - type: "step-start", - snapshot: before, + const userMsg2 = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "default", + model: { + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-4"), + }, + time: { + created: Date.now(), + }, }) - await Session.updatePart({ + + yield* session.updatePart({ id: PartID.ascending(), - messageID: a.id, - sessionID: sid, - type: "step-finish", - reason: "stop", - snapshot: after, + messageID: userMsg2.id, + sessionID, + type: "text", + text: "What's the capital of France?", + }) + + const assistantMsg2: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: dir, + root: dir, + }, cost: 0, - tokens, - }) - await Session.updatePart({ + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ModelID.make("gpt-4"), + providerID: ProviderID.make("openai"), + parentID: userMsg2.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + yield* session.updateMessage(assistantMsg2) + + yield* session.updatePart({ id: PartID.ascending(), - messageID: a.id, - sessionID: sid, - type: "patch", - hash: patch.hash, - files: patch.files, + messageID: assistantMsg2.id, + sessionID, + type: "text", + text: "The capital of France is Paris.", }) - return u.id - } - const first = await turn("a1") - const second = await turn("a2") - const third = await turn("a3") - expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a3") + let messages = yield* session.messages({ sessionID }) + expect(messages.length).toBe(4) + const messageIds = messages.map((m) => m.info.id) + expect(messageIds).toContain(userMsg1.id) + expect(messageIds).toContain(userMsg2.id) + expect(messageIds).toContain(assistantMsg1.id) + expect(messageIds).toContain(assistantMsg2.id) - await SessionRevert.revert({ - sessionID: sid, - messageID: first, - }) - expect((await Session.get(sid)).revert?.messageID).toBe(first) - expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a0") + yield* revert.revert({ + sessionID, + messageID: userMsg2.id, + }) - await SessionRevert.revert({ - sessionID: sid, - messageID: second, - }) - expect((await Session.get(sid)).revert?.messageID).toBe(second) - expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1") + let sessionInfo = yield* session.get(sessionID) + expect(sessionInfo.revert).toBeDefined() + expect(sessionInfo.revert?.messageID).toBeDefined() - await SessionRevert.revert({ - sessionID: sid, - messageID: third, - }) - expect((await Session.get(sid)).revert?.messageID).toBe(third) - expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a2") + messages = yield* session.messages({ sessionID }) + expect(messages.length).toBe(4) - await SessionRevert.unrevert({ - sessionID: sid, - }) - expect((await Session.get(sid)).revert).toBeUndefined() - expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a3") - }, - }) - }) + yield* revert.cleanup(sessionInfo) + + messages = yield* session.messages({ sessionID }) + const remainingIds = messages.map((m) => m.info.id) + expect(messages.length).toBeLessThan(4) + expect(remainingIds).not.toContain(userMsg2.id) + expect(remainingIds).not.toContain(assistantMsg2.id) + + sessionInfo = yield* session.get(sessionID) + expect(sessionInfo.revert).toBeUndefined() + + yield* session.remove(sessionID) + }), + { git: true }, + ), + ) + + it.live( + "should properly clean up revert state before creating compaction message", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const session = yield* Session.Service + const revert = yield* SessionRevert.Service + + const info = yield* session.create({}) + const sessionID = info.id + + const userMsg = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "default", + model: { + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-4"), + }, + time: { + created: Date.now(), + }, + }) + + yield* session.updatePart({ + id: PartID.ascending(), + messageID: userMsg.id, + sessionID, + type: "text", + text: "Hello", + }) + + const assistantMsg: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: dir, + root: dir, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ModelID.make("gpt-4"), + providerID: ProviderID.make("openai"), + parentID: userMsg.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + yield* session.updateMessage(assistantMsg) + + yield* session.updatePart({ + id: PartID.ascending(), + messageID: assistantMsg.id, + sessionID, + type: "text", + text: "Hi there!", + }) + + yield* revert.revert({ + sessionID, + messageID: userMsg.id, + }) + + let sessionInfo = yield* session.get(sessionID) + expect(sessionInfo.revert).toBeDefined() + + yield* revert.cleanup(sessionInfo) + + sessionInfo = yield* session.get(sessionID) + expect(sessionInfo.revert).toBeUndefined() + + const messages = yield* session.messages({ sessionID }) + expect(messages.length).toBe(0) + + yield* session.remove(sessionID) + }), + { git: true }, + ), + ) + + it.live( + "cleanup with partID removes parts from the revert point onward", + provideTmpdirInstance( + () => + Effect.gen(function* () { + const session = yield* Session.Service + const revert = yield* SessionRevert.Service + + const info = yield* session.create({}) + const sid = info.id + + const u1 = yield* user(sid) + const p1 = yield* text(sid, u1.id, "first part") + const p2 = yield* tool(sid, u1.id) + yield* text(sid, u1.id, "third part") + + yield* session.setRevert({ + sessionID: sid, + revert: { messageID: u1.id, partID: p2.id }, + summary: { additions: 0, deletions: 0, files: 0 }, + }) + + const state = yield* session.get(sid) + yield* revert.cleanup(state) + + const msgs = yield* session.messages({ sessionID: sid }) + expect(msgs.length).toBe(1) + expect(msgs[0].parts.length).toBe(1) + expect(msgs[0].parts[0].id).toBe(p1.id) + + const cleared = yield* session.get(sid) + expect(cleared.revert).toBeUndefined() + }), + { git: true }, + ), + ) + + it.live( + "cleanup removes messages after revert point but keeps earlier ones", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const session = yield* Session.Service + const revert = yield* SessionRevert.Service + + const info = yield* session.create({}) + const sid = info.id + + const u1 = yield* user(sid) + yield* text(sid, u1.id, "hello") + const a1 = yield* assistant(sid, u1.id, dir) + yield* text(sid, a1.id, "hi back") + + const u2 = yield* user(sid) + yield* text(sid, u2.id, "second question") + const a2 = yield* assistant(sid, u2.id, dir) + yield* text(sid, a2.id, "second answer") + + yield* session.setRevert({ + sessionID: sid, + revert: { messageID: u2.id }, + summary: { additions: 0, deletions: 0, files: 0 }, + }) + + const state = yield* session.get(sid) + yield* revert.cleanup(state) + + const msgs = yield* session.messages({ sessionID: sid }) + const ids = msgs.map((m) => m.info.id) + expect(ids).toContain(u1.id) + expect(ids).toContain(a1.id) + expect(ids).not.toContain(u2.id) + expect(ids).not.toContain(a2.id) + }), + { git: true }, + ), + ) + + it.live( + "cleanup is a no-op when session has no revert state", + provideTmpdirInstance( + () => + Effect.gen(function* () { + const session = yield* Session.Service + const revert = yield* SessionRevert.Service + + const info = yield* session.create({}) + const sid = info.id + + const u1 = yield* user(sid) + yield* text(sid, u1.id, "hello") + + const state = yield* session.get(sid) + expect(state.revert).toBeUndefined() + yield* revert.cleanup(state) + + const msgs = yield* session.messages({ sessionID: sid }) + expect(msgs.length).toBe(1) + }), + { git: true }, + ), + ) + + it.live( + "restore messages in sequential order", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const session = yield* Session.Service + const revert = yield* SessionRevert.Service + const snapshot = yield* Snapshot.Service + + yield* write(path.join(dir, "a.txt"), "a0") + yield* write(path.join(dir, "b.txt"), "b0") + yield* write(path.join(dir, "c.txt"), "c0") + + const info = yield* session.create({}) + const sid = info.id + + const turn = Effect.fn("test.turn")(function* (file: string, next: string) { + const u = yield* user(sid) + yield* text(sid, u.id, `${file}:${next}`) + const a = yield* assistant(sid, u.id, dir) + const before = yield* snapshot.track() + if (!before) throw new Error("expected snapshot") + yield* write(path.join(dir, file), next) + const after = yield* snapshot.track() + if (!after) throw new Error("expected snapshot") + const patch = yield* snapshot.patch(before) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: a.id, + sessionID: sid, + type: "step-start", + snapshot: before, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: a.id, + sessionID: sid, + type: "step-finish", + reason: "stop", + snapshot: after, + cost: 0, + tokens, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: a.id, + sessionID: sid, + type: "patch", + hash: patch.hash, + files: patch.files, + }) + return u.id + }) + + const first = yield* turn("a.txt", "a1") + const second = yield* turn("b.txt", "b2") + const third = yield* turn("c.txt", "c3") + + yield* revert.revert({ + sessionID: sid, + messageID: first, + }) + expect((yield* session.get(sid)).revert?.messageID).toBe(first) + expect(yield* read(path.join(dir, "a.txt"))).toBe("a0") + expect(yield* read(path.join(dir, "b.txt"))).toBe("b0") + expect(yield* read(path.join(dir, "c.txt"))).toBe("c0") + + yield* revert.revert({ + sessionID: sid, + messageID: second, + }) + expect((yield* session.get(sid)).revert?.messageID).toBe(second) + expect(yield* read(path.join(dir, "a.txt"))).toBe("a1") + expect(yield* read(path.join(dir, "b.txt"))).toBe("b0") + expect(yield* read(path.join(dir, "c.txt"))).toBe("c0") + + yield* revert.revert({ + sessionID: sid, + messageID: third, + }) + expect((yield* session.get(sid)).revert?.messageID).toBe(third) + expect(yield* read(path.join(dir, "a.txt"))).toBe("a1") + expect(yield* read(path.join(dir, "b.txt"))).toBe("b2") + expect(yield* read(path.join(dir, "c.txt"))).toBe("c0") + + yield* revert.unrevert({ + sessionID: sid, + }) + expect((yield* session.get(sid)).revert).toBeUndefined() + expect(yield* read(path.join(dir, "a.txt"))).toBe("a1") + expect(yield* read(path.join(dir, "b.txt"))).toBe("b2") + expect(yield* read(path.join(dir, "c.txt"))).toBe("c3") + }), + { git: true }, + ), + ) + + it.live( + "restore same file in sequential order", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const session = yield* Session.Service + const revert = yield* SessionRevert.Service + const snapshot = yield* Snapshot.Service + + yield* write(path.join(dir, "a.txt"), "a0") + + const info = yield* session.create({}) + const sid = info.id + + const turn = Effect.fn("test.turnSame")(function* (next: string) { + const u = yield* user(sid) + yield* text(sid, u.id, `a.txt:${next}`) + const a = yield* assistant(sid, u.id, dir) + const before = yield* snapshot.track() + if (!before) throw new Error("expected snapshot") + yield* write(path.join(dir, "a.txt"), next) + const after = yield* snapshot.track() + if (!after) throw new Error("expected snapshot") + const patch = yield* snapshot.patch(before) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: a.id, + sessionID: sid, + type: "step-start", + snapshot: before, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: a.id, + sessionID: sid, + type: "step-finish", + reason: "stop", + snapshot: after, + cost: 0, + tokens, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: a.id, + sessionID: sid, + type: "patch", + hash: patch.hash, + files: patch.files, + }) + return u.id + }) + + const first = yield* turn("a1") + const second = yield* turn("a2") + const third = yield* turn("a3") + expect(yield* read(path.join(dir, "a.txt"))).toBe("a3") + + yield* revert.revert({ + sessionID: sid, + messageID: first, + }) + expect((yield* session.get(sid)).revert?.messageID).toBe(first) + expect(yield* read(path.join(dir, "a.txt"))).toBe("a0") + + yield* revert.revert({ + sessionID: sid, + messageID: second, + }) + expect((yield* session.get(sid)).revert?.messageID).toBe(second) + expect(yield* read(path.join(dir, "a.txt"))).toBe("a1") + + yield* revert.revert({ + sessionID: sid, + messageID: third, + }) + expect((yield* session.get(sid)).revert?.messageID).toBe(third) + expect(yield* read(path.join(dir, "a.txt"))).toBe("a2") + + yield* revert.unrevert({ + sessionID: sid, + }) + expect((yield* session.get(sid)).revert).toBeUndefined() + expect(yield* read(path.join(dir, "a.txt"))).toBe("a3") + }), + { git: true }, + ), + ) }) From b22add292c194697c1581baf1a323996f966d04b Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 13 Apr 2026 16:51:59 -0400 Subject: [PATCH 6/7] refactor(core): publish sync events to global event stream (#22347) --- packages/app/src/context/global-sdk.tsx | 7 +- packages/opencode/src/bus/bus-event.ts | 29 +- .../opencode/src/cli/cmd/tui/context/event.ts | 4 + packages/opencode/src/project/instance.ts | 2 - .../opencode/src/server/instance/event.ts | 8 +- .../opencode/src/server/instance/global.ts | 48 +- packages/opencode/src/sync/index.ts | 59 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 20 - packages/sdk/js/src/v2/gen/types.gen.ts | 229 ++++--- packages/sdk/openapi.json | 600 ++++++++++++------ 10 files changed, 597 insertions(+), 409 deletions(-) diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 1205a8fa82..172b5c9664 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -155,7 +155,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo resetHeartbeat() streamErrorLogged = false const directory = event.directory ?? "global" - const payload = event.payload + if (event.payload.type === "sync") { + continue + } + + const payload = event.payload as Event + const k = key(directory, payload) if (k) { const i = coalesced.get(k) diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index d97922290e..aad5f398e0 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -16,25 +16,18 @@ export namespace BusEvent { } export function payloads() { - return z - .discriminatedUnion( - "type", - registry - .entries() - .map(([type, def]) => { - return z - .object({ - type: z.literal(type), - properties: def.properties, - }) - .meta({ - ref: "Event" + "." + def.type, - }) + return registry + .entries() + .map(([type, def]) => { + return z + .object({ + type: z.literal(type), + properties: def.properties, + }) + .meta({ + ref: "Event" + "." + def.type, }) - .toArray() as any, - ) - .meta({ - ref: "Event", }) + .toArray() } } diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts index da073f6e92..156f9c9476 100644 --- a/packages/opencode/src/cli/cmd/tui/context/event.ts +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -8,6 +8,10 @@ export function useEvent() { function subscribe(handler: (event: Event) => void) { return sdk.event.on("event", (event) => { + if (event.payload.type === "sync") { + return + } + // Special hack for truly global events if (event.directory === "global") { handler(event.payload) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 8d2d51db68..12de88a27a 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -21,8 +21,6 @@ const disposal = { all: undefined as Promise | undefined, } -function emitDisposed(directory: string) {} - function boot(input: { directory: string; init?: () => Promise; worktree?: string; project?: Project.Info }) { return iife(async () => { const ctx = diff --git a/packages/opencode/src/server/instance/event.ts b/packages/opencode/src/server/instance/event.ts index 989b857710..5d631d954e 100644 --- a/packages/opencode/src/server/instance/event.ts +++ b/packages/opencode/src/server/instance/event.ts @@ -1,8 +1,10 @@ +import z from "zod" import { Hono } from "hono" import { describeRoute, resolver } from "hono-openapi" import { streamSSE } from "hono/streaming" import { Log } from "@/util/log" import { BusEvent } from "@/bus/bus-event" +import { SyncEvent } from "@/sync" import { Bus } from "@/bus" import { AsyncQueue } from "../../util/queue" @@ -20,7 +22,11 @@ export const EventRoutes = () => description: "Event stream", content: { "text/event-stream": { - schema: resolver(BusEvent.payloads()), + schema: resolver( + z.union(BusEvent.payloads()).meta({ + ref: "Event", + }), + ), }, }, }, diff --git a/packages/opencode/src/server/instance/global.ts b/packages/opencode/src/server/instance/global.ts index daecb6bd36..d462a07f74 100644 --- a/packages/opencode/src/server/instance/global.ts +++ b/packages/opencode/src/server/instance/global.ts @@ -109,7 +109,7 @@ export const GlobalRoutes = lazy(() => directory: z.string(), project: z.string().optional(), workspace: z.string().optional(), - payload: BusEvent.payloads(), + payload: z.union([...BusEvent.payloads(), ...SyncEvent.payloads()]), }) .meta({ ref: "GlobalEvent", @@ -135,52 +135,6 @@ export const GlobalRoutes = lazy(() => }) }, ) - .get( - "/sync-event", - describeRoute({ - summary: "Subscribe to global sync events", - description: "Get global sync events", - operationId: "global.sync-event.subscribe", - responses: { - 200: { - description: "Event stream", - content: { - "text/event-stream": { - schema: resolver( - z - .object({ - payload: SyncEvent.payloads(), - }) - .meta({ - ref: "SyncEvent", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - log.info("global sync event connected") - c.header("Cache-Control", "no-cache, no-transform") - c.header("X-Accel-Buffering", "no") - c.header("X-Content-Type-Options", "nosniff") - return streamEvents(c, (q) => { - return SyncEvent.subscribeAll(({ def, event }) => { - // TODO: don't pass def, just pass the type (and it should - // be versioned) - q.push( - JSON.stringify({ - payload: { - ...event, - type: SyncEvent.versionedType(def.type, def.version), - }, - }), - ) - }) - }) - }, - ) .get( "/config", describeRoute({ diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index a409391915..d7cb7f774f 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -2,9 +2,12 @@ import z from "zod" import type { ZodObject } from "zod" import { EventEmitter } from "events" import { Database, eq } from "@/storage/db" +import { GlobalBus } from "@/bus/global" import { Bus as ProjectBus } from "@/bus" import { BusEvent } from "@/bus/bus-event" +import { Instance } from "@/project/instance" import { EventSequenceTable, EventTable } from "./event.sql" +import { WorkspaceContext } from "@/control-plane/workspace-context" import { EventID } from "./schema" import { Flag } from "@/flag/flag" @@ -37,8 +40,6 @@ export namespace SyncEvent { let frozen = false let convertEvent: (type: string, event: Event["data"]) => Promise> | Record - const Bus = new EventEmitter<{ event: [{ def: Definition; event: Event }] }>() - export function reset() { frozen = false projectors = undefined @@ -140,11 +141,6 @@ export namespace SyncEvent { } Database.effect(() => { - Bus.emit("event", { - def, - event, - }) - if (options?.publish) { const result = convertEvent(def.type, event.data) if (result instanceof Promise) { @@ -154,6 +150,17 @@ export namespace SyncEvent { } else { ProjectBus.publish({ type: def.type, properties: def.schema }, result) } + + GlobalBus.emit("event", { + directory: Instance.directory, + project: Instance.project.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "sync", + name: versionedType(def.type, def.version), + ...event, + }, + }) } }) }) @@ -235,31 +242,23 @@ export namespace SyncEvent { }) } - export function subscribeAll(handler: (event: { def: Definition; event: Event }) => void) { - Bus.on("event", handler) - return () => Bus.off("event", handler) - } - export function payloads() { - return z - .union( - registry - .entries() - .map(([type, def]) => { - return z - .object({ - type: z.literal(type), - aggregate: z.literal(def.aggregate), - data: def.schema, - }) - .meta({ - ref: "SyncEvent" + "." + def.type, - }) + return registry + .entries() + .map(([type, def]) => { + return z + .object({ + type: z.literal("sync"), + name: z.literal(type), + id: z.string(), + seq: z.number(), + aggregateID: z.literal(def.aggregate), + data: def.schema, + }) + .meta({ + ref: "SyncEvent" + "." + def.type, }) - .toArray() as any, - ) - .meta({ - ref: "SyncEvent", }) + .toArray() } } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f297cb18e2..b5fc976bba 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -51,7 +51,6 @@ import type { GlobalDisposeResponses, GlobalEventResponses, GlobalHealthResponses, - GlobalSyncEventSubscribeResponses, GlobalUpgradeErrors, GlobalUpgradeResponses, InstanceDisposeResponses, @@ -237,20 +236,6 @@ class HeyApiRegistry { } } -export class SyncEvent extends HeyApiClient { - /** - * Subscribe to global sync events - * - * Get global sync events - */ - public subscribe(options?: Options) { - return (options?.client ?? this.client).sse.get({ - url: "/global/sync-event", - ...options, - }) - } -} - export class Config extends HeyApiClient { /** * Get global configuration @@ -350,11 +335,6 @@ export class Global extends HeyApiClient { }) } - private _syncEvent?: SyncEvent - get syncEvent(): SyncEvent { - return (this._syncEvent ??= new SyncEvent({ client: this.client })) - } - private _config?: Config get config(): Config { return (this._config ??= new Config({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 83ec3d751a..e2a9a88ad3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -971,64 +971,12 @@ export type EventSessionDeleted = { } } -export type Event = - | EventProjectUpdated - | EventServerInstanceDisposed - | EventInstallationUpdated - | EventInstallationUpdateAvailable - | EventServerConnected - | EventGlobalDisposed - | EventFileEdited - | EventFileWatcherUpdated - | EventLspClientDiagnostics - | EventLspUpdated - | EventMessagePartDelta - | EventPermissionAsked - | EventPermissionReplied - | EventSessionDiff - | EventSessionError - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventTodoUpdated - | EventSessionStatus - | EventSessionIdle - | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventMcpToolsChanged - | EventMcpBrowserOpenFailed - | EventCommandExecuted - | EventVcsBranchUpdated - | EventWorktreeReady - | EventWorktreeFailed - | EventPtyCreated - | EventPtyUpdated - | EventPtyExited - | EventPtyDeleted - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceStatus - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted - -export type GlobalEvent = { - directory: string - project?: string - workspace?: string - payload: Event -} - export type SyncEventMessageUpdated = { - type: "message.updated.1" - aggregate: "sessionID" + type: "sync" + name: "message.updated.1" + id: string + seq: number + aggregateID: "sessionID" data: { sessionID: string info: Message @@ -1036,8 +984,11 @@ export type SyncEventMessageUpdated = { } export type SyncEventMessageRemoved = { - type: "message.removed.1" - aggregate: "sessionID" + type: "sync" + name: "message.removed.1" + id: string + seq: number + aggregateID: "sessionID" data: { sessionID: string messageID: string @@ -1045,8 +996,11 @@ export type SyncEventMessageRemoved = { } export type SyncEventMessagePartUpdated = { - type: "message.part.updated.1" - aggregate: "sessionID" + type: "sync" + name: "message.part.updated.1" + id: string + seq: number + aggregateID: "sessionID" data: { sessionID: string part: Part @@ -1055,8 +1009,11 @@ export type SyncEventMessagePartUpdated = { } export type SyncEventMessagePartRemoved = { - type: "message.part.removed.1" - aggregate: "sessionID" + type: "sync" + name: "message.part.removed.1" + id: string + seq: number + aggregateID: "sessionID" data: { sessionID: string messageID: string @@ -1065,8 +1022,11 @@ export type SyncEventMessagePartRemoved = { } export type SyncEventSessionCreated = { - type: "session.created.1" - aggregate: "sessionID" + type: "sync" + name: "session.created.1" + id: string + seq: number + aggregateID: "sessionID" data: { sessionID: string info: Session @@ -1074,8 +1034,11 @@ export type SyncEventSessionCreated = { } export type SyncEventSessionUpdated = { - type: "session.updated.1" - aggregate: "sessionID" + type: "sync" + name: "session.updated.1" + id: string + seq: number + aggregateID: "sessionID" data: { sessionID: string info: { @@ -1114,16 +1077,75 @@ export type SyncEventSessionUpdated = { } export type SyncEventSessionDeleted = { - type: "session.deleted.1" - aggregate: "sessionID" + type: "sync" + name: "session.deleted.1" + id: string + seq: number + aggregateID: "sessionID" data: { sessionID: string info: Session } } -export type SyncEvent = { - payload: SyncEvent +export type GlobalEvent = { + directory: string + project?: string + workspace?: string + payload: + | EventProjectUpdated + | EventServerInstanceDisposed + | EventInstallationUpdated + | EventInstallationUpdateAvailable + | EventServerConnected + | EventGlobalDisposed + | EventFileEdited + | EventFileWatcherUpdated + | EventLspClientDiagnostics + | EventLspUpdated + | EventMessagePartDelta + | EventPermissionAsked + | EventPermissionReplied + | EventSessionDiff + | EventSessionError + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventTodoUpdated + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed + | EventCommandExecuted + | EventVcsBranchUpdated + | EventWorktreeReady + | EventWorktreeFailed + | EventPtyCreated + | EventPtyUpdated + | EventPtyExited + | EventPtyDeleted + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceStatus + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | SyncEventMessageUpdated + | SyncEventMessageRemoved + | SyncEventMessagePartUpdated + | SyncEventMessagePartRemoved + | SyncEventSessionCreated + | SyncEventSessionUpdated + | SyncEventSessionDeleted } /** @@ -1982,6 +2004,54 @@ export type File = { status: "added" | "deleted" | "modified" } +export type Event = + | EventProjectUpdated + | EventServerInstanceDisposed + | EventInstallationUpdated + | EventInstallationUpdateAvailable + | EventServerConnected + | EventGlobalDisposed + | EventFileEdited + | EventFileWatcherUpdated + | EventLspClientDiagnostics + | EventLspUpdated + | EventMessagePartDelta + | EventPermissionAsked + | EventPermissionReplied + | EventSessionDiff + | EventSessionError + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventTodoUpdated + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed + | EventCommandExecuted + | EventVcsBranchUpdated + | EventWorktreeReady + | EventWorktreeFailed + | EventPtyCreated + | EventPtyUpdated + | EventPtyExited + | EventPtyDeleted + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceStatus + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + export type McpStatusConnected = { status: "connected" } @@ -2113,23 +2183,6 @@ export type GlobalEventResponses = { export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses] -export type GlobalSyncEventSubscribeData = { - body?: never - path?: never - query?: never - url: "/global/sync-event" -} - -export type GlobalSyncEventSubscribeResponses = { - /** - * Event stream - */ - 200: SyncEvent -} - -export type GlobalSyncEventSubscribeResponse = - GlobalSyncEventSubscribeResponses[keyof GlobalSyncEventSubscribeResponses] - export type GlobalConfigGetData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 85443726f3..54f11eccf3 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -66,31 +66,6 @@ ] } }, - "/global/sync-event": { - "get": { - "operationId": "global.sync-event.subscribe", - "summary": "Subscribe to global sync events", - "description": "Get global sync events", - "responses": { - "200": { - "description": "Event stream", - "content": { - "text/event-stream": { - "schema": { - "$ref": "#/components/schemas/SyncEvent" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.sync-event.subscribe({\n ...\n})" - } - ] - } - }, "/global/config": { "get": { "operationId": "global.config.get", @@ -9925,174 +9900,24 @@ }, "required": ["type", "properties"] }, - "Event": { - "anyOf": [ - { - "$ref": "#/components/schemas/Event.project.updated" - }, - { - "$ref": "#/components/schemas/Event.server.instance.disposed" - }, - { - "$ref": "#/components/schemas/Event.installation.updated" - }, - { - "$ref": "#/components/schemas/Event.installation.update-available" - }, - { - "$ref": "#/components/schemas/Event.server.connected" - }, - { - "$ref": "#/components/schemas/Event.global.disposed" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, - { - "$ref": "#/components/schemas/Event.lsp.client.diagnostics" - }, - { - "$ref": "#/components/schemas/Event.lsp.updated" - }, - { - "$ref": "#/components/schemas/Event.message.part.delta" - }, - { - "$ref": "#/components/schemas/Event.permission.asked" - }, - { - "$ref": "#/components/schemas/Event.permission.replied" - }, - { - "$ref": "#/components/schemas/Event.session.diff" - }, - { - "$ref": "#/components/schemas/Event.session.error" - }, - { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" - }, - { - "$ref": "#/components/schemas/Event.todo.updated" - }, - { - "$ref": "#/components/schemas/Event.session.status" - }, - { - "$ref": "#/components/schemas/Event.session.idle" - }, - { - "$ref": "#/components/schemas/Event.session.compacted" - }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/Event.mcp.tools.changed" - }, - { - "$ref": "#/components/schemas/Event.mcp.browser.open.failed" - }, - { - "$ref": "#/components/schemas/Event.command.executed" - }, - { - "$ref": "#/components/schemas/Event.vcs.branch.updated" - }, - { - "$ref": "#/components/schemas/Event.worktree.ready" - }, - { - "$ref": "#/components/schemas/Event.worktree.failed" - }, - { - "$ref": "#/components/schemas/Event.pty.created" - }, - { - "$ref": "#/components/schemas/Event.pty.updated" - }, - { - "$ref": "#/components/schemas/Event.pty.exited" - }, - { - "$ref": "#/components/schemas/Event.pty.deleted" - }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.message.updated" - }, - { - "$ref": "#/components/schemas/Event.message.removed" - }, - { - "$ref": "#/components/schemas/Event.message.part.updated" - }, - { - "$ref": "#/components/schemas/Event.message.part.removed" - }, - { - "$ref": "#/components/schemas/Event.session.created" - }, - { - "$ref": "#/components/schemas/Event.session.updated" - }, - { - "$ref": "#/components/schemas/Event.session.deleted" - } - ] - }, - "GlobalEvent": { - "type": "object", - "properties": { - "directory": { - "type": "string" - }, - "project": { - "type": "string" - }, - "workspace": { - "type": "string" - }, - "payload": { - "$ref": "#/components/schemas/Event" - } - }, - "required": ["directory", "payload"] - }, "SyncEvent.message.updated": { "type": "object", "properties": { "type": { + "type": "string", + "const": "sync" + }, + "name": { "type": "string", "const": "message.updated.1" }, - "aggregate": { + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { "type": "string", "const": "sessionID" }, @@ -10110,16 +9935,26 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "aggregate", "data"] + "required": ["type", "name", "id", "seq", "aggregateID", "data"] }, "SyncEvent.message.removed": { "type": "object", "properties": { "type": { + "type": "string", + "const": "sync" + }, + "name": { "type": "string", "const": "message.removed.1" }, - "aggregate": { + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { "type": "string", "const": "sessionID" }, @@ -10138,16 +9973,26 @@ "required": ["sessionID", "messageID"] } }, - "required": ["type", "aggregate", "data"] + "required": ["type", "name", "id", "seq", "aggregateID", "data"] }, "SyncEvent.message.part.updated": { "type": "object", "properties": { "type": { + "type": "string", + "const": "sync" + }, + "name": { "type": "string", "const": "message.part.updated.1" }, - "aggregate": { + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { "type": "string", "const": "sessionID" }, @@ -10168,16 +10013,26 @@ "required": ["sessionID", "part", "time"] } }, - "required": ["type", "aggregate", "data"] + "required": ["type", "name", "id", "seq", "aggregateID", "data"] }, "SyncEvent.message.part.removed": { "type": "object", "properties": { "type": { + "type": "string", + "const": "sync" + }, + "name": { "type": "string", "const": "message.part.removed.1" }, - "aggregate": { + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { "type": "string", "const": "sessionID" }, @@ -10200,16 +10055,26 @@ "required": ["sessionID", "messageID", "partID"] } }, - "required": ["type", "aggregate", "data"] + "required": ["type", "name", "id", "seq", "aggregateID", "data"] }, "SyncEvent.session.created": { "type": "object", "properties": { "type": { + "type": "string", + "const": "sync" + }, + "name": { "type": "string", "const": "session.created.1" }, - "aggregate": { + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { "type": "string", "const": "sessionID" }, @@ -10227,16 +10092,26 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "aggregate", "data"] + "required": ["type", "name", "id", "seq", "aggregateID", "data"] }, "SyncEvent.session.updated": { "type": "object", "properties": { "type": { + "type": "string", + "const": "sync" + }, + "name": { "type": "string", "const": "session.updated.1" }, - "aggregate": { + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { "type": "string", "const": "sessionID" }, @@ -10479,16 +10354,26 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "aggregate", "data"] + "required": ["type", "name", "id", "seq", "aggregateID", "data"] }, "SyncEvent.session.deleted": { "type": "object", "properties": { "type": { + "type": "string", + "const": "sync" + }, + "name": { "type": "string", "const": "session.deleted.1" }, - "aggregate": { + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { "type": "string", "const": "sessionID" }, @@ -10506,16 +10391,185 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "aggregate", "data"] + "required": ["type", "name", "id", "seq", "aggregateID", "data"] }, - "SyncEvent": { + "GlobalEvent": { "type": "object", "properties": { + "directory": { + "type": "string" + }, + "project": { + "type": "string" + }, + "workspace": { + "type": "string" + }, "payload": { - "$ref": "#/components/schemas/SyncEvent" + "anyOf": [ + { + "$ref": "#/components/schemas/Event.project.updated" + }, + { + "$ref": "#/components/schemas/Event.server.instance.disposed" + }, + { + "$ref": "#/components/schemas/Event.installation.updated" + }, + { + "$ref": "#/components/schemas/Event.installation.update-available" + }, + { + "$ref": "#/components/schemas/Event.server.connected" + }, + { + "$ref": "#/components/schemas/Event.global.disposed" + }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, + { + "$ref": "#/components/schemas/Event.lsp.client.diagnostics" + }, + { + "$ref": "#/components/schemas/Event.lsp.updated" + }, + { + "$ref": "#/components/schemas/Event.message.part.delta" + }, + { + "$ref": "#/components/schemas/Event.permission.asked" + }, + { + "$ref": "#/components/schemas/Event.permission.replied" + }, + { + "$ref": "#/components/schemas/Event.session.diff" + }, + { + "$ref": "#/components/schemas/Event.session.error" + }, + { + "$ref": "#/components/schemas/Event.question.asked" + }, + { + "$ref": "#/components/schemas/Event.question.replied" + }, + { + "$ref": "#/components/schemas/Event.question.rejected" + }, + { + "$ref": "#/components/schemas/Event.todo.updated" + }, + { + "$ref": "#/components/schemas/Event.session.status" + }, + { + "$ref": "#/components/schemas/Event.session.idle" + }, + { + "$ref": "#/components/schemas/Event.session.compacted" + }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/Event.tui.toast.show" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/Event.mcp.tools.changed" + }, + { + "$ref": "#/components/schemas/Event.mcp.browser.open.failed" + }, + { + "$ref": "#/components/schemas/Event.command.executed" + }, + { + "$ref": "#/components/schemas/Event.vcs.branch.updated" + }, + { + "$ref": "#/components/schemas/Event.worktree.ready" + }, + { + "$ref": "#/components/schemas/Event.worktree.failed" + }, + { + "$ref": "#/components/schemas/Event.pty.created" + }, + { + "$ref": "#/components/schemas/Event.pty.updated" + }, + { + "$ref": "#/components/schemas/Event.pty.exited" + }, + { + "$ref": "#/components/schemas/Event.pty.deleted" + }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, + { + "$ref": "#/components/schemas/Event.message.updated" + }, + { + "$ref": "#/components/schemas/Event.message.removed" + }, + { + "$ref": "#/components/schemas/Event.message.part.updated" + }, + { + "$ref": "#/components/schemas/Event.message.part.removed" + }, + { + "$ref": "#/components/schemas/Event.session.created" + }, + { + "$ref": "#/components/schemas/Event.session.updated" + }, + { + "$ref": "#/components/schemas/Event.session.deleted" + }, + { + "$ref": "#/components/schemas/SyncEvent.message.updated" + }, + { + "$ref": "#/components/schemas/SyncEvent.message.removed" + }, + { + "$ref": "#/components/schemas/SyncEvent.message.part.updated" + }, + { + "$ref": "#/components/schemas/SyncEvent.message.part.removed" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.created" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.updated" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.deleted" + } + ] } }, - "required": ["payload"] + "required": ["directory", "payload"] }, "LogLevel": { "description": "Log level", @@ -12608,6 +12662,148 @@ }, "required": ["path", "added", "removed", "status"] }, + "Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/Event.project.updated" + }, + { + "$ref": "#/components/schemas/Event.server.instance.disposed" + }, + { + "$ref": "#/components/schemas/Event.installation.updated" + }, + { + "$ref": "#/components/schemas/Event.installation.update-available" + }, + { + "$ref": "#/components/schemas/Event.server.connected" + }, + { + "$ref": "#/components/schemas/Event.global.disposed" + }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, + { + "$ref": "#/components/schemas/Event.lsp.client.diagnostics" + }, + { + "$ref": "#/components/schemas/Event.lsp.updated" + }, + { + "$ref": "#/components/schemas/Event.message.part.delta" + }, + { + "$ref": "#/components/schemas/Event.permission.asked" + }, + { + "$ref": "#/components/schemas/Event.permission.replied" + }, + { + "$ref": "#/components/schemas/Event.session.diff" + }, + { + "$ref": "#/components/schemas/Event.session.error" + }, + { + "$ref": "#/components/schemas/Event.question.asked" + }, + { + "$ref": "#/components/schemas/Event.question.replied" + }, + { + "$ref": "#/components/schemas/Event.question.rejected" + }, + { + "$ref": "#/components/schemas/Event.todo.updated" + }, + { + "$ref": "#/components/schemas/Event.session.status" + }, + { + "$ref": "#/components/schemas/Event.session.idle" + }, + { + "$ref": "#/components/schemas/Event.session.compacted" + }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/Event.tui.toast.show" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/Event.mcp.tools.changed" + }, + { + "$ref": "#/components/schemas/Event.mcp.browser.open.failed" + }, + { + "$ref": "#/components/schemas/Event.command.executed" + }, + { + "$ref": "#/components/schemas/Event.vcs.branch.updated" + }, + { + "$ref": "#/components/schemas/Event.worktree.ready" + }, + { + "$ref": "#/components/schemas/Event.worktree.failed" + }, + { + "$ref": "#/components/schemas/Event.pty.created" + }, + { + "$ref": "#/components/schemas/Event.pty.updated" + }, + { + "$ref": "#/components/schemas/Event.pty.exited" + }, + { + "$ref": "#/components/schemas/Event.pty.deleted" + }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, + { + "$ref": "#/components/schemas/Event.message.updated" + }, + { + "$ref": "#/components/schemas/Event.message.removed" + }, + { + "$ref": "#/components/schemas/Event.message.part.updated" + }, + { + "$ref": "#/components/schemas/Event.message.part.removed" + }, + { + "$ref": "#/components/schemas/Event.session.created" + }, + { + "$ref": "#/components/schemas/Event.session.updated" + }, + { + "$ref": "#/components/schemas/Event.session.deleted" + } + ] + }, "MCPStatusConnected": { "type": "object", "properties": { From 59c0fc28ee53b9e63381ebd1190bf35bd74353e5 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 13 Apr 2026 17:33:17 -0400 Subject: [PATCH 7/7] ignore: v2 thoughts --- packages/opencode/src/session/session.sql.ts | 3 +- packages/opencode/src/v2/session-entry.ts | 45 +++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 119ebeda37..35ed8fdda4 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -11,7 +11,6 @@ import { Timestamps } from "../storage/schema.sql" type PartData = Omit type InfoData = Omit -type EntryData = Omit export const SessionTable = sqliteTable( "session", @@ -104,7 +103,7 @@ export const SessionEntryTable = sqliteTable( .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), - type: text().notNull(), + type: text().$type().notNull(), ...Timestamps, data: text({ mode: "json" }).notNull().$type>(), }, diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index b931a4c494..03c8a85b07 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -1,6 +1,10 @@ import { Identifier } from "@/id/id" +import { Database } from "@/node" +import type { SessionID } from "@/session/schema" +import { SessionEntryTable } from "@/session/session.sql" import { withStatics } from "@/util/schema" -import { DateTime, Effect, Schema } from "effect" +import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { eq } from "../storage/db" export namespace SessionEntry { export const ID = Schema.String.pipe(Schema.brand("Session.Entry.ID")).pipe( @@ -181,6 +185,43 @@ export namespace SessionEntry { overflow: Schema.Boolean.pipe(Schema.optional), }) {} - export const Entry = Schema.Union([User, Synthetic, Request, Tool, Text, Reasoning, Complete, Retry, Compaction]) + export const Entry = Schema.Union([User, Synthetic, Request, Tool, Text, Reasoning, Complete, Retry, Compaction], { + mode: "oneOf", + }) export type Entry = Schema.Schema.Type + + export type Type = Entry["type"] + + export interface Interface { + readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry + readonly fromSession: (sessionID: SessionID) => Effect.Effect + } + + export class Service extends Context.Service()("@opencode/SessionEntry") {} + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const decodeEntry = Schema.decodeUnknownSync(Entry) + + const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type }) + + const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) { + return Database.use((db) => + db + .select() + .from(SessionEntryTable) + .where(eq(SessionEntryTable.session_id, sessionID)) + .orderBy(SessionEntryTable.id) + .all() + .map((row) => decode(row)), + ) + }) + + return Service.of({ + decode, + fromSession, + }) + }), + ) }