From 107180701f626eaf97e0f032c4a46fe4b4e9c0ec Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 4 Jun 2026 20:16:53 +0000 Subject: [PATCH 001/626] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 16326eaca6..409724656e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-dvFu5Cbs8MFoSBQXwv4HN2vyh5p20dh6QC5zZiFr0qs=", - "aarch64-linux": "sha256-l0xO7Nocl6enQxLQlLB71mG+NuT6I1eQQ1FgLtYGQOg=", - "aarch64-darwin": "sha256-WY6Lstxt4n4n63kYZUX09birHx7sNvl0Pegc6L13mGE=", - "x86_64-darwin": "sha256-sZdG40TSE9KhrmLQyQMPRugGo6R7AS3wgHiEGYtcXtc=" + "x86_64-linux": "sha256-mXTzANDuuy+BY4vzhuuL5Q6JVVTJCKdHuD/Fo8pSfgI=", + "aarch64-linux": "sha256-t1Uf+PIDvj9bogsSo2Dg1e+zJM2CHQ8lpA/I3vFQA1Q=", + "aarch64-darwin": "sha256-HKpMwzpYhCQOu0xHugi4ZIC/Va2BSiQpM2TbA6BEZDU=", + "x86_64-darwin": "sha256-m5h7h9KxkcIrdTO2QzQftq68d0Ru0IsCfu3WzMp4P68=" } } From b1a7ee5695bded3ebe4282a17bfe91717edea363 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:45:48 +1000 Subject: [PATCH 002/626] feat(desktop): surface local server startup failures (#30822) --- packages/app/src/i18n/en.ts | 1 + .../app/src/pages/error-description.test.ts | 17 +++++ packages/app/src/pages/error-description.ts | 11 +++ packages/app/src/pages/error.tsx | 3 +- packages/desktop/src/main/index.test.ts | 37 ++++++++++ packages/desktop/src/main/index.ts | 5 +- packages/desktop/src/main/initialization.ts | 6 ++ packages/desktop/src/renderer/index.tsx | 5 +- .../src/renderer/initialization.test.ts | 73 +++++++++++++++++++ .../desktop/src/renderer/initialization.ts | 22 ++++++ 10 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/pages/error-description.test.ts create mode 100644 packages/app/src/pages/error-description.ts create mode 100644 packages/desktop/src/main/index.test.ts create mode 100644 packages/desktop/src/main/initialization.ts create mode 100644 packages/desktop/src/renderer/initialization.test.ts create mode 100644 packages/desktop/src/renderer/initialization.ts diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 76c96dcc02..1e7af98cdd 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -467,6 +467,7 @@ export const dict = { "error.page.title": "Something went wrong", "error.page.description": "An error occurred while loading the application.", + "error.page.description.localServerStartup": "An error occurred while starting the local server.", "error.page.details.label": "Error Details", "error.page.action.restart": "Restart", "error.page.action.report": "Report Error", diff --git a/packages/app/src/pages/error-description.test.ts b/packages/app/src/pages/error-description.test.ts new file mode 100644 index 0000000000..222cd7a1ef --- /dev/null +++ b/packages/app/src/pages/error-description.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { errorDescriptionKey } from "./error-description" + +describe("error description", () => { + test("describes local server startup errors", () => { + expect(errorDescriptionKey(Object.assign(new Error("migration failed"), { localServerStartup: true }))).toBe( + "error.page.description.localServerStartup", + ) + }) + + test("uses the generic description for other errors", () => { + expect(errorDescriptionKey(new Error("unknown"))).toBe("error.page.description") + expect(errorDescriptionKey(Object.assign(new Error("unknown"), { localServerStartup: false }))).toBe( + "error.page.description", + ) + }) +}) diff --git a/packages/app/src/pages/error-description.ts b/packages/app/src/pages/error-description.ts new file mode 100644 index 0000000000..86accb6e1d --- /dev/null +++ b/packages/app/src/pages/error-description.ts @@ -0,0 +1,11 @@ +export function errorDescriptionKey(error: unknown) { + if ( + typeof error === "object" && + error !== null && + "localServerStartup" in error && + error.localServerStartup === true + ) { + return "error.page.description.localServerStartup" as const + } + return "error.page.description" as const +} diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index f9b65bb943..2a2b7d7279 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -7,6 +7,7 @@ import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { Icon } from "@opencode-ai/ui/icon" +import { errorDescriptionKey } from "./error-description" export type InitError = { name: string @@ -289,7 +290,7 @@ export const ErrorPage: Component = (props) => {

{language.t("error.page.title")}

-

{language.t("error.page.description")}

+

{language.t(errorDescriptionKey(props.error))}

{ + const failure = new Error("sidecar startup failed") + const expectFailure = (exit: Exit.Exit) => { + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + expect(Cause.squash(exit.cause)).toBe(failure) + } + + test("forwards loading task failures before renderer initialization", () => { + const exit = Effect.runSync( + Effect.gen(function* () { + const initialization = yield* Deferred.make() + yield* forwardInitializationFailure(initialization)(Effect.die(failure)).pipe(Effect.exit) + return yield* Deferred.await(initialization).pipe(Effect.exit) + }), + ) + + expectFailure(exit) + }) + + test("forwards loading task failures while renderer initialization waits", () => { + const exit = Effect.runSync( + Effect.gen(function* () { + const initialization = yield* Deferred.make() + const waiting = yield* Deferred.await(initialization).pipe(Effect.exit, Effect.forkChild) + yield* forwardInitializationFailure(initialization)(Effect.die(failure)).pipe(Effect.exit) + return yield* Fiber.join(waiting) + }), + ) + + expectFailure(exit) + }) +}) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 75e53e56a8..48d22f0db9 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -14,6 +14,7 @@ import type { ServerReadyData, WslConfig } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" import { CHANNEL, UPDATER_ENABLED } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc" +import { forwardInitializationFailure } from "./initialization" import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" @@ -207,7 +208,7 @@ const main = Effect.gen(function* () { }) } - const serverReady = Deferred.makeUnsafe() + const serverReady = Deferred.makeUnsafe() registerIpcHandlers({ killSidecar: () => killSidecar(), @@ -314,7 +315,7 @@ const main = Effect.gen(function* () { ) logger.log("loading task finished") - }).pipe(Effect.forkChild) + }).pipe(forwardInitializationFailure(serverReady), Effect.forkChild) yield* Fiber.await(loadingTask) diff --git a/packages/desktop/src/main/initialization.ts b/packages/desktop/src/main/initialization.ts new file mode 100644 index 0000000000..476abac7f5 --- /dev/null +++ b/packages/desktop/src/main/initialization.ts @@ -0,0 +1,6 @@ +import { Deferred, Effect } from "effect" + +export function forwardInitializationFailure(initialization: Deferred.Deferred) { + return (effect: Effect.Effect) => + effect.pipe(Effect.tapCause((cause) => Deferred.failCause(initialization, cause))) +} diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 8ad4a3b097..aaf0ba8011 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -21,6 +21,7 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" +import { initializationData, initializationReady } from "./initialization" import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" import "./styles.css" import { useTheme } from "@opencode-ai/ui/theme" @@ -329,7 +330,7 @@ render(() => { const [locale] = createResource(loadLocale) const servers = () => { - const data = sidecar() + const data = initializationData(sidecar) if (!data) return [] const server: ServerConnection.Sidecar = { displayName: "Local Server", @@ -383,7 +384,7 @@ render(() => { { + test("throws the original initialization error before rendering server providers", () => { + const error = new Error("sidecar startup failed") + + try { + initializationData(Object.assign(() => undefined, { error })) + throw new Error("expected initialization to fail") + } catch (failure) { + expect(failure).toBe(error) + expect((failure as Error & { localServerStartup?: boolean }).localServerStartup).toBe(true) + } + }) + + test("removes Electron's remote invocation wrapper from startup errors", () => { + const error = new Error( + "Error invoking remote method 'await-initialization': Error: Cannot migrate session_message projections", + ) + + try { + initializationData(Object.assign(() => undefined, { error })) + throw new Error("expected initialization to fail") + } catch (failure) { + expect(failure).toBe(error) + expect((failure as Error).message).toBe("Cannot migrate session_message projections") + } + }) + + test("returns initialized sidecar data", () => { + const sidecar = { url: "http://127.0.0.1:1234", username: "opencode", password: "secret" } + + expect(initializationData(Object.assign(() => sidecar, { error: undefined }))).toBe(sidecar) + }) + + test("does not discard falsy initialization errors", () => { + let caught: unknown + try { + initializationData(Object.assign(() => undefined, { error: "" })) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(Error) + if (!(caught instanceof Error)) return + expect(caught.message).toBe("") + expect((caught as Error & { localServerStartup?: boolean }).localServerStartup).toBe(true) + }) + + test("checks initialization errors before rendering server providers", () => { + const error = new Error("sidecar startup failed") + + expect(() => initializationReady(Object.assign(() => undefined, { error, loading: false }))).toThrow(error) + }) + + test("waits for pending initialization without reading it", () => { + let reads = 0 + + expect( + initializationReady( + Object.assign( + () => { + reads++ + return undefined + }, + { error: undefined, loading: true }, + ), + ), + ).toBe(false) + expect(reads).toBe(0) + }) +}) diff --git a/packages/desktop/src/renderer/initialization.ts b/packages/desktop/src/renderer/initialization.ts new file mode 100644 index 0000000000..b68eae09cb --- /dev/null +++ b/packages/desktop/src/renderer/initialization.ts @@ -0,0 +1,22 @@ +export function initializationData(state: (() => A | undefined) & { error: unknown }) { + if (state.error !== undefined) throw markLocalServerStartup(state.error) + return state() +} + +function markLocalServerStartup(error: unknown) { + const failure = error instanceof Error ? error : new Error(String(error)) + const prefix = "Error invoking remote method 'await-initialization': Error: " + if (failure.message.startsWith(prefix)) { + const previous = failure.message + failure.message = failure.message.slice(prefix.length) + if (failure.stack) failure.stack = failure.stack.replace(`Error: ${previous}`, `Error: ${failure.message}`) + } + Object.defineProperty(failure, "localServerStartup", { value: true }) + return failure +} + +export function initializationReady(state: (() => A | undefined) & { error: unknown; loading: boolean }) { + if (state.loading) return false + initializationData(state) + return true +} From 057958c933a25f0ce3aaf3ed1d495358635e7c38 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Jun 2026 19:23:26 -0400 Subject: [PATCH 003/626] ci: publish --- .github/workflows/publish.yml | 2 +- packages/cli/bin/lildax.cjs | 2 +- packages/cli/script/build.ts | 6 ++++-- packages/cli/script/publish.ts | 1 + 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 083a0a9e80..99fbdd66e5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -112,7 +112,7 @@ jobs: - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-preview-cli - path: packages/cli/dist/lildax-* + path: packages/cli/dist/cli-* outputs: version: ${{ needs.version.outputs.version }} diff --git a/packages/cli/bin/lildax.cjs b/packages/cli/bin/lildax.cjs index 1b84ed7631..ab99b84b0f 100644 --- a/packages/cli/bin/lildax.cjs +++ b/packages/cli/bin/lildax.cjs @@ -34,7 +34,7 @@ const scriptDir = path.dirname(fs.realpathSync(__filename)) const cached = path.join(scriptDir, ".lildax") const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform() const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch() -const base = "@opencode-ai/lildax-" + platform + "-" + arch +const base = "@opencode-ai/cli-" + platform + "-" + arch const binary = platform === "windows" ? "lildax.exe" : "lildax" function supportsAvx2() { diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index f91653c265..d6d8e9fcdf 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -44,7 +44,7 @@ const targets = singleFlag : allTargets for (const item of targets) { - const name = [ + const target = [ binary, item.os === "win32" ? "windows" : item.os, item.arch, @@ -53,6 +53,7 @@ for (const item of targets) { ] .filter(Boolean) .join("-") + const name = target.replace(binary, "cli") console.log(`building ${name}`) const result = await Bun.build({ entrypoints: ["./src/index.ts"], @@ -67,7 +68,7 @@ for (const item of targets) { autoloadDotenv: false, autoloadTsconfig: true, autoloadPackageJson: true, - target: name.replace(binary, "bun") as Bun.Build.CompileTarget, + target: target.replace(binary, "bun") as Bun.Build.CompileTarget, outfile: `./dist/${name}/bin/${binary}`, execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"], windows: {}, @@ -93,6 +94,7 @@ for (const item of targets) { name: `@opencode-ai/${name}`, version: Script.version, license: "MIT", + repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" }, os: [item.os], cpu: [item.arch], }, diff --git a/packages/cli/script/publish.ts b/packages/cli/script/publish.ts index b828d94819..d2855413ca 100755 --- a/packages/cli/script/publish.ts +++ b/packages/cli/script/publish.ts @@ -35,6 +35,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write( bin: { lildax: "./bin/lildax" }, version, license: pkg.license, + repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" }, os: ["darwin", "linux", "win32"], cpu: ["arm64", "x64"], optionalDependencies: binaries, From 76ecf2e58c23a384244ae816092244e0fbd3ef33 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 19:24:30 -0400 Subject: [PATCH 004/626] refactor(core): make v2 session inputs event sourced (#30785) --- .../migration.sql | 28 + .../snapshot.json | 1898 +++++++++++++++++ packages/core/src/database/migration.gen.ts | 1 + ...60604172448_event_sourced_session_input.ts | 47 + packages/core/src/event.ts | 121 +- packages/core/src/event/sql.ts | 4 +- packages/core/src/session.ts | 34 +- packages/core/src/session/event.ts | 48 +- packages/core/src/session/input.ts | 280 ++- packages/core/src/session/message-id.ts | 13 + packages/core/src/session/message-updater.ts | 114 +- packages/core/src/session/message.ts | 6 +- packages/core/src/session/projector.ts | 101 +- packages/core/src/session/runner/llm.ts | 19 +- .../src/session/runner/publish-llm-event.ts | 19 +- packages/core/src/session/sql.ts | 12 +- packages/core/test/database-migration.test.ts | 83 +- packages/core/test/event.test.ts | 180 +- packages/core/test/session-create.test.ts | 100 +- packages/core/test/session-projector.test.ts | 211 +- packages/core/test/session-prompt.test.ts | 188 +- .../core/test/session-runner-message.test.ts | 3 +- .../core/test/session-runner-recorded.test.ts | 6 +- packages/core/test/session-runner.test.ts | 66 +- .../core/test/session-tool-progress.test.ts | 6 +- .../src/cli/cmd/tui/context/sync-v2.tsx | 145 +- .../src/server/routes/instance/httpapi/api.ts | 2 +- .../routes/instance/httpapi/groups/global.ts | 6 +- .../routes/instance/httpapi/groups/sync.ts | 5 +- .../routes/instance/httpapi/handlers/sync.ts | 2 +- packages/opencode/src/session/compaction.ts | 4 +- packages/opencode/src/session/processor.ts | 81 +- packages/opencode/src/session/prompt.ts | 6 + .../opencode/test/cli/tui/sync-v2.test.tsx | 488 ++++- .../test/server/httpapi-session.test.ts | 19 +- .../opencode/test/server/httpapi-sync.test.ts | 7 + .../storage/workspace-time-migration.test.ts | 4 +- .../test/v2/session-message-updater.test.ts | 16 +- packages/sdk/js/src/v2/gen/types.gen.ts | 159 +- packages/sdk/openapi.json | 784 +++++-- packages/server/src/groups/v2/session.ts | 2 +- specs/v2/schema-changelog.md | 26 +- specs/v2/session.md | 6 +- 43 files changed, 4632 insertions(+), 718 deletions(-) create mode 100644 packages/core/migration/20260604172448_event_sourced_session_input/migration.sql create mode 100644 packages/core/migration/20260604172448_event_sourced_session_input/snapshot.json create mode 100644 packages/core/src/database/migration/20260604172448_event_sourced_session_input.ts create mode 100644 packages/core/src/session/message-id.ts diff --git a/packages/core/migration/20260604172448_event_sourced_session_input/migration.sql b/packages/core/migration/20260604172448_event_sourced_session_input/migration.sql new file mode 100644 index 0000000000..0c89cc8036 --- /dev/null +++ b/packages/core/migration/20260604172448_event_sourced_session_input/migration.sql @@ -0,0 +1,28 @@ +DELETE FROM `session_input`;--> statement-breakpoint +DELETE FROM `session_message`;--> statement-breakpoint +DELETE FROM `event`;--> statement-breakpoint +DELETE FROM `event_sequence`;--> statement-breakpoint +UPDATE `session` SET `workspace_id` = NULL;--> statement-breakpoint +DELETE FROM `workspace`;--> statement-breakpoint +DROP INDEX IF EXISTS `event_aggregate_seq_idx`;--> statement-breakpoint +CREATE UNIQUE INDEX `event_aggregate_seq_idx` ON `event` (`aggregate_id`,`seq`);--> statement-breakpoint +DROP INDEX IF EXISTS `session_message_session_seq_idx`;--> statement-breakpoint +CREATE UNIQUE INDEX `session_message_session_seq_idx` ON `session_message` (`session_id`,`seq`);--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_session_input` ( + `id` text PRIMARY KEY, + `session_id` text NOT NULL, + `prompt` text NOT NULL, + `delivery` text NOT NULL, + `admitted_seq` integer NOT NULL, + `promoted_seq` integer, + `time_created` integer NOT NULL, + CONSTRAINT `fk_session_input_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +DROP TABLE `session_input`;--> statement-breakpoint +ALTER TABLE `__new_session_input` RENAME TO `session_input`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `session_input_session_pending_delivery_seq_idx` ON `session_input` (`session_id`,`promoted_seq`,`delivery`,`admitted_seq`);--> statement-breakpoint +CREATE UNIQUE INDEX `session_input_session_admitted_seq_idx` ON `session_input` (`session_id`,`admitted_seq`);--> statement-breakpoint +CREATE UNIQUE INDEX `session_input_session_promoted_seq_idx` ON `session_input` (`session_id`,`promoted_seq`); diff --git a/packages/core/migration/20260604172448_event_sourced_session_input/snapshot.json b/packages/core/migration/20260604172448_event_sourced_session_input/snapshot.json new file mode 100644 index 0000000000..4e916637ba --- /dev/null +++ b/packages/core/migration/20260604172448_event_sourced_session_input/snapshot.json @@ -0,0 +1,1898 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "84c6ad6c-6116-48e1-b973-6fee4593496b", + "prevIds": ["fc92fa34-8074-44c3-88f0-a5417f7fd92d"], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "project_directory", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "session_input", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "admitted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "promoted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_project_directory_project_id_project_id_fk", + "entityType": "fks", + "table": "project_directory" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_input_session_id_session_id_fk", + "entityType": "fks", + "table": "session_input" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["project_id", "directory"], + "nameExplicit": false, + "name": "project_directory_pk", + "entityType": "pks", + "table": "project_directory" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["name"], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_input_pk", + "table": "session_input", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "event_aggregate_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_type_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "action", + "isExpression": false + }, + { + "value": "resource", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "permission_project_action_resource_idx", + "entityType": "indexes", + "table": "permission" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + }, + { + "value": "delivery", + "isExpression": false + }, + { + "value": "admitted_seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_input_session_pending_delivery_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "admitted_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_input_session_admitted_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_input_session_promoted_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_message_session_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_time_created_id_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 9f2ba82eb3..59ed2fdbcf 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -31,5 +31,6 @@ export const migrations = ( import("./migration/20260603040000_session_message_projection_order"), import("./migration/20260603141458_session_input_inbox"), import("./migration/20260603160727_jittery_ezekiel_stane"), + import("./migration/20260604172448_event_sourced_session_input"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260604172448_event_sourced_session_input.ts b/packages/core/src/database/migration/20260604172448_event_sourced_session_input.ts new file mode 100644 index 0000000000..24a31bfae1 --- /dev/null +++ b/packages/core/src/database/migration/20260604172448_event_sourced_session_input.ts @@ -0,0 +1,47 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260604172448_event_sourced_session_input", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL;`) + yield* tx.run(`DELETE FROM \`workspace\`;`) + yield* tx.run(`DROP INDEX IF EXISTS \`event_aggregate_seq_idx\`;`) + yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`) + yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_seq_idx\`;`) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`, + ) + yield* tx.run(`PRAGMA foreign_keys=OFF;`) + yield* tx.run(` + CREATE TABLE \`__new_session_input\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`prompt\` text NOT NULL, + \`delivery\` text NOT NULL, + \`admitted_seq\` integer NOT NULL, + \`promoted_seq\` integer, + \`time_created\` integer NOT NULL, + CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`DROP TABLE \`session_input\`;`) + yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`) + yield* tx.run(`PRAGMA foreign_keys=ON;`) + yield* tx.run( + `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`, + ) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 79708e4d3d..57dd587dec 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,14 +1,15 @@ export * as EventV2 from "./event" -import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" +import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { and, asc, eq, gt } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema" import { Identifier } from "./util/identifier" +import { isDeepStrictEqual } from "node:util" -export const ID = Schema.String.pipe( +export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( Schema.brand("Event.ID"), withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()), @@ -258,16 +259,47 @@ export const layerWith = (options?: LayerOptions) => .get() .pipe(Effect.orDie) const latest = row?.seq ?? -1 - if (input && input.seq <= latest) return - if (input && row?.ownerID && row.ownerID !== input.ownerID) { - if (input.strictOwner) { - yield* Effect.die( - new InvalidSyncEventError({ - type: event.type, - message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`, - }), - ) + const encoded = syncRegistry + .get(versionedType(definition.type, sync.version))! + .encode(event.data) as Record + if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) { + yield* Effect.die( + new InvalidSyncEventError({ + type: event.type, + message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`, + }), + ) + } + if (input && input.seq <= latest) { + const stored = yield* db + .select() + .from(EventTable) + .where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq))) + .get() + .pipe(Effect.orDie) + if ( + stored?.id === event.id && + stored.type === versionedType(definition.type, sync.version) && + isDeepStrictEqual(stored.data, encoded) + ) { + if (input.ownerID && row?.ownerID == null) { + yield* db + .update(EventSequenceTable) + .set({ owner_id: input.ownerID }) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .run() + .pipe(Effect.orDie) + } + return } + yield* Effect.die( + new InvalidSyncEventError({ + type: event.type, + message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`, + }), + ) + } + if (input && row?.ownerID && row.ownerID !== input.ownerID) { return } const seq = input?.seq ?? latest + 1 @@ -279,15 +311,25 @@ export const layerWith = (options?: LayerOptions) => }), ) } + const stored = yield* db + .select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.id, event.id)) + .get() + .pipe(Effect.orDie) + if (stored) + yield* Effect.die( + new InvalidSyncEventError({ + type: event.type, + message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`, + }), + ) for (const guard of commitGuards) { yield* guard(event) } for (const projector of list) { yield* projector({ ...event, seq } as Payload) } - const encoded = syncRegistry - .get(versionedType(definition.type, sync.version))! - .encode(event.data) yield* db .insert(EventSequenceTable) .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }]) @@ -308,7 +350,7 @@ export const layerWith = (options?: LayerOptions) => aggregate_id: aggregateID, seq, type: versionedType(definition.type, sync.version), - data: encoded as Record, + data: encoded, }, ]) .run() @@ -337,22 +379,43 @@ export const layerWith = (options?: LayerOptions) => return Effect.gen(function* () { const durable = registry.get(event.type)?.sync !== undefined if (durable) { - for (const sync of syncHandlers) { - yield* sync(event as Payload) - } const committed = yield* commitSyncEvent(event as Payload) - if (committed) event = { ...event, seq: committed.seq } + if (committed) { + event = { ...event, seq: committed.seq } + yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true }) + yield* notify(event as Payload, true) + return event + } } - for (const listener of listeners) { - yield* listener(event as Payload) - } - const pubsub = typed.get(event.type) - if (pubsub) yield* PubSub.publish(pubsub, event as Payload) - yield* PubSub.publish(all, event as Payload) + yield* notify(event as Payload, false) return event }) } + const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect) => + Effect.suspend(() => observer(event)).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logError("Event observer failed").pipe( + Effect.annotateLogs({ eventID: event.id, eventType: event.type, kind, cause }), + ), + ), + ) + + function notify(event: Payload, isolateListeners: boolean) { + return Effect.gen(function* () { + yield* Effect.forEach( + listeners, + (listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)), + { discard: true }, + ) + const pubsub = typed.get(event.type) + if (pubsub) yield* PubSub.publish(pubsub, event) + yield* PubSub.publish(all, event) + }) + } + function publish(definition: D, data: Data, options?: PublishOptions) { return Effect.gen(function* () { const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service)) @@ -396,13 +459,7 @@ export const layerWith = (options?: LayerOptions) => strictOwner: options?.strictOwner, }) if (committed && options?.publish) { - const published = { ...payload, seq: committed.seq } - for (const listener of listeners) { - yield* listener(published) - } - const pubsub = typed.get(payload.type) - if (pubsub) yield* PubSub.publish(pubsub, published) - yield* PubSub.publish(all, published) + yield* notify({ ...payload, seq: committed.seq }, true) } } }) diff --git a/packages/core/src/event/sql.ts b/packages/core/src/event/sql.ts index 74f0ab4834..38fe34f1e3 100644 --- a/packages/core/src/event/sql.ts +++ b/packages/core/src/event/sql.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core" +import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core" import type { EventV2 } from "../event" export const EventSequenceTable = sqliteTable("event_sequence", { @@ -19,7 +19,7 @@ export const EventTable = sqliteTable( data: text({ mode: "json" }).$type>().notNull(), }, (table) => [ - index("event_aggregate_seq_idx").on(table.aggregate_id, table.seq), + uniqueIndex("event_aggregate_seq_idx").on(table.aggregate_id, table.seq), index("event_aggregate_type_seq_idx").on(table.aggregate_id, table.type, table.seq), ], ) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 54203f1044..6ccc46da6c 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,7 @@ export * as SessionV2 from "./session" export * from "./session/schema" -import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect" +import { Cause, Effect, Layer, Schema, Context, Stream } from "effect" import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm" import { ProjectV2 } from "./project" import { WorkspaceV2 } from "./workspace" @@ -10,7 +10,6 @@ import { Location } from "./location" import { SessionMessage } from "./session/message" import { Prompt } from "./session/prompt" import { EventV2 } from "./event" -import { ProviderV2 } from "./provider" import { Database } from "./database/database" import { SessionProjector } from "./session/projector" import { SessionMessageTable, SessionTable } from "./session/sql" @@ -140,7 +139,7 @@ export interface Interface { prompt: Prompt delivery?: SessionInput.Delivery resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly shell: (input: { id?: EventV2.ID sessionID: SessionSchema.ID @@ -198,20 +197,6 @@ export const layer = Layer.effect( ), ) - const findExistingPrompt = Effect.fnUntraced(function* (input: { - sessionID: SessionSchema.ID - messageID: SessionMessage.ID - prompt: Prompt - delivery: SessionInput.Delivery - }) { - const stored = yield* SessionInput.find(db, input.messageID) - if (!stored) return yield* SessionInput.reconcileProjected(db, { id: input.messageID, ...input }) - if (!SessionInput.equivalent(stored, input)) { - return yield* new PromptConflictError({ sessionID: input.sessionID, messageID: input.messageID }) - } - return stored - }) - const result = Service.of({ create: Effect.fn("V2Session.create")(function* (input) { const sessionID = input.id ?? SessionSchema.ID.create() @@ -367,20 +352,23 @@ export const layer = Layer.effect( yield* result.get(input.sessionID) const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) { if (input.resume !== false) yield* enqueueWake(input.sessionID) - return SessionInput.toMessage(admitted) + return admitted }, Effect.uninterruptible) const messageID = input.id ?? SessionMessage.ID.create() const delivery = input.delivery ?? "steer" const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery } - const existing = yield* findExistingPrompt(expected) - if (existing) return yield* returnPrompt(existing) - const admitted = yield* SessionInput.admit(db, { + const admitted = yield* SessionInput.admit(db, events, { id: messageID, sessionID: input.sessionID, prompt: input.prompt, delivery, - }) - if (!admitted) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) + }).pipe( + Effect.catchDefect((defect) => + defect instanceof SessionInput.LifecycleConflict + ? new PromptConflictError({ sessionID: input.sessionID, messageID }) + : Effect.die(defect), + ), + ) if (!SessionInput.equivalent(admitted, expected)) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) return yield* returnPrompt(admitted) diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index acfbb3fb97..a85e807d24 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -9,6 +9,7 @@ import { FileAttachment, Prompt } from "./prompt" import { SessionSchema } from "./schema" import { Location } from "../location" import { RelativePath } from "../schema" +import { SessionMessageID } from "./message-id" export { FileAttachment } @@ -52,6 +53,7 @@ export const AgentSwitched = EventV2.define({ ...options, schema: { ...Base, + messageID: SessionMessageID.ID, agent: Schema.String, }, }) @@ -62,6 +64,7 @@ export const ModelSwitched = EventV2.define({ ...options, schema: { ...Base, + messageID: SessionMessageID.ID, model: ModelV2.Ref, }, }) @@ -83,17 +86,45 @@ export const Prompted = EventV2.define({ ...options, schema: { ...Base, + messageID: SessionMessageID.ID, prompt: Prompt, delivery: Schema.Literals(["steer", "queue"]), }, }) export type Prompted = typeof Prompted.Type +export namespace PromptLifecycle { + export const Admitted = EventV2.define({ + type: "session.next.prompt.admitted", + ...options, + schema: { + ...Base, + messageID: SessionMessageID.ID, + prompt: Prompt, + delivery: Schema.Literals(["steer", "queue"]), + }, + }) + export type Admitted = typeof Admitted.Type + + export const Promoted = EventV2.define({ + type: "session.next.prompt.promoted", + ...options, + schema: { + ...Base, + messageID: SessionMessageID.ID, + prompt: Prompt, + timeCreated: V2Schema.DateTimeUtcFromMillis, + }, + }) + export type Promoted = typeof Promoted.Type +} + export const Synthetic = EventV2.define({ type: "session.next.synthetic", ...options, schema: { ...Base, + messageID: SessionMessageID.ID, text: Schema.String, }, }) @@ -105,6 +136,7 @@ export namespace Shell { ...options, schema: { ...Base, + messageID: SessionMessageID.ID, callID: Schema.String, command: Schema.String, }, @@ -129,6 +161,7 @@ export namespace Step { ...options, schema: { ...Base, + assistantMessageID: SessionMessageID.ID, agent: Schema.String, model: ModelV2.Ref, snapshot: Schema.String.pipe(Schema.optional), @@ -141,7 +174,7 @@ export namespace Step { ...stepSettlementOptions, schema: { ...Base, - assistantMessageID: EventV2.ID, + assistantMessageID: SessionMessageID.ID, finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ @@ -163,7 +196,7 @@ export namespace Step { ...stepSettlementOptions, schema: { ...Base, - assistantMessageID: EventV2.ID, + assistantMessageID: SessionMessageID.ID, error: UnknownError, }, }) @@ -176,6 +209,7 @@ export namespace Text { ...options, schema: { ...Base, + assistantMessageID: SessionMessageID.ID, textID: Schema.String, }, }) @@ -186,6 +220,7 @@ export namespace Text { type: "session.next.text.delta", schema: { ...Base, + assistantMessageID: SessionMessageID.ID, textID: Schema.String, delta: Schema.String, }, @@ -197,6 +232,7 @@ export namespace Text { ...options, schema: { ...Base, + assistantMessageID: SessionMessageID.ID, textID: Schema.String, text: Schema.String, }, @@ -210,6 +246,7 @@ export namespace Reasoning { ...options, schema: { ...Base, + assistantMessageID: SessionMessageID.ID, reasoningID: Schema.String, providerMetadata: ProviderMetadata.pipe(Schema.optional), }, @@ -221,6 +258,7 @@ export namespace Reasoning { type: "session.next.reasoning.delta", schema: { ...Base, + assistantMessageID: SessionMessageID.ID, reasoningID: Schema.String, delta: Schema.String, }, @@ -232,6 +270,7 @@ export namespace Reasoning { ...options, schema: { ...Base, + assistantMessageID: SessionMessageID.ID, reasoningID: Schema.String, text: Schema.String, providerMetadata: ProviderMetadata.pipe(Schema.optional), @@ -243,7 +282,7 @@ export namespace Reasoning { export namespace Tool { const ToolBase = { ...Base, - assistantMessageID: EventV2.ID, + assistantMessageID: SessionMessageID.ID, callID: Schema.String, } @@ -370,6 +409,7 @@ export namespace Compaction { ...options, schema: { ...Base, + messageID: SessionMessageID.ID, reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), }, }) @@ -402,6 +442,8 @@ const DurableDefinitions = [ ModelSwitched, Moved, Prompted, + PromptLifecycle.Admitted, + PromptLifecycle.Promoted, Synthetic, Shell.Started, Shell.Ended, diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index e252c92d9b..0d8e9f2a66 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -1,11 +1,11 @@ export * as SessionInput from "./input" -import { and, asc, eq, inArray, isNull } from "drizzle-orm" +import { and, asc, eq, isNull, lte } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" import type { Database } from "../database/database" import type { EventV2 } from "../event" -import { EventTable } from "../event/sql" -import { NonNegativeInt, PositiveInt } from "../schema" +import { EventSequenceTable } from "../event/sql" +import { NonNegativeInt } from "../schema" import { V2Schema } from "../v2-schema" import { SessionEvent } from "./event" import { SessionMessage } from "./message" @@ -19,7 +19,7 @@ export const Delivery = Schema.Literals(["steer", "queue"]) export type Delivery = typeof Delivery.Type export class Admitted extends Schema.Class("SessionInput.Admitted")({ - seq: PositiveInt, + admittedSeq: NonNegativeInt, id: SessionMessage.ID, sessionID: SessionSchema.ID, prompt: Prompt, @@ -30,11 +30,10 @@ export class Admitted extends Schema.Class("SessionInput.Admitted")({ const decodePrompt = Schema.decodeUnknownSync(Prompt) const encodePrompt = Schema.encodeSync(Prompt) -const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted => new Admitted({ - seq: row.seq, + admittedSeq: row.admitted_seq, id: SessionMessage.ID.make(row.id), sessionID: SessionSchema.ID.make(row.session_id), prompt: decodePrompt(row.prompt), @@ -48,8 +47,13 @@ export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseServic return row === undefined ? undefined : fromRow(row) }) +export class LifecycleConflict extends Schema.TaggedErrorClass()("SessionInput.LifecycleConflict", { + id: SessionMessage.ID, +}) {} + export const admit = Effect.fn("SessionInput.admit")(function* ( db: DatabaseService, + events: EventV2.Interface, input: { readonly id: SessionMessage.ID readonly sessionID: SessionSchema.ID @@ -57,49 +61,124 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( readonly delivery: Delivery }, ) { - return yield* db - .transaction( - () => - Effect.gen(function* () { - const existing = yield* find(db, input.id) - if (existing !== undefined) return existing - const event = yield* db - .select({ id: EventTable.id }) - .from(EventTable) - .where(eq(EventTable.id, input.id)) - .get() - .pipe(Effect.orDie) - const message = yield* db - .select({ id: SessionMessageTable.id }) - .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, input.id)) - .get() - .pipe(Effect.orDie) - if (event !== undefined || message !== undefined) return undefined - const row = yield* db - .insert(SessionInputTable) - .values({ - id: input.id, - session_id: input.sessionID, - prompt: encodePrompt(input.prompt), - delivery: input.delivery, - }) - .returning() - .get() - .pipe(Effect.orDie) - return fromRow(row) - }), - { behavior: "immediate" }, + const existing = yield* find(db, input.id) + if (existing !== undefined) return existing + const timestamp = yield* DateTime.now + return yield* events + .publish(SessionEvent.PromptLifecycle.Admitted, { + messageID: input.id, + sessionID: input.sessionID, + timestamp, + prompt: input.prompt, + delivery: input.delivery, + }) + .pipe( + Effect.flatMap((event) => + event.seq === undefined + ? Effect.die("Prompt admission event is missing aggregate sequence") + : Effect.succeed( + new Admitted({ + admittedSeq: event.seq, + id: input.id, + sessionID: input.sessionID, + prompt: input.prompt, + delivery: input.delivery, + timeCreated: timestamp, + }), + ), + ), + Effect.catchDefect((defect) => + find(db, input.id).pipe(Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect)))), + ), ) +}) + +export const latestSeq = Effect.fn("SessionInput.latestSeq")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, +) { + const row = yield* db + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, sessionID)) + .get() .pipe(Effect.orDie) + return row?.seq ?? -1 +}) + +export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* ( + db: DatabaseService, + input: { + readonly admittedSeq: number + readonly id: SessionMessage.ID + readonly sessionID: SessionSchema.ID + readonly prompt: Prompt + readonly delivery: Delivery + readonly timeCreated: DateTime.Utc + }, +) { + const message = yield* db + .select({ id: SessionMessageTable.id }) + .from(SessionMessageTable) + .where(eq(SessionMessageTable.id, input.id)) + .get() + .pipe(Effect.orDie) + if (message) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + const stored = yield* db + .insert(SessionInputTable) + .values({ + id: input.id, + session_id: input.sessionID, + admitted_seq: input.admittedSeq, + prompt: encodePrompt(input.prompt), + delivery: input.delivery, + time_created: DateTime.toEpochMillis(input.timeCreated), + }) + .onConflictDoNothing() + .returning({ id: SessionInputTable.id }) + .get() + .pipe(Effect.orDie) + if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) +}) + +export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* ( + db: DatabaseService, + input: { + readonly id: SessionMessage.ID + readonly sessionID: SessionSchema.ID + readonly prompt: Prompt + readonly timeCreated: DateTime.Utc + readonly promotedSeq: number + }, +) { + const updated = yield* db + .update(SessionInputTable) + .set({ promoted_seq: input.promotedSeq }) + .where( + and( + eq(SessionInputTable.id, input.id), + eq(SessionInputTable.session_id, input.sessionID), + isNull(SessionInputTable.promoted_seq), + ), + ) + .returning() + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + const stored = fromRow(updated) + if ( + !matchesPrompt(stored, input) || + DateTime.toEpochMillis(stored.timeCreated) !== DateTime.toEpochMillis(input.timeCreated) + ) + return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return toMessage(stored) }) export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - deliveries: ReadonlyArray = ["steer", "queue"], + delivery: Delivery, ) { - if (deliveries.length === 0) return false const row = yield* db .select({ id: SessionInputTable.id }) .from(SessionInputTable) @@ -107,7 +186,7 @@ export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( and( eq(SessionInputTable.session_id, sessionID), isNull(SessionInputTable.promoted_seq), - inArray(SessionInputTable.delivery, deliveries), + eq(SessionInputTable.delivery, delivery), ), ) .limit(1) @@ -133,14 +212,34 @@ export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(functio db: DatabaseService, event: EventV2.Payload, ) { - const admitted = yield* find(db, event.id) + if ( + Schema.is(SessionEvent.PromptLifecycle.Admitted)(event) || + Schema.is(SessionEvent.PromptLifecycle.Promoted)(event) + ) + return + const id = reservedID(event) + if (id === undefined) return + const admitted = yield* db + .select({ id: SessionInputTable.id }) + .from(SessionInputTable) + .where(eq(SessionInputTable.id, id)) + .get() + .pipe(Effect.orDie) if (admitted === undefined) return - if (!Schema.is(SessionEvent.Prompted)(event)) - return yield* Effect.die("Durable event conflicts with admitted prompt input") - if (!equivalent(admitted, event.data)) return yield* Effect.die("Prompt projection conflicts with admitted input") + return yield* Effect.die(new LifecycleConflict({ id })) }) -export const project = Effect.fn("SessionInput.project")(function* ( +const reservedID = (event: EventV2.Payload) => { + if (Schema.is(SessionEvent.Step.Started)(event)) return event.data.assistantMessageID + if (Schema.is(SessionEvent.AgentSwitched)(event)) return event.data.messageID + if (Schema.is(SessionEvent.ModelSwitched)(event)) return event.data.messageID + if (Schema.is(SessionEvent.Prompted)(event)) return event.data.messageID + if (Schema.is(SessionEvent.Synthetic)(event)) return event.data.messageID + if (Schema.is(SessionEvent.Shell.Started)(event)) return event.data.messageID + if (Schema.is(SessionEvent.Compaction.Started)(event)) return event.data.messageID +} + +export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* ( db: DatabaseService, input: { readonly id: SessionMessage.ID @@ -151,82 +250,49 @@ export const project = Effect.fn("SessionInput.project")(function* ( readonly promotedSeq: number }, ) { - yield* db + const inserted = yield* db .insert(SessionInputTable) .values({ id: input.id, session_id: input.sessionID, + admitted_seq: input.promotedSeq, prompt: encodePrompt(input.prompt), delivery: input.delivery, promoted_seq: input.promotedSeq, time_created: DateTime.toEpochMillis(input.timeCreated), }) .onConflictDoNothing() - .run() - .pipe(Effect.orDie) - const admitted = yield* find(db, input.id) - if (admitted === undefined || admitted.delivery !== input.delivery || !matchesPrompt(admitted, input)) - return yield* Effect.die("Prompt projection conflicts with admitted input") - yield* db - .update(SessionInputTable) - .set({ promoted_seq: input.promotedSeq }) - .where( - and( - eq(SessionInputTable.id, input.id), - eq(SessionInputTable.session_id, input.sessionID), - isNull(SessionInputTable.promoted_seq), - ), - ) - .run() - .pipe(Effect.orDie) - return yield* find(db, input.id) -}) - -export const reconcileProjected = Effect.fn("SessionInput.reconcileProjected")(function* ( - db: DatabaseService, - expected: { - readonly id: SessionMessage.ID - readonly sessionID: SessionSchema.ID - readonly prompt: Prompt - readonly delivery: Delivery - }, -) { - if (expected.delivery !== "steer") return undefined - const row = yield* db - .select() - .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, expected.id)) + .returning() .get() .pipe(Effect.orDie) - if (row === undefined || row.session_id !== expected.sessionID || row.type !== "user") return undefined - const message = decodeMessage({ ...row.data, id: row.id, type: row.type }) - if (message.type !== "user" || !Prompt.equivalence(Prompt.fromUserMessage(message), expected.prompt)) return undefined - return yield* project(db, { - id: expected.id, - sessionID: expected.sessionID, - prompt: expected.prompt, - delivery: expected.delivery, - timeCreated: message.time.created, - promotedSeq: row.seq, - }) + if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input") + return fromRow(inserted) }) const publish = Effect.fn("SessionInput.publish")(function* ( + db: DatabaseService, events: EventV2.Interface, sessionID: SessionSchema.ID, rows: ReadonlyArray, ) { for (const row of rows) { - yield* events.publish( - SessionEvent.Prompted, - { + yield* events + .publish(SessionEvent.PromptLifecycle.Promoted, { sessionID, - timestamp: DateTime.makeUnsafe(row.time_created), + timestamp: yield* DateTime.now, + messageID: SessionMessage.ID.make(row.id), prompt: decodePrompt(row.prompt), - delivery: row.delivery, - }, - { id: SessionMessage.ID.make(row.id) }, - ) + timeCreated: DateTime.makeUnsafe(row.time_created), + }) + .pipe( + Effect.catchDefect((defect) => + defect instanceof LifecycleConflict + ? find(db, SessionMessage.ID.make(row.id)).pipe( + Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)), + ) + : Effect.die(defect), + ), + ) } return rows.length }) @@ -235,6 +301,7 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* ( db: DatabaseService, events: EventV2.Interface, sessionID: SessionSchema.ID, + cutoff: number, ) { const rows = yield* db .select() @@ -244,12 +311,13 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* ( eq(SessionInputTable.session_id, sessionID), isNull(SessionInputTable.promoted_seq), eq(SessionInputTable.delivery, "steer"), + lte(SessionInputTable.admitted_seq, cutoff), ), ) - .orderBy(asc(SessionInputTable.seq)) + .orderBy(asc(SessionInputTable.admitted_seq)) .all() .pipe(Effect.orDie) - return yield* publish(events, sessionID, rows) + return yield* publish(db, events, sessionID, rows) }) export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* ( @@ -267,14 +335,14 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun eq(SessionInputTable.delivery, "queue"), ), ) - .orderBy(asc(SessionInputTable.seq)) + .orderBy(asc(SessionInputTable.admitted_seq)) .limit(1) .get() .pipe(Effect.orDie) - return row === undefined ? false : yield* publish(events, sessionID, [row]).pipe(Effect.as(true)) + return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true)) }) -export const toMessage = (input: Admitted) => +const toMessage = (input: Admitted) => new SessionMessage.User({ id: input.id, type: "user", diff --git a/packages/core/src/session/message-id.ts b/packages/core/src/session/message-id.ts new file mode 100644 index 0000000000..f06fc0fcd5 --- /dev/null +++ b/packages/core/src/session/message-id.ts @@ -0,0 +1,13 @@ +export * as SessionMessageID from "./message-id" + +import { Schema } from "effect" +import { withStatics } from "../schema" +import { Identifier } from "../util/identifier" + +export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( + Schema.brand("Session.Message.ID"), + withStatics((schema) => ({ + create: () => schema.make("msg_" + Identifier.ascending()), + })), +) +export type ID = typeof ID.Type diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 65d53e1517..a7a3383205 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -123,7 +123,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.agent.switched": (event) => { return adapter.appendMessage( new SessionMessage.AgentSwitched({ - id: event.id, + id: event.data.messageID, type: "agent-switched", metadata: event.metadata, agent: event.data.agent, @@ -134,7 +134,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.model.switched": (event) => { return adapter.appendMessage( new SessionMessage.ModelSwitched({ - id: event.id, + id: event.data.messageID, type: "model-switched", metadata: event.metadata, model: event.data.model, @@ -146,7 +146,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.prompted": (event) => { return adapter.appendMessage( new SessionMessage.User({ - id: event.id, + id: event.data.messageID, type: "user", metadata: event.metadata, text: event.data.prompt.text, @@ -157,12 +157,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }), ) }, + "session.next.prompt.admitted": () => Effect.void, + "session.next.prompt.promoted": () => Effect.void, "session.next.synthetic": (event) => { return adapter.appendMessage( new SessionMessage.Synthetic({ sessionID: event.data.sessionID, text: event.data.text, - id: event.id, + id: event.data.messageID, type: "synthetic", time: { created: event.data.timestamp }, }), @@ -171,7 +173,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.shell.started": (event) => { return adapter.appendMessage( new SessionMessage.Shell({ - id: event.id, + id: event.data.messageID, type: "shell", metadata: event.metadata, callID: event.data.callID, @@ -206,7 +208,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } yield* adapter.appendMessage( new SessionMessage.Assistant({ - id: event.id, + id: event.data.assistantMessageID, type: "assistant", agent: event.data.agent, model: event.data.model, @@ -234,43 +236,22 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }) }, "session.next.text.started": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push( - castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })), - ) - }), - ) - } + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + draft.content.push( + castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })), + ) }) }, "session.next.text.delta": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestText(draft, event.data.textID) - if (match) match.text += event.data.delta - }), - ) - } + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + const match = latestText(draft, event.data.textID) + if (match) match.text += event.data.delta }) }, "session.next.text.ended": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestText(draft, event.data.textID) - if (match) match.text = event.data.text - }), - ) - } + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + const match = latestText(draft, event.data.textID) + if (match) match.text = event.data.text }) }, "session.next.tool.input.started": (event) => { @@ -367,52 +348,31 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }) }, "session.next.reasoning.started": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push( - castDraft( - new SessionMessage.AssistantReasoning({ - type: "reasoning", - id: event.data.reasoningID, - text: "", - providerMetadata: event.data.providerMetadata, - }), - ), - ) + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + draft.content.push( + castDraft( + new SessionMessage.AssistantReasoning({ + type: "reasoning", + id: event.data.reasoningID, + text: "", + providerMetadata: event.data.providerMetadata, }), - ) - } + ), + ) }) }, "session.next.reasoning.delta": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft, event.data.reasoningID) - if (match) match.text += event.data.delta - }), - ) - } + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text += event.data.delta }) }, "session.next.reasoning.ended": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft, event.data.reasoningID) - if (match) { - match.text = event.data.text - if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata - } - }), - ) + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) { + match.text = event.data.text + if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata } }) }, @@ -420,7 +380,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.compaction.started": (event) => { return adapter.appendMessage( new SessionMessage.Compaction({ - id: event.id, + id: event.data.messageID, type: "compaction", metadata: event.metadata, reason: event.data.reason, diff --git a/packages/core/src/session/message.ts b/packages/core/src/session/message.ts index f394df5a57..7f62236bef 100644 --- a/packages/core/src/session/message.ts +++ b/packages/core/src/session/message.ts @@ -2,15 +2,15 @@ export * as SessionMessage from "./message" import { Schema } from "effect" import { ProviderMetadata } from "@opencode-ai/llm" -import { EventV2 } from "../event" import { ModelV2 } from "../model" import { ToolOutput } from "../tool-output" import { V2Schema } from "../v2-schema" import { SessionEvent } from "./event" import { Prompt } from "./prompt" +import { SessionMessageID } from "./message-id" -export const ID = EventV2.ID -export type ID = Schema.Schema.Type +export const ID = SessionMessageID.ID +export type ID = typeof ID.Type const Base = { id: ID, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index f7bb63e979..e780d7ceaa 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -19,7 +19,7 @@ type DatabaseService = Database.Interface["db"] const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message) -export class PromptAlreadyProjected extends Error {} +class PromptAlreadyProjected extends Error {} export class SessionAlreadyProjected extends Error {} type Usage = { @@ -112,29 +112,23 @@ function run(db: DatabaseService, event: SessionEvent.Event) { return Effect.gen(function* () { const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }) - const writeMessage = (message: SessionMessage.Message) => { + const updateMessage = (message: SessionMessage.Message) => { if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") const encoded = encodeMessage(message) const { id, type, ...data } = encoded return db - .insert(SessionMessageTable) - .values([ - { - id: SessionMessage.ID.make(id), - session_id: event.data.sessionID, - type, - seq: event.seq, - time_created: DateTime.toEpochMillis(message.time.created), - data, - }, - ]) - .onConflictDoUpdate({ - target: SessionMessageTable.id, - set: { type, time_created: DateTime.toEpochMillis(message.time.created), data }, - }) + .update(SessionMessageTable) + .set({ type, time_created: DateTime.toEpochMillis(message.time.created), data }) + .where( + and( + eq(SessionMessageTable.id, SessionMessage.ID.make(id)), + eq(SessionMessageTable.session_id, event.data.sessionID), + ), + ) .run() .pipe(Effect.orDie) } + const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message) const adapter: SessionMessageUpdater.Adapter = { getCurrentAssistant() { return Effect.gen(function* () { @@ -204,15 +198,33 @@ function run(db: DatabaseService, event: SessionEvent.Event) { .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID) }) }, - updateAssistant: writeMessage, - updateCompaction: writeMessage, - updateShell: writeMessage, - appendMessage: writeMessage, + updateAssistant: updateMessage, + updateCompaction: updateMessage, + updateShell: updateMessage, + appendMessage, } yield* SessionMessageUpdater.update(adapter, event) }) } +function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) { + if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + const encoded = encodeMessage(message) + const { id, type, ...data } = encoded + return db + .insert(SessionMessageTable) + .values({ + id: SessionMessage.ID.make(id), + session_id: event.data.sessionID, + type, + seq: event.seq, + time_created: DateTime.toEpochMillis(message.time.created), + data, + }) + .run() + .pipe(Effect.orDie) +} + export const layer = Layer.effectDiscard( Effect.gen(function* () { const events = yield* EventV2.Service @@ -349,27 +361,19 @@ export const layer = Layer.effectDiscard( ) yield* events.project(SessionEvent.Prompted, (event) => Effect.gen(function* () { + const messageID = event.data.messageID const existing = yield* db .select({ id: SessionMessageTable.id }) .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, event.id)) + .where(eq(SessionMessageTable.id, messageID)) .get() .pipe(Effect.orDie) if (existing) return yield* Effect.die(new PromptAlreadyProjected()) yield* run(db, event) - const row = yield* db - .select() - .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, event.id)) - .get() - .pipe(Effect.orDie) - if (!row) return yield* Effect.die("Prompt projection was not stored") - const message = decodeMessage({ ...row.data, id: row.id, type: row.type }) - if (message.type !== "user") return yield* Effect.die("Prompt projection did not produce a user message") if (event.seq === undefined) return yield* Effect.die("Synchronized Session event is missing aggregate sequence") - yield* SessionInput.project(db, { - id: SessionMessage.ID.make(event.id), + yield* SessionInput.projectLegacyPrompted(db, { + id: messageID, sessionID: event.data.sessionID, prompt: event.data.prompt, delivery: event.data.delivery, @@ -378,6 +382,37 @@ export const layer = Layer.effectDiscard( }) }), ) + yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) => + Effect.gen(function* () { + if (event.seq === undefined) + return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + yield* SessionInput.projectAdmitted(db, { + admittedSeq: event.seq, + id: event.data.messageID, + sessionID: event.data.sessionID, + prompt: event.data.prompt, + delivery: event.data.delivery, + timeCreated: event.data.timestamp, + }) + }), + ) + yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) => + Effect.gen(function* () { + if (event.seq === undefined) + return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + yield* insertMessage( + db, + event, + yield* SessionInput.projectPromoted(db, { + id: event.data.messageID, + sessionID: event.data.sessionID, + prompt: event.data.prompt, + timeCreated: event.data.timeCreated, + promotedSeq: event.seq, + }), + ) + }), + ) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 6b51e3900c..a322b21098 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -132,10 +132,13 @@ export const layer = Layer.effect( const model = yield* models.resolve(session) const toolFibers = yield* FiberSet.make() let needsContinuation = false - if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id) - if (promotion === "queue") { - yield* SessionInput.promoteNextQueued(db, events, session.id) - yield* SessionInput.promoteSteers(db, events, session.id) + if (promotion) { + const cutoff = yield* SessionInput.latestSeq(db, session.id) + if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff) + if (promotion === "queue") { + yield* SessionInput.promoteNextQueued(db, events, session.id) + yield* SessionInput.promoteSteers(db, events, session.id, cutoff) + } } yield* failInterruptedTools(session.id) const context = yield* getContext(session.id) @@ -233,8 +236,8 @@ export const layer = Layer.effect( readonly force?: boolean }) { const session = yield* getSession(input.sessionID) - const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, ["steer"]) - const hasQueue = yield* SessionInput.hasPending(db, input.sessionID, ["queue"]) + const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") + const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") if (input.force !== true && !hasSteer && !hasQueue) return let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined let openActivity = input.force === true || hasSteer || hasQueue @@ -243,12 +246,12 @@ export const layer = Layer.effect( for (let step = 0; step < MAX_STEPS; step++) { needsContinuation = yield* runTurn(session, promotion) promotion = "steer" - if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, ["steer"]) + if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") if (!needsContinuation) break } if (needsContinuation) return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS }) - openActivity = yield* SessionInput.hasPending(db, input.sessionID, ["queue"]) + openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue") promotion = openActivity ? "queue" : undefined } }) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 6c9bec6dd3..034018fd12 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -10,6 +10,7 @@ import { DateTime, Effect } from "effect" import { EventV2 } from "../../event" import { ModelV2 } from "../../model" import { SessionEvent } from "../event" +import { SessionMessage } from "../message" import { SessionSchema } from "../schema" type Input = { @@ -60,7 +61,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const tools = new Map< string, { - readonly assistantMessageID: EventV2.ID + readonly assistantMessageID: SessionMessage.ID readonly name: string inputEnded: boolean called: boolean @@ -70,13 +71,17 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } >() const timestamp = DateTime.now - let assistantMessageID: EventV2.ID | undefined + let assistantMessageID: SessionMessage.ID | undefined let providerFailed = false const startAssistant = Effect.fnUntraced(function* () { if (assistantMessageID !== undefined) return assistantMessageID - assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { ...input, timestamp: yield* timestamp })) - .id + assistantMessageID = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { + ...input, + assistantMessageID, + timestamp: yield* timestamp, + }) return assistantMessageID }) const currentAssistantMessageID = () => @@ -118,6 +123,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) Effect.gen(function* () { yield* events.publish(SessionEvent.Text.Ended, { sessionID: input.sessionID, + assistantMessageID: yield* currentAssistantMessageID(), timestamp: yield* timestamp, textID, text: value, @@ -128,6 +134,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) Effect.gen(function* () { yield* events.publish(SessionEvent.Reasoning.Ended, { sessionID: input.sessionID, + assistantMessageID: yield* currentAssistantMessageID(), timestamp: yield* timestamp, reasoningID, text: value, @@ -220,6 +227,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* text.start(event.id) yield* events.publish(SessionEvent.Text.Started, { sessionID: input.sessionID, + assistantMessageID: yield* startAssistant(), timestamp: yield* timestamp, textID: event.id, }) @@ -228,6 +236,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* text.append(event.id, event.text) yield* events.publish(SessionEvent.Text.Delta, { sessionID: input.sessionID, + assistantMessageID: yield* currentAssistantMessageID(), timestamp: yield* timestamp, textID: event.id, delta: event.text, @@ -240,6 +249,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* reasoning.start(event.id) yield* events.publish(SessionEvent.Reasoning.Started, { sessionID: input.sessionID, + assistantMessageID: yield* startAssistant(), timestamp: yield* timestamp, reasoningID: event.id, providerMetadata: event.providerMetadata, @@ -249,6 +259,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* reasoning.append(event.id, event.text) yield* events.publish(SessionEvent.Reasoning.Delta, { sessionID: input.sessionID, + assistantMessageID: yield* currentAssistantMessageID(), timestamp: yield* timestamp, reasoningID: event.id, delta: event.text, diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 8ecbbea240..27f22bd4bf 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core" +import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core" import * as DatabasePath from "../database/path" import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" @@ -127,7 +127,7 @@ export const SessionMessageTable = sqliteTable( data: text({ mode: "json" }).notNull().$type(), }, (table) => [ - index("session_message_session_seq_idx").on(table.session_id, table.seq), + uniqueIndex("session_message_session_seq_idx").on(table.session_id, table.seq), index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq), index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id), index("session_message_time_created_idx").on(table.time_created), @@ -137,14 +137,14 @@ export const SessionMessageTable = sqliteTable( export const SessionInputTable = sqliteTable( "session_input", { - seq: integer().primaryKey({ autoIncrement: true }), - id: text().$type().notNull().unique(), + id: text().$type().primaryKey(), session_id: text() .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), prompt: text({ mode: "json" }).notNull().$type(), delivery: text().$type().notNull(), + admitted_seq: integer().notNull(), promoted_seq: integer(), time_created: integer() .notNull() @@ -155,7 +155,9 @@ export const SessionInputTable = sqliteTable( table.session_id, table.promoted_seq, table.delivery, - table.seq, + table.admitted_seq, ), + uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq), + uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq), ], ) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 4c0ccb9832..6b0f1d2692 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -10,6 +10,7 @@ import { DatabaseMigration } from "@opencode-ai/core/database/migration" import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage" import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths" import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order" +import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -62,15 +63,17 @@ describe("DatabaseMigration", () => { expect( yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`), ).toEqual({ name: "session_input" }) - expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 29 }) + expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 30 }) expect( yield* db.all( - sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`, + sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`, ), ).toEqual([ { name: "event_aggregate_seq_idx" }, { name: "event_aggregate_type_seq_idx" }, + { name: "session_input_session_admitted_seq_idx" }, { name: "session_input_session_pending_delivery_seq_idx" }, + { name: "session_input_session_promoted_seq_idx" }, { name: "session_message_session_seq_idx" }, { name: "session_message_session_time_created_id_idx" }, { name: "session_message_session_type_seq_idx" }, @@ -79,6 +82,82 @@ describe("DatabaseMigration", () => { ) }) + test("resets beta history and rebuilds event-sourced Session input storage", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, workspace_id text)`) + yield* db.run(sql`CREATE TABLE workspace (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) + yield* db.run( + sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`CREATE INDEX event_aggregate_seq_idx ON event (aggregate_id, seq)`) + yield* db.run(sql`CREATE INDEX event_aggregate_type_seq_idx ON event (aggregate_id, type, seq)`) + yield* db.run( + sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`CREATE INDEX session_message_session_seq_idx ON session_message (session_id, seq)`) + yield* db.run( + sql`CREATE TABLE session_input (seq integer PRIMARY KEY AUTOINCREMENT, id text NOT NULL UNIQUE, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, + ) + yield* db.run( + sql`CREATE INDEX session_input_session_pending_delivery_seq_idx ON session_input (session_id, promoted_seq, delivery, seq)`, + ) + yield* db.run(sql`INSERT INTO session (id, workspace_id) VALUES ('session', 'wrk_old')`) + yield* db.run(sql`INSERT INTO workspace (id) VALUES ('wrk_old')`) + yield* db.run(sql`INSERT INTO message (id) VALUES ('message')`) + yield* db.run(sql`INSERT INTO part (id) VALUES ('part')`) + yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 0)`) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('evt_old', 'session', 0, 'old.1', '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_old', 'session', 'user', 0, 1, 1, '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`, + ) + + yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionInputMigration]) + + expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([ + { id: "session", workspace_id: null }, + ]) + expect(yield* db.all(sql`SELECT id FROM workspace`)).toEqual([]) + expect(yield* db.all(sql`SELECT id FROM message`)).toEqual([{ id: "message" }]) + expect(yield* db.all(sql`SELECT id FROM part`)).toEqual([{ id: "part" }]) + expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([]) + expect(yield* db.all(sql`SELECT aggregate_id FROM event_sequence`)).toEqual([]) + expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([]) + expect(yield* db.all(sql`SELECT id FROM session_input`)).toEqual([]) + expect( + (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_input)`)).map((column) => column.name), + ).toEqual(["id", "session_id", "prompt", "delivery", "admitted_seq", "promoted_seq", "time_created"]) + expect( + (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_message)`)).find( + (index) => index.name === "session_message_session_seq_idx", + ), + ).toMatchObject({ unique: 1 }) + expect( + (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(event)`)).find( + (index) => index.name === "event_aggregate_seq_idx", + ), + ).toMatchObject({ unique: 1 }) + expect( + (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_input)`)).filter((index) => + ["session_input_session_admitted_seq_idx", "session_input_session_promoted_seq_idx"].includes(index.name), + ), + ).toEqual([ + expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }), + expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }), + ]) + }), + ) + }) + test("resets incompatible projected Session messages before adding sequence order", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index a51c9f093f..c91d34fcfa 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect" +import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" import { Database } from "@opencode-ai/core/database/database" import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" @@ -236,6 +236,56 @@ describe("EventV2", () => { }), ) + it.effect("isolates observer defects after durable events commit", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + yield* events.sync(() => Effect.die("sync defect")) + yield* events.listen(() => { + throw new Error("listener defect") + }) + yield* events.listen((event) => + Effect.sync(() => { + received.push(event.type) + }), + ) + + const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" }) + + expect(received).toEqual([SyncMessage.type]) + expect(event.seq).toBeNumber() + }), + ) + + it.effect("preserves observer interruption", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + yield* events.listen(() => Effect.interrupt) + + const exit = yield* events.publish(SyncMessage, { id: "interrupted", text: "hello" }).pipe(Effect.exit) + const committed = yield* db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, "interrupted")) + .get() + .pipe(Effect.orDie) + + expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue() + expect(committed).toBeDefined() + }), + ) + + it.effect("keeps live-only listener defects fail-fast", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const defect = new Error("listener defect") + yield* events.listen(() => Effect.die(defect)) + + expect(yield* events.publish(Message, { text: "hello" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) + }), + ) + it.effect("does not synchronize live-only events", () => Effect.gen(function* () { const events = yield* EventV2.Service @@ -254,6 +304,30 @@ describe("EventV2", () => { }), ) + it.effect("synchronizes only after the durable event commits", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const synchronized = new Array() + yield* events.sync((event) => + db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.id, event.id)) + .get() + .pipe( + Effect.orDie, + Effect.map((row) => synchronized.push(row !== undefined)), + Effect.asVoid, + ), + ) + + yield* events.publish(SyncMessage, { id: EventV2.ID.create(), text: "durable" }) + + expect(synchronized).toEqual([true]) + }), + ) + it.effect("inserts sync event rows on publish", () => Effect.gen(function* () { const events = yield* EventV2.Service @@ -690,6 +764,59 @@ describe("EventV2", () => { }), ) + it.effect("strict owner fences exact replay", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = EventV2.ID.create() + const id = EventV2.ID.create() + const replayed = { + id, + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "owned" }, + } + yield* events.replay(replayed, { ownerID: "owner-a" }) + + const exit = yield* events.replay(replayed, { ownerID: "owner-b", strictOwner: true }).pipe(Effect.exit) + + expect(String(exit)).toContain("Replay owner mismatch") + }), + ) + + it.effect("exact replay claims an unowned aggregate", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + const published = yield* events.publish(SyncMessage, { id: aggregateID, text: "owned" }) + const replayed = { + id: published.id, + type: EventV2.versionedType(SyncMessage.type, 1), + seq: published.seq!, + aggregateID, + data: published.data, + } + + yield* events.replay(replayed, { ownerID: "owner-a", strictOwner: true }) + const row = yield* db + .select({ ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .get() + .pipe(Effect.orDie) + + expect(row?.ownerID).toBe("owner-a") + const exit = yield* events + .replay( + { ...replayed, id: EventV2.ID.create(), seq: 1, data: { id: aggregateID, text: "conflict" } }, + { ownerID: "owner-b", strictOwner: true }, + ) + .pipe(Effect.exit) + expect(String(exit)).toContain("Replay owner mismatch") + }), + ) + it.effect("replay with owner claims an unowned sequence", () => Effect.gen(function* () { const events = yield* EventV2.Service @@ -815,6 +942,57 @@ describe("EventV2", () => { }), ) + it.effect("rejects divergent stale replay without publishing it", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + const aggregateID = EventV2.ID.create() + const replayed = { + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "original" }, + } + yield* events.listen((event) => Effect.sync(() => received.push(event))) + yield* events.replay(replayed, { publish: true }) + + const exit = yield* events + .replay({ ...replayed, data: { id: aggregateID, text: "divergent" } }, { publish: true }) + .pipe(Effect.exit) + + expect(String(exit)).toContain("Replay diverged") + expect(received).toHaveLength(1) + }), + ) + + it.effect("rejects an event ID reused at another aggregate position", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = EventV2.ID.create() + const id = EventV2.ID.create() + yield* events.replay({ + id, + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "first" }, + }) + + const exit = yield* events + .replay({ + id, + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 1, + aggregateID, + data: { id: aggregateID, text: "second" }, + }) + .pipe(Effect.exit) + + expect(String(exit)).toContain(`Event ${id} already exists`) + }), + ) + it.effect("replay from a different owner leaves claimed sequence unchanged", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 7c83ec5696..06eedd5e44 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1,13 +1,15 @@ import { describe, expect } from "bun:test" +import path from "path" import { Effect, Layer, Stream } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" -import { eq } from "drizzle-orm" +import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" @@ -16,10 +18,12 @@ import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" +import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { testEffect } from "./lib/effect" +import { tmpdir } from "./fixture/tmpdir" const database = Database.layerFromPath(":memory:") const events = EventV2.layer.pipe(Layer.provide(database)) @@ -211,11 +215,99 @@ describe("SessionV2.create", () => { const { db } = yield* Database.Service const created = yield* session.create({ location }) yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false }) - yield* SessionInput.promoteSteers(db, events, created.id) + yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER) expect( - Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)), - ).toMatchObject([{ cursor: 1, event: { type: "session.next.prompted", data: { prompt: { text: "Hello" } } } }]) + Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)), + ).toMatchObject([ + { cursor: 1, event: { type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } } }, + { cursor: 2, event: { type: "session.next.prompt.promoted" } }, + ]) + }), + ) + + it.effect("replays one prompt lifecycle into a fresh target database", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const sourceEvents = yield* EventV2.Service + const sourceDb = (yield* Database.Service).db + const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location }) + const admitted = yield* session.prompt({ + sessionID: created.id, + prompt: new Prompt({ text: "Replay lifecycle" }), + resume: false, + }) + yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER) + const serialized = (yield* sourceDb + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, created.id)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie)).map((event) => ({ + id: event.id, + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + })) + + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite")) + const targetEvents = EventV2.layer.pipe(Layer.provide(targetDatabase)) + const targetProjector = SessionProjector.layer.pipe(Layer.provide(targetEvents), Layer.provide(targetDatabase)) + const targetStore = SessionStore.layer.pipe(Layer.provide(targetDatabase)) + + yield* Effect.gen(function* () { + const db = (yield* Database.Service).db + const events = yield* EventV2.Service + const store = yield* SessionStore.Service + yield* db + .insert(ProjectTable) + .values({ id: ProjectV2.ID.global, worktree: location.directory, sandboxes: [] }) + .run() + .pipe(Effect.orDie) + + expect(yield* store.get(created.id)).toBeUndefined() + expect(yield* events.replayAll(serialized.slice(0, 2))).toBe(created.id) + expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ + id: admitted.id, + sessionID: created.id, + prompt: { text: "Replay lifecycle" }, + delivery: "steer", + admittedSeq: 1, + }) + expect(yield* store.context(created.id)).toEqual([]) + + expect(yield* events.replayAll(serialized.slice(2))).toBe(created.id) + expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ + id: admitted.id, + sessionID: created.id, + prompt: { text: "Replay lifecycle" }, + delivery: "steer", + admittedSeq: 1, + promotedSeq: 2, + }) + expect(yield* store.context(created.id)).toMatchObject([ + { id: admitted.id, type: "user", text: "Replay lifecycle" }, + ]) + expect( + (yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, created.id)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie)).map((event) => [event.seq, event.type]), + ).toEqual([ + [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)], + [1, EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1)], + [2, EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1)], + ]) + }).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore)))) }), ) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 1493bad841..0cbadaf241 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -67,13 +67,25 @@ describe("SessionProjector", () => { yield* events.publish( SessionEvent.Prompted, - { sessionID, timestamp: created, prompt: new Prompt({ text: "first" }), delivery: "steer" }, - { id: SessionMessage.ID.make("evt_z") }, + { + sessionID, + messageID: SessionMessage.ID.make("msg_first"), + timestamp: created, + prompt: new Prompt({ text: "first" }), + delivery: "steer", + }, + { id: EventV2.ID.make("evt_z") }, ) yield* events.publish( SessionEvent.Prompted, - { sessionID, timestamp: created, prompt: new Prompt({ text: "second" }), delivery: "steer" }, - { id: SessionMessage.ID.make("evt_a") }, + { + sessionID, + messageID: SessionMessage.ID.make("msg_second"), + timestamp: created, + prompt: new Prompt({ text: "second" }), + delivery: "steer", + }, + { id: EventV2.ID.make("evt_a") }, ) const sessions = yield* SessionV2.Service @@ -110,7 +122,7 @@ describe("SessionProjector", () => { ), ) - it.effect("marks an admitted inbox row promoted with the Prompted event sequence", () => + it.effect("marks an admitted lifecycle row promoted with the PromptPromoted event sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db @@ -131,14 +143,21 @@ describe("SessionProjector", () => { .run() .pipe(Effect.orDie) const events = yield* EventV2.Service - const id = SessionMessage.ID.make("evt_admitted") - yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "promote me" }), delivery: "steer" }) + const id = SessionMessage.ID.make("msg_admitted") + yield* SessionInput.admit(db, events, { + id, + sessionID, + prompt: new Prompt({ text: "promote me" }), + delivery: "steer", + }) - const event = yield* events.publish( - SessionEvent.Prompted, - { sessionID, timestamp: created, prompt: new Prompt({ text: "promote me" }), delivery: "steer" }, - { id }, - ) + const event = yield* events.publish(SessionEvent.PromptLifecycle.Promoted, { + sessionID, + timestamp: created, + messageID: id, + prompt: new Prompt({ text: "promote me" }), + timeCreated: created, + }) expect( yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), @@ -168,11 +187,27 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service - yield* events.publish(SessionEvent.AgentSwitched, { sessionID, timestamp: created, agent: "build" }) - yield* events.publish(SessionEvent.ModelSwitched, { sessionID, timestamp: created, model }) - yield* events.publish(SessionEvent.Synthetic, { sessionID, timestamp: created, text: "synthetic context" }) + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: created, + agent: "build", + }) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: created, + model, + }) + yield* events.publish(SessionEvent.Synthetic, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: created, + text: "synthetic context", + }) yield* events.publish(SessionEvent.Shell.Started, { sessionID, + messageID: SessionMessage.ID.create(), timestamp: created, callID: "shell-1", command: "pwd", @@ -183,7 +218,12 @@ describe("SessionProjector", () => { callID: "shell-1", output: "/project", }) - yield* events.publish(SessionEvent.Compaction.Started, { sessionID, timestamp: created, reason: "manual" }) + yield* events.publish(SessionEvent.Compaction.Started, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: created, + reason: "manual", + }) yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, timestamp: created, text: "partial" }) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, @@ -228,6 +268,47 @@ describe("SessionProjector", () => { }), ) + it.effect("rejects distinct creator events that reuse one projected message ID", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + .pipe(Effect.orDie) + const events = yield* EventV2.Service + const id = SessionMessage.ID.make("msg_creator_collision") + + yield* events.publish(SessionEvent.Synthetic, { sessionID, messageID: id, timestamp: created, text: "keep me" }) + const exit = yield* events + .publish(SessionEvent.Step.Started, { + sessionID, + assistantMessageID: id, + timestamp: created, + agent: "build", + model, + }) + .pipe(Effect.exit) + + expect(exit._tag).toBe("Failure") + expect( + yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie), + ).toMatchObject({ type: "synthetic" }) + }), + ) + it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () => Effect.gen(function* () { const { db } = yield* Database.Service @@ -249,24 +330,77 @@ describe("SessionProjector", () => { .run() .pipe(Effect.orDie) const events = yield* EventV2.Service - const id = SessionMessage.ID.make("evt_conflict") - yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "admitted" }), delivery: "steer" }) + const id = SessionMessage.ID.make("msg_conflict") + yield* SessionInput.admit(db, events, { + id, + sessionID, + prompt: new Prompt({ text: "admitted" }), + delivery: "steer", + }) const exit = yield* events - .publish( - SessionEvent.Prompted, - { sessionID, timestamp: created, prompt: new Prompt({ text: "different" }), delivery: "steer" }, - { id }, - ) + .publish(SessionEvent.Prompted, { + sessionID, + messageID: id, + timestamp: created, + prompt: new Prompt({ text: "different" }), + delivery: "steer", + }) .pipe(Effect.exit) - expect(String(exit)).toContain("Prompt projection conflicts with admitted input") + expect(String(exit)).toContain("SessionInput.LifecycleConflict") expect( yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), ).toMatchObject({ promoted_seq: null }) }), ) + it.effect("rejects an assistant message ID that conflicts with an admitted inbox row", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + .pipe(Effect.orDie) + const events = yield* EventV2.Service + const id = SessionMessage.ID.make("msg_conflict") + yield* SessionInput.admit(db, events, { + id, + sessionID, + prompt: new Prompt({ text: "admitted" }), + delivery: "steer", + }) + + const exit = yield* events + .publish(SessionEvent.Step.Started, { + sessionID, + timestamp: created, + assistantMessageID: id, + agent: "build", + model, + }) + .pipe(Effect.exit) + + expect(String(exit)).toContain("SessionInput.LifecycleConflict") + expect( + yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie), + ).toBeUndefined() + }), + ) + it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () => Effect.gen(function* () { const { db } = yield* Database.Service @@ -288,15 +422,15 @@ describe("SessionProjector", () => { .run() .pipe(Effect.orDie) const events = yield* EventV2.Service - const id = SessionMessage.ID.make("evt_delivery_conflict") + const id = SessionMessage.ID.make("msg_delivery_conflict") const prompt = new Prompt({ text: "admitted" }) - yield* SessionInput.admit(db, { id, sessionID, prompt, delivery: "queue" }) + yield* SessionInput.admit(db, events, { id, sessionID, prompt, delivery: "queue" }) const exit = yield* events - .publish(SessionEvent.Prompted, { sessionID, timestamp: created, prompt, delivery: "steer" }, { id }) + .publish(SessionEvent.Prompted, { sessionID, messageID: id, timestamp: created, prompt, delivery: "steer" }) .pipe(Effect.exit) - expect(String(exit)).toContain("Prompt projection conflicts with admitted input") + expect(String(exit)).toContain("SessionInput.LifecycleConflict") expect( yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), ).toMatchObject({ delivery: "queue", promoted_seq: null }) @@ -306,7 +440,7 @@ describe("SessionProjector", () => { it.effect("does not revive a stale incomplete in-memory assistant projection", () => Effect.gen(function* () { const stale = new SessionMessage.Assistant({ - id: SessionMessage.ID.make("evt_assistant_stale"), + id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", agent: "build", model, @@ -314,7 +448,7 @@ describe("SessionProjector", () => { time: { created }, }) const completed = new SessionMessage.Assistant({ - id: SessionMessage.ID.make("evt_assistant_completed"), + id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", agent: "build", model, @@ -351,8 +485,8 @@ describe("SessionProjector", () => { yield* db .insert(SessionMessageTable) .values([ - assistantRow(SessionMessage.ID.make("evt_assistant_1"), 0), - assistantRow(SessionMessage.ID.make("evt_assistant_2"), 1), + assistantRow(SessionMessage.ID.make("msg_assistant_1"), 0), + assistantRow(SessionMessage.ID.make("msg_assistant_2"), 1), ]) .run() .pipe(Effect.orDie) @@ -361,7 +495,7 @@ describe("SessionProjector", () => { yield* service.publish(SessionEvent.Step.Ended, { sessionID, timestamp: DateTime.makeUnsafe(1), - assistantMessageID: SessionMessage.ID.make("evt_assistant_2"), + assistantMessageID: SessionMessage.ID.make("msg_assistant_2"), finish: "stop", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, @@ -409,8 +543,8 @@ describe("SessionProjector", () => { yield* db .insert(SessionMessageTable) .values([ - assistantRow(SessionMessage.ID.make("evt_assistant_stale"), 0), - assistantRow(SessionMessage.ID.make("evt_assistant_completed"), 1, { + assistantRow(SessionMessage.ID.make("msg_assistant_stale"), 0), + assistantRow(SessionMessage.ID.make("msg_assistant_completed"), 1, { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2), }), @@ -421,6 +555,7 @@ describe("SessionProjector", () => { const service = yield* EventV2.Service yield* service.publish(SessionEvent.Text.Started, { sessionID, + assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"), timestamp: DateTime.makeUnsafe(3), textID: "text-stale", }) @@ -437,15 +572,15 @@ describe("SessionProjector", () => { ) expect(messages).toEqual([ new SessionMessage.Assistant({ - id: SessionMessage.ID.make("evt_assistant_completed"), + id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", agent: "build", model, - content: [], + content: [new SessionMessage.AssistantText({ type: "text", id: "text-stale", text: "" })], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, }), new SessionMessage.Assistant({ - id: SessionMessage.ID.make("evt_assistant_stale"), + id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", agent: "build", model, diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 431261749e..d73170ef6b 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -1,7 +1,9 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Fiber, Layer, Stream } from "effect" +import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" +import { EventTable } from "@opencode-ai/core/event/sql" import { SessionEvent } from "@opencode-ai/core/session/event" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -12,7 +14,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" -import { SessionInputTable, SessionTable } from "@opencode-ai/core/session/sql" +import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { testEffect } from "./lib/effect" @@ -80,6 +82,18 @@ const admittedCount = Database.Service.use(({ db }) => Effect.map((rows) => rows.length), ), ) +const eventCount = (type: string) => + Database.Service.use(({ db }) => + db + .select() + .from(EventTable) + .where(eq(EventTable.type, type)) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => rows.length), + ), + ) describe("SessionV2.prompt", () => { it.effect("delegates execution continuation through SessionExecution", () => @@ -105,8 +119,7 @@ describe("SessionV2.prompt", () => { resume: false, }) - expect(message.type).toBe("user") - expect(message.text).toBe("Fix the failing tests") + expect(message.prompt.text).toBe("Fix the failing tests") expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admitted(message.id)).toMatchObject({ id: message.id, @@ -123,25 +136,25 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service const { db } = yield* Database.Service - const fiber = yield* session.events({ sessionID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) + const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - yield* SessionInput.promoteSteers(db, events, sessionID) + yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) const streamed = Array.from(yield* Fiber.join(fiber)) - expect( - streamed.map((event) => [event.cursor, event.event.type, (event.event.data as { prompt: Prompt }).prompt.text]), - ).toEqual([ - [EventV2.Cursor.make(0), "session.next.prompted", "First"], - [EventV2.Cursor.make(1), "session.next.prompted", "Second"], + expect(streamed.map((event) => [event.cursor, event.event.type])).toEqual([ + [EventV2.Cursor.make(0), "session.next.prompt.admitted"], + [EventV2.Cursor.make(1), "session.next.prompt.admitted"], + [EventV2.Cursor.make(2), "session.next.prompt.promoted"], + [EventV2.Cursor.make(3), "session.next.prompt.promoted"], ]) expect( Array.from( yield* session.events({ sessionID, after: streamed[0]!.cursor }).pipe(Stream.take(1), Stream.runCollect), - ).map((event) => [event.cursor, (event.event.data as { prompt: Prompt }).prompt.text]), - ).toEqual([[EventV2.Cursor.make(1), "Second"]]) + ).map((event) => [event.cursor, event.event.type]), + ).toEqual([[EventV2.Cursor.make(1), "session.next.prompt.admitted"]]) }), ) @@ -271,17 +284,6 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("does not match pending inputs when no delivery modes are eligible", () => - Effect.gen(function* () { - yield* setup - const { db } = yield* Database.Service - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Wait" }), resume: false }) - - expect(yield* SessionInput.hasPending(db, sessionID, [])).toBe(false) - }), - ) - it.effect("returns one recorded message to concurrent exact retries", () => Effect.gen(function* () { yield* setup @@ -298,43 +300,127 @@ describe("SessionV2.prompt", () => { expect(messages[1]).toEqual(messages[0]) expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admittedCount).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1))).toBe(1) }), ) - it.effect("reconciles an existing projected prompt into a promoted inbox record", () => + it.effect("promotes one message once under concurrent promotion attempts", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Promote once" }), resume: false }) + + yield* Effect.all( + [ + SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER), + SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER), + ], + { concurrency: "unbounded" }, + ) + + expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1))).toBe(1) + expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) + expect(yield* session.messages({ sessionID })).toMatchObject([ + { id: messageID, type: "user", text: "Promote once" }, + ]) + }), + ) + + it.effect("promotes steers only through the captured aggregate cutoff", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false }) + const cutoff = yield* SessionInput.latestSeq(db, sessionID) + const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false }) + + yield* SessionInput.promoteSteers(db, events, sessionID, cutoff) + + expect(yield* admitted(first.id)).toHaveProperty("promotedSeq") + expect(yield* admitted(second.id)).not.toHaveProperty("promotedSeq") + }), + ) + + it.effect("reprojects one pending lifecycle without scheduling execution", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + wakeCalls.length = 0 + yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Replay pending" }), resume: false }) + const recorded = yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, sessionID)) + .all() + .pipe(Effect.orDie) + + yield* events.remove(sessionID) + yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, sessionID)).run().pipe(Effect.orDie) + yield* db + .delete(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, sessionID)) + .run() + .pipe(Effect.orDie) + yield* events.replayAll( + recorded.map((event) => ({ + id: event.id, + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + })), + ) + + expect(yield* admitted(messageID)).toMatchObject({ id: messageID, prompt: { text: "Replay pending" } }) + expect(yield* session.messages({ sessionID })).toEqual([]) + expect(wakeCalls).toEqual([]) + }), + ) + + it.effect("returns an exact retry of a legacy projected prompt", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service const prompt = new Prompt({ text: "Historical prompt" }) - yield* events.publish( - SessionEvent.Prompted, - { sessionID, timestamp: yield* DateTime.now, prompt, delivery: "steer" }, - { id: messageID }, - ) + yield* events.publish(SessionEvent.Prompted, { + sessionID, + messageID, + timestamp: yield* DateTime.now, + prompt, + delivery: "steer", + }) const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) - expect(retried).toMatchObject({ id: messageID, text: "Historical prompt" }) + expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical prompt" } }) expect(yield* admitted(messageID)).toHaveProperty("promotedSeq") }), ) - it.effect("reconciles an existing projected queued prompt with its delivery mode", () => + it.effect("returns an exact retry of a legacy projected queued prompt", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service const prompt = new Prompt({ text: "Historical queued prompt" }) - yield* events.publish( - SessionEvent.Prompted, - { sessionID, timestamp: yield* DateTime.now, prompt, delivery: "queue" }, - { id: messageID }, - ) + yield* events.publish(SessionEvent.Prompted, { + sessionID, + messageID, + timestamp: yield* DateTime.now, + prompt, + delivery: "queue", + }) const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false }) - expect(retried).toMatchObject({ id: messageID, text: "Historical queued prompt" }) + expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical queued prompt" } }) expect(yield* admitted(messageID)).toMatchObject({ delivery: "queue" }) }), ) @@ -344,11 +430,12 @@ describe("SessionV2.prompt", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* events.publish( - SessionEvent.Synthetic, - { sessionID, timestamp: yield* DateTime.now, text: "Collision" }, - { id: messageID }, - ) + yield* events.publish(SessionEvent.Synthetic, { + sessionID, + messageID, + timestamp: yield* DateTime.now, + text: "Collision", + }) const failure = yield* session .prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Collision" }), resume: false }) @@ -369,20 +456,21 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) const failure = yield* events - .publish( - SessionEvent.Synthetic, - { sessionID, timestamp: yield* DateTime.now, text: "Conflicting synthetic" }, - { id: messageID }, - ) + .publish(SessionEvent.Synthetic, { + sessionID, + messageID, + timestamp: yield* DateTime.now, + text: "Conflicting synthetic", + }) .pipe(Effect.catchDefect(Effect.succeed)) - expect(failure).toBe("Durable event conflicts with admitted prompt input") + expect(String(failure)).toContain("SessionInput.LifecycleConflict") expect(yield* admitted(messageID)).not.toHaveProperty("promotedSeq") expect(yield* session.messages({ sessionID })).toEqual([]) - yield* SessionInput.promoteSteers(db, events, sessionID) + yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) - expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 0 }) + expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) expect(yield* session.messages({ sessionID })).toMatchObject([ { id: messageID, type: "user", text: "Reserved prompt" }, ]) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index de6cc025dd..775e744d84 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test" import { Message, Model } from "@opencode-ai/llm" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" -import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -12,7 +11,7 @@ import { ToolOutput } from "@opencode-ai/core/tool-output" import { DateTime } from "effect" const created = DateTime.makeUnsafe(0) -const id = (value: string) => EventV2.ID.make(`evt_${value}`) +const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) describe("toLLMMessages", () => { diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 10f8938fca..4268859bd1 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -14,7 +14,6 @@ import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" -import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool-registry" @@ -131,7 +130,7 @@ describe("SessionRunnerLLM recorded", () => { const messages = yield* session.context(sessionID) expect(messages).toHaveLength(2) - expect(messages[0]).toEqual(prompt) + expect(messages[0]).toMatchObject({ id: prompt.id, type: "user", text: "Say hello in one short sentence." }) expect(messages[1]).toMatchObject({ type: "assistant", agent: "build", finish: "stop" }) expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([ { type: "text", text: "Hello!" }, @@ -144,7 +143,8 @@ describe("SessionRunnerLLM recorded", () => { .orderBy(EventTable.seq) .all()).map((event) => event.type), ).toEqual([ - "session.next.prompted.1", + "session.next.prompt.admitted.1", + "session.next.prompt.promoted.1", "session.next.step.started.1", "session.next.text.started.1", "session.next.text.ended.1", diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 640cf3cedc..bc476aee14 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -21,6 +21,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionInput } from "@opencode-ai/core/session/input" +import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -29,7 +30,7 @@ import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool-registry" -import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -240,6 +241,7 @@ const replaySessionProjection = (id: SessionV2.ID) => .pipe(Effect.orDie) yield* events.remove(id) + yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, id)).run().pipe(Effect.orDie) yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie) yield* events.replayAll( recorded.map((event) => ({ @@ -425,7 +427,9 @@ describe("SessionRunnerLLM", () => { const message = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run automatically" }) }) expect(requests).toHaveLength(1) - expect(yield* session.messages({ sessionID })).toEqual([message]) + expect(yield* session.messages({ sessionID })).toMatchObject([ + { id: message.id, type: "user", text: "Run automatically" }, + ]) }), ) @@ -1217,9 +1221,11 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover interrupted tool" }), resume: false }) - yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) - const assistant = yield* events.publish(SessionEvent.Step.Started, { + yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) + const assistantMessageID = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { sessionID, + assistantMessageID, timestamp: yield* DateTime.now, agent: "build", model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, @@ -1227,21 +1233,21 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-interrupted", name: "echo", }) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-interrupted", text: '{"text":"stale"}', }) yield* events.publish(SessionEvent.Tool.Called, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-interrupted", tool: "echo", input: { text: "stale" }, @@ -1279,9 +1285,11 @@ describe("SessionRunnerLLM", () => { prompt: new Prompt({ text: "Recover interrupted hosted tool" }), resume: false, }) - yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) - const assistant = yield* events.publish(SessionEvent.Step.Started, { + yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) + const assistantMessageID = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { sessionID, + assistantMessageID, timestamp: yield* DateTime.now, agent: "build", model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, @@ -1289,21 +1297,21 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-hosted-interrupted", name: "web_search", }) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-hosted-interrupted", text: '{"query":"stale"}', }) yield* events.publish(SessionEvent.Tool.Called, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-hosted-interrupted", tool: "web_search", input: { query: "stale" }, @@ -1337,9 +1345,11 @@ describe("SessionRunnerLLM", () => { prompt: new Prompt({ text: "Recover interrupted tool input" }), resume: false, }) - yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) - const assistant = yield* events.publish(SessionEvent.Step.Started, { + yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) + const assistantMessageID = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { sessionID, + assistantMessageID, timestamp: yield* DateTime.now, agent: "build", model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, @@ -1347,7 +1357,7 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-pending-interrupted", name: "echo", }) @@ -1414,7 +1424,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true - yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void)) + yield* events.project(SessionEvent.PromptLifecycle.Promoted, () => (fail ? Effect.die(defect) : Effect.void)) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover promoted input" }), resume: false }) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) @@ -1433,6 +1443,30 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("does not strand a committed promotion when a post-commit listener defects", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* events.listen((event) => + event.type === SessionEvent.PromptLifecycle.Promoted.type + ? Effect.die("fail after prompt promotion commits") + : Effect.void, + ) + yield* session.prompt({ + sessionID, + prompt: new Prompt({ text: "Run committed promotion" }), + resume: false, + }) + + requests.length = 0 + yield* session.resume(sessionID) + + expect(requests).toHaveLength(1) + expect(userTexts(requests[0]!)).toEqual(["Run committed promotion"]) + }), + ) + it.effect("runs different sessions concurrently", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index f69743e701..9ed10b69e5 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -50,12 +50,14 @@ describe("Tool.Progress", () => { }) .run() .pipe(Effect.orDie) - const assistantMessageID = (yield* service.publish(SessionEvent.Step.Started, { + const assistantMessageID = SessionMessage.ID.create() + yield* service.publish(SessionEvent.Step.Started, { sessionID, + assistantMessageID, timestamp, agent: "build", model, - })).id + }) const readAssistant = Effect.gen(function* () { const row = yield* db .select() diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index 0b0690d9d2..be13d2007d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -1,5 +1,6 @@ import { useEvent } from "@tui/context/event" import type { + Event, SessionMessage, SessionMessageAssistant, SessionMessageAssistantReasoning, @@ -54,6 +55,11 @@ function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoni ) } +function prepend(messages: SessionMessage[], message: SessionMessage) { + if (messages.some((item) => item.id === message.id)) return + messages.unshift(message) +} + export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({ name: "SyncV2", init: () => { @@ -67,6 +73,18 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( const event = useEvent() const sdk = useSDK() + const applied = new Set() + const buffering = new Map() + const syncing = new Map>() + + function duplicate(id: string) { + if (applied.has(id)) return true + applied.add(id) + if (applied.size <= 1000) return false + const oldest = applied.values().next() + if (!oldest.done) applied.delete(oldest.value) + return false + } function update(sessionID: string, fn: (messages: SessionMessage[]) => void) { setStore( @@ -77,12 +95,41 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( ) } - event.subscribe((event) => { + async function hydrate(sessionID: string) { + const pending: Event[] = [] + const before = JSON.parse(JSON.stringify(store.messages[sessionID] ?? [])) as SessionMessage[] + buffering.set(sessionID, pending) + try { + const response = await sdk.client.v2.session.messages({ sessionID }) + const messages = response.data?.data ?? [] + const snapshotIDs = new Set(messages.map((message) => message.id)) + setStore( + "messages", + sessionID, + reconcile([...messages, ...before.filter((message) => !snapshotIDs.has(message.id))]), + ) + buffering.delete(sessionID) + for (const event of pending) apply(event) + } catch (error) { + buffering.delete(sessionID) + throw error + } + } + + function sync(sessionID: string) { + const existing = syncing.get(sessionID) + if (existing) return existing + const result = hydrate(sessionID).finally(() => syncing.delete(sessionID)) + syncing.set(sessionID, result) + return result + } + + function apply(event: Event) { switch (event.type) { case "session.next.agent.switched": update(event.properties.sessionID, (draft) => { - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.messageID, type: "agent-switched", agent: event.properties.agent, time: { created: event.properties.timestamp }, @@ -91,8 +138,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.model.switched": update(event.properties.sessionID, (draft) => { - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.messageID, type: "model-switched", model: event.properties.model, time: { created: event.properties.timestamp }, @@ -101,8 +148,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.prompted": { update(event.properties.sessionID, (draft) => { - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.messageID, type: "user", text: event.properties.prompt.text, files: event.properties.prompt.files, @@ -113,10 +160,25 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( }) break } + case "session.next.prompt.admitted": + break + case "session.next.prompt.promoted": + update(event.properties.sessionID, (draft) => { + prepend(draft, { + id: event.properties.messageID, + type: "user", + text: event.properties.prompt.text, + files: event.properties.prompt.files, + agents: event.properties.prompt.agents, + references: event.properties.prompt.references, + time: { created: event.properties.timeCreated }, + }) + }) + break case "session.next.synthetic": update(event.properties.sessionID, (draft) => { - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.messageID, type: "synthetic", sessionID: event.properties.sessionID, text: event.properties.text, @@ -126,8 +188,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.shell.started": update(event.properties.sessionID, (draft) => { - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.messageID, type: "shell", callID: event.properties.callID, command: event.properties.command, @@ -146,10 +208,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.step.started": update(event.properties.sessionID, (draft) => { + if (draft.some((message) => message.id === event.properties.assistantMessageID)) return const currentAssistant = activeAssistant(draft) if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.assistantMessageID, type: "assistant", agent: event.properties.agent, model: event.properties.model, @@ -182,18 +245,28 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.text.started": update(event.properties.sessionID, (draft) => { - activeAssistant(draft)?.content.push({ type: "text", id: event.properties.textID, text: "" }) + ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({ + type: "text", + id: event.properties.textID, + text: "", + }) }) break case "session.next.text.delta": update(event.properties.sessionID, (draft) => { - const match = latestText(activeAssistant(draft), event.properties.textID) + const match = latestText( + ownedAssistant(draft, event.properties.assistantMessageID), + event.properties.textID, + ) if (match) match.text += event.properties.delta }) break case "session.next.text.ended": update(event.properties.sessionID, (draft) => { - const match = latestText(activeAssistant(draft), event.properties.textID) + const match = latestText( + ownedAssistant(draft, event.properties.assistantMessageID), + event.properties.textID, + ) if (match) match.text = event.properties.text }) break @@ -263,7 +336,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( content: [...event.properties.content], result: event.properties.result, } - match.provider = event.properties.provider + match.provider = { + executed: event.properties.provider.executed || match.provider?.executed === true, + metadata: match.provider?.metadata, + resultMetadata: event.properties.provider.metadata, + } match.time.completed = event.properties.timestamp }) break @@ -282,13 +359,17 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( content: match.state.status === "running" ? match.state.content : [], result: event.properties.result, } - match.provider = event.properties.provider + match.provider = { + executed: event.properties.provider.executed || match.provider?.executed === true, + metadata: match.provider?.metadata, + resultMetadata: event.properties.provider.metadata, + } match.time.completed = event.properties.timestamp }) break case "session.next.reasoning.started": update(event.properties.sessionID, (draft) => { - activeAssistant(draft)?.content.push({ + ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({ type: "reasoning", id: event.properties.reasoningID, text: "", @@ -298,13 +379,19 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.reasoning.delta": update(event.properties.sessionID, (draft) => { - const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + const match = latestReasoning( + ownedAssistant(draft, event.properties.assistantMessageID), + event.properties.reasoningID, + ) if (match) match.text += event.properties.delta }) break case "session.next.reasoning.ended": update(event.properties.sessionID, (draft) => { - const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + const match = latestReasoning( + ownedAssistant(draft, event.properties.assistantMessageID), + event.properties.reasoningID, + ) if (match) { match.text = event.properties.text if (event.properties.providerMetadata !== undefined) @@ -316,8 +403,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.compaction.started": update(event.properties.sessionID, (draft) => { - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.messageID, type: "compaction", reason: event.properties.reason, summary: "", @@ -340,16 +427,20 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( }) break } + } + + event.subscribe((event) => { + if (duplicate(event.id)) return + if ("sessionID" in event.properties && typeof event.properties.sessionID === "string") + buffering.get(event.properties.sessionID)?.push(event) + apply(event) }) const result = { data: store, session: { message: { - async sync(sessionID: string) { - const response = await sdk.client.v2.session.messages({ sessionID }) - setStore("messages", sessionID, reconcile(response.data?.data ?? [])) - }, + sync, fromSession(sessionID: string) { const messages = store.messages[sessionID] if (!messages) return [] diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index b4ce2e011f..afc03db30d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -32,7 +32,7 @@ const EventSchema = Schema.Union([ .values() .map((definition) => Schema.Struct({ - id: Schema.String, + id: EventV2.ID, type: Schema.Literal(definition.type), properties: definition.data, }).annotate({ identifier: `Event.${definition.type}` }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index cd84990524..87556a1ad6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -20,10 +20,10 @@ const SyncEventSchemas = EventV2.registry return [ Schema.Struct({ type: Schema.Literal("sync"), - id: Schema.String, + id: EventV2.ID, syncEvent: Schema.Struct({ type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)), - id: Schema.String, + id: EventV2.ID, seq: Schema.Finite, aggregateID: Schema.String, data: definition.data, @@ -41,7 +41,7 @@ const GlobalEventSchema = Schema.Struct({ ...EventV2.registry .values() .map((definition) => - Schema.Struct({ id: Schema.String, type: Schema.Literal(definition.type), properties: definition.data }), + Schema.Struct({ id: EventV2.ID, type: Schema.Literal(definition.type), properties: definition.data }), ) .toArray(), InstanceDisposed, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 38a93240eb..ca9b1cdaf0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,4 +1,5 @@ import { NonNegativeInt } from "@opencode-ai/core/schema" +import { EventV2 } from "@opencode-ai/core/event" import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -9,7 +10,7 @@ import { described } from "./metadata" const root = "/sync" export const ReplayEvent = Schema.Struct({ - id: Schema.String, + id: EventV2.ID, aggregateID: Schema.String, seq: NonNegativeInt, type: Schema.String, @@ -27,7 +28,7 @@ export const SessionPayload = Schema.Struct({ }) export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) export const HistoryEvent = Schema.Struct({ - id: Schema.String, + id: EventV2.ID, aggregate_id: Schema.String, seq: NonNegativeInt, type: Schema.String, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index 32b02b4c74..5314e9d297 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -36,7 +36,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) { const payload: EventV2.SerializedEvent[] = ctx.payload.events.map((event) => ({ - id: EventV2.ID.make(event.id), + id: event.id, aggregateID: event.aggregateID, seq: event.seq, type: event.type, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index f0d7e0dfec..b165aa813f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -12,7 +12,7 @@ import { Plugin } from "@/plugin" import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" -import { Effect, Layer, Context, Schema } from "effect" +import { Effect, Layer, Context } from "effect" import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" @@ -20,6 +20,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessage } from "@opencode-ai/core/session/message" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { EventV2 } from "@opencode-ai/core/event" @@ -609,6 +610,7 @@ export const layer = Layer.effect( if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(Date.now()), reason: input.auto ? "auto" : "manual", }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index e0027b989b..907134df01 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -25,13 +25,13 @@ import { isRecord } from "@/util/record" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessage } from "@opencode-ai/core/session/message" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" import { RuntimeFlags } from "@/effect/runtime-flags" import { toolFileSourceFromUri, Usage, type LLMEvent } from "@opencode-ai/llm" import { ToolOutput } from "@opencode-ai/core/tool-output" -import type { EventV2 } from "@opencode-ai/core/event" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -67,7 +67,7 @@ export interface Interface { } type ToolCall = { - assistantMessageID?: EventV2.ID + assistantMessageID?: SessionMessage.ID partID: SessionV1.ToolPart["id"] messageID: SessionV1.ToolPart["messageID"] sessionID: SessionV1.ToolPart["sessionID"] @@ -85,7 +85,7 @@ interface ProcessorContext extends Input { currentText: SessionV1.TextPart | undefined currentTextID: string | undefined reasoningMap: Record - v2AssistantMessageID: EventV2.ID | undefined + v2AssistantMessageID: SessionMessage.ID | undefined } type StreamEvent = LLMEvent @@ -129,6 +129,7 @@ export const layer = Layer.effect( reasoningMap: {}, v2AssistantMessageID: undefined, } + const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary let aborted = false const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id) @@ -146,8 +147,10 @@ export const layer = Layer.effect( const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () { if (ctx.v2AssistantMessageID) return ctx.v2AssistantMessageID - ctx.v2AssistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { + ctx.v2AssistantMessageID = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { sessionID: ctx.sessionID, + assistantMessageID: ctx.v2AssistantMessageID, agent: input.assistantMessage.agent, model: { id: ModelV2.ID.make(ctx.model.id), @@ -156,7 +159,7 @@ export const layer = Layer.effect( }, snapshot: ctx.snapshot, timestamp: DateTime.makeUnsafe(Date.now()), - })).id + }) return ctx.v2AssistantMessageID }) @@ -249,9 +252,10 @@ export const layer = Layer.effect( const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) { if (!(reasoningID in ctx.reasoningMap)) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Reasoning.Ended, { sessionID: ctx.sessionID, + assistantMessageID: yield* currentV2AssistantMessage(), reasoningID, text: ctx.reasoningMap[reasoningID].text, providerMetadata: ctx.reasoningMap[reasoningID].metadata, @@ -266,23 +270,29 @@ export const layer = Layer.effect( }) const flushV2Fragments = Effect.fn("SessionProcessor.flushV2Fragments")(function* () { - if (!flags.experimentalEventSystem) return + if (!mirrorAssistant) return if (!ctx.assistantMessage.summary && ctx.currentText && ctx.currentTextID) { yield* events.publish(SessionEvent.Text.Ended, { sessionID: ctx.sessionID, + assistantMessageID: yield* currentV2AssistantMessage(), textID: ctx.currentTextID, text: ctx.currentText.text, timestamp: DateTime.makeUnsafe(Date.now()), }) } yield* Effect.forEach(Object.entries(ctx.reasoningMap), ([reasoningID, part]) => - events.publish(SessionEvent.Reasoning.Ended, { - sessionID: ctx.sessionID, - reasoningID, - text: part.text, - providerMetadata: part.metadata, - timestamp: DateTime.makeUnsafe(Date.now()), - }), + currentV2AssistantMessage().pipe( + Effect.flatMap((assistantMessageID) => + events.publish(SessionEvent.Reasoning.Ended, { + sessionID: ctx.sessionID, + assistantMessageID, + reasoningID, + text: part.text, + providerMetadata: part.metadata, + timestamp: DateTime.makeUnsafe(Date.now()), + }), + ), + ), ) }) @@ -307,7 +317,7 @@ export const layer = Layer.effect( return { call: ctx.toolcalls[input.id], part } } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - const assistantMessageID = flags.experimentalEventSystem ? yield* ensureV2AssistantMessage() : undefined + const assistantMessageID = mirrorAssistant ? yield* ensureV2AssistantMessage() : undefined if (assistantMessageID) { yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID: ctx.sessionID, @@ -367,9 +377,10 @@ export const layer = Layer.effect( case "reasoning-start": if (value.id in ctx.reasoningMap) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Reasoning.Started, { sessionID: ctx.sessionID, + assistantMessageID: yield* ensureV2AssistantMessage(), reasoningID: value.id, providerMetadata: value.providerMetadata, timestamp: DateTime.makeUnsafe(Date.now()), @@ -392,9 +403,10 @@ export const layer = Layer.effect( if (!(value.id in ctx.reasoningMap)) return ctx.reasoningMap[value.id].text += value.text if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Reasoning.Delta, { sessionID: ctx.sessionID, + assistantMessageID: yield* currentV2AssistantMessage(), reasoningID: value.id, delta: value.text, timestamp: DateTime.makeUnsafe(Date.now()), @@ -426,9 +438,7 @@ export const layer = Layer.effect( case "tool-input-delta": { const toolCall = yield* ensureToolCall(value) - const assistantMessageID = flags.experimentalEventSystem - ? yield* requireV2AssistantMessage(toolCall.call) - : undefined + const assistantMessageID = mirrorAssistant ? yield* requireV2AssistantMessage(toolCall.call) : undefined if (assistantMessageID) { yield* events.publish(SessionEvent.Tool.Input.Delta, { sessionID: ctx.sessionID, @@ -445,7 +455,7 @@ export const layer = Layer.effect( case "tool-input-end": { const toolCall = yield* ensureToolCall(value) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID: ctx.sessionID, @@ -467,7 +477,7 @@ export const layer = Layer.effect( const input = isRecord(value.input) ? value.input : { value: value.input } if (!toolCall.call.inputEnded) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID: ctx.sessionID, @@ -479,7 +489,7 @@ export const layer = Layer.effect( } } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call) yield* events.publish(SessionEvent.Tool.Called, { sessionID: ctx.sessionID, @@ -545,7 +555,7 @@ export const layer = Layer.effect( if (!toolCall && value.result.type === "error") return if (value.result.type === "error") { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call) yield* events.publish(SessionEvent.Tool.Failed, { sessionID: ctx.sessionID, @@ -586,7 +596,7 @@ export const layer = Layer.effect( attachments: attachments.length ? attachments : undefined, } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call) const content = [ ToolOutput.text({ type: "text", text: output.output }), @@ -642,7 +652,7 @@ export const layer = Layer.effect( case "tool-error": { const toolCall = yield* readToolCall(value.id) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call) yield* events.publish(SessionEvent.Tool.Failed, { sessionID: ctx.sessionID, @@ -670,7 +680,7 @@ export const layer = Layer.effect( if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* ensureV2AssistantMessage() } } @@ -693,7 +703,7 @@ export const layer = Layer.effect( }) if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Step.Ended, { sessionID: ctx.sessionID, assistantMessageID: yield* currentV2AssistantMessage(), @@ -752,9 +762,10 @@ export const layer = Layer.effect( case "text-start": if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Text.Started, { sessionID: ctx.sessionID, + assistantMessageID: yield* ensureV2AssistantMessage(), timestamp: DateTime.makeUnsafe(Date.now()), textID: value.id, }) @@ -777,9 +788,10 @@ export const layer = Layer.effect( if (!ctx.currentText) return ctx.currentText.text += value.text if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Text.Delta, { sessionID: ctx.sessionID, + assistantMessageID: yield* currentV2AssistantMessage(), textID: value.id, delta: value.text, timestamp: DateTime.makeUnsafe(Date.now()), @@ -809,9 +821,10 @@ export const layer = Layer.effect( )).text if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Text.Ended, { sessionID: ctx.sessionID, + assistantMessageID: yield* currentV2AssistantMessage(), text: ctx.currentText.text, timestamp: DateTime.makeUnsafe(Date.now()), textID: value.id, @@ -876,7 +889,7 @@ export const layer = Layer.effect( const match = yield* readToolCall(toolCallID) if (!match) continue const part = match.part - if (flags.experimentalEventSystem && match.call.assistantMessageID) { + if (mirrorAssistant && match.call.assistantMessageID) { yield* events.publish(SessionEvent.Tool.Failed, { sessionID: ctx.sessionID, assistantMessageID: match.call.assistantMessageID, @@ -922,7 +935,7 @@ export const layer = Layer.effect( } if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Step.Failed, { sessionID: ctx.sessionID, assistantMessageID: yield* ensureV2AssistantMessage(), @@ -979,7 +992,7 @@ export const layer = Layer.effect( parse, set: (info) => { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - const event = flags.experimentalEventSystem + const event = mirrorAssistant ? events.publish(SessionEvent.Retried, { sessionID: ctx.sessionID, attempt: info.attempt, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1ac1efb1f8..a75a3c993a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -51,6 +51,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessage } from "@opencode-ai/core/session/message" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AgentAttachment, FileAttachment, Prompt, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt" @@ -563,6 +564,7 @@ export const layer = Layer.effect( if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Shell.Started, { sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(started), callID: part.callID, command: input.command, @@ -738,6 +740,7 @@ export const layer = Layer.effect( if (current?.agent !== info.agent) { yield* events.publish(SessionEvent.AgentSwitched, { sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(info.time.created), agent: info.agent, }) @@ -749,6 +752,7 @@ export const layer = Layer.effect( ) { yield* events.publish(SessionEvent.ModelSwitched, { sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(info.time.created), model: { id: ModelV2.ID.make(info.model.modelID), @@ -1190,6 +1194,7 @@ export const layer = Layer.effect( if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Prompted, { sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(info.time.created), delivery: "steer", prompt: new Prompt({ @@ -1205,6 +1210,7 @@ export const layer = Layer.effect( if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Synthetic, { sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(info.time.created), text, }) diff --git a/packages/opencode/test/cli/tui/sync-v2.test.tsx b/packages/opencode/test/cli/tui/sync-v2.test.tsx index 9cc495e466..1b995d2943 100644 --- a/packages/opencode/test/cli/tui/sync-v2.test.tsx +++ b/packages/opencode/test/cli/tui/sync-v2.test.tsx @@ -6,7 +6,7 @@ import { onMount } from "solid-js" import { ProjectProvider } from "../../../src/cli/cmd/tui/context/project" import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" import { SyncProviderV2, useSyncV2 } from "../../../src/cli/cmd/tui/context/sync-v2" -import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk" +import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" async function wait(fn: () => boolean, timeout = 2000) { const start = Date.now() @@ -20,6 +20,12 @@ function global(payload: Event): GlobalEvent { return { directory, project: "proj_test", payload } } +function emitTwice(events: ReturnType, payload: Event) { + const event = global(payload) + events.emit(event) + events.emit(event) +} + test("sync v2 settles pending tools when a live failure arrives", async () => { const events = createEventSource() const calls = createFetch() @@ -47,63 +53,68 @@ test("sync v2 settles pending tools when a live failure arrives", async () => { try { await mounted - events.emit( - global({ - id: "agent-1", - type: "session.next.agent.switched", - properties: { sessionID: "session-1", timestamp: 0, agent: "build" }, - }), - ) - events.emit( - global({ - id: "model-1", - type: "session.next.model.switched", - properties: { - sessionID: "session-1", - timestamp: 0, - model: { id: "model-1", providerID: "provider-1" }, - }, - }), - ) - events.emit( - global({ - id: "assistant-1", - type: "session.next.step.started", - properties: { - sessionID: "session-1", - timestamp: 1, - agent: "build", - model: { id: "model-1", providerID: "provider-1" }, - }, - }), - ) - events.emit( - global({ - id: "input-1", - type: "session.next.tool.input.started", - properties: { - sessionID: "session-1", - timestamp: 2, - assistantMessageID: "assistant-1", - callID: "call-1", - name: "bash", - }, - }), - ) - events.emit( - global({ - id: "failed-1", - type: "session.next.tool.failed", - properties: { - sessionID: "session-1", - timestamp: 3, - assistantMessageID: "assistant-1", - callID: "call-1", - error: { type: "unknown", message: "aborted" }, - provider: { executed: false }, - }, - }), - ) + emitTwice(events, { + id: "evt_agent_1", + type: "session.next.agent.switched", + properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, + }) + emitTwice(events, { + id: "evt_model_1", + type: "session.next.model.switched", + properties: { + sessionID: "session-1", + messageID: "msg_model_1", + timestamp: 0, + model: { id: "model-1", providerID: "provider-1" }, + }, + }) + emitTwice(events, { + id: "evt_step_started_1", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_explicit_assistant_9", + timestamp: 1, + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + }, + }) + emitTwice(events, { + id: "evt_input_1", + type: "session.next.tool.input.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_explicit_assistant_9", + timestamp: 2, + callID: "call-1", + name: "bash", + }, + }) + emitTwice(events, { + id: "evt_called_1", + type: "session.next.tool.called", + properties: { + sessionID: "session-1", + timestamp: 2, + assistantMessageID: "msg_explicit_assistant_9", + callID: "call-1", + tool: "bash", + input: {}, + provider: { executed: false, metadata: { fake: { call: true } } }, + }, + }) + emitTwice(events, { + id: "evt_failed_1", + type: "session.next.tool.failed", + properties: { + sessionID: "session-1", + timestamp: 3, + assistantMessageID: "msg_explicit_assistant_9", + callID: "call-1", + error: { type: "unknown", message: "aborted" }, + provider: { executed: false, metadata: { fake: { result: true } } }, + }, + }) await wait(() => { const assistant = sync.session.message.fromSession("session-1")[0] @@ -117,6 +128,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => { const assistant = sync.session.message.fromSession("session-1")[0] expect(assistant?.type).toBe("assistant") if (assistant?.type !== "assistant") return + expect(assistant.id).toBe("msg_explicit_assistant_9") const tool = assistant.content[0] expect(tool?.type).toBe("tool") if (tool?.type !== "tool") return @@ -126,6 +138,11 @@ test("sync v2 settles pending tools when a live failure arrives", async () => { expect(tool.state.input).toEqual({}) expect(tool.state.structured).toEqual({}) expect(tool.state.content).toEqual([]) + expect(tool.provider).toEqual({ + executed: false, + metadata: { fake: { call: true } }, + resultMetadata: { fake: { result: true } }, + }) expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([ "assistant", "model-switched", @@ -135,3 +152,358 @@ test("sync v2 settles pending tools when a live failure arrives", async () => { app.renderer.destroy() } }) + +test("sync v2 renders admitted prompts only after promotion", async () => { + const events = createEventSource() + const calls = createFetch() + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + emitTwice(events, { + id: "evt_admitted_1", + type: "session.next.prompt.admitted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 0, + prompt: { text: "hello" }, + delivery: "steer", + }, + }) + expect(sync.session.message.fromSession("session-1")).toEqual([]) + + emitTwice(events, { + id: "evt_promoted_1", + type: "session.next.prompt.promoted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 1, + prompt: { text: "hello" }, + timeCreated: 0, + }, + }) + + await wait(() => sync.session.message.fromSession("session-1").length === 1) + const message = sync.session.message.fromSession("session-1")[0] + expect(message?.type).toBe("user") + if (message?.type !== "user") return + expect(message).toMatchObject({ id: "msg_user_1", text: "hello" }) + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 renders a promoted prompt when admission was missed", async () => { + const events = createEventSource() + const calls = createFetch() + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + emitTwice(events, { + id: "evt_promoted_1", + type: "session.next.prompt.promoted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 1, + prompt: { text: "hello" }, + timeCreated: 0, + }, + }) + + await wait(() => sync.session.message.fromSession("session-1").length === 1) + expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1") + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 preserves live events while snapshot hydration is in flight", async () => { + const events = createEventSource() + const response = Promise.withResolvers() + const calls = createFetch((url) => { + if (url.pathname === "/api/session/session-1/message") return response.promise + return undefined + }) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + const hydration = sync.session.message.sync("session-1") + emitTwice(events, { + id: "evt_agent_1", + type: "session.next.agent.switched", + properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, + }) + response.resolve(json({ data: [] })) + await hydration + + expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([ + ["msg_agent_1", "agent-switched"], + ]) + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => { + const events = createEventSource() + const response = Promise.withResolvers() + const calls = createFetch((url) => { + if (url.pathname === "/api/session/session-1/message") return response.promise + return undefined + }) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + emitTwice(events, { + id: "evt_promoted_1", + type: "session.next.prompt.promoted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 1, + prompt: { text: "stale" }, + timeCreated: 0, + }, + }) + await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1") + const hydration = sync.session.message.sync("session-1") + emitTwice(events, { + id: "evt_agent_1", + type: "session.next.agent.switched", + properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" }, + }) + await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1") + response.resolve( + json({ + data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }], + }), + ) + await hydration + + expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([ + ["msg_agent_1", "agent-switched"], + ["msg_user_1", "user"], + ]) + expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" }) + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => { + const events = createEventSource() + const response = Promise.withResolvers() + const calls = createFetch((url) => { + if (url.pathname === "/api/session/session-1/message") return response.promise + return undefined + }) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + emitTwice(events, { + id: "evt_step_older", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_older", + timestamp: 0, + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + emitTwice(events, { + id: "evt_step_1", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_old", + timestamp: 1, + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old") + const hydration = sync.session.message.sync("session-1") + emitTwice(events, { + id: "evt_text_1", + type: "session.next.text.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_old", + timestamp: 2, + textID: "text-1", + }, + }) + emitTwice(events, { + id: "evt_text_older", + type: "session.next.text.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_older", + timestamp: 2, + textID: "text-older", + }, + }) + await wait(() => { + const messages = sync.session.message.fromSession("session-1") + return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text") + }) + response.resolve( + json({ + data: [ + { + id: "msg_assistant_new", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + time: { created: 3 }, + }, + { + id: "msg_assistant_old", + type: "assistant", + metadata: { source: "snapshot" }, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + time: { created: 1 }, + }, + ], + }), + ) + await hydration + emitTwice(events, { + id: "evt_step_late_duplicate", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_old", + timestamp: 1, + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + + expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([ + "msg_assistant_new", + "msg_assistant_old", + "msg_assistant_older", + ]) + expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({ + metadata: { source: "snapshot" }, + content: [{ type: "text", id: "text-1", text: "" }], + }) + expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({ + content: [{ type: "text", id: "text-older", text: "" }], + }) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index d1b21b34fb..f4e2bff781 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -584,17 +584,19 @@ describe("session HttpApi", () => { request(`/api/session/${session.id}/prompt`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "hello" } }), + body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" } }), }) const first = yield* recordPrompt() const retried = yield* recordPrompt() - type PromptBody = { id: string; type: string; text: string } + type PromptBody = { id: string; prompt: { text: string }; delivery: string; promotedSeq?: number } const firstBody = yield* json<{ data: PromptBody }>(first) const retriedBody = yield* json<{ data: PromptBody }>(retried) expect(first.status).toBe(200) expect(retried.status).toBe(200) expect(retriedBody).toEqual(firstBody) - expect(firstBody).toMatchObject({ data: { type: "user", text: "hello" } }) + expect(firstBody).toMatchObject({ + data: { id: "msg_http_prompt", prompt: { text: "hello" }, delivery: "steer" }, + }) const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, { headers, @@ -604,27 +606,26 @@ describe("session HttpApi", () => { db .select() .from(SessionInputTable) - .where(eq(SessionInputTable.id, SessionMessage.ID.make("evt_http_prompt"))) + .where(eq(SessionInputTable.id, SessionMessage.ID.make("msg_http_prompt"))) .get() .pipe(Effect.orDie), ) expect(admitted).toMatchObject({ - id: "evt_http_prompt", + id: "msg_http_prompt", session_id: session.id, delivery: "steer", promoted_seq: null, }) - const conflict = yield* request(`/api/session/${session.id}/prompt`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "goodbye" } }), + body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "goodbye" } }), }) expect(conflict.status).toBe(409) expect(yield* responseJson(conflict)).toEqual({ _tag: "ConflictError", - message: "Prompt message ID conflicts with an existing durable record: evt_http_prompt", - resource: "evt_http_prompt", + message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt", + resource: "msg_http_prompt", }) }), { git: true, config: { formatter: false, lsp: false } }, diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 0db044a86a..95b7004e8b 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -106,6 +106,13 @@ describe("sync HttpApi", () => { events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }], }, }, + { + path: SyncPaths.replay, + body: { + directory: tmp.directory, + events: [{ id: "event", aggregateID: "session", seq: 0, type: "session.created", data: {} }], + }, + }, ] for (const item of cases) { diff --git a/packages/opencode/test/storage/workspace-time-migration.test.ts b/packages/opencode/test/storage/workspace-time-migration.test.ts index a33d6d5e5a..063b26a4b1 100644 --- a/packages/opencode/test/storage/workspace-time-migration.test.ts +++ b/packages/opencode/test/storage/workspace-time-migration.test.ts @@ -26,7 +26,7 @@ function migrations() { } describe("workspace time migration", () => { - test("migrates existing workspace rows", () => { + test("discards existing workspace rows during the beta reset", () => { const sqlite = new Database(":memory:") const db = drizzle({ client: sqlite }) const entries = migrations() @@ -45,6 +45,6 @@ describe("workspace time migration", () => { ) expect(() => migrate(db, entries.slice(index))).not.toThrow() - expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 }) + expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toBeNull() }) }) diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index d240324c28..d937950a48 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -7,19 +7,21 @@ import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" +import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolOutput } from "@opencode-ai/core/tool-output" test.skip("step snapshots carry over to assistant messages", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") - const assistantMessageID = EventV2.ID.create() + const assistantMessageID = SessionMessage.ID.create() Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { - id: assistantMessageID, + id: EventV2.ID.create(), type: "session.next.step.started", data: { sessionID, + assistantMessageID, timestamp: DateTime.makeUnsafe(1), agent: "build", model: { @@ -62,6 +64,7 @@ test.skip("step snapshots carry over to assistant messages", () => { test.skip("text ended populates assistant text content", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") + const assistantMessageID = SessionMessage.ID.create() Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { @@ -69,6 +72,7 @@ test.skip("text ended populates assistant text content", () => { type: "session.next.step.started", data: { sessionID, + assistantMessageID, timestamp: DateTime.makeUnsafe(1), agent: "build", model: { @@ -86,6 +90,7 @@ test.skip("text ended populates assistant text content", () => { type: "session.next.text.started", data: { sessionID, + assistantMessageID, timestamp: DateTime.makeUnsafe(2), textID: "text-1", }, @@ -98,6 +103,7 @@ test.skip("text ended populates assistant text content", () => { type: "session.next.text.ended", data: { sessionID, + assistantMessageID, timestamp: DateTime.makeUnsafe(3), textID: "text-1", text: "hello assistant", @@ -114,14 +120,15 @@ test.skip("tool completion stores completed timestamp", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") const callID = "call" - const assistantMessageID = EventV2.ID.create() + const assistantMessageID = SessionMessage.ID.create() Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { - id: assistantMessageID, + id: EventV2.ID.create(), type: "session.next.step.started", data: { sessionID, + assistantMessageID, timestamp: DateTime.makeUnsafe(1), agent: "build", model: { @@ -198,6 +205,7 @@ test.skip("compaction events reduce to compaction message", () => { type: "session.next.compaction.started", data: { sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(1), reason: "auto", }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 81cee54803..f3a9cca90a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -19,6 +19,8 @@ export type Event = | EventSessionNextModelSwitched | EventSessionNextMoved | EventSessionNextPrompted + | EventSessionNextPromptAdmitted + | EventSessionNextPromptPromoted | EventSessionNextSynthetic | EventSessionNextShellStarted | EventSessionNextShellEnded @@ -804,6 +806,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + messageID: string agent: string } } @@ -813,6 +816,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + messageID: string model: { id: string providerID: string @@ -836,16 +840,40 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + messageID: string prompt: Prompt delivery: "steer" | "queue" } } + | { + id: string + type: "session.next.prompt.admitted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } + } + | { + id: string + type: "session.next.prompt.promoted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + timeCreated: number + } + } | { id: string type: "session.next.synthetic" properties: { timestamp: number sessionID: string + messageID: string text: string } } @@ -855,6 +883,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + messageID: string callID: string command: string } @@ -875,6 +904,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string agent: string model: { id: string @@ -921,6 +951,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string textID: string } } @@ -930,6 +961,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string textID: string delta: string } @@ -940,6 +972,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string textID: string text: string } @@ -950,6 +983,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string providerMetadata?: { [key: string]: { @@ -964,6 +998,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string delta: string } @@ -974,6 +1009,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string text: string providerMetadata?: { @@ -1111,6 +1147,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + messageID: string reason: "auto" | "manual" } } @@ -1576,6 +1613,8 @@ export type GlobalEvent = { | SyncEventSessionNextModelSwitched | SyncEventSessionNextMoved | SyncEventSessionNextPrompted + | SyncEventSessionNextPromptAdmitted + | SyncEventSessionNextPromptPromoted | SyncEventSessionNextSynthetic | SyncEventSessionNextShellStarted | SyncEventSessionNextShellEnded @@ -3122,6 +3161,7 @@ export type SyncEventSessionNextAgentSwitched = { data: { timestamp: number sessionID: string + messageID: string agent: string } } @@ -3138,6 +3178,7 @@ export type SyncEventSessionNextModelSwitched = { data: { timestamp: number sessionID: string + messageID: string model: { id: string providerID: string @@ -3175,12 +3216,49 @@ export type SyncEventSessionNextPrompted = { data: { timestamp: number sessionID: string + messageID: string prompt: Prompt delivery: "steer" | "queue" } } } +export type SyncEventSessionNextPromptAdmitted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.prompt.admitted.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } + } +} + +export type SyncEventSessionNextPromptPromoted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.prompt.promoted.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + timeCreated: number + } + } +} + export type SyncEventSessionNextSynthetic = { type: "sync" id: string @@ -3192,6 +3270,7 @@ export type SyncEventSessionNextSynthetic = { data: { timestamp: number sessionID: string + messageID: string text: string } } @@ -3208,6 +3287,7 @@ export type SyncEventSessionNextShellStarted = { data: { timestamp: number sessionID: string + messageID: string callID: string command: string } @@ -3242,6 +3322,7 @@ export type SyncEventSessionNextStepStarted = { data: { timestamp: number sessionID: string + assistantMessageID: string agent: string model: { id: string @@ -3309,6 +3390,7 @@ export type SyncEventSessionNextTextStarted = { data: { timestamp: number sessionID: string + assistantMessageID: string textID: string } } @@ -3325,6 +3407,7 @@ export type SyncEventSessionNextTextEnded = { data: { timestamp: number sessionID: string + assistantMessageID: string textID: string text: string } @@ -3342,6 +3425,7 @@ export type SyncEventSessionNextReasoningStarted = { data: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string providerMetadata?: { [key: string]: { @@ -3363,6 +3447,7 @@ export type SyncEventSessionNextReasoningEnded = { data: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string text: string providerMetadata?: { @@ -3545,6 +3630,7 @@ export type SyncEventSessionNextCompactionStarted = { data: { timestamp: number sessionID: string + messageID: string reason: "auto" | "manual" } } @@ -3670,19 +3756,14 @@ export type SessionV2Info = { subpath?: string } -export type SessionMessageUser = { +export type SessionInputAdmitted = { + admittedSeq: number id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - text: string - files?: Array - agents?: Array - references?: Array - type: "user" + sessionID: string + prompt: Prompt + delivery: "steer" | "queue" + timeCreated: number + promotedSeq?: number } export type SessionMessageAgentSwitched = { @@ -3713,6 +3794,21 @@ export type SessionMessageModelSwitched = { } } +export type SessionMessageUser = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + text: string + files?: Array + agents?: Array + references?: Array + type: "user" +} + export type SessionMessageSynthetic = { id: string metadata?: { @@ -4176,6 +4272,7 @@ export type EventSessionNextAgentSwitched = { properties: { timestamp: number sessionID: string + messageID: string agent: string } } @@ -4186,6 +4283,7 @@ export type EventSessionNextModelSwitched = { properties: { timestamp: number sessionID: string + messageID: string model: { id: string providerID: string @@ -4211,17 +4309,43 @@ export type EventSessionNextPrompted = { properties: { timestamp: number sessionID: string + messageID: string prompt: Prompt delivery: "steer" | "queue" } } +export type EventSessionNextPromptAdmitted = { + id: string + type: "session.next.prompt.admitted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type EventSessionNextPromptPromoted = { + id: string + type: "session.next.prompt.promoted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + timeCreated: number + } +} + export type EventSessionNextSynthetic = { id: string type: "session.next.synthetic" properties: { timestamp: number sessionID: string + messageID: string text: string } } @@ -4232,6 +4356,7 @@ export type EventSessionNextShellStarted = { properties: { timestamp: number sessionID: string + messageID: string callID: string command: string } @@ -4254,6 +4379,7 @@ export type EventSessionNextStepStarted = { properties: { timestamp: number sessionID: string + assistantMessageID: string agent: string model: { id: string @@ -4303,6 +4429,7 @@ export type EventSessionNextTextStarted = { properties: { timestamp: number sessionID: string + assistantMessageID: string textID: string } } @@ -4313,6 +4440,7 @@ export type EventSessionNextTextDelta = { properties: { timestamp: number sessionID: string + assistantMessageID: string textID: string delta: string } @@ -4324,6 +4452,7 @@ export type EventSessionNextTextEnded = { properties: { timestamp: number sessionID: string + assistantMessageID: string textID: string text: string } @@ -4335,6 +4464,7 @@ export type EventSessionNextReasoningStarted = { properties: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string providerMetadata?: { [key: string]: { @@ -4350,6 +4480,7 @@ export type EventSessionNextReasoningDelta = { properties: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string delta: string } @@ -4361,6 +4492,7 @@ export type EventSessionNextReasoningEnded = { properties: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string text: string providerMetadata?: { @@ -4507,6 +4639,7 @@ export type EventSessionNextCompactionStarted = { properties: { timestamp: number sessionID: string + messageID: string reason: "auto" | "manual" } } @@ -9263,7 +9396,7 @@ export type V2SessionPromptResponses = { * Success */ 200: { - data: SessionMessageUser + data: SessionInputAdmitted } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 614a95fad3..6c9affdbb7 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10122,7 +10122,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionMessageUser" + "$ref": "#/components/schemas/SessionInputAdmitted" } }, "required": ["data"], @@ -10181,7 +10181,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "prompt": { "$ref": "#/components/schemas/Prompt" @@ -12017,6 +12018,12 @@ { "$ref": "#/components/schemas/EventSessionNextPrompted" }, + { + "$ref": "#/components/schemas/EventSessionNextPromptAdmitted" + }, + { + "$ref": "#/components/schemas/EventSessionNextPromptPromoted" + }, { "$ref": "#/components/schemas/EventSessionNextSynthetic" }, @@ -14383,11 +14390,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "agent": { "type": "string" } }, - "required": ["timestamp", "sessionID", "agent"], + "required": ["timestamp", "sessionID", "messageID", "agent"], "additionalProperties": false } }, @@ -14414,6 +14425,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "model": { "type": "object", "properties": { @@ -14431,7 +14446,7 @@ "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "model"], + "required": ["timestamp", "sessionID", "messageID", "model"], "additionalProperties": false } }, @@ -14492,6 +14507,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "prompt": { "$ref": "#/components/schemas/Prompt" }, @@ -14500,7 +14519,84 @@ "enum": ["steer", "queue"] } }, - "required": ["timestamp", "sessionID", "prompt", "delivery"], + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.promoted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "timeCreated": { + "type": "number" + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], "additionalProperties": false } }, @@ -14527,11 +14623,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "text": { "type": "string" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["timestamp", "sessionID", "messageID", "text"], "additionalProperties": false } }, @@ -14558,6 +14658,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "callID": { "type": "string" }, @@ -14565,7 +14669,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "command"], + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], "additionalProperties": false } }, @@ -14626,6 +14730,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "agent": { "type": "string" }, @@ -14649,7 +14757,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "agent", "model"], + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], "additionalProperties": false } }, @@ -14677,7 +14785,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "finish": { "type": "string" @@ -14746,7 +14855,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -14779,11 +14889,15 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" } }, - "required": ["timestamp", "sessionID", "textID"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], "additionalProperties": false } }, @@ -14810,6 +14924,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" }, @@ -14817,7 +14935,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "textID", "delta"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "delta"], "additionalProperties": false } }, @@ -14844,6 +14962,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" }, @@ -14851,7 +14973,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "textID", "text"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], "additionalProperties": false } }, @@ -14878,6 +15000,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -14888,7 +15014,7 @@ } } }, - "required": ["timestamp", "sessionID", "reasoningID"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], "additionalProperties": false } }, @@ -14915,6 +15041,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -14922,7 +15052,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "delta"], "additionalProperties": false } }, @@ -14949,6 +15079,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -14962,7 +15096,7 @@ } } }, - "required": ["timestamp", "sessionID", "reasoningID", "text"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], "additionalProperties": false } }, @@ -14990,7 +15124,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15027,7 +15162,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15064,7 +15200,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15101,7 +15238,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15157,7 +15295,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15207,7 +15346,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15282,7 +15422,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15369,12 +15510,16 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "reason": { "type": "string", "enum": ["auto", "manual"] } }, - "required": ["timestamp", "sessionID", "reason"], + "required": ["timestamp", "sessionID", "messageID", "reason"], "additionalProperties": false } }, @@ -16904,6 +17049,12 @@ { "$ref": "#/components/schemas/SyncEventSessionNextPrompted" }, + { + "$ref": "#/components/schemas/SyncEventSessionNextPromptAdmitted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted" + }, { "$ref": "#/components/schemas/SyncEventSessionNextSynthetic" }, @@ -21285,11 +21436,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "agent": { "type": "string" } }, - "required": ["timestamp", "sessionID", "agent"], + "required": ["timestamp", "sessionID", "messageID", "agent"], "additionalProperties": false } }, @@ -21336,6 +21491,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "model": { "type": "object", "properties": { @@ -21353,7 +21512,7 @@ "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "model"], + "required": ["timestamp", "sessionID", "messageID", "model"], "additionalProperties": false } }, @@ -21454,6 +21613,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "prompt": { "$ref": "#/components/schemas/Prompt" }, @@ -21462,7 +21625,124 @@ "enum": ["steer", "queue"] } }, - "required": ["timestamp", "sessionID", "prompt", "delivery"], + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["type", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + } + }, + "required": ["type", "id", "syncEvent"], + "additionalProperties": false + }, + "SyncEventSessionNextPromptAdmitted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "id": { + "type": "string" + }, + "syncEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["type", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + } + }, + "required": ["type", "id", "syncEvent"], + "additionalProperties": false + }, + "SyncEventSessionNextPromptPromoted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "id": { + "type": "string" + }, + "syncEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["session.next.prompt.promoted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "timeCreated": { + "type": "number" + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], "additionalProperties": false } }, @@ -21509,11 +21789,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "text": { "type": "string" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["timestamp", "sessionID", "messageID", "text"], "additionalProperties": false } }, @@ -21560,6 +21844,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "callID": { "type": "string" }, @@ -21567,7 +21855,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "command"], + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], "additionalProperties": false } }, @@ -21668,6 +21956,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "agent": { "type": "string" }, @@ -21691,7 +21983,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "agent", "model"], + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], "additionalProperties": false } }, @@ -21739,7 +22031,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "finish": { "type": "string" @@ -21828,7 +22121,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -21881,11 +22175,15 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" } }, - "required": ["timestamp", "sessionID", "textID"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], "additionalProperties": false } }, @@ -21932,6 +22230,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" }, @@ -21939,7 +22241,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "textID", "text"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], "additionalProperties": false } }, @@ -21986,6 +22288,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -21996,7 +22302,7 @@ } } }, - "required": ["timestamp", "sessionID", "reasoningID"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], "additionalProperties": false } }, @@ -22043,6 +22349,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -22056,7 +22366,7 @@ } } }, - "required": ["timestamp", "sessionID", "reasoningID", "text"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], "additionalProperties": false } }, @@ -22104,7 +22414,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -22161,7 +22472,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -22218,7 +22530,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -22294,7 +22607,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -22364,7 +22678,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -22459,7 +22774,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -22586,12 +22902,16 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "reason": { "type": "string", "enum": ["auto", "manual"] } }, - "required": ["timestamp", "sessionID", "reason"], + "required": ["timestamp", "sessionID", "messageID", "reason"], "additionalProperties": false } }, @@ -22965,11 +23285,120 @@ "required": ["id", "projectID", "cost", "tokens", "time", "title", "location"], "additionalProperties": false }, + "SessionInputAdmitted": { + "type": "object", + "properties": { + "admittedSeq": { + "type": "integer", + "minimum": 0 + }, + "id": { + "type": "string", + "pattern": "^msg_" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + }, + "timeCreated": { + "type": "number" + }, + "promotedSeq": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["admittedSeq", "id", "sessionID", "prompt", "delivery", "timeCreated"], + "additionalProperties": false + }, + "SessionMessageAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["agent-switched"] + }, + "agent": { + "type": "string" + } + }, + "required": ["id", "time", "type", "agent"], + "additionalProperties": false + }, + "SessionMessageModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["model-switched"] + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + } + }, + "required": ["id", "time", "type", "model"], + "additionalProperties": false + }, "SessionMessageUser": { "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "metadata": { "type": "object" @@ -23013,84 +23442,12 @@ "required": ["id", "time", "text", "type"], "additionalProperties": false }, - "SessionMessageAgentSwitched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "number" - } - }, - "required": ["created"], - "additionalProperties": false - }, - "type": { - "type": "string", - "enum": ["agent-switched"] - }, - "agent": { - "type": "string" - } - }, - "required": ["id", "time", "type", "agent"], - "additionalProperties": false - }, - "SessionMessageModelSwitched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "number" - } - }, - "required": ["created"], - "additionalProperties": false - }, - "type": { - "type": "string", - "enum": ["model-switched"] - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - } - }, - "required": ["id", "time", "type", "model"], - "additionalProperties": false - }, "SessionMessageSynthetic": { "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "metadata": { "type": "object" @@ -23124,7 +23481,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "metadata": { "type": "object" @@ -23392,7 +23750,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "metadata": { "type": "object" @@ -23521,7 +23880,8 @@ "type": "string" }, "id": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "metadata": { "type": "object" @@ -24428,11 +24788,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "agent": { "type": "string" } }, - "required": ["timestamp", "sessionID", "agent"], + "required": ["timestamp", "sessionID", "messageID", "agent"], "additionalProperties": false } }, @@ -24459,6 +24823,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "model": { "type": "object", "properties": { @@ -24476,7 +24844,7 @@ "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "model"], + "required": ["timestamp", "sessionID", "messageID", "model"], "additionalProperties": false } }, @@ -24537,6 +24905,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "prompt": { "$ref": "#/components/schemas/Prompt" }, @@ -24545,7 +24917,84 @@ "enum": ["steer", "queue"] } }, - "required": ["timestamp", "sessionID", "prompt", "delivery"], + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextPromptAdmitted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextPromptPromoted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.promoted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "timeCreated": { + "type": "number" + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], "additionalProperties": false } }, @@ -24572,11 +25021,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "text": { "type": "string" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["timestamp", "sessionID", "messageID", "text"], "additionalProperties": false } }, @@ -24603,6 +25056,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "callID": { "type": "string" }, @@ -24610,7 +25067,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "command"], + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], "additionalProperties": false } }, @@ -24671,6 +25128,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "agent": { "type": "string" }, @@ -24694,7 +25155,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "agent", "model"], + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], "additionalProperties": false } }, @@ -24722,7 +25183,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "finish": { "type": "string" @@ -24791,7 +25253,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -24824,11 +25287,15 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" } }, - "required": ["timestamp", "sessionID", "textID"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], "additionalProperties": false } }, @@ -24855,6 +25322,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" }, @@ -24862,7 +25333,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "textID", "delta"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "delta"], "additionalProperties": false } }, @@ -24889,6 +25360,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" }, @@ -24896,7 +25371,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "textID", "text"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], "additionalProperties": false } }, @@ -24923,6 +25398,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -24933,7 +25412,7 @@ } } }, - "required": ["timestamp", "sessionID", "reasoningID"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], "additionalProperties": false } }, @@ -24960,6 +25439,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -24967,7 +25450,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "delta"], "additionalProperties": false } }, @@ -24994,6 +25477,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -25007,7 +25494,7 @@ } } }, - "required": ["timestamp", "sessionID", "reasoningID", "text"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], "additionalProperties": false } }, @@ -25035,7 +25522,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25072,7 +25560,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25109,7 +25598,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25146,7 +25636,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25202,7 +25693,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25252,7 +25744,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25319,7 +25812,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25406,12 +25900,16 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "reason": { "type": "string", "enum": ["auto", "manual"] } }, - "required": ["timestamp", "sessionID", "reason"], + "required": ["timestamp", "sessionID", "messageID", "reason"], "additionalProperties": false } }, diff --git a/packages/server/src/groups/v2/session.ts b/packages/server/src/groups/v2/session.ts index a830e4bf3c..83ae83750a 100644 --- a/packages/server/src/groups/v2/session.ts +++ b/packages/server/src/groups/v2/session.ts @@ -114,7 +114,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") delivery: SessionInput.Delivery.pipe(Schema.optional), resume: Schema.Boolean.pipe(Schema.optional), }), - success: Schema.Struct({ data: SessionMessage.User }), + success: Schema.Struct({ data: SessionInput.Admitted }), error: [ConflictError, SessionNotFoundError], }).annotateMerge( OpenApi.annotations({ diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 0daa32a290..33a619b222 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -4,6 +4,28 @@ Record V2 database, durable-event, projected-message, HTTP, and generated SDK sc This document covers meaningful contract changes introduced on the `feat/opencode-embedded-api` branch since its divergence from `origin/dev`. Mechanical file moves and internal refactors are omitted unless they changed stored data, replay behavior, public HTTP or SDK shapes, or model-facing tool contracts. +## 2026-06-04 Event-Sourced Session Input Cutover + +Affected schema: + +- `session_input`, `session_message`, `event`, `event_sequence`, and disposable workspace beta storage. +- New synchronized `session.next.prompt.admitted.1` and `session.next.prompt.promoted.1` events. +- Experimental `SessionV2.prompt(...)`, HTTP, and generated SDK admission receipt. + +Change: + +- Replace inbox-local admission sequence with event-sourced prompt admission and promotion sequences. +- Give projected Session messages stable `msg_*` resource IDs distinct from `evt_*` creator event IDs. +- Give every event that creates a projected transcript resource an explicit `msg_*` resource ID. Assistant steps propagate one `assistantMessageID` through assistant-owned events. +- Reset incompatible unreleased beta event history, derived Session projections, workspace rows, and Session workspace links. + +Compatibility: + +- The reset preserves canonical V1 `session`, `message`, and `part` rows. +- Existing synchronized workspaces are disposable beta state and are removed by the reset. +- Before starting the new build, discard adapter-managed external workspace resources created by unreleased builds. The SQL migration cannot remove external resources through runtime adapters, and rediscovering retained resources after startup can replay incompatible beta history. +- Exact prompt retries reconcile one stable `msg_*` identity when Session, prompt, and delivery mode match. + ## Earlier Branch History ### Replayable Session Event Refinement And Cursor Stream @@ -60,7 +82,7 @@ Affected schema: Change: -- Bind step settlement to an explicit `assistantMessageID`. +- Bind step settlement to an explicit assistant message ID. Reason: @@ -409,7 +431,7 @@ Affected schema: Change: - Preserve stable IDs on projected assistant text parts. -- Route durable tool projection updates through explicit owning `assistantMessageID` values rather than provider-local call IDs alone. +- Route durable tool projection updates through explicit owning assistant message IDs rather than provider-local call IDs alone. - Replay full-value text and tool-input end checkpoints while keeping fragment deltas ephemeral. Reason: diff --git a/specs/v2/session.md b/specs/v2/session.md index 20cd4393ab..56384e78bd 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -13,14 +13,14 @@ sessions.create({ id?, location, ... }) sessions.prompt({ id?, sessionID, prompt, delivery?, resume? }) -> omitted ID generates one internal message ID -> supplied ID admits one durable Session input when absent - -> exact reuse returns the same admitted user-shaped message + -> exact reuse returns the same admitted lifecycle receipt -> reusing one message ID for another Session, prompt, or delivery mode fails -> exact retry schedules another wake unless resume is false -> resume omitted or true schedules execution after admission -> resume false admits only ``` -`session_input` is the durable admission inbox. Admitted inputs remain outside model-visible Session history until the serialized runner promotes them by publishing ordinary `Prompted` events. The existing projector atomically writes the visible user message and marks its inbox row promoted in the same event transaction. +`session_input` is the durable admission inbox. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `PromptLifecycle.Promoted`. The projector atomically writes the visible user message and marks its inbox row promoted in the same event transaction. The legacy V1-to-V2 shadow bridge continues publishing ordinary `Prompted` events for already-visible V1 prompts. Execution routing starts from only the Session ID: @@ -33,7 +33,7 @@ SessionExecution.resume(sessionID) `SessionExecution` and the read-side `SessionStore` are process-global. `SessionRunner`, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. -The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, reloads projected history once before continuation, and fails after 25 provider turns within one local drain activity only when work remains. Tool settlement events carry the owning assistant-message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. +The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, reloads projected history once before continuation, and fails after 25 provider turns within one local drain activity only when work remains. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted. From ab5a12d916dd72eab0c84afb1f6de5a07c16a7e4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 4 Jun 2026 23:26:09 +0000 Subject: [PATCH 005/626] chore: generate --- packages/sdk/openapi.json | 714 +++++++++++++++++++++++++------------- 1 file changed, 476 insertions(+), 238 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6c9affdbb7..0a3d5fac38 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8233,7 +8233,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "aggregateID": { "type": "string" @@ -8388,7 +8389,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "aggregate_id": { "type": "string" @@ -14103,7 +14105,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14121,7 +14124,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14145,7 +14149,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14169,7 +14174,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14197,7 +14203,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14225,7 +14232,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14253,7 +14261,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14281,7 +14290,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14310,7 +14320,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14341,7 +14352,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14374,7 +14386,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14409,7 +14422,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14457,7 +14471,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14491,7 +14506,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14530,7 +14546,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14569,7 +14586,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14607,7 +14625,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14642,7 +14661,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14680,7 +14700,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14714,7 +14735,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14768,7 +14790,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14838,7 +14861,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14873,7 +14897,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14908,7 +14933,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14946,7 +14972,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -14984,7 +15011,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15025,7 +15053,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15063,7 +15092,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15107,7 +15137,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15145,7 +15176,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15183,7 +15215,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15221,7 +15254,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15278,7 +15312,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15329,7 +15364,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15405,7 +15441,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15460,7 +15497,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15494,7 +15532,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15530,7 +15569,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15561,7 +15601,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15595,7 +15636,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15634,7 +15676,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15665,7 +15708,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15714,7 +15758,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15738,7 +15783,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15762,7 +15808,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15786,7 +15833,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15810,7 +15858,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15834,7 +15883,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15864,7 +15914,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15914,7 +15965,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15946,7 +15998,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15974,7 +16027,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -15998,7 +16052,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16022,7 +16077,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16051,7 +16107,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16076,7 +16133,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16115,7 +16173,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16150,7 +16209,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16179,7 +16239,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16210,7 +16271,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16228,7 +16290,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16288,7 +16351,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16321,7 +16385,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16345,7 +16410,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16394,7 +16460,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16429,7 +16496,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16455,7 +16523,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16479,7 +16548,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16506,7 +16576,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16541,7 +16612,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16565,7 +16637,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16649,7 +16722,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16688,7 +16762,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16723,7 +16798,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16752,7 +16828,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16780,7 +16857,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16805,7 +16883,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16830,7 +16909,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16853,7 +16933,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16880,7 +16961,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16904,7 +16986,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16928,7 +17011,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16952,7 +17036,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16981,7 +17066,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -16999,7 +17085,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -20278,7 +20365,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -20302,7 +20390,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -20351,7 +20440,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -20386,7 +20476,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -21063,7 +21154,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21073,7 +21165,8 @@ "enum": ["session.created.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21111,7 +21204,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21121,7 +21215,8 @@ "enum": ["session.updated.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21159,7 +21254,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21169,7 +21265,8 @@ "enum": ["session.deleted.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21207,7 +21304,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21217,7 +21315,8 @@ "enum": ["message.updated.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21255,7 +21354,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21265,7 +21365,8 @@ "enum": ["message.removed.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21304,7 +21405,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21314,7 +21416,8 @@ "enum": ["message.part.updated.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21355,7 +21458,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21365,7 +21469,8 @@ "enum": ["message.part.removed.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21408,7 +21513,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21418,7 +21524,8 @@ "enum": ["session.next.agent.switched.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21463,7 +21570,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21473,7 +21581,8 @@ "enum": ["session.next.model.switched.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21531,7 +21640,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21541,7 +21651,8 @@ "enum": ["session.next.moved.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21585,7 +21696,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21595,7 +21707,8 @@ "enum": ["session.next.prompted.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21644,7 +21757,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21654,7 +21768,8 @@ "enum": ["session.next.prompt.admitted.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21703,7 +21818,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21713,7 +21829,8 @@ "enum": ["session.next.prompt.promoted.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21761,7 +21878,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21771,7 +21889,8 @@ "enum": ["session.next.synthetic.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21816,7 +21935,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21826,7 +21946,8 @@ "enum": ["session.next.shell.started.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21874,7 +21995,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21884,7 +22006,8 @@ "enum": ["session.next.shell.ended.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -21928,7 +22051,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -21938,7 +22062,8 @@ "enum": ["session.next.step.started.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22002,7 +22127,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22012,7 +22138,8 @@ "enum": ["session.next.step.ended.2"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22092,7 +22219,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22102,7 +22230,8 @@ "enum": ["session.next.step.failed.2"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22147,7 +22276,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22157,7 +22287,8 @@ "enum": ["session.next.text.started.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22202,7 +22333,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22212,7 +22344,8 @@ "enum": ["session.next.text.ended.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22260,7 +22393,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22270,7 +22404,8 @@ "enum": ["session.next.reasoning.started.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22321,7 +22456,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22331,7 +22467,8 @@ "enum": ["session.next.reasoning.ended.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22385,7 +22522,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22395,7 +22533,8 @@ "enum": ["session.next.tool.input.started.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22443,7 +22582,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22453,7 +22593,8 @@ "enum": ["session.next.tool.input.ended.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22501,7 +22642,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22511,7 +22653,8 @@ "enum": ["session.next.tool.called.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22578,7 +22721,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22588,7 +22732,8 @@ "enum": ["session.next.tool.progress.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22649,7 +22794,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22659,7 +22805,8 @@ "enum": ["session.next.tool.success.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22745,7 +22892,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22755,7 +22903,8 @@ "enum": ["session.next.tool.failed.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22820,7 +22969,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22830,7 +22980,8 @@ "enum": ["session.next.retried.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22874,7 +23025,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22884,7 +23036,8 @@ "enum": ["session.next.compaction.started.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22930,7 +23083,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22940,7 +23094,8 @@ "enum": ["session.next.compaction.delta.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -22981,7 +23136,8 @@ "enum": ["sync"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "syncEvent": { "type": "object", @@ -22991,7 +23147,8 @@ "enum": ["session.next.compaction.ended.1"] }, "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "seq": { "type": "number" @@ -24267,7 +24424,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24285,7 +24443,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24543,7 +24702,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24567,7 +24727,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24595,7 +24756,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24623,7 +24785,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24651,7 +24814,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24679,7 +24843,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24708,7 +24873,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24739,7 +24905,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24772,7 +24939,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24807,7 +24975,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24855,7 +25024,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24889,7 +25059,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24928,7 +25099,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -24967,7 +25139,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25005,7 +25178,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25040,7 +25214,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25078,7 +25253,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25112,7 +25288,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25166,7 +25343,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25236,7 +25414,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25271,7 +25450,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25306,7 +25486,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25344,7 +25525,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25382,7 +25564,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25423,7 +25606,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25461,7 +25645,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25505,7 +25690,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25543,7 +25729,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25581,7 +25768,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25619,7 +25807,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25676,7 +25865,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25727,7 +25917,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25795,7 +25986,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25850,7 +26042,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25884,7 +26077,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25920,7 +26114,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25951,7 +26146,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -25985,7 +26181,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26024,7 +26221,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26055,7 +26253,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26104,7 +26303,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26128,7 +26328,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26152,7 +26353,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26176,7 +26378,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26200,7 +26403,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26224,7 +26428,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26254,7 +26459,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26304,7 +26510,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26336,7 +26543,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26364,7 +26572,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26388,7 +26597,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26412,7 +26622,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26441,7 +26652,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26466,7 +26678,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26505,7 +26718,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26540,7 +26754,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26569,7 +26784,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26600,7 +26816,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26618,7 +26835,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26678,7 +26896,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26711,7 +26930,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26735,7 +26955,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26762,7 +26983,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26797,7 +27019,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26821,7 +27044,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26905,7 +27129,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26944,7 +27169,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -26979,7 +27205,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -27008,7 +27235,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -27036,7 +27264,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -27061,7 +27290,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -27086,7 +27316,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -27109,7 +27340,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -27136,7 +27368,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -27160,7 +27393,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -27184,7 +27418,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -27208,7 +27443,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -27237,7 +27473,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", @@ -27255,7 +27492,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^evt_" }, "type": { "type": "string", From f011d77128cec05664fbf451165728b151e29e5a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 19:56:31 -0400 Subject: [PATCH 006/626] fix(llm): normalize OpenAI function tool schemas --- packages/llm/src/protocols/openai-chat.ts | 2 +- .../llm/src/protocols/openai-responses.ts | 2 +- packages/llm/src/protocols/shared.ts | 37 ++++++++++++- .../test/provider/openai-responses.test.ts | 52 +++++++++++++++++++ 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index b9bf1fc7a3..c0769bf1f6 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -165,7 +165,7 @@ const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({ function: { name: tool.name, description: tool.description, - parameters: tool.inputSchema, + parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema), }, }) diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 2f354427fe..06d07f8ccb 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -255,7 +255,7 @@ const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({ type: "function", name: tool.name, description: tool.description, - parameters: tool.inputSchema, + parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema), }) const lowerToolChoice = (toolChoice: NonNullable) => diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index ca325ff823..46e5509250 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer" -import { Effect, Schema, Stream } from "effect" +import { Effect, JsonSchema, Schema, Stream } from "effect" import * as Sse from "effect/unstable/encoding/Sse" import { Headers, HttpClientRequest } from "effect/unstable/http" import { @@ -12,7 +12,8 @@ import { type TextPart, type ToolResultPart, } from "../schema" -export { isRecord } from "../utils/record" +import { isRecord } from "../utils/record" +export { isRecord } export const Json = Schema.fromJsonString(Schema.Unknown) export const decodeJson = Schema.decodeUnknownSync(Json) @@ -21,6 +22,38 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown) export const optionalArray = (schema: S) => Schema.optional(Schema.Array(schema)) export const optionalNull = (schema: S) => Schema.optional(Schema.NullOr(schema)) +/** OpenAI function schemas require one flat object at the top level. */ +export const openAiToolInputSchema = (schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema => { + const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : [] + const flattened = variants.length === 0 + ? { ...schema, type: "object" } + : { + ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")), + type: "object", + properties: variants.reduce( + (properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }), + {}, + ), + additionalProperties: false, + } + const normalized = removeNullSchemas(flattened) + return isRecord(normalized) ? normalized : { type: "object" } +} + +const removeNullSchemas = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(removeNullSchemas) + if (!isRecord(value)) return value + const fields = Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "anyOf") + .map(([key, field]) => [key, removeNullSchemas(field)]), + ) + if (!Array.isArray(value.anyOf)) return fields + const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas) + if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] } + return { ...fields, anyOf: variants } +} + /** * Streaming tool-call accumulator. Adapters that build a tool call across * multiple `tool-input-delta` chunks store the partial JSON input string here diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 72de93ed11..aee6454fd6 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -57,6 +57,58 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("flattens top-level object unions in function schemas", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + tools: [ + { + name: "read", + description: "Read a path or resource.", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + path: { type: "string" }, + reference: { anyOf: [{ type: "string" }, { type: "null" }] }, + limit: { type: "integer", maximum: 2000 }, + }, + required: ["path"], + }, + { + type: "object", + properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } }, + required: ["resource"], + }, + ], + }, + }, + ], + }), + ) + + expect(prepared.body.tools).toEqual([ + { + type: "function", + name: "read", + description: "Read a path or resource.", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + reference: { type: "string" }, + limit: { type: "integer", maximum: 2000 }, + resource: { type: "string" }, + }, + additionalProperties: false, + }, + }, + ]) + }), + ) + it.effect("lowers chronological system updates to escaped user wrappers in order", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( From 134f4136da372fdf8f0c3cec2cea3ff81010baf8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 4 Jun 2026 23:58:11 +0000 Subject: [PATCH 007/626] chore: generate --- packages/llm/src/protocols/shared.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 46e5509250..1bcd8d4dcb 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -25,17 +25,18 @@ export const optionalNull = (schema: S) => Schema.op /** OpenAI function schemas require one flat object at the top level. */ export const openAiToolInputSchema = (schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema => { const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : [] - const flattened = variants.length === 0 - ? { ...schema, type: "object" } - : { - ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")), - type: "object", - properties: variants.reduce( - (properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }), - {}, - ), - additionalProperties: false, - } + const flattened = + variants.length === 0 + ? { ...schema, type: "object" } + : { + ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")), + type: "object", + properties: variants.reduce( + (properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }), + {}, + ), + additionalProperties: false, + } const normalized = removeNullSchemas(flattened) return isRecord(normalized) ? normalized : { type: "object" } } From 47f3d8b7093c26c23b3b199c602494eb76ca3d25 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:32:49 -0500 Subject: [PATCH 008/626] feat(stats): refresh stats routes and homepage (#30419) --- .../stats/app/src/routes/[lab]/[model].tsx | 826 ++++++++++++++++++ packages/stats/app/src/routes/[lab]/index.tsx | 427 +++++++++ packages/stats/app/src/routes/index.css | 782 ++++++++++++++++- packages/stats/app/src/routes/index.tsx | 492 +---------- .../stats/app/src/routes/model-catalog.ts | 224 +++++ packages/stats/app/src/routes/stats-shell.tsx | 493 +++++++++++ packages/stats/core/src/domain/geo.ts | 26 +- packages/stats/core/src/domain/home.ts | 343 +++++++- 8 files changed, 3125 insertions(+), 488 deletions(-) create mode 100644 packages/stats/app/src/routes/[lab]/[model].tsx create mode 100644 packages/stats/app/src/routes/[lab]/index.tsx create mode 100644 packages/stats/app/src/routes/model-catalog.ts create mode 100644 packages/stats/app/src/routes/stats-shell.tsx diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx new file mode 100644 index 0000000000..c7c928bb42 --- /dev/null +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -0,0 +1,826 @@ +import "../index.css" +import { Link, Meta, Title } from "@solidjs/meta" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { geoEquirectangular, geoPath } from "d3-geo" +import { scaleSqrt } from "d3-scale" +import countryCodesSource from "i18n-iso-countries/codes.json?raw" +import { feature, mesh } from "topojson-client" +import countriesTopologySource from "world-atlas/countries-110m.json?raw" +import { + getStatsModelData, + type CountryEntry, + type ModelPeerEntry, + type ModelUsagePoint, + type StatsModelData, + type UsageRange, +} from "@opencode-ai/stats-core/domain/home" +import { runtime } from "@opencode-ai/stats-core/runtime" +import { createAsync, query, useParams } from "@solidjs/router" +import { createMemo, createSignal, For, onMount, Show, type JSX } from "solid-js" +import { getRequestEvent } from "solid-js/web" +import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson" +import type { GeometryCollection, Topology } from "topojson-specification" +import { findModelCatalogEntry, formatCatalogLabName, getModelCatalog, type ModelCatalogEntry } from "../model-catalog" +import { + applyThemePreference, + Footer, + getGitHubStars, + Header, + isThemePreference, + themeStorageKey, + type HeaderLink, + type ThemePreference, +} from "../stats-shell" + +const statsModelFallbackUrl = "https://stats.opencode.ai" +const modelHeaderLinks: readonly HeaderLink[] = [ + { href: "#overview", label: "Overview" }, + { href: "#usage", label: "Usage" }, + { href: "#efficiency", label: "Efficiency" }, + { href: "#geo-breakdown", label: "Geo Breakdown" }, + { href: "#peers", label: "Peers" }, +] +const modelFooterLinks: readonly HeaderLink[] = [ + { href: import.meta.env.BASE_URL, label: "Stats Home" }, + { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" }, + { href: `${import.meta.env.BASE_URL}#leaderboard`, label: "Leaderboard" }, + { href: `${import.meta.env.BASE_URL}#session-cost`, label: "Session Cost" }, + { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" }, + { href: `${import.meta.env.BASE_URL}#market-share`, label: "Market Share" }, + { href: `${import.meta.env.BASE_URL}#geo-breakdown`, label: "Geo Breakdown" }, +] +const geoMapWidth = 960 +const geoMapHeight = 430 +const countryDisplayNames = new Intl.DisplayNames(["en"], { type: "region" }) + +type IsoCountryCode = readonly [string, string, string] +type WorldCountryProperties = GeoJsonProperties & { name?: string } +type WorldTopology = Topology<{ countries: GeometryCollection }> + +const countryNumericIds = new Map( + (JSON.parse(countryCodesSource) as IsoCountryCode[]).map((country) => [country[0], country[2]] as const), +) +const worldTopology = JSON.parse(countriesTopologySource) as WorldTopology +const worldCountryGeometries: GeometryCollection = { + ...worldTopology.objects.countries, + geometries: worldTopology.objects.countries.geometries.filter((country) => String(country.id ?? "") !== "010"), +} +const worldCountries = feature(worldTopology, worldCountryGeometries) as FeatureCollection< + GeometryObject, + WorldCountryProperties +> +const worldProjection = geoEquirectangular().fitExtent( + [ + [10, 12], + [geoMapWidth - 10, geoMapHeight - 12], + ], + worldCountries, +) +const worldPath = geoPath(worldProjection) +const worldCountryPaths = worldCountries.features.map((country) => ({ + id: String(country.id ?? "").padStart(3, "0"), + path: worldPath(country) ?? "", +})) +const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? "" + +const getModelData = query(async (lab: string, model: string) => { + "use server" + return runtime.runPromise(getStatsModelData(model, lab)) +}, "getStatsModelData") + +export default function StatsModel() { + const event = getRequestEvent() + event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400") + const params = useParams() + const labParam = createMemo(() => params.lab ?? "") + const modelParam = createMemo(() => params.model ?? "") + const catalog = createAsync(() => getModelCatalog()) + const catalogEntry = createMemo(() => { + const data = catalog() + if (!data) return undefined + return findModelCatalogEntry(data, modelParam(), labParam()) ?? null + }) + const stats = createAsync(() => { + const entry = catalogEntry() + if (catalog() === undefined || entry === undefined) return Promise.resolve(undefined) + if (!entry && (!labParam() || !modelParam())) return Promise.resolve(null) + return getModelData(labParam(), entry?.slug ?? modelParam()) + }) + const githubStars = createAsync(() => getGitHubStars()) + const [themePreference, setThemePreference] = createSignal("system") + const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? "Model") + const labName = createMemo(() => formatCatalogLabName(catalogEntry()?.lab ?? stats()?.provider ?? labParam())) + const modelTitle = createMemo(() => `${modelName()} Stats`) + const modelDescription = createMemo(() => + stats() + ? `${modelName()} usage, rank, token mix, cost, geo breakdown, and peer stats across OpenCode.` + : `${modelName()} model facts, limits, and OpenCode usage availability.`, + ) + const modelUrl = createMemo(() => + new URL( + `${import.meta.env.BASE_URL}${catalogEntry()?.id ?? `${labParam()}/${stats()?.slug ?? modelParam()}`}`, + event?.request.url ?? (typeof window === "undefined" ? statsModelFallbackUrl : window.location.href), + ).toString(), + ) + const updateThemePreference = (preference: ThemePreference) => { + applyThemePreference(preference) + setThemePreference(preference) + if (typeof window === "undefined") return + window.localStorage.setItem(themeStorageKey, preference) + } + + onMount(() => { + if (typeof window === "undefined") return + const preference = window.localStorage.getItem(themeStorageKey) + const nextPreference = isThemePreference(preference) ? preference : "system" + applyThemePreference(nextPreference) + setThemePreference(nextPreference) + }) + + return ( +
+ {modelTitle()} + + + + + + + + + + +
+
+
+ }> + }> + <> + + + + + + + + + +
+
+
+
+ ) +} + +function ModelLoading() { + return ( + <> +
+
+
+ + Stats + +

Model Stats

+

Reading model aggregates from model_stat.

+
+
+
+
+ +
+ + ) +} + +function ModelNotFound(props: { lab: string; model: string }) { + return ( + <> +
+
+
+ + Stats + +

{props.model || "Model"}

+

No model facts or model_stat rows matched {props.lab ? `${props.lab}/${props.model}` : props.model}.

+
+
+
+
+ +
+ + ) +} + +function ModelHero(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null; labName: string }) { + const labId = () => props.catalog?.lab ?? props.data?.provider ?? props.labName + const modelId = () => props.catalog?.id ?? props.data?.model ?? "Model" + const weights = () => props.catalog?.weights[0] + return ( +
+ + Stats + +
+
+
+ + + {modelId()} +
+

{props.catalog?.name ?? props.data?.model ?? "Model"}

+ Model facts from the shared model index. OpenCode usage appears once this model has activity.

+ } + > + {(data) => ( +

+ Ranked #{data().rank} across recent OpenCode token usage with {formatPercent(data().tokenShare)} of + observed volume. +

+ )} +
+ + {(weight) => ( + + Model weights: {weight().label} + + )} + +
+ }> + {(data) => ( +
+ Current Rank + #{data().rank} +

{formatRankMoveLabel(data().previousRank, data().rank)}

+
+ )} +
+
+
+ ) +} + +function ModelCatalogCallout(props: { catalog: ModelCatalogEntry | null }) { + return ( +
+ Model Profile + {props.catalog?.releaseDate ? formatCatalogDate(props.catalog.releaseDate) : "Listed"} +

No OpenCode usage in the current stats window.

+
+ ) +} + +function ModelCatalogPanel(props: { data: ModelCatalogEntry }) { + return ( + + ) +} + +function CatalogDatum(props: { label: string; value: string }) { + return ( +
+ {props.label} + {props.value} +
+ ) +} + +function ModelOverview(props: { data: StatsModelData | null }) { + return ( +
+ + } + > + {(data) => ( +
+ + + + +
+ )} +
+
+ ) +} + +function ModelUsageSection(props: { data: ModelUsagePoint[] }) { + const [activeIndex, setActiveIndex] = createSignal() + const max = createMemo(() => Math.max(0, ...props.data.map((item) => item.tokens)) || 1) + const activePoint = createMemo(() => { + const index = activeIndex() + if (index === undefined) return undefined + return props.data[index] + }) + + return ( +
+ + item.tokens > 0)} + fallback={} + > +
{ + if (event.pointerType === "touch") return + setActiveIndex(undefined) + }} + > + +
+ + {(point, index) => ( +
{ + if (event.pointerType !== "touch") return + setActiveIndex(index()) + }} + onPointerEnter={() => setActiveIndex(index())} + onPointerMove={(event) => { + if (event.pointerType === "touch") return + setActiveIndex(index()) + }} + onClick={() => setActiveIndex(index())} + onFocus={() => setActiveIndex(index())} + onBlur={() => setActiveIndex(undefined)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + setActiveIndex(index()) + }} + > +
+ + {(active) => ( +
props.data.length * 0.62 ? "left" : "right"} + > + {active().date} + {formatTokens(active().tokens)} tokens +
+

+ + Daily tokens + + {formatTokens(active().tokens)} +

+
+ )} + +
+ )} + +
+
+ +
+ ) +} + +function ModelEfficiencySection(props: { data: StatsModelData | null }) { + return ( +
+ + + } + > + {(data) => ( +
+ + + + + +
+ )} +
+
+ ) +} + +function ModelGeoBreakdownSection(props: { data: Record }) { + const [activeCountry, setActiveCountry] = createSignal() + const data = createMemo(() => props.data["2M"]) + const countryById = createMemo( + () => + new Map( + data().flatMap((country) => { + const id = countryNumericId(country.country) + return id ? [[id, country] as const] : [] + }), + ), + ) + const maxTokens = createMemo(() => Math.max(0, ...data().map((country) => country.tokens)) || 1) + const topCountries = createMemo(() => data().slice(0, 15)) + const active = createMemo(() => data().find((country) => country.country === activeCountry()) ?? data()[0]) + + return ( +
{ + if (event.pointerType === "touch") return + setActiveCountry(undefined) + }} + > + + 0} + fallback={} + > +
+
+ + + {(country) => ( +
+ #{String(country().rank).padStart(2, "0")} + {formatCountryName(country().country)} +

+ {formatGeoTokens(country().tokens)} + {formatGeoShare(country().share)} +

+
+ )} +
+
+ +
+
+
+ ) +} + +function GeoWorldMap(props: { + countryById: Map + activeCountry: string | undefined + maxTokens: number + onActiveCountryChange: (country: string | undefined) => void +}) { + const opacityScale = createMemo(() => scaleSqrt().domain([0, props.maxTokens]).range([0.26, 0.96]).clamp(true)) + const countryOpacity = (country: CountryEntry | undefined) => { + if (!country) return 0 + const opacity = opacityScale()(country.tokens) + if (!props.activeCountry || props.activeCountry === country.country) return opacity + return Math.max(0.18, opacity * 0.36) + } + + return ( + + Geo Breakdown map + + + {(country) => { + const entry = () => props.countryById.get(country.id) + return ( + + + + + ) +} + +function GeoCountryList(props: { + data: CountryEntry[] + activeCountry: string | undefined + maxTokens: number + onActiveCountryChange: (country: string | undefined) => void +}) { + const opacityScale = createMemo(() => scaleSqrt().domain([0, props.maxTokens]).range([0.26, 0.96]).clamp(true)) + + return ( +
    + + {(country) => ( +
  1. + +
  2. + )} +
    +
+ ) +} + +function ModelPeersSection(props: { data: StatsModelData | null }) { + return ( +
+ + } + > +
    + + {(peer) => } + +
+
+
+ ) +} + +function MetricCard(props: { label: string; value: string; detail: string; state?: "positive" | "negative" }) { + return ( +
+ {props.label} + {props.value} +

{props.detail}

+
+ ) +} + +function PeerRow(props: { peer: ModelPeerEntry; active: boolean }) { + return ( +
  • + + {String(props.peer.rank).padStart(2, "0")} + +
  • + ) +} + +function SectionTitle(props: { title: string; description: string }) { + return ( +

    + {props.title}. {props.description} +

    + ) +} + +function ModelEmptyState(props: { title: string; description: string; compact?: boolean }) { + return ( +
    + {props.title} +

    {props.description}

    +
    + ) +} + +function getProviderIconId(author: string) { + if (author === "MiniMax") return "minimax" + if (author === "Moonshot") return "moonshotai" + if (author === "Zhipu") return "zhipuai" + return author.toLowerCase().replace(/[^a-z0-9]+/g, "") +} + +function emptyCountryRecord(): Record { + return { + "1D": [], + "1W": [], + "2W": [], + "1M": [], + "2M": [], + "3M": [], + YTD: [], + ALL: [], + } +} + +function countryNumericId(country: string) { + return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0") +} + +function formatCountryName(country: string) { + const code = country.toUpperCase() + if (code === "ZZ") return "Unknown" + if (!countryNumericId(code)) return code + return countryDisplayNames.of(code) ?? code +} + +function formatGeoTokens(value: number) { + return formatTokens(value * 1_000_000_000_000) +} + +function formatGeoShare(value: number) { + return `${value.toFixed(value > 0 && value < 1 ? 1 : 0)}%` +} + +function modelUsageHeight(tokens: number, max: number) { + if (tokens <= 0) return 0 + return Math.max(2, Math.min(100, (tokens / max) * 100)) +} + +function isModelUsageDense(count: number) { + return count > 20 +} + +function isModelUsageLabelHidden(index: number, count: number) { + if (count <= 16) return false + const interval = Math.ceil(count / 8) + return index !== count - 1 && index % interval !== 0 +} + +function formatRankMove(previousRank: number, rank: number) { + const change = previousRank - rank + if (change > 0) return `+${change}` + if (change < 0) return `${change}` + return "Even" +} + +function formatRankMoveLabel(previousRank: number | null, rank: number) { + return previousRank === null ? "New in window" : `${formatRankMove(previousRank, rank)} vs previous window` +} + +function formatTokens(value: number) { + if (value >= 1_000_000_000_000) + return `${trimNumber(value / 1_000_000_000_000, value >= 10_000_000_000_000 ? 0 : 1)}T` + if (value >= 1_000_000_000) return `${trimNumber(value / 1_000_000_000, value >= 10_000_000_000 ? 0 : 1)}B` + if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M` + if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K` + return String(Math.round(value)) +} + +function formatInteger(value: number) { + return new Intl.NumberFormat("en").format(value) +} + +function formatPercent(value: number) { + return `${value.toFixed(value > 0 && value < 10 ? 1 : 0)}%` +} + +function formatMoney(value: number) { + if (value >= 1_000_000) return `$${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M` + if (value >= 1_000) return `$${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K` + return `$${value.toFixed(value >= 10 ? 0 : 2)}` +} + +function formatSessionCost(value: number) { + return `$${value.toFixed(value > 0 && value < 0.01 ? 4 : 2)}` +} + +function formatChange(value: number) { + if (value > 0) return `+${value}%` + return `${value}%` +} + +function formatCatalogLimit(value: number | undefined) { + return value === undefined ? "Unknown" : formatTokens(value) +} + +function formatCatalogModalities(value: string[]) { + if (value.length === 0) return "Unknown" + return value.map(formatCatalogModality).join(", ") +} + +function formatCatalogModality(value: string) { + if (value === "pdf") return "PDF" + return value.charAt(0).toUpperCase() + value.slice(1) +} + +function formatCatalogDate(value: string | undefined) { + if (!value) return "Unknown" + const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value) + if (!match) return value + const year = Number(match[1]) + const month = match[2] ? Number(match[2]) - 1 : 0 + const day = match[3] ? Number(match[3]) : 1 + return new Intl.DateTimeFormat("en", { + month: match[2] ? "short" : undefined, + day: match[3] ? "numeric" : undefined, + year: "numeric", + timeZone: "UTC", + }).format(new Date(Date.UTC(year, month, day))) +} + +function trimNumber(value: number, digits: number) { + return Number(value.toFixed(digits)).toLocaleString("en") +} + +function providerSlug(provider: string) { + return provider + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-") +} diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx new file mode 100644 index 0000000000..c4a59aad65 --- /dev/null +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -0,0 +1,427 @@ +import "../index.css" +import { Link, Meta, Title } from "@solidjs/meta" +import { + getStatsLabData, + type LabUsageModelEntry, + type ModelUsagePoint, + type StatsLabData, +} from "@opencode-ai/stats-core/domain/home" +import { runtime } from "@opencode-ai/stats-core/runtime" +import { createAsync, query, useParams } from "@solidjs/router" +import { createMemo, createSignal, For, onMount, Show, type JSX } from "solid-js" +import { getRequestEvent } from "solid-js/web" +import { + findModelCatalogLab, + formatCatalogLabName, + getModelCatalog, + type ModelCatalogEntry, + type ModelCatalogLab, +} from "../model-catalog" +import { + applyThemePreference, + Footer, + getGitHubStars, + Header, + isThemePreference, + themeStorageKey, + type HeaderLink, + type ThemePreference, +} from "../stats-shell" + +const statsLabFallbackUrl = "https://stats.opencode.ai" +const labHeaderLinks: readonly HeaderLink[] = [ + { href: "#overview", label: "Overview" }, + { href: "#usage", label: "Usage" }, + { href: "#models", label: "Models" }, +] +const labFooterLinks: readonly HeaderLink[] = [ + { href: import.meta.env.BASE_URL, label: "Stats Home" }, + { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" }, + { href: `${import.meta.env.BASE_URL}#market-share`, label: "Market Share" }, + { href: `${import.meta.env.BASE_URL}#geo-breakdown`, label: "Geo Breakdown" }, +] + +const getLabData = query(async (lab: string) => { + "use server" + return runtime.runPromise(getStatsLabData(lab)) +}, "getStatsLabData") + +export default function StatsLab() { + const event = getRequestEvent() + event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400") + const params = useParams() + const labParam = createMemo(() => params.lab ?? "") + const catalog = createAsync(() => getModelCatalog()) + const lab = createMemo(() => { + const data = catalog() + if (!data) return undefined + return findModelCatalogLab(data, labParam()) ?? null + }) + const stats = createAsync(() => { + const entry = lab() + if (catalog() === undefined || entry === undefined) return Promise.resolve(undefined) + if (!entry) return Promise.resolve(null) + return getLabData(entry.id) + }) + const githubStars = createAsync(() => getGitHubStars()) + const [themePreference, setThemePreference] = createSignal("system") + const labName = createMemo(() => lab()?.name ?? formatCatalogLabName(labParam())) + const labTitle = createMemo(() => `${labName()} Models`) + const labDescription = createMemo( + () => + `Explore ${labName()} models used in OpenCode, with recent token usage, context windows, release dates, and model-specific stats.`, + ) + const labUrl = createMemo(() => + new URL( + `${import.meta.env.BASE_URL}${lab()?.id ?? labParam()}`, + event?.request.url ?? (typeof window === "undefined" ? statsLabFallbackUrl : window.location.href), + ).toString(), + ) + const updateThemePreference = (preference: ThemePreference) => { + applyThemePreference(preference) + setThemePreference(preference) + if (typeof window === "undefined") return + window.localStorage.setItem(themeStorageKey, preference) + } + + onMount(() => { + if (typeof window === "undefined") return + const preference = window.localStorage.getItem(themeStorageKey) + const nextPreference = isThemePreference(preference) ? preference : "system" + applyThemePreference(nextPreference) + setThemePreference(nextPreference) + }) + + return ( +
    + {labTitle()} + + + + + + + + + + +
    +
    +
    + }> + }> + {(data) => ( + <> + + + + + )} + + +
    +
    +
    +
    + ) +} + +function LabLoading() { + return ( +
    +
    +
    + + Stats + +

    Model Lab

    +

    Reading model availability and recent OpenCode usage.

    +
    +
    +
    + ) +} + +function LabNotFound(props: { lab: string }) { + return ( +
    +
    +
    + + Stats + +

    {formatCatalogLabName(props.lab)}

    +

    No models matched this lab.

    +
    +
    +
    + ) +} + +function LabHero(props: { lab: ModelCatalogLab; stats: StatsLabData | null }) { + const latest = createMemo( + () => + props.lab.models + .map((model) => model.releaseDate) + .filter((value): value is string => value !== undefined) + .toSorted((a, b) => new Date(b).getTime() - new Date(a).getTime())[0], + ) + const featuredModels = createMemo(() => props.lab.models.slice(0, 3).map((model) => model.name)) + + return ( +
    + + Stats + +
    +
    +

    {props.lab.name}

    + +
    + Tokens Processed + {props.stats ? formatTokens(props.stats.totals.tokens) : "Pending"} +

    + {props.stats + ? `${formatPercent(props.stats.tokenShare)} of recent OpenCode usage` + : latest() + ? `Latest release ${formatCatalogDate(latest())}` + : "Usage appears after model activity lands"} +

    +
    +
    +
    + ) +} + +function LabUsageSection(props: { lab: ModelCatalogLab; data: StatsLabData | null }) { + const [activeIndex, setActiveIndex] = createSignal() + const usage = createMemo(() => props.data?.usage ?? []) + const max = createMemo(() => Math.max(0, ...usage().map((item) => item.tokens)) || 1) + const activePoint = createMemo(() => { + const index = activeIndex() + if (index === undefined) return undefined + return usage()[index] + }) + + return ( +
    +

    + {props.lab.name} token usage.{" "} + Daily OpenCode token volume over the last two months. +

    + item.tokens > 0)} + fallback={ + + } + > +
    { + if (event.pointerType === "touch") return + setActiveIndex(undefined) + }} + > + +
    + + {(point, index) => ( +
    { + if (event.pointerType !== "touch") return + setActiveIndex(index()) + }} + onPointerEnter={() => setActiveIndex(index())} + onPointerMove={(event) => { + if (event.pointerType === "touch") return + setActiveIndex(index()) + }} + onClick={() => setActiveIndex(index())} + onFocus={() => setActiveIndex(index())} + onBlur={() => setActiveIndex(undefined)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + setActiveIndex(index()) + }} + > +
    + + {(active) => ( +
    usage().length * 0.62 ? "left" : "right"} + > + {active().date} + {formatTokens(active().tokens)} tokens +
    +

    + + Daily tokens + + {formatTokens(active().tokens)} +

    +
    + )} + +
    + )} + +
    +
    + +
    + ) +} + +function LabModelsSection(props: { lab: ModelCatalogLab; usage: LabUsageModelEntry[] }) { + const usageBySlug = createMemo(() => new Map(props.usage.map((item) => [item.slug, item]))) + return ( +
    +

    + {props.lab.name} models. Recent usage and limits. +

    +
    + + {(model) => } + +
    +
    + ) +} + +function LabModelCard(props: { model: ModelCatalogEntry; usage: LabUsageModelEntry | undefined }) { + return ( + + {props.model.name} +
    +

    + Usage + {props.usage ? formatTokens(props.usage.tokens) : "—"} +

    +

    + Share + {props.usage ? formatPercent(props.usage.share) : "—"} +

    +

    + Context + {formatCatalogLimit(props.model.limit?.context)} +

    +

    + Output + {formatCatalogLimit(props.model.limit?.output)} +

    +

    + Release + {formatCatalogDate(props.model.releaseDate)} +

    +
    +
    + ) +} + +function LabEmptyState(props: { title: string; description: string }) { + return ( +
    + {props.title} +

    {props.description}

    +
    + ) +} + +function formatCatalogLimit(value: number | undefined) { + return value === undefined ? "Unknown" : formatTokens(value) +} + +function formatCatalogDate(value: string | undefined) { + if (!value) return "Unknown" + const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value) + if (!match) return value + const year = Number(match[1]) + const month = match[2] ? Number(match[2]) - 1 : 0 + const day = match[3] ? Number(match[3]) : 1 + return new Intl.DateTimeFormat("en", { + month: match[2] ? "short" : undefined, + day: match[3] ? "numeric" : undefined, + year: "numeric", + timeZone: "UTC", + }).format(new Date(Date.UTC(year, month, day))) +} + +function formatList(values: string[]) { + if (values.length <= 1) return values[0] ?? "" + if (values.length === 2) return `${values[0]} and ${values[1]}` + return `${values.slice(0, -1).join(", ")}, and ${values[values.length - 1]}` +} + +function formatPercent(value: number) { + return `${trimNumber(value, value >= 10 ? 1 : 2)}%` +} + +function formatTokens(value: number) { + if (value >= 1_000_000_000_000) + return `${trimNumber(value / 1_000_000_000_000, value >= 10_000_000_000_000 ? 0 : 1)}T` + if (value >= 1_000_000_000) return `${trimNumber(value / 1_000_000_000, value >= 10_000_000_000 ? 0 : 1)}B` + if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M` + if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K` + return String(Math.round(value)) +} + +function trimNumber(value: number, digits: number) { + return Number(value.toFixed(digits)).toLocaleString("en") +} + +function usageHeight(value: number, max: number) { + if (value <= 0 || max <= 0) return 0 + return Math.max(4, (value / max) * 100) +} + +function isLabUsageDense(count: number) { + return count > 20 +} + +function isLabUsageLabelHidden(index: number, count: number) { + if (count <= 14) return false + const cadence = count > 45 ? 7 : count > 28 ? 4 : 2 + return index % cadence !== 0 && index !== count - 1 +} diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index 576c28e598..f6d65e93e1 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -740,8 +740,8 @@ } [data-page="stats"] [data-component="content"] a:hover { - text-decoration: underline; - text-underline-offset: 4px; + color: var(--stats-text); + text-decoration: none; } [data-page="stats"] [data-section="chart"] { @@ -1393,6 +1393,10 @@ line-height: 1.5; } +[data-page="stats"] [data-component="empty-state"][data-compact="true"] { + min-height: 172px; +} + [data-page="stats"] [data-component="usage-chart"] svg { display: block; width: 100%; @@ -1790,6 +1794,11 @@ transform 140ms ease; } +[data-page="stats"] a[data-component="leader-card"], +[data-page="stats"] a[data-component="leader-card"]:hover { + text-decoration: none; +} + [data-page="stats"] [data-slot="rank"], [data-page="stats"] [data-slot="delta"][data-negative="true"] { color: var(--stats-faint); @@ -2603,6 +2612,621 @@ width: fit-content; } +[data-page="stats"] [data-section="model-hero"], +[data-page="stats"] [data-section="lab-hero"], +[data-page="stats"] [data-section="model-panel"] { + position: relative; + box-sizing: border-box; + box-shadow: + inset 0 -1px var(--stats-line), + inset 1px 0 var(--stats-line), + inset -1px 0 var(--stats-line); + color: var(--stats-text); +} + +[data-page="stats"] [data-section="model-hero"], +[data-page="stats"] [data-section="lab-hero"] { + display: grid; + gap: 28px; + padding: 120px 40px 64px; +} + +[data-page="stats"] [data-slot="model-hero-grid"] { + display: grid; + grid-template-columns: minmax(0, 1fr) 240px; + gap: 40px; + align-items: end; +} + +[data-page="stats"] [data-slot="model-hero-copy"] { + display: grid; + gap: 20px; + min-width: 0; +} + +[data-page="stats"] [data-slot="model-back-link"] { + width: fit-content; + color: var(--stats-faint); + font-size: 11px; + font-weight: 500; + line-height: 1; + text-decoration: none; + text-transform: uppercase; +} + +[data-page="stats"] [data-slot="model-back-link"]::before { + content: "‹ "; +} + +[data-page="stats"] [data-slot="model-back-link"]:hover, +[data-page="stats"] [data-slot="model-back-link"]:focus-visible { + color: var(--stats-text); + outline: none; + text-decoration: none; +} + +[data-page="stats"] [data-section="model-hero"] [data-slot="hero-meta"], +[data-page="stats"] [data-section="lab-hero"] [data-slot="hero-meta"] { + display: inline-flex; + align-items: center; + gap: 8px; + padding-left: 6px; + color: var(--stats-hero-muted); + text-decoration: none; +} + +[data-page="stats"] [data-section="model-hero"] a[data-slot="hero-meta"]:hover, +[data-page="stats"] [data-section="model-hero"] a[data-slot="hero-meta"]:focus-visible { + color: var(--stats-text); + outline: none; + text-decoration: none; +} + +[data-page="stats"] [data-section="model-hero"] [data-slot="hero-meta"] svg, +[data-page="stats"] [data-section="lab-hero"] [data-slot="hero-meta"] svg { + width: 14px; + height: 14px; +} + +[data-page="stats"] [data-slot="model-hero-tags"] { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-width: 0; +} + +[data-page="stats"] [data-slot="model-id-tag"] { + display: inline-flex; + align-items: center; + max-width: 100%; + height: 24px; + padding: 0 8px; + overflow: hidden; + color: var(--stats-hero-muted); + background: var(--stats-layer-2); + font-size: 13px; + font-weight: 500; + line-height: 1.1; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-page="stats"] [data-section="model-hero"] h1, +[data-page="stats"] [data-section="lab-hero"] h1 { + max-width: 980px; + color: var(--stats-text); + font-size: clamp(38px, 7vw, 72px); + font-weight: 500; + line-height: 0.95; + letter-spacing: 0; + overflow-wrap: anywhere; +} + +[data-page="stats"] [data-slot="model-hero-pattern"] { + width: 100%; + height: 18px; + overflow: hidden; + background: var(--stats-hero-pattern); + mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + mask-repeat: repeat; + mask-size: 6px 6px; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + -webkit-mask-repeat: repeat; + -webkit-mask-size: 6px 6px; +} + +[data-page="stats"] [data-slot="model-hero-copy"] p:not([data-slot="hero-meta"]) { + max-width: 720px; + color: var(--stats-muted); + font-size: 16px; + font-weight: 400; + line-height: 1.5; +} + +[data-page="stats"] [data-slot="model-weight-link"] { + width: fit-content; + color: var(--stats-muted); + font-size: 12px; + font-weight: 500; + line-height: 1.3; + text-decoration: none; +} + +[data-page="stats"] [data-slot="model-weight-link"]::after { + content: " ↗"; + color: var(--stats-muted); +} + +[data-page="stats"] [data-slot="model-weight-link"]:hover, +[data-page="stats"] [data-slot="model-weight-link"]:focus-visible { + color: var(--stats-text); + outline: none; + text-decoration: none; +} + +[data-page="stats"] [data-slot="model-weight-link"]:hover::after, +[data-page="stats"] [data-slot="model-weight-link"]:focus-visible::after { + color: var(--stats-text); +} + +[data-page="stats"] [data-component="model-rank-panel"] { + display: grid; + gap: 10px; + min-height: 164px; + padding: 18px; + border: 1px solid var(--stats-line); + background: var(--stats-layer); +} + +[data-page="stats"] [data-component="model-rank-panel"] span, +[data-page="stats"] [data-component="model-rank-panel"] p { + color: var(--stats-muted); + font-size: 11px; + font-weight: 500; + line-height: 1.4; +} + +[data-page="stats"] [data-component="model-rank-panel"] strong { + color: var(--stats-text); + font-size: 64px; + font-weight: 500; + line-height: 0.95; + letter-spacing: 0; +} + +[data-page="stats"] [data-component="model-catalog"] { + min-width: 0; +} + +[data-page="stats"] [data-component="model-catalog-datum"] span { + color: var(--stats-muted); + font-size: 11px; + font-weight: 500; + line-height: 1.3; +} + +[data-page="stats"] [data-slot="model-catalog-grid"] { + display: flex; + flex-wrap: wrap; + row-gap: 18px; + min-width: 0; +} + +[data-page="stats"] [data-component="model-catalog-datum"] { + display: grid; + align-content: start; + flex: 1 1 132px; + gap: 7px; + max-width: 220px; + min-width: 0; + padding: 0 24px; + border-left: 1px solid var(--stats-line); +} + +[data-page="stats"] [data-component="model-catalog-datum"]:first-child { + padding-left: 0; + border-left: 0; +} + +[data-page="stats"] [data-component="model-catalog-datum"] strong { + min-width: 0; + color: var(--stats-text); + font-size: 20px; + font-weight: 500; + line-height: 1; + overflow-wrap: anywhere; +} + +[data-page="stats"] [data-section="model-panel"] { + padding: 80px 40px; +} + +[data-page="stats"] [data-component="model-metric-grid"] { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +[data-page="stats"] [data-component="model-metric-grid"][data-variant="dense"] { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +[data-page="stats"] [data-component="model-metric"] { + display: grid; + gap: 16px; + min-width: 0; + min-height: 132px; + padding: 16px; + border: 1px solid var(--stats-line); + background: var(--stats-layer); +} + +[data-page="stats"] [data-component="model-metric"] span, +[data-page="stats"] [data-component="model-metric"] p { + color: var(--stats-muted); + font-size: 11px; + font-weight: 500; + line-height: 1.3; +} + +[data-page="stats"] [data-component="model-metric"] strong { + min-width: 0; + color: var(--stats-text); + font-size: 28px; + font-weight: 500; + line-height: 1; + letter-spacing: 0; + overflow-wrap: anywhere; +} + +[data-page="stats"] [data-component="model-metric"][data-state="positive"] strong { + color: #198b43; +} + +[data-page="stats"] [data-component="model-metric"][data-state="negative"] strong { + color: #b82d35; +} + +[data-page="stats"] [data-component="lab-model-grid"] { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +[data-page="stats"] [data-component="lab-model-card"] { + position: relative; + display: grid; + align-content: space-between; + gap: 28px; + min-height: 220px; + padding: 16px; + overflow: hidden; + border: 1px solid var(--stats-line); + color: var(--stats-text); + background: var(--stats-layer); +} + +[data-page="stats"] [data-component="lab-model-card"]:hover, +[data-page="stats"] [data-component="lab-model-card"]:focus-visible { + z-index: 1; + border-color: var(--stats-text); + outline: none; +} + +[data-page="stats"] [data-component="lab-model-card"] > strong { + align-self: start; + min-width: 0; + color: var(--stats-text); + font-size: 24px; + font-weight: 500; + line-height: 1.05; + letter-spacing: 0; + overflow-wrap: anywhere; +} + +[data-page="stats"] [data-slot="lab-model-card-meta"] { + display: grid; + gap: 8px; +} + +[data-page="stats"] [data-slot="lab-model-card-meta"] p { + display: flex; + justify-content: space-between; + gap: 12px; + margin: 0; + padding-top: 8px; + border-top: 1px solid var(--stats-line); + color: var(--stats-muted); + font-size: 11px; + font-weight: 500; + line-height: 1.3; +} + +[data-page="stats"] [data-slot="lab-model-card-meta"] b, +[data-page="stats"] [data-slot="lab-model-card-meta"] em { + font: inherit; +} + +[data-page="stats"] [data-slot="lab-model-card-meta"] em { + color: var(--stats-text); + text-align: right; +} + +[data-page="stats"] [data-component="model-usage-chart"] { + --model-usage-bar-gap: 6px; + --model-usage-dot-size: 6px; + --model-usage-dot-offset: 2px; + position: relative; + display: grid; + grid-template-rows: 40px minmax(0, 1fr); + gap: 12px; + height: clamp(320px, 42vh, 392px); + min-width: 0; +} + +[data-page="stats"] [data-slot="model-usage-axis"], +[data-page="stats"] [data-slot="model-usage-bars"] { + display: flex; + min-width: 0; +} + +[data-page="stats"] [data-slot="model-usage-axis"] { + gap: var(--model-usage-bar-gap); +} + +[data-page="stats"] [data-slot="model-usage-axis"] > div { + flex: 1 1 0; + min-width: 0; + color: var(--stats-faint); + font-size: 11px; + font-weight: 500; + line-height: 1.35; +} + +[data-page="stats"] [data-slot="model-usage-axis"] > div[data-active="true"] { + color: var(--stats-text); +} + +[data-page="stats"] [data-slot="model-usage-label"] { + display: flex; + flex-direction: column; + min-width: 0; +} + +[data-page="stats"] [data-slot="model-usage-total"] { + color: var(--stats-muted); + font-weight: 600; +} + +[data-page="stats"] [data-slot="model-usage-date"] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-page="stats"] [data-slot="model-usage-column"] { + position: relative; + box-sizing: border-box; + flex: 1 1 0; + min-width: 0; + height: 100%; + padding-inline: calc(var(--model-usage-bar-gap) / 2); + outline: none; + cursor: pointer; +} + +[data-page="stats"] [data-slot="model-usage-column"]::before { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: calc(100% - var(--model-usage-bar-gap)); + transform: translateX(-50%); + content: ""; + background: var(--stats-dot); + mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + mask-position: var(--model-usage-dot-offset) top; + mask-repeat: repeat; + mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); + -webkit-mask-position: var(--model-usage-dot-offset) top; + -webkit-mask-repeat: repeat; + -webkit-mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size); +} + +@supports (width: round(down, 100%, 1px)) { + [data-page="stats"] [data-slot="model-usage-column"]::before { + width: round(down, calc(100% - var(--model-usage-bar-gap)), var(--model-usage-dot-size)); + } +} + +[data-page="stats"] [data-slot="model-usage-bar"] { + position: absolute; + right: calc(var(--model-usage-bar-gap) / 2); + bottom: 0; + left: calc(var(--model-usage-bar-gap) / 2); + z-index: 1; + height: var(--model-usage-fill); + min-height: 0; + background: var(--stats-accent); + box-shadow: 0 -4px 0 var(--stats-bg); + transition: + background 120ms ease, + opacity 120ms ease; +} + +[data-page="stats"] [data-slot="model-usage-column"][data-muted="true"] [data-slot="model-usage-bar"] { + background: var(--stats-layer-2); +} + +[data-page="stats"] + [data-component="model-usage-chart"][data-dense-labels="true"] + [data-slot="model-usage-axis"] + > div { + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: 40px; + font-weight: 600; +} + +[data-page="stats"] [data-component="model-usage-chart"][data-dense-labels="true"] [data-slot="model-usage-label"] { + position: absolute; + left: 50%; + width: max-content; + max-width: 72px; + transform: rotate(-90deg) translateX(-50%); + transform-origin: left center; +} + +[data-page="stats"] [data-component="model-usage-chart"][data-dense-labels="true"] [data-slot="model-usage-total"] { + display: none; +} + +[data-page="stats"] + [data-slot="model-usage-axis"] + > div[data-label-hidden="true"]:not([data-active="true"]) + [data-slot="model-usage-label"] { + visibility: hidden; +} + +[data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"] { + top: 12px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 0; + width: 176px; + min-width: 176px; + padding: 0; + border: 0; + box-shadow: + 0 0 0 0.5px #00000024, + 0 8px 16px #0000000f, + 0 4px 8px #00000014; + color: var(--stats-text); +} + +[data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"][data-placement="right"] { + right: auto; + left: calc(100% + 8px); +} + +[data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"][data-placement="left"] { + right: calc(100% + 8px); + left: auto; +} + +[data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"] strong, +[data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"] > span { + display: block; + font-size: 11px; + line-height: 12px; + white-space: nowrap; +} + +[data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"] strong { + padding: 8px 8px 0; + font-weight: 500; +} + +[data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"] > span { + padding: 4px 8px 8px; + color: var(--stats-muted); +} + +[data-page="stats"] + [data-component="model-usage-chart"] + [data-component="chart-tooltip"] + [data-slot="tooltip-divider"] { + height: 0.5px; + margin: 0; +} + +[data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"] p { + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px; + height: 20px; + margin: 8px 0; + padding: 0 8px; + font-size: 11px; + font-weight: 500; + line-height: 12px; +} + +[data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"] [data-slot="tooltip-label"] { + grid-template-columns: 16px minmax(0, 1fr); + gap: 4px; +} + +[data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"] i { + width: 6px; + height: 6px; + justify-self: center; + background: var(--stats-accent); +} + +[data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"] b { + font-weight: 500; +} + +[data-page="stats"] [data-component="model-peer-list"] { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +[data-page="stats"] [data-component="model-peer-list"] a { + display: grid; + grid-template-columns: 38px 24px minmax(0, 1fr) minmax(96px, auto) minmax(80px, auto); + gap: 12px; + align-items: center; + min-height: 44px; + padding: 0 12px; + border: 1px solid var(--stats-line); + background: var(--stats-layer); + color: var(--stats-text); + text-decoration: none; +} + +[data-page="stats"] [data-component="model-peer-list"] a:hover, +[data-page="stats"] [data-component="model-peer-list"] a[data-active="true"] { + border-color: var(--stats-line-strong); + background: var(--stats-bg); + text-decoration: none; +} + +[data-page="stats"] [data-component="model-peer-list"] span, +[data-page="stats"] [data-component="model-peer-list"] em, +[data-page="stats"] [data-component="model-peer-list"] b { + color: var(--stats-muted); + font-size: 11px; + font-style: normal; + font-weight: 500; + line-height: 1.2; +} + +[data-page="stats"] [data-component="model-peer-list"] svg { + width: 18px; + height: 18px; + color: var(--stats-muted); +} + +[data-page="stats"] [data-component="model-peer-list"] strong { + min-width: 0; + overflow: hidden; + color: var(--stats-text); + font-size: 13px; + font-weight: 600; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + [data-page="stats"][data-theme="dark"], :root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) { color-scheme: dark; @@ -2923,7 +3547,10 @@ [data-page="stats"] [data-section="geo-breakdown"], [data-page="stats"] [data-section="token-cost"], [data-page="stats"] [data-section="cache-ratio"], - [data-page="stats"] [data-section="session-cost"] { + [data-page="stats"] [data-section="session-cost"], + [data-page="stats"] [data-section="model-hero"], + [data-page="stats"] [data-section="lab-hero"], + [data-page="stats"] [data-section="model-panel"] { padding: 64px 32px; } } @@ -2951,6 +3578,29 @@ flex-wrap: wrap; } + [data-page="stats"] [data-slot="model-hero-grid"] { + grid-template-columns: 1fr; + align-items: start; + } + + [data-page="stats"] [data-component="model-rank-panel"] { + min-height: 0; + } + + [data-page="stats"] [data-component="model-metric-grid"], + [data-page="stats"] [data-component="model-metric-grid"][data-variant="dense"], + [data-page="stats"] [data-component="lab-model-grid"] { + grid-template-columns: 1fr 1fr; + } + + [data-page="stats"] [data-component="model-peer-list"] a { + grid-template-columns: 32px 22px minmax(0, 1fr) minmax(72px, auto); + } + + [data-page="stats"] [data-component="model-peer-list"] em { + display: none; + } + [data-page="stats"] [data-slot="chart-footer"] { align-items: flex-start; flex-direction: column; @@ -3056,7 +3706,10 @@ [data-page="stats"] [data-section="geo-breakdown"], [data-page="stats"] [data-section="token-cost"], [data-page="stats"] [data-section="cache-ratio"], - [data-page="stats"] [data-section="session-cost"] { + [data-page="stats"] [data-section="session-cost"], + [data-page="stats"] [data-section="model-hero"], + [data-page="stats"] [data-section="lab-hero"], + [data-page="stats"] [data-section="model-panel"] { padding: 48px 24px; } @@ -3078,6 +3731,110 @@ width: 100%; } + [data-page="stats"] [data-section="model-hero"], + [data-page="stats"] [data-section="lab-hero"] { + gap: 20px; + } + + [data-page="stats"] [data-slot="model-hero-grid"] { + gap: 24px; + } + + [data-page="stats"] [data-section="model-hero"] h1, + [data-page="stats"] [data-section="lab-hero"] h1 { + font-size: 38px; + line-height: 1; + } + + [data-page="stats"] [data-slot="model-hero-pattern"] { + height: 14px; + } + + [data-page="stats"] [data-slot="model-catalog-grid"] { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px 24px; + } + + [data-page="stats"] [data-component="model-catalog-datum"] { + max-width: none; + padding: 0; + border-left: 0; + } + + [data-page="stats"] [data-component="model-catalog-datum"] strong { + font-size: 18px; + } + + [data-page="stats"] [data-slot="model-hero-copy"] p:not([data-slot="hero-meta"]) { + font-size: 13px; + } + + [data-page="stats"] [data-component="model-metric-grid"], + [data-page="stats"] [data-component="model-metric-grid"][data-variant="dense"], + [data-page="stats"] [data-component="lab-model-grid"] { + grid-template-columns: 1fr; + } + + [data-page="stats"] [data-component="model-metric"] { + min-height: 112px; + } + + [data-page="stats"] [data-component="model-usage-chart"] { + --model-usage-mobile-bar-width: 16px; + --model-usage-mobile-track-width: calc( + var(--model-usage-count) * (var(--model-usage-mobile-bar-width) + var(--model-usage-bar-gap)) - + var(--model-usage-bar-gap) + ); + width: calc(100% + 48px); + height: 360px; + margin-inline: -24px; + padding-inline: 24px; + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; + } + + [data-page="stats"] [data-component="model-usage-chart"]::-webkit-scrollbar { + display: none; + } + + [data-page="stats"] [data-slot="model-usage-axis"], + [data-page="stats"] [data-slot="model-usage-bars"] { + width: var(--model-usage-mobile-track-width); + min-width: var(--model-usage-mobile-track-width); + } + + [data-page="stats"] [data-slot="model-usage-bars"] { + gap: var(--model-usage-bar-gap); + } + + [data-page="stats"] [data-slot="model-usage-axis"] > div, + [data-page="stats"] [data-slot="model-usage-column"] { + flex: 0 0 var(--model-usage-mobile-bar-width); + } + + [data-page="stats"] [data-slot="model-usage-column"] { + padding-inline: 0; + } + + [data-page="stats"] [data-slot="model-usage-column"]::before { + width: 100%; + } + + [data-page="stats"] [data-slot="model-usage-bar"] { + right: 0; + left: 0; + } + + [data-page="stats"] [data-component="model-peer-list"] a { + grid-template-columns: 30px minmax(0, 1fr) minmax(64px, auto); + } + + [data-page="stats"] [data-component="model-peer-list"] svg { + display: none; + } + [data-page="stats"] [data-section="top-models"] [data-slot="chart-footer"] { display: flex; margin-top: 24px; @@ -3267,6 +4024,23 @@ right: 12px; left: 12px; } + + [data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"] { + position: fixed; + top: auto; + right: 12px; + bottom: 12px; + left: 12px; + z-index: 40; + width: auto; + min-width: 0; + transform: none; + } + + [data-page="stats"] [data-component="model-usage-chart"] [data-component="chart-tooltip"][data-placement] { + right: 12px; + left: 12px; + } } @media (max-width: 40rem) { diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 3353fe8508..fb03b0e2f9 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -10,7 +10,6 @@ import ibmPlexMonoRegularLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/ import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url" import ibmPlexMonoSemiBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2?url" import ibmPlexMonoBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2?url" -import opencodeWordmarkDark from "../asset/logo-ornate-dark.svg" import { getStatsHomeData, type CacheRatioEntry, @@ -28,6 +27,16 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, import { getRequestEvent } from "solid-js/web" import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson" import type { GeometryCollection, Topology } from "topojson-specification" +import { + applyThemePreference, + Footer, + getGitHubStars, + githubLink, + Header, + isThemePreference, + themeStorageKey, + type ThemePreference, +} from "./stats-shell" const products = ["All Users", "Zen", "Go"] as const const tokenProducts = ["Zen", "Go"] as const @@ -44,25 +53,6 @@ const statsHomeDescription = "OpenCode usage, market share, token cost, and sess const statsHomeFallbackUrl = "https://opencode.ai/stats/" const statsUnfurlPath = "banner.png" const statsUnfurlAlt = "OpenCode Stats wordmark on a dark patterned background" -const headerLinks = [ - { href: "#top-models", label: "Top Models" }, - { href: "#session-cost", label: "Session Cost" }, - { href: "#token-cost", label: "Token Cost" }, - { href: "#cache-ratio", label: "Cache Ratio" }, - { href: "#market-share", label: "Market Share" }, - { href: "#geo-breakdown", label: "Geo Breakdown" }, -] as const -const githubLink = { - href: "https://github.com/anomalyco/opencode", - apiHref: "https://api.github.com/repos/anomalyco/opencode", - label: "GitHub", - fallbackStars: "150K", - ariaLabel: "Star OpenCode on GitHub", -} -const compactNumberFormatter = new Intl.NumberFormat("en", { - notation: "compact", - maximumFractionDigits: 1, -}) const usageColors = [ "#ed6aff", "#a684ff", @@ -77,13 +67,6 @@ const usageColors = [ "#ff6467", ] const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"] -const themePreferences = ["dark", "light", "system"] as const -const themePreferenceLabels = { - dark: "Dark", - light: "Light", - system: "System", -} as const -const themeStorageKey = "opencode:stats-theme" const geoMapWidth = 960 const geoMapHeight = 430 const countryDisplayNames = new Intl.DisplayNames(["en"], { type: "region" }) @@ -91,7 +74,6 @@ const countryDisplayNames = new Intl.DisplayNames(["en"], { type: "region" }) type UsageProduct = (typeof products)[number] type TokenProduct = (typeof tokenProducts)[number] type UsageRange = (typeof ranges)[number] -type ThemePreference = (typeof themePreferences)[number] type IsoCountryCode = readonly [string, string, string] type WorldCountryProperties = GeoJsonProperties & { name?: string } type WorldTopology = Topology<{ countries: GeometryCollection }> @@ -127,23 +109,6 @@ const getData = query(async () => { return runtime.runPromise(getStatsHomeData()) }, "getStatsHomeData") -const getGitHubStars = query(async () => { - "use server" - return fetch(githubLink.apiHref, { - headers: { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - }) - .then((response) => (response.ok ? response.json() : undefined)) - .then((body: unknown) => - body && typeof body === "object" && "stargazers_count" in body && typeof body.stargazers_count === "number" - ? compactNumberFormatter.format(body.stargazers_count) - : githubLink.fallbackStars, - ) - .catch(() => githubLink.fallbackStars) -}, "getGitHubStars") - export default function StatsHome() { const event = getRequestEvent() event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400") @@ -224,20 +189,6 @@ function getStatsHomeUrl(base: string, requestUrl: string) { return url.toString() } -function isThemePreference(value: string | null): value is ThemePreference { - return value === "dark" || value === "light" || value === "system" -} - -function applyThemePreference(preference: ThemePreference) { - if (typeof document === "undefined") return - document.documentElement.dataset.statsTheme = preference - if (preference === "system") { - document.documentElement.style.removeProperty("color-scheme") - return - } - document.documentElement.style.setProperty("color-scheme", preference) -} - function Hero(props: { updatedAt: string | null }) { const [timeZone, setTimeZone] = createSignal("UTC") const [previousTimeZone, setPreviousTimeZone] = createSignal("UTC") @@ -977,10 +928,11 @@ function LeaderboardCard(props: { onActiveModelChange: (model: string | undefined) => void }) { return ( -
    -
    + ) } @@ -1807,415 +1759,11 @@ function formatSessionCost(value: number) { return `$${value.toFixed(4)}` } -function Header(props: { githubStars: string }) { - const [menuOpen, setMenuOpen] = createSignal(false) - const [menuViewport, setMenuViewport] = createSignal(false) - - createEffect(() => { - if (typeof window === "undefined") return - const media = window.matchMedia("(max-width: 74.999rem)") - const update = () => setMenuViewport(media.matches) - update() - media.addEventListener("change", update) - onCleanup(() => media.removeEventListener("change", update)) - }) - - createEffect(() => { - if (!menuOpen()) return - if (!menuViewport()) return - if (typeof document === "undefined") return - const page = document.querySelector('[data-page="stats"]') - const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth - const htmlOverflow = document.documentElement.style.overflow - const pagePaddingRight = page?.style.paddingRight - const bodyOverflow = document.body.style.overflow - document.documentElement.style.overflow = "hidden" - if (scrollbarWidth > 0 && page) page.style.paddingRight = `${scrollbarWidth}px` - document.body.style.overflow = "hidden" - onCleanup(() => { - document.documentElement.style.overflow = htmlOverflow - if (page && pagePaddingRight !== undefined) page.style.paddingRight = pagePaddingRight - document.body.style.overflow = bodyOverflow - }) - }) - - return ( -
    -
    - - - - -
    - - {githubLink.label} - [{props.githubStars}] - - - Try OpenCode - - -
    -
    - -
    - ) -} - -function StatsWordmark() { - return ( - - ) -} - -function StatsMark() { - return ( - - ) -} - -function OpenCodeMark() { - return ( - - ) -} - -function Footer(props: { - themePreference: ThemePreference - onThemePreferenceChange: (preference: ThemePreference) => void -}) { - const [subscribeOpen, setSubscribeOpen] = createSignal(false) - const modelStats = [ - { href: "#top-models", label: "Top Models" }, - { href: "#session-cost", label: "Session Cost" }, - { href: "#token-cost", label: "Token Cost" }, - { href: "#cache-ratio", label: "Cache Ratio" }, - { href: "#market-share", label: "Market Share" }, - { href: "#geo-breakdown", label: "Geo Breakdown" }, - ] - const legal = [ - { href: "https://opencode.ai/legal/terms-of-service", label: "Terms of service" }, - { href: "https://opencode.ai/legal/privacy-policy", label: "Privacy policy" }, - ] - const connect = [ - { href: "mailto:hello@opencode.ai", label: "Contact us" }, - { href: "https://opencode.ai/discord", label: "Community" }, - { href: "https://x.com/opencode", label: "X" }, - githubLink, - { href: "https://www.youtube.com/@anomaly-co", label: "YouTube" }, - ] - - return ( -
    - -
    - - - - - - -
    -

    Newsletter

    -

    Be the first to know about new releases.

    - -
    -
    -
    - ) -} - -function ThemePreferenceIcon(props: { preference: ThemePreference }) { - return ( - - ) -} - -function SubscribeModal(props: { onClose: () => void }) { - const [status, setStatus] = createSignal<"idle" | "pending" | "success" | "error">("idle") - const [message, setMessage] = createSignal("") - let input: HTMLInputElement | undefined - - onMount(() => { - if (typeof document === "undefined") return - const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : undefined - const htmlOverflow = document.documentElement.style.overflow - const bodyOverflow = document.body.style.overflow - document.documentElement.style.overflow = "hidden" - document.body.style.overflow = "hidden" - const focusTimeout = window.setTimeout(() => input?.focus(), 0) - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") props.onClose() - } - document.addEventListener("keydown", onKeyDown) - onCleanup(() => { - window.clearTimeout(focusTimeout) - document.documentElement.style.overflow = htmlOverflow - document.body.style.overflow = bodyOverflow - document.removeEventListener("keydown", onKeyDown) - activeElement?.focus() - }) - }) - - return ( -
    - - ) -} - -function newsletterErrorMessage(response: Response) { - return response.json().then( - (body: unknown) => - body && typeof body === "object" && "error" in body && typeof body.error === "string" - ? body.error - : "Failed to subscribe", - () => "Failed to subscribe", - ) -} - -function FooterColumn(props: { title: string; links: { href: string; label: string }[] }) { - return ( -
    -

    {props.title}

    - -
    - ) +function modelSlug(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-") } diff --git a/packages/stats/app/src/routes/model-catalog.ts b/packages/stats/app/src/routes/model-catalog.ts new file mode 100644 index 0000000000..8adddd39da --- /dev/null +++ b/packages/stats/app/src/routes/model-catalog.ts @@ -0,0 +1,224 @@ +import { query } from "@solidjs/router" + +export const modelCatalogSourceUrl = "https://models.dev/models.json" + +export type ModelCatalogEntry = { + id: string + lab: string + slug: string + name: string + family?: string + knowledge?: string + releaseDate?: string + lastUpdated?: string + limit?: { context?: number; output?: number } + modalities: { input: string[]; output: string[] } + openWeights: boolean + reasoning: boolean + toolCall: boolean + attachment: boolean + temperature: boolean + weights: { label: string; url: string }[] + benchmarks: ModelCatalogBenchmark[] +} + +export type ModelCatalogBenchmark = { + name: string + score: number + metric?: string + harness?: string + variant?: string + dataset?: string + version?: string + source?: string +} + +export type ModelCatalogLab = { + id: string + name: string + models: ModelCatalogEntry[] +} + +export type ModelCatalog = { + models: ModelCatalogEntry[] + labs: ModelCatalogLab[] +} + +export const getModelCatalog = query(async () => { + "use server" + const payload = await fetch(modelCatalogSourceUrl) + .then((response): Promise => (response.ok ? (response.json() as Promise) : Promise.resolve())) + .catch(() => undefined) + return buildModelCatalog(payload) +}, "getModelCatalog") + +export function findModelCatalogEntry(catalog: ModelCatalog, model: string, lab?: string) { + const normalizedId = lab ? `${catalogSlug(lab)}/${catalogSlug(model)}` : model.trim().toLowerCase() + const leaf = catalogSlug(model) + return ( + catalog.models.find((entry) => entry.id.toLowerCase() === normalizedId) ?? + catalog.models.find((entry) => (lab ? entry.lab === catalogSlug(lab) : true) && entry.slug === leaf) ?? + catalog.models.find((entry) => entry.slug === leaf) + ) +} + +export function findModelCatalogLab(catalog: ModelCatalog, lab: string) { + const id = catalogSlug(lab) + return catalog.labs.find((entry) => entry.id === id) +} + +export function formatCatalogLabName(lab: string) { + const known: Record = { + alibaba: "Alibaba", + anthropic: "Anthropic", + cohere: "Cohere", + deepseek: "DeepSeek", + google: "Google", + meta: "Meta", + minimax: "MiniMax", + mistral: "Mistral", + moonshotai: "Moonshot", + openai: "OpenAI", + perplexity: "Perplexity", + stepfun: "StepFun", + tencent: "Tencent", + xai: "xAI", + xiaomi: "Xiaomi", + zai: "Z.ai", + zhipuai: "Zhipu", + } + return known[catalogSlug(lab)] ?? lab.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()) +} + +export function catalogSlug(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-") +} + +function buildModelCatalog(payload: unknown): ModelCatalog { + const models = (Array.isArray(payload) ? payload : isRecord(payload) ? Object.values(payload) : []) + .flatMap(readModelCatalogEntry) + .toSorted((a, b) => a.lab.localeCompare(b.lab) || displayDateTime(b.releaseDate) - displayDateTime(a.releaseDate)) + return { + models, + labs: Object.values( + models.reduce>((result, model) => { + result[model.lab] = { + id: model.lab, + name: formatCatalogLabName(model.lab), + models: [...(result[model.lab]?.models ?? []), model], + } + return result + }, {}), + ).toSorted((a, b) => a.name.localeCompare(b.name)), + } +} + +function readModelCatalogEntry(value: unknown): ModelCatalogEntry[] { + if (!isRecord(value)) return [] + const id = stringValue(value.id) + const name = stringValue(value.name) + const lab = id?.split("/")[0] + const slug = id?.split("/").slice(1).join("/") + if (!id || !name || !lab || !slug) return [] + return [ + { + id, + lab: catalogSlug(lab), + slug: catalogSlug(slug), + name, + family: stringValue(value.family), + knowledge: stringValue(value.knowledge), + releaseDate: stringValue(value.release_date), + lastUpdated: stringValue(value.last_updated), + limit: readCatalogLimit(value.limit), + modalities: readCatalogModalities(value.modalities), + openWeights: booleanValue(value.open_weights), + reasoning: booleanValue(value.reasoning), + toolCall: booleanValue(value.tool_call), + attachment: booleanValue(value.attachment), + temperature: booleanValue(value.temperature), + weights: readCatalogWeights(value.weights), + benchmarks: readCatalogBenchmarks(value.benchmarks), + }, + ] +} + +function readCatalogLimit(value: unknown) { + if (!isRecord(value)) return undefined + return { + context: numberValue(value.context), + output: numberValue(value.output), + } +} + +function readCatalogModalities(value: unknown) { + if (!isRecord(value)) return { input: [], output: [] } + return { + input: stringArrayValue(value.input), + output: stringArrayValue(value.output), + } +} + +function readCatalogWeights(value: unknown) { + if (!Array.isArray(value)) return [] + return value.flatMap((item) => { + if (!isRecord(item)) return [] + const label = stringValue(item.label) + const url = stringValue(item.url) + return label && url ? [{ label, url }] : [] + }) +} + +function readCatalogBenchmarks(value: unknown) { + if (!Array.isArray(value)) return [] + return value.flatMap((item) => { + if (!isRecord(item)) return [] + const name = stringValue(item.name) + const score = numberValue(item.score) + return name && score !== undefined + ? [ + { + name, + score, + metric: stringValue(item.metric), + harness: stringValue(item.harness), + variant: stringValue(item.variant), + dataset: stringValue(item.dataset), + version: stringValue(item.version), + source: stringValue(item.source), + }, + ] + : [] + }) +} + +function displayDateTime(value: string | undefined) { + return value ? new Date(value).getTime() || 0 : 0 +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function stringValue(value: unknown) { + return typeof value === "string" && value.trim() ? value : undefined +} + +function numberValue(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +function booleanValue(value: unknown) { + return value === true +} + +function stringArrayValue(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string" && item.trim() !== "") + : [] +} diff --git a/packages/stats/app/src/routes/stats-shell.tsx b/packages/stats/app/src/routes/stats-shell.tsx new file mode 100644 index 0000000000..80fdc5619b --- /dev/null +++ b/packages/stats/app/src/routes/stats-shell.tsx @@ -0,0 +1,493 @@ +import opencodeWordmarkDark from "../asset/logo-ornate-dark.svg" +import { query } from "@solidjs/router" +import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" + +export type HeaderLink = { href: string; label: string } + +export const headerLinks = [ + { href: "#top-models", label: "Top Models" }, + { href: "#leaderboard", label: "Leaderboard" }, + { href: "#session-cost", label: "Session Cost" }, + { href: "#token-cost", label: "Token Cost" }, + { href: "#cache-ratio", label: "Cache Ratio" }, + { href: "#market-share", label: "Market Share" }, + { href: "#geo-breakdown", label: "Geo Breakdown" }, +] as const +export const githubLink = { + href: "https://github.com/anomalyco/opencode", + apiHref: "https://api.github.com/repos/anomalyco/opencode", + label: "GitHub", + fallbackStars: "150K", + ariaLabel: "Star OpenCode on GitHub", +} +export const themePreferences = ["dark", "light", "system"] as const +export const themeStorageKey = "opencode:stats-theme" +export type ThemePreference = (typeof themePreferences)[number] + +const compactNumberFormatter = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, +}) +const themePreferenceLabels = { + dark: "Dark", + light: "Light", + system: "System", +} as const + +export const getGitHubStars = query(async () => { + "use server" + return fetch(githubLink.apiHref, { + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }) + .then((response) => (response.ok ? response.json() : undefined)) + .then((body: unknown) => + body && typeof body === "object" && "stargazers_count" in body && typeof body.stargazers_count === "number" + ? compactNumberFormatter.format(body.stargazers_count) + : githubLink.fallbackStars, + ) + .catch(() => githubLink.fallbackStars) +}, "getGitHubStars") + +export function isThemePreference(value: string | null): value is ThemePreference { + return value === "dark" || value === "light" || value === "system" +} + +export function applyThemePreference(preference: ThemePreference) { + if (typeof document === "undefined") return + document.documentElement.dataset.statsTheme = preference + if (preference === "system") { + document.documentElement.style.removeProperty("color-scheme") + return + } + document.documentElement.style.setProperty("color-scheme", preference) +} + +export function Header(props: { githubStars: string; links?: readonly HeaderLink[]; brandHref?: string }) { + const [menuOpen, setMenuOpen] = createSignal(false) + const [menuViewport, setMenuViewport] = createSignal(false) + const links = createMemo(() => props.links ?? headerLinks) + + createEffect(() => { + if (typeof window === "undefined") return + const media = window.matchMedia("(max-width: 74.999rem)") + const update = () => setMenuViewport(media.matches) + update() + media.addEventListener("change", update) + onCleanup(() => media.removeEventListener("change", update)) + }) + + createEffect(() => { + if (!menuOpen()) return + if (!menuViewport()) return + if (typeof document === "undefined") return + const page = document.querySelector('[data-page="stats"]') + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth + const htmlOverflow = document.documentElement.style.overflow + const pagePaddingRight = page?.style.paddingRight + const bodyOverflow = document.body.style.overflow + document.documentElement.style.overflow = "hidden" + if (scrollbarWidth > 0 && page) page.style.paddingRight = `${scrollbarWidth}px` + document.body.style.overflow = "hidden" + onCleanup(() => { + document.documentElement.style.overflow = htmlOverflow + if (page && pagePaddingRight !== undefined) page.style.paddingRight = pagePaddingRight + document.body.style.overflow = bodyOverflow + }) + }) + + return ( +
    +
    + + + + +
    + + {githubLink.label} + [{props.githubStars}] + + + Try OpenCode + + +
    +
    + +
    + ) +} + +function StatsWordmark() { + return ( + + ) +} + +function StatsMark() { + return ( + + ) +} + +function OpenCodeMark() { + return ( + + ) +} + +export function Footer(props: { + themePreference: ThemePreference + onThemePreferenceChange: (preference: ThemePreference) => void + links?: readonly HeaderLink[] +}) { + const [subscribeOpen, setSubscribeOpen] = createSignal(false) + const modelStats = props.links ?? [ + { href: "#top-models", label: "Top Models" }, + { href: "#leaderboard", label: "Leaderboard" }, + { href: "#session-cost", label: "Session Cost" }, + { href: "#token-cost", label: "Token Cost" }, + { href: "#cache-ratio", label: "Cache Ratio" }, + { href: "#market-share", label: "Market Share" }, + { href: "#geo-breakdown", label: "Geo Breakdown" }, + ] + const legal = [ + { href: "https://opencode.ai/legal/terms-of-service", label: "Terms of service" }, + { href: "https://opencode.ai/legal/privacy-policy", label: "Privacy policy" }, + ] + const connect = [ + { href: "mailto:hello@opencode.ai", label: "Contact us" }, + { href: "https://opencode.ai/discord", label: "Community" }, + { href: "https://x.com/opencode", label: "X" }, + githubLink, + { href: "https://www.youtube.com/@anomaly-co", label: "YouTube" }, + ] + + return ( +
    + +
    + + + + + + +
    +

    Newsletter

    +

    Be the first to know about new releases.

    + +
    +
    +
    + ) +} + +function SectionBridge(props: { label: string; href: string }) { + return ( + + LEAN MORE + + {props.label} + + + ) +} + +function ThemePreferenceIcon(props: { preference: ThemePreference }) { + return ( + + ) +} + +function SubscribeModal(props: { onClose: () => void }) { + const [status, setStatus] = createSignal<"idle" | "pending" | "success" | "error">("idle") + const [message, setMessage] = createSignal("") + let input: HTMLInputElement | undefined + + onMount(() => { + if (typeof document === "undefined") return + const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : undefined + const htmlOverflow = document.documentElement.style.overflow + const bodyOverflow = document.body.style.overflow + document.documentElement.style.overflow = "hidden" + document.body.style.overflow = "hidden" + const focusTimeout = window.setTimeout(() => input?.focus(), 0) + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") props.onClose() + } + document.addEventListener("keydown", onKeyDown) + onCleanup(() => { + window.clearTimeout(focusTimeout) + document.documentElement.style.overflow = htmlOverflow + document.body.style.overflow = bodyOverflow + document.removeEventListener("keydown", onKeyDown) + activeElement?.focus() + }) + }) + + return ( +
    + + ) +} + +function newsletterErrorMessage(response: Response) { + return response.json().then( + (body: unknown) => + body && typeof body === "object" && "error" in body && typeof body.error === "string" + ? body.error + : "Failed to subscribe", + () => "Failed to subscribe", + ) +} + +function FooterColumn(props: { title: string; links: readonly { href: string; label: string }[] }) { + return ( +
    +

    {props.title}

    + +
    + ) +} diff --git a/packages/stats/core/src/domain/geo.ts b/packages/stats/core/src/domain/geo.ts index 25c5d90145..2512ffa498 100644 --- a/packages/stats/core/src/domain/geo.ts +++ b/packages/stats/core/src/domain/geo.ts @@ -37,7 +37,10 @@ export type GeoStatMetric = { export declare namespace GeoStatRepo { export interface Service { - readonly listDaily: () => Effect.Effect + readonly listDaily: (opts?: { + readonly provider?: string + readonly model?: string + }) => Effect.Effect readonly listByPeriod: (opts: { readonly grain: string readonly periodKey: string @@ -59,7 +62,16 @@ export class GeoStatRepo extends Context.Service db @@ -74,15 +86,7 @@ export class GeoStatRepo extends Context.Service DatabaseError.make({ cause }), }) diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts index 097a3814ec..cc7a3300bd 100644 --- a/packages/stats/core/src/domain/home.ts +++ b/packages/stats/core/src/domain/home.ts @@ -9,11 +9,78 @@ export type TokenProduct = "Zen" | "Go" | "Enterprise" export type UsageRange = "1D" | "1W" | "2W" | "1M" | "2M" | "3M" | "YTD" | "ALL" export type UsagePoint = { date: string; segments: { model: string; value: number }[] } export type MarketDay = { date: string; total: number; authors: { author: string; share: number; tokens: number }[] } -export type LeaderboardEntry = { model: string; author: string; tokens: number; change: number; rank: number } +export type LeaderboardEntry = { + model: string + provider: string + author: string + tokens: number + change: number + rank: number +} export type TokenCostEntry = { model: string; total: number; input: number; output: number; cached: number } export type CacheRatioEntry = { model: string; ratio: number; cached: number; uncached: number; total: number } export type SessionCostEntry = { model: string; cost: number; tokens: number } export type CountryEntry = { country: string; continent: string; tokens: number; share: number; rank: number } +export type ModelUsagePoint = { date: string; tokens: number; sessions: number; cost: number } +export type ModelMixEntry = { label: string; tokens: number; share: number } +export type ModelProductEntry = { product: string; tokens: number; sessions: number; share: number } +export type ModelPeerEntry = { + model: string + provider: string + author: string + rank: number + tokens: number + share: number + slug: string +} +export type LabUsageModelEntry = { + model: string + provider: string + author: string + tokens: number + share: number + slug: string +} +export type StatsModelData = { + updatedAt: string | null + model: string + slug: string + provider: string + author: string + rank: number + previousRank: number | null + totalModels: number + tokenShare: number + tokenChange: number + totals: { + sessions: number + tokens: number + cost: number + tokensPerSession: number + costPerSession: number + costPerMillion: number + cacheRatio: number + } + usage: ModelUsagePoint[] + tokenMix: ModelMixEntry[] + productMix: ModelProductEntry[] + country: Record + peers: ModelPeerEntry[] +} +export type StatsLabData = { + updatedAt: string | null + provider: string + author: string + tokenShare: number + tokenChange: number + totals: { + sessions: number + tokens: number + models: number + } + usage: ModelUsagePoint[] + models: LabUsageModelEntry[] +} export type StatsHomeData = { updatedAt: string | null usage: Record> @@ -74,6 +141,35 @@ export const getStatsHomeData: () => Effect.Effect< return buildStatsHomeData(modelRows, providerRows, geoRows) }) +export const getStatsModelData: ( + model: string, + provider?: string, +) => Effect.Effect = Effect.fn("StatsModel.getData")( + function* (model, provider) { + const modelStats = yield* ModelStatRepo + const geoStats = yield* GeoStatRepo + const modelRows = yield* modelStats.listDaily() + const normalized = modelRows.flatMap(normalizeStatRow) + const resolvedModel = resolveModelName(model, normalized, provider) + if (!resolvedModel) return null + return buildStatsModelData( + resolvedModel, + modelRows, + yield* geoStats.listDaily({ + model: resolvedModel, + provider: resolveModelProvider(resolvedModel, normalized, provider), + }), + provider, + ) + }, +) + +export const getStatsLabData: (provider: string) => Effect.Effect = + Effect.fn("StatsLab.getData")(function* (provider) { + const modelStats = yield* ModelStatRepo + return buildStatsLabData(provider, yield* modelStats.listDaily()) + }) + function buildStatsHomeData( modelRows: ModelStatMetric[], providerRows: ProviderStatMetric[], @@ -111,6 +207,119 @@ function buildStatsHomeData( } } +function buildStatsModelData( + modelParam: string, + modelRows: ModelStatMetric[], + geoRows: GeoStatMetric[], + providerParam?: string, +): StatsModelData | null { + const normalized = modelRows.flatMap(normalizeStatRow) + const geo = geoRows.flatMap(normalizeGeoRow) + if (normalized.length === 0) return null + + const model = resolveModelName(modelParam, normalized, providerParam) + if (!model) return null + + const modelScopedRows = normalized.filter((row) => row.model === model) + const earliest = Math.min(...normalized.map((row) => row.periodStart)) + const latest = Math.max(...normalized.map((row) => row.periodStart)) + const latestUpdate = Math.max(...modelScopedRows.map((row) => row.updatedAt)) + const window = getWindow("2M", earliest, latest) + const currentRows = rowsForProduct(modelScopedRows, "All Users", window.start, window.end) + const previousRows = rowsForProduct(modelScopedRows, "All Users", window.previousStart, window.previousEnd) + const current = combineRowsForModel(model, currentRows) + const previous = combineRowsForModel(model, previousRows) + const peers = aggregateByModelName(rowsForProduct(normalized, "All Users", window.start, window.end)) + .filter((item) => item.totalTokens > 0) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model)) + const previousPeers = aggregateByModelName( + rowsForProduct(normalized, "All Users", window.previousStart, window.previousEnd), + ) + .filter((item) => item.totalTokens > 0) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model)) + const rank = Math.max(1, peers.findIndex((item) => item.model === model) + 1) + const previousRankIndex = previousPeers.findIndex((item) => item.model === model) + const totalTokens = peers.reduce((sum, item) => sum + item.totalTokens, 0) + + return { + updatedAt: Number.isFinite(latestUpdate) ? new Date(latestUpdate).toISOString() : null, + model, + slug: modelSlug(model), + provider: current.provider, + author: formatProvider(current.provider), + rank, + previousRank: previousRankIndex >= 0 ? previousRankIndex + 1 : null, + totalModels: peers.length, + tokenShare: totalTokens > 0 ? round((current.totalTokens / totalTokens) * 100, 2) : 0, + tokenChange: percentChange(current.totalTokens, previous.totalTokens), + totals: { + sessions: current.sessions, + tokens: current.totalTokens, + cost: round(microcentsToDollars(current.totalCostMicrocents), 2), + tokensPerSession: current.sessions > 0 ? Math.round(current.totalTokens / current.sessions) : 0, + costPerSession: + current.sessions > 0 ? round(microcentsToDollars(current.totalCostMicrocents) / current.sessions, 4) : 0, + costPerMillion: costPerMillion(current.totalCostMicrocents, current.totalTokens), + cacheRatio: + current.inputTokens + current.cacheReadTokens > 0 + ? round((current.cacheReadTokens / (current.inputTokens + current.cacheReadTokens)) * 100, 1) + : 0, + }, + usage: buildModelUsage(currentRows, window, "2M"), + tokenMix: buildModelTokenMix(current), + productMix: buildModelProductMix(modelScopedRows, window, current), + country: createRangeRecord((range) => buildCountryStats(geo, getWindow(range, earliest, latest))), + peers: buildModelPeers(peers, rank, totalTokens), + } +} + +function buildStatsLabData(providerParam: string, modelRows: ModelStatMetric[]): StatsLabData | null { + const normalized = modelRows.flatMap(normalizeStatRow) + if (normalized.length === 0) return null + + const provider = resolveProviderName(providerParam, normalized) + if (!provider) return null + + const providerRows = normalized.filter((row) => providerMatches(row.provider, provider)) + if (providerRows.length === 0) return null + + const earliest = Math.min(...normalized.map((row) => row.periodStart)) + const latest = Math.max(...normalized.map((row) => row.periodStart)) + const latestUpdate = Math.max(...providerRows.map((row) => row.updatedAt)) + const window = getWindow("2M", earliest, latest) + const currentRows = rowsForProduct(providerRows, "All Users", window.start, window.end) + const previousRows = rowsForProduct(providerRows, "All Users", window.previousStart, window.previousEnd) + const current = combineRowsForModel("", currentRows) + const previous = combineRowsForModel("", previousRows) + const allCurrent = aggregateByModel(rowsForProduct(normalized, "All Users", window.start, window.end)) + const totalTokens = allCurrent.reduce((sum, item) => sum + item.totalTokens, 0) + const models = aggregateByModel(currentRows) + .filter((item) => item.totalTokens > 0) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model)) + + return { + updatedAt: Number.isFinite(latestUpdate) ? new Date(latestUpdate).toISOString() : null, + provider, + author: formatProvider(provider), + tokenShare: totalTokens > 0 ? round((current.totalTokens / totalTokens) * 100, 2) : 0, + tokenChange: percentChange(current.totalTokens, previous.totalTokens), + totals: { + sessions: current.sessions, + tokens: current.totalTokens, + models: models.length, + }, + usage: buildModelUsage(currentRows, window, "2M"), + models: models.map((item) => ({ + model: item.model, + provider: item.provider, + author: formatProvider(item.provider), + tokens: item.totalTokens, + share: current.totalTokens > 0 ? round((item.totalTokens / current.totalTokens) * 100, 2) : 0, + slug: modelSlug(item.model), + })), + } +} + function emptyStatsHomeData(): StatsHomeData { return { updatedAt: null, @@ -160,6 +369,7 @@ function buildLeaderboard(rows: StatMetricRow[], product: UsageProduct, window: .slice(0, 18) .map((item, index) => ({ model: item.model, + provider: item.provider, author: formatProvider(item.provider), tokens: Math.round(item.totalTokens / 1_000_000_000), change: percentChange(item.totalTokens, previous.get(modelKey(item.provider, item.model)) ?? 0), @@ -261,6 +471,66 @@ function buildSessionCost(rows: StatMetricRow[], product: TokenProduct, window: .slice(0, 17) } +function buildModelUsage(rows: StatMetricRow[], window: DateWindow, range: UsageRange) { + return createBuckets(window, range).map((bucket) => { + const aggregate = combineRowsForModel( + "", + rows.filter((row) => row.periodStart >= bucket.start && row.periodStart < bucket.end), + ) + return { + date: bucket.label, + tokens: aggregate.totalTokens, + sessions: aggregate.sessions, + cost: round(microcentsToDollars(aggregate.totalCostMicrocents), 2), + } + }) +} + +function buildModelTokenMix(aggregate: ModelAggregate): ModelMixEntry[] { + const items = [ + { label: "Input", tokens: aggregate.inputTokens }, + { label: "Output", tokens: aggregate.outputTokens }, + { label: "Reasoning", tokens: aggregate.reasoningTokens }, + { label: "Cached", tokens: aggregate.cacheReadTokens }, + ].filter((item) => item.tokens > 0) + const total = items.reduce((sum, item) => sum + item.tokens, 0) + if (total === 0) return [] + return items.map((item) => ({ ...item, share: round((item.tokens / total) * 100, 1) })) +} + +function buildModelProductMix( + rows: StatMetricRow[], + window: DateWindow, + fallback: ModelAggregate, +): ModelProductEntry[] { + const products = ["Go", "Zen", "Enterprise"] as const + const items = products.flatMap((product) => { + const aggregate = combineRowsForModel( + fallback.model, + rows.filter((row) => row.tier === product && row.periodStart >= window.start && row.periodStart < window.end), + ) + if (aggregate.totalTokens === 0) return [] + return [{ product, tokens: aggregate.totalTokens, sessions: aggregate.sessions }] + }) + const total = items.reduce((sum, item) => sum + item.tokens, 0) + if (total > 0) return items.map((item) => ({ ...item, share: round((item.tokens / total) * 100, 1) })) + if (fallback.totalTokens === 0) return [] + return [{ product: "All Users", tokens: fallback.totalTokens, sessions: fallback.sessions, share: 100 }] +} + +function buildModelPeers(peers: ModelAggregate[], rank: number, totalTokens: number): ModelPeerEntry[] { + const start = Math.max(0, Math.min(rank - 4, Math.max(peers.length - 7, 0))) + return peers.slice(start, start + 7).map((item, index) => ({ + model: item.model, + provider: item.provider, + author: formatProvider(item.provider), + rank: start + index + 1, + tokens: item.totalTokens, + share: totalTokens > 0 ? round((item.totalTokens / totalTokens) * 100, 2) : 0, + slug: modelSlug(item.model), + })) +} + function rowsForProduct( rows: T[], product: UsageProduct, @@ -285,6 +555,15 @@ function aggregateByModel(rows: StatMetricRow[]) { ) } +function aggregateByModelName(rows: StatMetricRow[]) { + return Object.values( + rows.reduce>((result, row) => { + result[row.model] = combineModelAggregate(result[row.model], row) + return result + }, {}), + ) +} + function aggregateByProvider(rows: ProviderMetricRow[]) { return Object.values( rows.reduce>((result, row) => { @@ -310,6 +589,27 @@ function aggregateByCountry(rows: GeoMetricRow[]) { ) } +function combineRowsForModel(model: string, rows: StatMetricRow[]): ModelAggregate { + const aggregate = rows.reduce( + (result, row) => combineModelAggregate(result, row), + undefined, + ) + if (aggregate) return { ...aggregate, model: model || aggregate.model } + return { + model, + provider: "unknown", + sessions: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + inputCostMicrocents: 0, + outputCostMicrocents: 0, + totalCostMicrocents: 0, + } +} + function combineModelAggregate(current: ModelAggregate | undefined, row: StatMetricRow): ModelAggregate { return { model: row.model, @@ -501,6 +801,47 @@ function formatProvider(provider: string) { return known[normalized] ?? provider.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()) } +function resolveModelName(modelParam: string, rows: StatMetricRow[], providerParam?: string) { + const input = modelParam.trim() + if (!input) return undefined + const normalizedInput = input.toLowerCase() + const inputSlug = modelSlug(input) + const candidates = providerParam + ? aggregateByModel(rows).filter((item) => providerMatches(item.provider, providerParam)) + : aggregateByModelName(rows) + return candidates + .filter((item) => item.model.toLowerCase() === normalizedInput || modelSlug(item.model) === inputSlug) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model))[0]?.model +} + +function resolveModelProvider(model: string, rows: StatMetricRow[], providerParam?: string) { + return aggregateByModel(rows) + .filter((item) => item.model === model && (!providerParam || providerMatches(item.provider, providerParam))) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.provider.localeCompare(b.provider))[0]?.provider +} + +function providerMatches(provider: string, providerParam: string) { + return modelSlug(provider) === modelSlug(providerParam) +} + +function resolveProviderName(providerParam: string, rows: StatMetricRow[]) { + const input = providerParam.trim() + if (!input) return undefined + const inputSlug = modelSlug(input) + return aggregateByModel(rows) + .filter((item) => modelSlug(item.provider) === inputSlug) + .toSorted((a, b) => b.totalTokens - a.totalTokens || a.provider.localeCompare(b.provider))[0]?.provider +} + +export function modelSlug(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-") +} + function modelKey(provider: string, model: string) { return `${provider}\u0000${model}` } From 46e9863589746c3f84f148974582b6428bbbfdf8 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:10:34 -0500 Subject: [PATCH 009/626] fix(stats): sort metric charts by top usage --- packages/stats/core/src/domain/home.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts index cc7a3300bd..1d3cc8e9f0 100644 --- a/packages/stats/core/src/domain/home.ts +++ b/packages/stats/core/src/domain/home.ts @@ -95,6 +95,7 @@ export type StatsHomeData = { const DAY_MS = 86_400_000 const TOKEN_SCALE = 1_000_000 const DOLLARS_PER_MICROCENT = 1 / 100_000_000 +const METRIC_MODEL_LIMIT = 10 const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const type StatMetricRow = Omit & { @@ -422,7 +423,7 @@ function buildCountryStats(rows: GeoMetricRow[], window: DateWindow) { } function buildTokenCost(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) { - return aggregateByModel(rowsForProduct(rows, product, window.start, window.end)) + return topModelsByUsage(rows, product, window) .flatMap((item) => { const total = costPerMillion(item.totalCostMicrocents, item.totalTokens) if (total === 0) return [] @@ -437,11 +438,10 @@ function buildTokenCost(rows: StatMetricRow[], product: TokenProduct, window: Da ] }) .toSorted((a, b) => a.total - b.total) - .slice(0, 17) } function buildCacheRatio(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) { - return aggregateByModel(rowsForProduct(rows, product, window.start, window.end)) + return topModelsByUsage(rows, product, window) .flatMap((item) => { const total = item.inputTokens + item.cacheReadTokens if (total === 0) return [] @@ -456,11 +456,10 @@ function buildCacheRatio(rows: StatMetricRow[], product: TokenProduct, window: D ] }) .toSorted((a, b) => b.ratio - a.ratio || b.cached - a.cached) - .slice(0, 17) } function buildSessionCost(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) { - return aggregateByModel(rowsForProduct(rows, product, window.start, window.end)) + return topModelsByUsage(rows, product, window) .flatMap((item) => { if (item.sessions === 0) return [] const cost = round(microcentsToDollars(item.totalCostMicrocents) / item.sessions, 4) @@ -468,7 +467,12 @@ function buildSessionCost(rows: StatMetricRow[], product: TokenProduct, window: return [{ model: item.model, cost, tokens: Math.round(item.totalTokens / item.sessions) }] }) .toSorted((a, b) => a.cost - b.cost) - .slice(0, 17) +} + +function topModelsByUsage(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) { + return aggregateByModel(rowsForProduct(rows, product, window.start, window.end)) + .toSorted((a, b) => b.totalTokens - a.totalTokens) + .slice(0, METRIC_MODEL_LIMIT) } function buildModelUsage(rows: StatMetricRow[], window: DateWindow, range: UsageRange) { From 773d33e282f302947c846f7bde5e0297bf666852 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 20:49:43 -0400 Subject: [PATCH 010/626] feat(core): add public native API (#30828) --- packages/core/package.json | 1 + packages/core/src/opencode.ts | 39 ---------- packages/core/src/public/agent.ts | 6 ++ packages/core/src/public/index.ts | 8 ++ packages/core/src/public/location.ts | 6 ++ packages/core/src/public/model.ts | 9 +++ packages/core/src/public/opencode.ts | 70 +++++++++++++++++ packages/core/src/public/session.ts | 91 ++++++++++++++++++++++ packages/core/test/opencode.test.ts | 16 ---- packages/core/test/public-opencode.test.ts | 29 +++++++ 10 files changed, 220 insertions(+), 55 deletions(-) delete mode 100644 packages/core/src/opencode.ts create mode 100644 packages/core/src/public/agent.ts create mode 100644 packages/core/src/public/index.ts create mode 100644 packages/core/src/public/location.ts create mode 100644 packages/core/src/public/model.ts create mode 100644 packages/core/src/public/opencode.ts create mode 100644 packages/core/src/public/session.ts delete mode 100644 packages/core/test/opencode.test.ts create mode 100644 packages/core/test/public-opencode.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index be6c96b849..848a99fe2d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,6 +17,7 @@ "opencode": "./bin/opencode" }, "exports": { + "./public": "./src/public/index.ts", "./session/runner": "./src/session/runner/index.ts", "./*": "./src/*.ts" }, diff --git a/packages/core/src/opencode.ts b/packages/core/src/opencode.ts deleted file mode 100644 index 7c71916426..0000000000 --- a/packages/core/src/opencode.ts +++ /dev/null @@ -1,39 +0,0 @@ -export * as OpenCode from "./opencode" - -import { Context, Effect, Layer } from "effect" -import { Database } from "./database/database" -import { EventV2 } from "./event" -import { LocationServiceMap } from "./location-layer" -import { ProjectV2 } from "./project" -import { SessionV2 } from "./session" -import { SessionProjector } from "./session/projector" -import * as SessionExecutionLocal from "./session/execution/local" -import { SessionStore } from "./session/store" - -export interface Interface { - readonly sessions: SessionV2.Interface -} - -/** Public embedded OpenCode API for Effect-native applications. */ -export class Service extends Context.Service()("@opencode/OpenCode") {} - -const DefaultSessions = SessionV2.layer.pipe( - Layer.provide(SessionProjector.layer), - Layer.provide(SessionExecutionLocal.layer), - Layer.provide(LocationServiceMap.layer), - Layer.provide(SessionStore.layer), - Layer.provide(EventV2.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.orDie, -) - -// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - return Service.of({ sessions: yield* SessionV2.Service }) - }), -).pipe(Layer.provide(DefaultSessions)) - -// TODO: Add OpenCode.create(...) as the Promise facade over the same embedded API semantics. diff --git a/packages/core/src/public/agent.ts b/packages/core/src/public/agent.ts new file mode 100644 index 0000000000..ade2096f89 --- /dev/null +++ b/packages/core/src/public/agent.ts @@ -0,0 +1,6 @@ +export * as Agent from "./agent" + +import { AgentV2 } from "../agent" + +export const ID = AgentV2.ID +export type ID = AgentV2.ID diff --git a/packages/core/src/public/index.ts b/packages/core/src/public/index.ts new file mode 100644 index 0000000000..0fd1b9537e --- /dev/null +++ b/packages/core/src/public/index.ts @@ -0,0 +1,8 @@ +/** Intentional supported native API. Other core subpaths remain internal implementation surfaces. */ +export { Agent } from "./agent" +export { Model } from "./model" +export { OpenCode } from "./opencode" +export { Session } from "./session" +export { Location } from "./location" +export { Prompt } from "../session/prompt" +export { AbsolutePath } from "../schema" diff --git a/packages/core/src/public/location.ts b/packages/core/src/public/location.ts new file mode 100644 index 0000000000..aab15181d1 --- /dev/null +++ b/packages/core/src/public/location.ts @@ -0,0 +1,6 @@ +export * as Location from "./location" + +import { Location } from "../location" + +export const Ref = Location.Ref +export type Ref = Location.Ref diff --git a/packages/core/src/public/model.ts b/packages/core/src/public/model.ts new file mode 100644 index 0000000000..ab92b8dfe7 --- /dev/null +++ b/packages/core/src/public/model.ts @@ -0,0 +1,9 @@ +export * as Model from "./model" + +import { ModelV2 } from "../model" + +export const ID = ModelV2.ID +export type ID = ModelV2.ID + +export const Ref = ModelV2.Ref +export type Ref = ModelV2.Ref diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts new file mode 100644 index 0000000000..67b26b00cc --- /dev/null +++ b/packages/core/src/public/opencode.ts @@ -0,0 +1,70 @@ +export * as OpenCode from "./opencode" + +import { Context, Effect, Layer } from "effect" +import { Database } from "../database/database" +import { EventV2 } from "../event" +import { LocationServiceMap } from "../location-layer" +import { ProjectV2 } from "../project" +import { SessionV2 } from "../session" +import * as SessionExecutionLocal from "../session/execution/local" +import { SessionProjector } from "../session/projector" +import { SessionStore } from "../session/store" +import { Session } from "./session" + +export interface Interface { + readonly sessions: Session.Interface +} + +/** Intentional public native API for Effect applications embedding OpenCode. */ +export class Service extends Context.Service()("@opencode/public/OpenCode") {} + +const SessionsLayer = SessionV2.layer.pipe( + Layer.provide(SessionProjector.layer), + Layer.provide(SessionExecutionLocal.layer), + Layer.provide(LocationServiceMap.layer), + Layer.provide(SessionStore.layer), + Layer.provide(EventV2.layer), + Layer.provide(Database.defaultLayer), + Layer.provide(ProjectV2.defaultLayer), + Layer.orDie, +) + +// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const sessions = yield* SessionV2.Service + return Service.of({ + sessions: { + create: (input) => + sessions.create({ + id: input.id, + agent: input.agent, + model: input.model, + location: input.location, + }), + get: sessions.get, + list: sessions.list, + prompt: (input) => + sessions.prompt({ + id: input.id, + sessionID: input.sessionID, + prompt: input.prompt, + delivery: input.delivery, + }), + messages: (input) => + sessions.messages({ + sessionID: input.sessionID, + limit: input.limit, + order: input.order, + cursor: input.cursor, + }), + message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }), + context: sessions.context, + events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }), + }, + }) + }), +).pipe(Layer.provide(SessionsLayer)) + +// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics. diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts new file mode 100644 index 0000000000..f66fe2b088 --- /dev/null +++ b/packages/core/src/public/session.ts @@ -0,0 +1,91 @@ +export * as Session from "./session" + +import { Effect, Stream } from "effect" +import { EventV2 } from "../event" +import { SessionV2 } from "../session" +import { MessageDecodeError } from "../session/error" +import { SessionEvent } from "../session/event" +import { SessionInput } from "../session/input" +import { SessionMessage } from "../session/message" +import { Prompt } from "../session/prompt" +import { Agent } from "./agent" +import { Location } from "./location" +import { Model } from "./model" + +export const ID = SessionV2.ID +export type ID = SessionV2.ID + +export const Info = SessionV2.Info +export type Info = SessionV2.Info + +export const MessageID = SessionMessage.ID +export type MessageID = SessionMessage.ID + +export const Message = SessionMessage.Message +export type Message = SessionMessage.Message + +export const Admission = SessionInput.Admitted +export type Admission = SessionInput.Admitted + +export const Delivery = SessionInput.Delivery +export type Delivery = SessionInput.Delivery + +export const ListInput = SessionV2.ListInput +export type ListInput = SessionV2.ListInput + +export const EventCursor = EventV2.Cursor +export type EventCursor = EventV2.Cursor +export type Event = EventV2.CursorEvent + +export const NotFoundError = SessionV2.NotFoundError +export type NotFoundError = SessionV2.NotFoundError + +export const PromptConflictError = SessionV2.PromptConflictError +export type PromptConflictError = SessionV2.PromptConflictError + +export { MessageDecodeError } + +export interface CreateInput { + readonly id?: ID + readonly agent?: Agent.ID + readonly model?: Model.Ref + readonly location: Location.Ref +} + +export interface PromptInput { + readonly id?: MessageID + readonly sessionID: ID + readonly prompt: Prompt + readonly delivery?: Delivery +} + +export interface MessagesInput { + readonly sessionID: ID + readonly limit?: number + readonly order?: "asc" | "desc" + readonly cursor?: { + readonly id: MessageID + readonly direction: "previous" | "next" + } +} + +export interface MessageInput { + readonly sessionID: ID + readonly messageID: MessageID +} + +export interface EventsInput { + readonly sessionID: ID + readonly after?: EventCursor +} + +export interface Interface { + readonly create: (input: CreateInput) => Effect.Effect + readonly get: (sessionID: ID) => Effect.Effect + readonly list: (input?: ListInput) => Effect.Effect + readonly prompt: (input: PromptInput) => Effect.Effect + readonly messages: (input: MessagesInput) => Effect.Effect + readonly message: (input: MessageInput) => Effect.Effect + readonly context: (sessionID: ID) => Effect.Effect + readonly events: (input: EventsInput) => Stream.Stream +} diff --git a/packages/core/test/opencode.test.ts b/packages/core/test/opencode.test.ts deleted file mode 100644 index 3c0c499f5b..0000000000 --- a/packages/core/test/opencode.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect } from "bun:test" -import { Effect } from "effect" -import { OpenCode } from "@opencode-ai/core/opencode" -import { testEffect } from "./lib/effect" - -const it = testEffect(OpenCode.layer) - -describe("OpenCode.layer", () => { - it.effect("exposes Sessions through the public embedded API", () => - Effect.gen(function* () { - const opencode = yield* OpenCode.Service - - expect(yield* opencode.sessions.list()).toBeArray() - }), - ) -}) diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts new file mode 100644 index 0000000000..c74421218b --- /dev/null +++ b/packages/core/test/public-opencode.test.ts @@ -0,0 +1,29 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { OpenCode, Session } from "@opencode-ai/core/public" +import { testEffect } from "./lib/effect" + +const it = testEffect(OpenCode.layer) + +describe("public native OpenCode API", () => { + it.effect("exposes only the intentional Session capabilities", () => + Effect.gen(function* () { + const opencode = yield* OpenCode.Service + + expect(Object.keys(opencode.sessions).sort()).toEqual([ + "context", + "create", + "events", + "get", + "list", + "message", + "messages", + "prompt", + ]) + expect(Session.ID.create()).toStartWith("ses_") + expect(Session.MessageID.create()).toStartWith("msg_") + expect(yield* opencode.sessions.list()).toBeArray() + }), + ) + +}) From 75557000de3a3c53c536102bf5de6536c9ee0ce2 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 5 Jun 2026 00:50:58 +0000 Subject: [PATCH 011/626] chore: generate --- packages/core/src/public/opencode.ts | 2 +- packages/core/test/public-opencode.test.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index 67b26b00cc..38541faa8f 100644 --- a/packages/core/src/public/opencode.ts +++ b/packages/core/src/public/opencode.ts @@ -42,7 +42,7 @@ export const layer = Layer.effect( agent: input.agent, model: input.model, location: input.location, - }), + }), get: sessions.get, list: sessions.list, prompt: (input) => diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts index c74421218b..1aa9130fa8 100644 --- a/packages/core/test/public-opencode.test.ts +++ b/packages/core/test/public-opencode.test.ts @@ -25,5 +25,4 @@ describe("public native OpenCode API", () => { expect(yield* opencode.sessions.list()).toBeArray() }), ) - }) From 605ae48c6f3317725338be5ab6f7d83db4760cc1 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 5 Jun 2026 06:23:36 +0530 Subject: [PATCH 012/626] feat(app): color themes (#30824) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> --- .../settings-v2/dialog-settings-v2.tsx | 10 +- .../components/settings-v2/settings-v2.css | 16 +- packages/app/src/pages/home.tsx | 2 +- packages/desktop/src/renderer/index.tsx | 2 +- packages/ui/package.json | 3 +- packages/ui/script/build-oc2-v2-overrides.ts | 32 ++ packages/ui/src/theme/context.tsx | 10 +- .../ui/src/theme/desktop-theme.schema.json | 7 + packages/ui/src/theme/index.ts | 3 + packages/ui/src/theme/loader.ts | 19 +- packages/ui/src/theme/themes/matrix.json | 22 + packages/ui/src/theme/themes/oc-2.json | 376 ++++++++++++++++++ packages/ui/src/theme/types.ts | 5 + packages/ui/src/theme/v2/avatar.ts | 48 +++ .../ui/src/theme/v2/default-primitives.ts | 113 ++++++ packages/ui/src/theme/v2/foreground.ts | 24 ++ packages/ui/src/theme/v2/mapping.ts | 150 +++++++ packages/ui/src/theme/v2/resolve.ts | 153 +++++++ packages/ui/src/v2/components/dialog-v2.css | 5 +- .../src/v2/components/dialog-v2.stories.tsx | 2 +- packages/ui/src/v2/components/dialog-v2.tsx | 10 +- .../src/v2/components/project-avatar-v2.css | 2 +- packages/ui/src/v2/styles/theme.css | 113 +++--- 23 files changed, 1040 insertions(+), 87 deletions(-) create mode 100644 packages/ui/script/build-oc2-v2-overrides.ts create mode 100644 packages/ui/src/theme/v2/avatar.ts create mode 100644 packages/ui/src/theme/v2/default-primitives.ts create mode 100644 packages/ui/src/theme/v2/foreground.ts create mode 100644 packages/ui/src/theme/v2/mapping.ts create mode 100644 packages/ui/src/theme/v2/resolve.ts diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index 7408767b96..932f2dedd4 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -16,14 +16,8 @@ export const DialogSettings: Component = () => { const platform = usePlatform() return ( - - + +
    diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index 29c2d1ecaf..38b6733621 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -1,11 +1,15 @@ @import "@opencode-ai/ui/v2/text-input-v2.css"; @import "@opencode-ai/ui/v2/button-v2.css"; -[data-component="settings-v2"] { +[data-component="tabs-v2"][data-variant="settings"] { height: 100%; } -[data-component="settings-v2-dialog"] [data-slot="dialog-body"] { +[data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-container"] { + background: var(--v2-background-bg-base); +} + +[data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-body"] { padding: 0; overflow: hidden; } @@ -81,10 +85,6 @@ box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); } -[data-slot="dialog-container"]:has(.settings-v2-dialog) { - box-shadow: var(--v2-elevation-overlay); -} - [data-component="settings-v2-row"] { display: flex; flex-wrap: wrap; @@ -153,12 +153,12 @@ width: 100%; } -[data-component="settings-v2-dialog"] [data-component="select-v2-root"] { +[data-component="dialog-v2"][data-variant="settings"] [data-component="select-v2-root"] { width: fit-content; max-width: 100%; } -[data-component="settings-v2-dialog"] [data-component="button-v2"] { +[data-component="dialog-v2"][data-variant="settings"] [data-component="button-v2"] { width: fit-content; max-width: 100%; } diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index e7bc61ce0a..cf100bf014 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -270,7 +270,7 @@ function HomeDesign() { } return ( -
    +
    key !== "v2-avatar-fg" && key !== "v2-font-family-sans") + .map(([, key, value]) => [key, value!.replace(/\s+/g, " ").trim()]), + ) +} diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index 5bf8bfb0e0..a1505a0e39 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -1,9 +1,12 @@ +// @refresh reload + import { createEffect, onMount } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { createSimpleContext } from "../context/helper" import oc2ThemeJson from "./themes/oc-2.json" import { resolveThemeVariant, themeToCss } from "./resolve" +import { resolveThemeVariantV2, themeV2ToCss } from "./v2/resolve" import type { DesktopTheme } from "./types" export type ColorScheme = "light" | "dark" | "system" @@ -132,15 +135,17 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) + const v2 = themeV2ToCss(resolveThemeVariantV2(variant, isDark)) if (themeId !== "oc-2") { - write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, `${css}\n ${v2}`) } const fullCss = `:root { color-scheme: ${mode}; --text-mix-blend-mode: ${isDark ? "plus-lighter" : "multiply"}; ${css} + ${v2} }` document.getElementById("oc-theme-preload")?.remove() @@ -160,7 +165,8 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) { const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) - write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) + const v2 = themeV2ToCss(resolveThemeVariantV2(variant, isDark)) + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, `${css}\n ${v2}`) } } diff --git a/packages/ui/src/theme/desktop-theme.schema.json b/packages/ui/src/theme/desktop-theme.schema.json index 36f4c2c991..368f337ba1 100644 --- a/packages/ui/src/theme/desktop-theme.schema.json +++ b/packages/ui/src/theme/desktop-theme.schema.json @@ -154,6 +154,13 @@ "additionalProperties": { "$ref": "#/definitions/ColorValue" } + }, + "v2Overrides": { + "type": "object", + "description": "Optional direct overrides for any v2 CSS variable (without -- prefix)", + "additionalProperties": { + "type": "string" + } } } } diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts index 86d30eab81..deb66b6829 100644 --- a/packages/ui/src/theme/index.ts +++ b/packages/ui/src/theme/index.ts @@ -8,6 +8,8 @@ export type { ResolvedTheme, ColorValue, CssVarRef, + V2ColorValue, + ResolvedV2Theme, } from "./types" export { @@ -30,6 +32,7 @@ export { } from "./color" export { resolveThemeVariant, resolveTheme, themeToCss } from "./resolve" +export { resolveThemeVariantV2, resolveThemeV2, themeV2ToCss, generateV2Primitives } from "./v2/resolve" export { applyTheme, loadThemeFromUrl, getActiveTheme, removeTheme, setColorScheme } from "./loader" export { ThemeProvider, useTheme, type ColorScheme } from "./context" diff --git a/packages/ui/src/theme/loader.ts b/packages/ui/src/theme/loader.ts index 4d48000daf..ddf9ca09eb 100644 --- a/packages/ui/src/theme/loader.ts +++ b/packages/ui/src/theme/loader.ts @@ -1,5 +1,6 @@ -import type { DesktopTheme, ResolvedTheme } from "./types" +import type { DesktopTheme, ResolvedTheme, ResolvedV2Theme } from "./types" import { resolveThemeVariant, themeToCss } from "./resolve" +import { resolveThemeVariantV2, themeV2ToCss } from "./v2/resolve" let activeTheme: DesktopTheme | null = null const THEME_STYLE_ID = "opencode-theme" @@ -19,17 +20,25 @@ export function applyTheme(theme: DesktopTheme, themeId?: string): void { activeTheme = theme const lightTokens = resolveThemeVariant(theme.light, false) const darkTokens = resolveThemeVariant(theme.dark, true) + const lightV2Tokens = resolveThemeVariantV2(theme.light, false) + const darkV2Tokens = resolveThemeVariantV2(theme.dark, true) const targetThemeId = themeId ?? theme.id - const css = buildThemeCss(lightTokens, darkTokens, targetThemeId) + const css = buildThemeCss(lightTokens, darkTokens, lightV2Tokens, darkV2Tokens, targetThemeId) const themeStyleElement = ensureLoaderStyleElement() themeStyleElement.textContent = css document.documentElement.setAttribute("data-theme", targetThemeId) } -function buildThemeCss(light: ResolvedTheme, dark: ResolvedTheme, themeId: string): string { +function buildThemeCss( + light: ResolvedTheme, + dark: ResolvedTheme, + lightV2: ResolvedV2Theme, + darkV2: ResolvedV2Theme, + themeId: string, +): string { const isDefaultTheme = themeId === "oc-2" - const lightCss = themeToCss(light) - const darkCss = themeToCss(dark) + const lightCss = `${themeToCss(light)}\n ${themeV2ToCss(lightV2)}` + const darkCss = `${themeToCss(dark)}\n ${themeV2ToCss(darkV2)}` if (isDefaultTheme) { return ` diff --git a/packages/ui/src/theme/themes/matrix.json b/packages/ui/src/theme/themes/matrix.json index adc379326e..7c6d87926f 100644 --- a/packages/ui/src/theme/themes/matrix.json +++ b/packages/ui/src/theme/themes/matrix.json @@ -43,6 +43,17 @@ "markdown-image": "#30b3ff", "markdown-image-text": "#24f6d9", "markdown-code-block": "#203022" + }, + "v2Overrides": { + "v2-text-text-base": "#353535", + "v2-text-text-muted": "#748476", + "v2-text-text-faint": "#748476", + "v2-background-bg-accent": "var(--v2-green-600)", + "v2-text-text-accent": "var(--v2-green-600)", + "v2-text-text-accent-hover": "var(--v2-green-700)", + "v2-icon-icon-accent": "var(--v2-green-600)", + "v2-icon-icon-accent-hover": "var(--v2-green-700)", + "v2-border-border-focus": "var(--v2-green-500)" } }, "dark": { @@ -86,6 +97,17 @@ "markdown-image": "#30b3ff", "markdown-image-text": "#24f6d9", "markdown-code-block": "#62ff94" + }, + "v2Overrides": { + "v2-text-text-base": "#ececec", + "v2-text-text-muted": "#8ca391", + "v2-text-text-faint": "#8ca391", + "v2-background-bg-accent": "var(--v2-green-600)", + "v2-text-text-accent": "var(--v2-green-400)", + "v2-text-text-accent-hover": "var(--v2-green-300)", + "v2-icon-icon-accent": "var(--v2-green-400)", + "v2-icon-icon-accent-hover": "var(--v2-green-300)", + "v2-border-border-focus": "var(--v2-green-500)" } } } diff --git a/packages/ui/src/theme/themes/oc-2.json b/packages/ui/src/theme/themes/oc-2.json index 6eb0db71b1..c14799c0b7 100644 --- a/packages/ui/src/theme/themes/oc-2.json +++ b/packages/ui/src/theme/themes/oc-2.json @@ -42,6 +42,194 @@ "syntax-diff-delete": "#ff8c00", "syntax-diff-unknown": "#a753ae", "surface-critical-base": "#FFF2F0" + }, + "v2Overrides": { + "v2-grey-100": "#ffffffff", + "v2-grey-200": "#fafafaff", + "v2-grey-300": "#eeeeeeff", + "v2-grey-400": "#d4d4d4ff", + "v2-grey-500": "#aeaeaeff", + "v2-grey-600": "#808080ff", + "v2-grey-700": "#5c5c5cff", + "v2-grey-800": "#3a3a3aff", + "v2-grey-900": "#242424ff", + "v2-grey-1000": "#161616ff", + "v2-grey-1100": "#080808ff", + "v2-grey-1200": "#000000ff", + "v2-red-100": "#fcecebff", + "v2-red-200": "#f6d5d3ff", + "v2-red-300": "#f2bbb7ff", + "v2-red-400": "#f29b96ff", + "v2-red-500": "#f17471ff", + "v2-red-600": "#f1484fff", + "v2-red-700": "#d92e3cff", + "v2-red-800": "#b82d35ff", + "v2-red-900": "#97252bff", + "v2-red-1000": "#7a1f23ff", + "v2-red-1100": "#5f1a1cff", + "v2-red-1200": "#461516ff", + "v2-orange-100": "#fdf2edff", + "v2-orange-200": "#ffe7dcff", + "v2-orange-300": "#ffd8c6ff", + "v2-orange-400": "#ffc1a4ff", + "v2-orange-500": "#ffa478ff", + "v2-orange-600": "#ff8648ff", + "v2-orange-700": "#ee7330ff", + "v2-orange-800": "#d16427ff", + "v2-orange-900": "#b35624ff", + "v2-orange-1000": "#954c27ff", + "v2-orange-1100": "#723d22ff", + "v2-orange-1200": "#5a2c14ff", + "v2-yellow-100": "#fefaecff", + "v2-yellow-200": "#fcefd0ff", + "v2-yellow-300": "#f7e5b5ff", + "v2-yellow-400": "#f3da9bff", + "v2-yellow-500": "#f2cf76ff", + "v2-yellow-600": "#f6c251ff", + "v2-yellow-700": "#e7af36ff", + "v2-yellow-800": "#cb9f34ff", + "v2-yellow-900": "#ac8833ff", + "v2-yellow-1000": "#8e7231ff", + "v2-yellow-1100": "#68552bff", + "v2-yellow-1200": "#4b4025ff", + "v2-green-100": "#e7f9eaff", + "v2-green-200": "#d0f0d5ff", + "v2-green-300": "#b8e9c1ff", + "v2-green-400": "#96e3a6ff", + "v2-green-500": "#6bd586ff", + "v2-green-600": "#49c970ff", + "v2-green-700": "#2eaf5aff", + "v2-green-800": "#198b43ff", + "v2-green-900": "#1d783cff", + "v2-green-1000": "#196130ff", + "v2-green-1100": "#164c26ff", + "v2-green-1200": "#14361dff", + "v2-cyan-100": "#e2f7fbff", + "v2-cyan-200": "#c4edf4ff", + "v2-cyan-300": "#a3e4efff", + "v2-cyan-400": "#65d9ebff", + "v2-cyan-500": "#00c5dfff", + "v2-cyan-600": "#00abcfff", + "v2-cyan-700": "#0096b8ff", + "v2-cyan-800": "#007d9bff", + "v2-cyan-900": "#006c85ff", + "v2-cyan-1000": "#005a6eff", + "v2-cyan-1100": "#004756ff", + "v2-cyan-1200": "#00353fff", + "v2-blue-100": "#ecf1feff", + "v2-blue-200": "#d7e2fcff", + "v2-blue-300": "#c3d4fdff", + "v2-blue-400": "#a2bcffff", + "v2-blue-500": "#7698fdff", + "v2-blue-600": "#3b5cf6ff", + "v2-blue-700": "#3250dfff", + "v2-blue-800": "#2c47c8ff", + "v2-blue-900": "#263fa9ff", + "v2-blue-1000": "#22388fff", + "v2-blue-1100": "#1c2e70ff", + "v2-blue-1200": "#1b2852ff", + "v2-purple-100": "#ebecfeff", + "v2-purple-200": "#d5d5fcff", + "v2-purple-300": "#b9b8f5ff", + "v2-purple-400": "#9e99f7ff", + "v2-purple-500": "#8271f8ff", + "v2-purple-600": "#7152f4ff", + "v2-purple-700": "#623be2ff", + "v2-purple-800": "#5230c2ff", + "v2-purple-900": "#442aa1ff", + "v2-purple-1000": "#361f83ff", + "v2-purple-1100": "#2b1b6aff", + "v2-purple-1200": "#221358ff", + "v2-pink-100": "#fdecf3ff", + "v2-pink-200": "#f7d5e4ff", + "v2-pink-300": "#fabcd8ff", + "v2-pink-400": "#f799c6ff", + "v2-pink-500": "#f26cb2ff", + "v2-pink-600": "#f64aabff", + "v2-pink-700": "#e4429eff", + "v2-pink-800": "#c83d8bff", + "v2-pink-900": "#aa3576ff", + "v2-pink-1000": "#8c2d61ff", + "v2-pink-1100": "#6f284fff", + "v2-pink-1200": "#5c1d3fff", + "v2-background-bg-base": "var(--v2-grey-100)", + "v2-background-bg-deep": "var(--v2-grey-200)", + "v2-background-bg-layer-01": "var(--v2-grey-200)", + "v2-background-bg-layer-02": "var(--v2-grey-300)", + "v2-background-bg-layer-03": "var(--v2-grey-400)", + "v2-background-bg-inverse": "var(--v2-grey-1000)", + "v2-background-bg-contrast": "var(--v2-grey-900)", + "v2-background-bg-button-neutral": "var(--v2-grey-100)", + "v2-background-bg-accent": "var(--v2-blue-600)", + "v2-text-text-base": "var(--v2-grey-1000)", + "v2-text-text-muted": "var(--v2-grey-700)", + "v2-text-text-faint": "var(--v2-grey-600)", + "v2-text-text-inverse": "var(--v2-grey-100)", + "v2-text-text-contrast": "var(--v2-grey-100)", + "v2-text-text-accent": "var(--v2-blue-600)", + "v2-text-text-accent-hover": "var(--v2-blue-700)", + "v2-icon-icon-base": "var(--v2-grey-800)", + "v2-icon-icon-muted": "var(--v2-grey-600)", + "v2-icon-icon-inverse": "var(--v2-grey-100)", + "v2-icon-icon-contrast": "var(--v2-grey-200)", + "v2-icon-icon-accent": "var(--v2-blue-600)", + "v2-icon-icon-accent-hover": "var(--v2-blue-700)", + "v2-border-border-muted": "var(--v2-alpha-dark-8)", + "v2-border-border-base": "var(--v2-alpha-dark-10)", + "v2-border-border-strong": "var(--v2-alpha-dark-20)", + "v2-border-border-inverse": "var(--v2-grey-1000)", + "v2-border-border-focus": "var(--v2-blue-500)", + "v2-overlay-simple-overlay-hover": "var(--v2-alpha-dark-4)", + "v2-overlay-simple-overlay-pressed": "var(--v2-alpha-dark-8)", + "v2-overlay-simple-overlay-contrast-hover": "var(--v2-alpha-light-12)", + "v2-overlay-simple-overlay-contrast-pressed": "var(--v2-alpha-light-24)", + "v2-overlay-simple-overlay-scrim": "var(--v2-alpha-dark-40)", + "v2-overlay-gradient-depth-overlay-depth-top": "var(--v2-alpha-light-100)", + "v2-overlay-gradient-depth-overlay-depth-bot": "var(--v2-alpha-light-0)", + "v2-overlay-simple-tab-active-scrim": "#fafafa00", + "v2-overlay-simple-tab-hover-scrim": "#eeeeee00", + "v2-overlay-simple-tab-scrim": "#fafafa00", + "v2-state-bg-success": "var(--v2-green-100)", + "v2-state-fg-success": "var(--v2-green-800)", + "v2-state-border-success": "var(--v2-green-300)", + "v2-state-bg-warning": "var(--v2-yellow-100)", + "v2-state-fg-warning": "var(--v2-yellow-800)", + "v2-state-border-warning": "var(--v2-yellow-300)", + "v2-state-bg-danger": "var(--v2-red-100)", + "v2-state-fg-danger": "var(--v2-red-800)", + "v2-state-border-danger": "var(--v2-red-300)", + "v2-state-bg-info": "var(--v2-blue-100)", + "v2-state-fg-info": "var(--v2-blue-800)", + "v2-state-border-info": "var(--v2-blue-300)", + "v2-avatar-bg-orange": "#ee7330ff", + "v2-avatar-border-orange": "#d16427ff", + "v2-avatar-bg-yellow": "#e7af36ff", + "v2-avatar-border-yellow": "#cb9f34ff", + "v2-avatar-bg-cyan": "#0096b8ff", + "v2-avatar-border-cyan": "#007d9bff", + "v2-avatar-bg-green": "#2eaf5aff", + "v2-avatar-border-green": "#198b43ff", + "v2-avatar-bg-red": "#d92e3cff", + "v2-avatar-border-red": "#b82d35ff", + "v2-avatar-bg-pink": "#e4429eff", + "v2-avatar-border-pink": "#c83d8bff", + "v2-avatar-bg-blue": "#3250dfff", + "v2-avatar-border-blue": "#2c47c8ff", + "v2-avatar-bg-purple": "#623be2ff", + "v2-avatar-border-purple": "#5230c2ff", + "v2-avatar-bg-gray": "#5c5c5cff", + "v2-avatar-border-gray": "#3a3a3aff", + "v2-elevation-raised": "0px 2px 4px 0px var(--v2-alpha-dark-4), 0px 1px 2px -1px var(--v2-alpha-dark-8), 0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0)", + "v2-elevation-floating": "0px 8px 16px 0px var(--v2-alpha-dark-4), 0px 4px 8px 0px var(--v2-alpha-dark-8), 0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0)", + "v2-elevation-overlay": "0px 16px 32px 0px var(--v2-alpha-dark-4), 0px 8px 16px 0px var(--v2-alpha-dark-8), 0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0)", + "v2-elevation-button-neutral": "0px 1px 1.5px 0px var(--v2-alpha-dark-10), 0px 0px 0px 0.5px var(--v2-alpha-dark-14), 0px 0px 0px 0px var(--v2-alpha-dark-0)", + "v2-elevation-button-contrast": "0px 1px 1.5px 0px var(--v2-alpha-dark-20), 0px 0px 0px 0.5px var(--v2-grey-800), inset 0px 1px 2px 0px var(--v2-alpha-light-14), inset 0px -1px 2px 0px var(--v2-alpha-dark-6), 0px 0px 0px 0px var(--v2-alpha-dark-0)", + "v2-elevation-elements": "0px 0.5px 0.5px 0px var(--v2-alpha-dark-40)", + "v2-elevation-switch-off": "inset 0px 1px 1px 0px var(--v2-alpha-dark-8), inset 0px 0.5px 0.5px 0px var(--v2-alpha-dark-8), inset 0px 0px 0px 0.5px var(--v2-alpha-dark-10)", + "v2-elevation-switch-on": "inset 0px 2px 2px 0px var(--v2-alpha-dark-10), inset 0px 1px 1px 0px var(--v2-alpha-dark-10), inset 0px 0px 0px 0.5px var(--v2-alpha-dark-20)", + "v2-illustration-illustration-layer-01": "var(--v2-grey-300)", + "v2-illustration-illustration-layer-02": "var(--v2-grey-400)", + "v2-illustration-illustration-layer-03": "var(--v2-grey-500)" } }, "dark": { @@ -83,6 +271,194 @@ "syntax-diff-delete": "#fab283", "syntax-diff-unknown": "#edb2f1", "surface-critical-base": "#1F0603" + }, + "v2Overrides": { + "v2-grey-100": "#ffffffff", + "v2-grey-200": "#fafafaff", + "v2-grey-300": "#eeeeeeff", + "v2-grey-400": "#d4d4d4ff", + "v2-grey-500": "#aeaeaeff", + "v2-grey-600": "#808080ff", + "v2-grey-700": "#5c5c5cff", + "v2-grey-800": "#3a3a3aff", + "v2-grey-900": "#242424ff", + "v2-grey-1000": "#161616ff", + "v2-grey-1100": "#080808ff", + "v2-grey-1200": "#000000ff", + "v2-red-100": "#fcecebff", + "v2-red-200": "#f6d5d3ff", + "v2-red-300": "#f2bbb7ff", + "v2-red-400": "#f29b96ff", + "v2-red-500": "#f17471ff", + "v2-red-600": "#f1484fff", + "v2-red-700": "#d92e3cff", + "v2-red-800": "#b82d35ff", + "v2-red-900": "#97252bff", + "v2-red-1000": "#7a1f23ff", + "v2-red-1100": "#5f1a1cff", + "v2-red-1200": "#461516ff", + "v2-orange-100": "#fdf2edff", + "v2-orange-200": "#ffe7dcff", + "v2-orange-300": "#ffd8c6ff", + "v2-orange-400": "#ffc1a4ff", + "v2-orange-500": "#ffa478ff", + "v2-orange-600": "#ff8648ff", + "v2-orange-700": "#ee7330ff", + "v2-orange-800": "#d16427ff", + "v2-orange-900": "#b35624ff", + "v2-orange-1000": "#954c27ff", + "v2-orange-1100": "#723d22ff", + "v2-orange-1200": "#5a2c14ff", + "v2-yellow-100": "#fefaecff", + "v2-yellow-200": "#fcefd0ff", + "v2-yellow-300": "#f7e5b5ff", + "v2-yellow-400": "#f3da9bff", + "v2-yellow-500": "#f2cf76ff", + "v2-yellow-600": "#f6c251ff", + "v2-yellow-700": "#e7af36ff", + "v2-yellow-800": "#cb9f34ff", + "v2-yellow-900": "#ac8833ff", + "v2-yellow-1000": "#8e7231ff", + "v2-yellow-1100": "#68552bff", + "v2-yellow-1200": "#4b4025ff", + "v2-green-100": "#e7f9eaff", + "v2-green-200": "#d0f0d5ff", + "v2-green-300": "#b8e9c1ff", + "v2-green-400": "#96e3a6ff", + "v2-green-500": "#6bd586ff", + "v2-green-600": "#49c970ff", + "v2-green-700": "#2eaf5aff", + "v2-green-800": "#198b43ff", + "v2-green-900": "#1d783cff", + "v2-green-1000": "#196130ff", + "v2-green-1100": "#164c26ff", + "v2-green-1200": "#14361dff", + "v2-cyan-100": "#e2f7fbff", + "v2-cyan-200": "#c4edf4ff", + "v2-cyan-300": "#a3e4efff", + "v2-cyan-400": "#65d9ebff", + "v2-cyan-500": "#00c5dfff", + "v2-cyan-600": "#00abcfff", + "v2-cyan-700": "#0096b8ff", + "v2-cyan-800": "#007d9bff", + "v2-cyan-900": "#006c85ff", + "v2-cyan-1000": "#005a6eff", + "v2-cyan-1100": "#004756ff", + "v2-cyan-1200": "#00353fff", + "v2-blue-100": "#ecf1feff", + "v2-blue-200": "#d7e2fcff", + "v2-blue-300": "#c3d4fdff", + "v2-blue-400": "#a2bcffff", + "v2-blue-500": "#7698fdff", + "v2-blue-600": "#3b5cf6ff", + "v2-blue-700": "#3250dfff", + "v2-blue-800": "#2c47c8ff", + "v2-blue-900": "#263fa9ff", + "v2-blue-1000": "#22388fff", + "v2-blue-1100": "#1c2e70ff", + "v2-blue-1200": "#1b2852ff", + "v2-purple-100": "#ebecfeff", + "v2-purple-200": "#d5d5fcff", + "v2-purple-300": "#b9b8f5ff", + "v2-purple-400": "#9e99f7ff", + "v2-purple-500": "#8271f8ff", + "v2-purple-600": "#7152f4ff", + "v2-purple-700": "#623be2ff", + "v2-purple-800": "#5230c2ff", + "v2-purple-900": "#442aa1ff", + "v2-purple-1000": "#361f83ff", + "v2-purple-1100": "#2b1b6aff", + "v2-purple-1200": "#221358ff", + "v2-pink-100": "#fdecf3ff", + "v2-pink-200": "#f7d5e4ff", + "v2-pink-300": "#fabcd8ff", + "v2-pink-400": "#f799c6ff", + "v2-pink-500": "#f26cb2ff", + "v2-pink-600": "#f64aabff", + "v2-pink-700": "#e4429eff", + "v2-pink-800": "#c83d8bff", + "v2-pink-900": "#aa3576ff", + "v2-pink-1000": "#8c2d61ff", + "v2-pink-1100": "#6f284fff", + "v2-pink-1200": "#5c1d3fff", + "v2-background-bg-base": "var(--v2-grey-1000)", + "v2-background-bg-deep": "var(--v2-grey-1100)", + "v2-background-bg-layer-01": "var(--v2-grey-900)", + "v2-background-bg-layer-02": "var(--v2-grey-800)", + "v2-background-bg-layer-03": "var(--v2-grey-700)", + "v2-background-bg-inverse": "var(--v2-grey-100)", + "v2-background-bg-contrast": "var(--v2-grey-700)", + "v2-background-bg-button-neutral": "var(--v2-alpha-light-6)", + "v2-background-bg-accent": "var(--v2-blue-600)", + "v2-text-text-base": "var(--v2-grey-200)", + "v2-text-text-muted": "var(--v2-grey-500)", + "v2-text-text-faint": "var(--v2-grey-600)", + "v2-text-text-inverse": "var(--v2-grey-1000)", + "v2-text-text-contrast": "var(--v2-grey-100)", + "v2-text-text-accent": "var(--v2-blue-400)", + "v2-text-text-accent-hover": "var(--v2-blue-300)", + "v2-icon-icon-base": "var(--v2-grey-400)", + "v2-icon-icon-muted": "var(--v2-grey-600)", + "v2-icon-icon-inverse": "var(--v2-grey-1000)", + "v2-icon-icon-contrast": "var(--v2-grey-200)", + "v2-icon-icon-accent": "var(--v2-blue-400)", + "v2-icon-icon-accent-hover": "var(--v2-blue-300)", + "v2-border-border-muted": "var(--v2-alpha-light-8)", + "v2-border-border-base": "var(--v2-alpha-light-10)", + "v2-border-border-strong": "var(--v2-alpha-light-20)", + "v2-border-border-inverse": "var(--v2-grey-100)", + "v2-border-border-focus": "var(--v2-blue-500)", + "v2-overlay-simple-overlay-hover": "var(--v2-alpha-light-6)", + "v2-overlay-simple-overlay-pressed": "var(--v2-alpha-light-10)", + "v2-overlay-simple-overlay-contrast-hover": "var(--v2-alpha-dark-24)", + "v2-overlay-simple-overlay-contrast-pressed": "var(--v2-alpha-dark-40)", + "v2-overlay-simple-overlay-scrim": "var(--v2-alpha-light-30)", + "v2-overlay-gradient-depth-overlay-depth-top": "var(--v2-alpha-light-100)", + "v2-overlay-gradient-depth-overlay-depth-bot": "var(--v2-alpha-light-0)", + "v2-overlay-simple-tab-active-scrim": "#24242400", + "v2-overlay-simple-tab-hover-scrim": "#3a3a3a00", + "v2-overlay-simple-tab-scrim": "#08080800", + "v2-state-bg-success": "var(--v2-green-1200)", + "v2-state-fg-success": "var(--v2-green-500)", + "v2-state-border-success": "var(--v2-green-900)", + "v2-state-bg-warning": "var(--v2-yellow-1200)", + "v2-state-fg-warning": "var(--v2-yellow-500)", + "v2-state-border-warning": "var(--v2-yellow-900)", + "v2-state-bg-danger": "var(--v2-red-1200)", + "v2-state-fg-danger": "var(--v2-red-500)", + "v2-state-border-danger": "var(--v2-red-900)", + "v2-state-bg-info": "var(--v2-blue-1200)", + "v2-state-fg-info": "var(--v2-blue-500)", + "v2-state-border-info": "var(--v2-blue-900)", + "v2-avatar-bg-orange": "#723d22ff", + "v2-avatar-border-orange": "#ff8648ff", + "v2-avatar-bg-yellow": "#68552bff", + "v2-avatar-border-yellow": "#e7af36ff", + "v2-avatar-bg-cyan": "#005a6eff", + "v2-avatar-border-cyan": "#0096b8ff", + "v2-avatar-bg-green": "#196130ff", + "v2-avatar-border-green": "#49c970ff", + "v2-avatar-bg-red": "#7a1f23ff", + "v2-avatar-border-red": "#d92e3cff", + "v2-avatar-bg-pink": "#8c2d61ff", + "v2-avatar-border-pink": "#e4429eff", + "v2-avatar-bg-blue": "#263fa9ff", + "v2-avatar-border-blue": "#7698fdff", + "v2-avatar-bg-purple": "#361f83ff", + "v2-avatar-border-purple": "#7152f4ff", + "v2-avatar-bg-gray": "#5c5c5cff", + "v2-avatar-border-gray": "#aeaeaeff", + "v2-elevation-raised": "0px 2px 4px 0px var(--v2-alpha-dark-30), 0px 1px 2px 0px var(--v2-alpha-dark-30), 0px 0px 0px 0.5px var(--v2-alpha-light-16), 0px -0.5px 0px 0px var(--v2-alpha-light-6)", + "v2-elevation-floating": "0px 8px 16px 0px var(--v2-alpha-dark-30), 0px 4px 8px 0px var(--v2-alpha-dark-30), 0px 0px 0px 0.5px var(--v2-alpha-light-16), 0px -0.5px 0px 0px var(--v2-alpha-light-6)", + "v2-elevation-overlay": "0px 16px 32px 0px var(--v2-alpha-dark-30), 0px 8px 16px 0px var(--v2-alpha-dark-30), 0px 0px 0px 0.5px var(--v2-alpha-light-16), 0px -0.5px 0px 0px var(--v2-alpha-light-6)", + "v2-elevation-button-neutral": "0px 1px 2px 0px var(--v2-alpha-dark-40), 0px 0px 0px 0.5px var(--v2-alpha-light-20), 0px -0.5px 0px 0px var(--v2-alpha-light-10)", + "v2-elevation-button-contrast": "0px 1px 2px 0px var(--v2-alpha-dark-40), 0px 0px 0px 0.5px var(--v2-alpha-light-40), inset 0px 0px 0px 0px var(--v2-alpha-light-0), inset 0px 0px 0px 0px var(--v2-alpha-light-0), 0px -0.5px 0px 0px var(--v2-alpha-light-30)", + "v2-elevation-elements": "0px 0.5px 0.5px 0px var(--v2-alpha-dark-40)", + "v2-elevation-switch-off": "inset 0px -0.5px 0px 0px var(--v2-alpha-light-10), inset 0px 0px 0px 0px var(--v2-alpha-light-0), inset 0px 0px 0px 0.5px var(--v2-alpha-light-16)", + "v2-elevation-switch-on": "inset 0px -0.5px 0px 0px var(--v2-alpha-light-10), inset 0px 0px 0px 0px var(--v2-alpha-light-0), inset 0px 0px 0px 0.5px var(--v2-alpha-light-16)", + "v2-illustration-illustration-layer-01": "var(--v2-grey-900)", + "v2-illustration-illustration-layer-02": "var(--v2-grey-800)", + "v2-illustration-illustration-layer-03": "var(--v2-grey-700)" } } } diff --git a/packages/ui/src/theme/types.ts b/packages/ui/src/theme/types.ts index bec67abc01..2284659b48 100644 --- a/packages/ui/src/theme/types.ts +++ b/packages/ui/src/theme/types.ts @@ -34,6 +34,7 @@ export interface ThemePaletteColors { type ThemeVariantBase = { overrides?: Record + v2Overrides?: Record } export type ThemeVariant = @@ -67,4 +68,8 @@ export type CssVarRef = `var(--${string})` export type ColorValue = HexColor | CssVarRef +export type V2ColorValue = HexColor | CssVarRef | string + export type ResolvedTheme = Record + +export type ResolvedV2Theme = Record diff --git a/packages/ui/src/theme/v2/avatar.ts b/packages/ui/src/theme/v2/avatar.ts new file mode 100644 index 0000000000..52c0cc67ac --- /dev/null +++ b/packages/ui/src/theme/v2/avatar.ts @@ -0,0 +1,48 @@ +import type { V2ColorValue } from "../types" + +/** Fixed project avatar colors (OC-2); theme-independent like v1 `avatar-background-*`. */ +export const V2_AVATAR_FG = "#ffffffff" + +export const V2_AVATAR_LIGHT: Record = { + "v2-avatar-fg": V2_AVATAR_FG, + "v2-avatar-bg-orange": "#ee7330ff", + "v2-avatar-border-orange": "#d16427ff", + "v2-avatar-bg-yellow": "#e7af36ff", + "v2-avatar-border-yellow": "#cb9f34ff", + "v2-avatar-bg-cyan": "#0096b8ff", + "v2-avatar-border-cyan": "#007d9bff", + "v2-avatar-bg-green": "#2eaf5aff", + "v2-avatar-border-green": "#198b43ff", + "v2-avatar-bg-red": "#d92e3cff", + "v2-avatar-border-red": "#b82d35ff", + "v2-avatar-bg-pink": "#e4429eff", + "v2-avatar-border-pink": "#c83d8bff", + "v2-avatar-bg-blue": "#3250dfff", + "v2-avatar-border-blue": "#2c47c8ff", + "v2-avatar-bg-purple": "#623be2ff", + "v2-avatar-border-purple": "#5230c2ff", + "v2-avatar-bg-gray": "#5c5c5cff", + "v2-avatar-border-gray": "#3a3a3aff", +} + +export const V2_AVATAR_DARK: Record = { + "v2-avatar-fg": V2_AVATAR_FG, + "v2-avatar-bg-orange": "#723d22ff", + "v2-avatar-border-orange": "#ff8648ff", + "v2-avatar-bg-yellow": "#68552bff", + "v2-avatar-border-yellow": "#e7af36ff", + "v2-avatar-bg-cyan": "#005a6eff", + "v2-avatar-border-cyan": "#0096b8ff", + "v2-avatar-bg-green": "#196130ff", + "v2-avatar-border-green": "#49c970ff", + "v2-avatar-bg-red": "#7a1f23ff", + "v2-avatar-border-red": "#d92e3cff", + "v2-avatar-bg-pink": "#8c2d61ff", + "v2-avatar-border-pink": "#e4429eff", + "v2-avatar-bg-blue": "#263fa9ff", + "v2-avatar-border-blue": "#7698fdff", + "v2-avatar-bg-purple": "#361f83ff", + "v2-avatar-border-purple": "#7152f4ff", + "v2-avatar-bg-gray": "#5c5c5cff", + "v2-avatar-border-gray": "#aeaeaeff", +} diff --git a/packages/ui/src/theme/v2/default-primitives.ts b/packages/ui/src/theme/v2/default-primitives.ts new file mode 100644 index 0000000000..51619b19bf --- /dev/null +++ b/packages/ui/src/theme/v2/default-primitives.ts @@ -0,0 +1,113 @@ +import type { V2ColorValue } from "../types" + +/** Default v2 hue ramps from `v2/styles/colors.css` (OC-2). Alpha ramps live in CSS only. */ +export const V2_PRIMITIVES_DEFAULT: Record = { + "v2-grey-100": "#ffffffff", + "v2-grey-200": "#fafafaff", + "v2-grey-300": "#eeeeeeff", + "v2-grey-400": "#d4d4d4ff", + "v2-grey-500": "#aeaeaeff", + "v2-grey-600": "#808080ff", + "v2-grey-700": "#5c5c5cff", + "v2-grey-800": "#3a3a3aff", + "v2-grey-900": "#242424ff", + "v2-grey-1000": "#161616ff", + "v2-grey-1100": "#080808ff", + "v2-grey-1200": "#000000ff", + "v2-red-100": "#fcecebff", + "v2-red-200": "#f6d5d3ff", + "v2-red-300": "#f2bbb7ff", + "v2-red-400": "#f29b96ff", + "v2-red-500": "#f17471ff", + "v2-red-600": "#f1484fff", + "v2-red-700": "#d92e3cff", + "v2-red-800": "#b82d35ff", + "v2-red-900": "#97252bff", + "v2-red-1000": "#7a1f23ff", + "v2-red-1100": "#5f1a1cff", + "v2-red-1200": "#461516ff", + "v2-orange-100": "#fdf2edff", + "v2-orange-200": "#ffe7dcff", + "v2-orange-300": "#ffd8c6ff", + "v2-orange-400": "#ffc1a4ff", + "v2-orange-500": "#ffa478ff", + "v2-orange-600": "#ff8648ff", + "v2-orange-700": "#ee7330ff", + "v2-orange-800": "#d16427ff", + "v2-orange-900": "#b35624ff", + "v2-orange-1000": "#954c27ff", + "v2-orange-1100": "#723d22ff", + "v2-orange-1200": "#5a2c14ff", + "v2-yellow-100": "#fefaecff", + "v2-yellow-200": "#fcefd0ff", + "v2-yellow-300": "#f7e5b5ff", + "v2-yellow-400": "#f3da9bff", + "v2-yellow-500": "#f2cf76ff", + "v2-yellow-600": "#f6c251ff", + "v2-yellow-700": "#e7af36ff", + "v2-yellow-800": "#cb9f34ff", + "v2-yellow-900": "#ac8833ff", + "v2-yellow-1000": "#8e7231ff", + "v2-yellow-1100": "#68552bff", + "v2-yellow-1200": "#4b4025ff", + "v2-green-100": "#e7f9eaff", + "v2-green-200": "#d0f0d5ff", + "v2-green-300": "#b8e9c1ff", + "v2-green-400": "#96e3a6ff", + "v2-green-500": "#6bd586ff", + "v2-green-600": "#49c970ff", + "v2-green-700": "#2eaf5aff", + "v2-green-800": "#198b43ff", + "v2-green-900": "#1d783cff", + "v2-green-1000": "#196130ff", + "v2-green-1100": "#164c26ff", + "v2-green-1200": "#14361dff", + "v2-cyan-100": "#e2f7fbff", + "v2-cyan-200": "#c4edf4ff", + "v2-cyan-300": "#a3e4efff", + "v2-cyan-400": "#65d9ebff", + "v2-cyan-500": "#00c5dfff", + "v2-cyan-600": "#00abcfff", + "v2-cyan-700": "#0096b8ff", + "v2-cyan-800": "#007d9bff", + "v2-cyan-900": "#006c85ff", + "v2-cyan-1000": "#005a6eff", + "v2-cyan-1100": "#004756ff", + "v2-cyan-1200": "#00353fff", + "v2-blue-100": "#ecf1feff", + "v2-blue-200": "#d7e2fcff", + "v2-blue-300": "#c3d4fdff", + "v2-blue-400": "#a2bcffff", + "v2-blue-500": "#7698fdff", + "v2-blue-600": "#3b5cf6ff", + "v2-blue-700": "#3250dfff", + "v2-blue-800": "#2c47c8ff", + "v2-blue-900": "#263fa9ff", + "v2-blue-1000": "#22388fff", + "v2-blue-1100": "#1c2e70ff", + "v2-blue-1200": "#1b2852ff", + "v2-purple-100": "#ebecfeff", + "v2-purple-200": "#d5d5fcff", + "v2-purple-300": "#b9b8f5ff", + "v2-purple-400": "#9e99f7ff", + "v2-purple-500": "#8271f8ff", + "v2-purple-600": "#7152f4ff", + "v2-purple-700": "#623be2ff", + "v2-purple-800": "#5230c2ff", + "v2-purple-900": "#442aa1ff", + "v2-purple-1000": "#361f83ff", + "v2-purple-1100": "#2b1b6aff", + "v2-purple-1200": "#221358ff", + "v2-pink-100": "#fdecf3ff", + "v2-pink-200": "#f7d5e4ff", + "v2-pink-300": "#fabcd8ff", + "v2-pink-400": "#f799c6ff", + "v2-pink-500": "#f26cb2ff", + "v2-pink-600": "#f64aabff", + "v2-pink-700": "#e4429eff", + "v2-pink-800": "#c83d8bff", + "v2-pink-900": "#aa3576ff", + "v2-pink-1000": "#8c2d61ff", + "v2-pink-1100": "#6f284fff", + "v2-pink-1200": "#5c1d3fff", +} diff --git a/packages/ui/src/theme/v2/foreground.ts b/packages/ui/src/theme/v2/foreground.ts new file mode 100644 index 0000000000..f188e5e4db --- /dev/null +++ b/packages/ui/src/theme/v2/foreground.ts @@ -0,0 +1,24 @@ +import { blend, hexToOklch, shift } from "../color" +import type { ColorValue, HexColor, V2ColorValue } from "../types" + +export function mapV2Foreground( + ink: HexColor, + isDark: boolean, + overrides: Record = {}, +): Record { + const tint = hexToOklch(ink) + const body = shift(ink, { + l: isDark ? Math.max(0, 0.88 - tint.l) * 0.4 : -Math.max(0, tint.l - 0.18) * 0.24, + c: isDark ? 1.04 : 1.02, + }) + + return { + "v2-text-text-base": isDark + ? blend("#ffffff", body, 0.9) + : shift(body, { l: -0.07, c: 1.04 }), + "v2-text-text-muted": + overrides["text-weak"] ?? + shift(body, { l: isDark ? -0.11 : 0.11, c: 0.9 }), + "v2-text-text-faint": shift(body, { l: isDark ? -0.2 : 0.21, c: isDark ? 0.78 : 0.72 }), + } +} diff --git a/packages/ui/src/theme/v2/mapping.ts b/packages/ui/src/theme/v2/mapping.ts new file mode 100644 index 0000000000..60b34118c6 --- /dev/null +++ b/packages/ui/src/theme/v2/mapping.ts @@ -0,0 +1,150 @@ +import type { V2ColorValue } from "../types" +import { V2_AVATAR_DARK, V2_AVATAR_LIGHT } from "./avatar" + +const ref = (name: string): V2ColorValue => `var(--${name})` + +const light: Record = { + "v2-background-bg-base": ref("v2-grey-100"), + "v2-background-bg-deep": ref("v2-grey-200"), + "v2-background-bg-layer-01": ref("v2-grey-300"), + "v2-background-bg-layer-02": ref("v2-grey-400"), + "v2-background-bg-layer-03": ref("v2-grey-500"), + "v2-background-bg-inverse": ref("v2-grey-1000"), + "v2-background-bg-contrast": ref("v2-grey-900"), + "v2-background-bg-button-neutral": ref("v2-grey-100"), + "v2-background-bg-accent": ref("v2-blue-600"), + "v2-text-text-inverse": ref("v2-grey-100"), + "v2-text-text-contrast": ref("v2-grey-100"), + "v2-text-text-accent": ref("v2-blue-600"), + "v2-text-text-accent-hover": ref("v2-blue-700"), + "v2-icon-icon-base": ref("v2-grey-1000"), + "v2-icon-icon-muted": ref("v2-grey-800"), + "v2-icon-icon-inverse": ref("v2-grey-100"), + "v2-icon-icon-contrast": ref("v2-grey-200"), + "v2-icon-icon-accent": ref("v2-blue-600"), + "v2-icon-icon-accent-hover": ref("v2-blue-700"), + "v2-border-border-muted": ref("v2-alpha-dark-8"), + "v2-border-border-base": ref("v2-alpha-dark-10"), + "v2-border-border-strong": ref("v2-alpha-dark-20"), + "v2-border-border-inverse": ref("v2-grey-1000"), + "v2-border-border-focus": ref("v2-blue-500"), + "v2-overlay-simple-overlay-hover": ref("v2-alpha-dark-4"), + "v2-overlay-simple-overlay-pressed": ref("v2-alpha-dark-8"), + "v2-overlay-simple-overlay-contrast-hover": ref("v2-alpha-light-12"), + "v2-overlay-simple-overlay-contrast-pressed": ref("v2-alpha-light-24"), + "v2-overlay-simple-overlay-scrim": ref("v2-alpha-dark-40"), + "v2-overlay-gradient-depth-overlay-depth-top": ref("v2-alpha-light-100"), + "v2-overlay-gradient-depth-overlay-depth-bot": ref("v2-alpha-light-0"), + "v2-overlay-simple-tab-active-scrim": "#fafafa00", + "v2-overlay-simple-tab-hover-scrim": "#eeeeee00", + "v2-overlay-simple-tab-scrim": "#fafafa00", + "v2-state-bg-success": ref("v2-green-100"), + "v2-state-fg-success": ref("v2-green-800"), + "v2-state-border-success": ref("v2-green-300"), + "v2-state-bg-warning": ref("v2-yellow-100"), + "v2-state-fg-warning": ref("v2-yellow-800"), + "v2-state-border-warning": ref("v2-yellow-300"), + "v2-state-bg-danger": ref("v2-red-100"), + "v2-state-fg-danger": ref("v2-red-800"), + "v2-state-border-danger": ref("v2-red-300"), + "v2-state-bg-info": ref("v2-blue-100"), + "v2-state-fg-info": ref("v2-blue-800"), + "v2-state-border-info": ref("v2-blue-300"), + ...V2_AVATAR_LIGHT, + "v2-elevation-raised": + "0px 2px 4px 0px var(--v2-alpha-dark-4), 0px 1px 2px -1px var(--v2-alpha-dark-8), 0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0)", + "v2-elevation-floating": + "0px 8px 16px 0px var(--v2-alpha-dark-4), 0px 4px 8px 0px var(--v2-alpha-dark-8), 0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0)", + "v2-elevation-overlay": + "0px 16px 32px 0px var(--v2-alpha-dark-4), 0px 8px 16px 0px var(--v2-alpha-dark-8), 0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0)", + "v2-elevation-button-neutral": + "0px 1px 1.5px 0px var(--v2-alpha-dark-10), 0px 0px 0px 0.5px var(--v2-alpha-dark-14), 0px 0px 0px 0px var(--v2-alpha-dark-0)", + "v2-elevation-button-contrast": + "0px 1px 1.5px 0px var(--v2-alpha-dark-20), 0px 0px 0px 0.5px var(--v2-grey-800), inset 0px 1px 2px 0px var(--v2-alpha-light-14), inset 0px -1px 2px 0px var(--v2-alpha-dark-6), 0px 0px 0px 0px var(--v2-alpha-dark-0)", + "v2-elevation-elements": "0px 0.5px 0.5px 0px var(--v2-alpha-dark-40)", + "v2-elevation-switch-off": + "inset 0px 1px 1px 0px var(--v2-alpha-dark-8), inset 0px 0.5px 0.5px 0px var(--v2-alpha-dark-8), inset 0px 0px 0px 0.5px var(--v2-alpha-dark-10)", + "v2-elevation-switch-on": + "inset 0px 2px 2px 0px var(--v2-alpha-dark-10), inset 0px 1px 1px 0px var(--v2-alpha-dark-10), inset 0px 0px 0px 0.5px var(--v2-alpha-dark-20)", + "v2-illustration-illustration-layer-01": ref("v2-grey-300"), + "v2-illustration-illustration-layer-02": ref("v2-grey-400"), + "v2-illustration-illustration-layer-03": ref("v2-grey-500"), +} + +const dark: Record = { + "v2-background-bg-base": ref("v2-grey-1000"), + "v2-background-bg-deep": ref("v2-grey-1100"), + "v2-background-bg-layer-01": ref("v2-grey-800"), + "v2-background-bg-layer-02": ref("v2-grey-600"), + "v2-background-bg-layer-03": ref("v2-grey-500"), + "v2-background-bg-inverse": ref("v2-grey-100"), + "v2-background-bg-contrast": ref("v2-grey-700"), + "v2-background-bg-button-neutral": ref("v2-alpha-light-6"), + "v2-background-bg-accent": ref("v2-blue-600"), + "v2-text-text-inverse": ref("v2-grey-1000"), + "v2-text-text-contrast": ref("v2-grey-100"), + "v2-text-text-accent": ref("v2-blue-400"), + "v2-text-text-accent-hover": ref("v2-blue-300"), + "v2-icon-icon-base": ref("v2-grey-300"), + "v2-icon-icon-muted": ref("v2-grey-400"), + "v2-icon-icon-inverse": ref("v2-grey-1000"), + "v2-icon-icon-contrast": ref("v2-grey-200"), + "v2-icon-icon-accent": ref("v2-blue-400"), + "v2-icon-icon-accent-hover": ref("v2-blue-300"), + "v2-border-border-muted": ref("v2-alpha-light-8"), + "v2-border-border-base": ref("v2-alpha-light-10"), + "v2-border-border-strong": ref("v2-alpha-light-20"), + "v2-border-border-inverse": ref("v2-grey-100"), + "v2-border-border-focus": ref("v2-blue-500"), + "v2-overlay-simple-overlay-hover": ref("v2-alpha-light-6"), + "v2-overlay-simple-overlay-pressed": ref("v2-alpha-light-10"), + "v2-overlay-simple-overlay-contrast-hover": ref("v2-alpha-dark-24"), + "v2-overlay-simple-overlay-contrast-pressed": ref("v2-alpha-dark-40"), + "v2-overlay-simple-overlay-scrim": ref("v2-alpha-light-30"), + "v2-overlay-gradient-depth-overlay-depth-top": ref("v2-alpha-light-100"), + "v2-overlay-gradient-depth-overlay-depth-bot": ref("v2-alpha-light-0"), + "v2-overlay-simple-tab-active-scrim": "#24242400", + "v2-overlay-simple-tab-hover-scrim": "#3a3a3a00", + "v2-overlay-simple-tab-scrim": "#08080800", + "v2-state-bg-success": ref("v2-green-1200"), + "v2-state-fg-success": ref("v2-green-500"), + "v2-state-border-success": ref("v2-green-900"), + "v2-state-bg-warning": ref("v2-yellow-1200"), + "v2-state-fg-warning": ref("v2-yellow-500"), + "v2-state-border-warning": ref("v2-yellow-900"), + "v2-state-bg-danger": ref("v2-red-1200"), + "v2-state-fg-danger": ref("v2-red-500"), + "v2-state-border-danger": ref("v2-red-900"), + "v2-state-bg-info": ref("v2-blue-1200"), + "v2-state-fg-info": ref("v2-blue-500"), + "v2-state-border-info": ref("v2-blue-900"), + ...V2_AVATAR_DARK, + "v2-elevation-raised": + "0px 2px 4px 0px var(--v2-alpha-dark-30), 0px 1px 2px 0px var(--v2-alpha-dark-30), 0px 0px 0px 0.5px var(--v2-alpha-light-16), 0px -0.5px 0px 0px var(--v2-alpha-light-6)", + "v2-elevation-floating": + "0px 8px 16px 0px var(--v2-alpha-dark-30), 0px 4px 8px 0px var(--v2-alpha-dark-30), 0px 0px 0px 0.5px var(--v2-alpha-light-16), 0px -0.5px 0px 0px var(--v2-alpha-light-6)", + "v2-elevation-overlay": + "0px 16px 32px 0px var(--v2-alpha-dark-30), 0px 8px 16px 0px var(--v2-alpha-dark-30), 0px 0px 0px 0.5px var(--v2-alpha-light-16), 0px -0.5px 0px 0px var(--v2-alpha-light-6)", + "v2-elevation-button-neutral": + "0px 1px 2px 0px var(--v2-alpha-dark-40), 0px 0px 0px 0.5px var(--v2-alpha-light-20), 0px -0.5px 0px 0px var(--v2-alpha-light-10)", + "v2-elevation-button-contrast": + "0px 1px 2px 0px var(--v2-alpha-dark-40), 0px 0px 0px 0.5px var(--v2-alpha-light-40), inset 0px 0px 0px 0px var(--v2-alpha-light-0), inset 0px 0px 0px 0px var(--v2-alpha-light-0), 0px -0.5px 0px 0px var(--v2-alpha-light-30)", + "v2-elevation-elements": "0px 0.5px 0.5px 0px var(--v2-alpha-dark-40)", + "v2-elevation-switch-off": + "inset 0px -0.5px 0px 0px var(--v2-alpha-light-10), inset 0px 0px 0px 0px var(--v2-alpha-light-0), inset 0px 0px 0px 0.5px var(--v2-alpha-light-16)", + "v2-elevation-switch-on": + "inset 0px -0.5px 0px 0px var(--v2-alpha-light-10), inset 0px 0px 0px 0px var(--v2-alpha-light-0), inset 0px 0px 0px 0.5px var(--v2-alpha-light-16)", + "v2-illustration-illustration-layer-01": ref("v2-grey-900"), + "v2-illustration-illustration-layer-02": ref("v2-grey-800"), + "v2-illustration-illustration-layer-03": ref("v2-grey-700"), +} + +export function mapV2Semantics(isDark: boolean): Record { + return isDark ? dark : light +} + +export function mergeV2Tokens( + ...layers: Record[] +): Record { + return Object.assign({}, ...layers) +} diff --git a/packages/ui/src/theme/v2/resolve.ts b/packages/ui/src/theme/v2/resolve.ts new file mode 100644 index 0000000000..2377caf930 --- /dev/null +++ b/packages/ui/src/theme/v2/resolve.ts @@ -0,0 +1,153 @@ +// @refresh reload + +import { generateNeutralScale, hexToOklch, oklchToHex, shift } from "../color" +import { mapV2Foreground } from "./foreground" +import { mapV2Semantics, mergeV2Tokens } from "./mapping" +import type { DesktopTheme, HexColor, ResolvedV2Theme, ThemeVariant, V2ColorValue } from "../types" +import { V2_PRIMITIVES_DEFAULT } from "./default-primitives" + +const V2_STEPS = [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200] as const + +interface PaletteInput { + neutral: HexColor + ink: HexColor + primary: HexColor + accent: HexColor + success: HexColor + warning: HexColor + error: HexColor + info: HexColor + interactive: HexColor + diffAdd: HexColor + diffDelete: HexColor +} + +function clamp(v: number, min: number, max: number) { + return Math.max(min, Math.min(max, v)) +} + +/** v2 ramps: 100 = lightest, 1200 = darkest — wider spread than v1 `generateScale`. */ +function generateV2HueScale(seed: HexColor, isDark: boolean): HexColor[] { + const base = hexToOklch(seed) + const chromaBoost = isDark ? 1 : 1.05 + const lightSteps = [ + 0.99, + 0.965, + 0.93, + 0.885, + 0.835, + clamp(base.l, 0.48, 0.72), + clamp(base.l - 0.07, 0.4, 0.64), + clamp(base.l - 0.14, 0.32, 0.55), + clamp(base.l - 0.21, 0.24, 0.46), + clamp(base.l - 0.28, 0.17, 0.38), + clamp(base.l - 0.34, 0.12, 0.3), + clamp(base.l - 0.4, 0.08, 0.22), + ] + const chromaMultipliers = [0.28, 0.48, 0.68, 0.86, 1.02, 1.28, 1.34, 1.28, 1.18, 1.08, 0.98, 0.88] + + return lightSteps.map((l, i) => + oklchToHex({ + l, + c: base.c * chromaMultipliers[i]! * chromaBoost, + h: base.h, + }), + ) +} + +/** Grey ramp: 100 = lightest, 1200 = darkest. Derived from palette neutral → ink like v1. */ +function generateV2NeutralScale(neutral: HexColor, ink: HexColor, isDark: boolean): HexColor[] { + const scale = generateNeutralScale(neutral, isDark, ink) + return isDark ? scale.toReversed() : scale +} + +function assignHueRamp(prefix: string, scale: HexColor[]): Record { + const tokens: Record = {} + for (let i = 0; i < V2_STEPS.length; i++) { + tokens[`v2-${prefix}-${V2_STEPS[i]}`] = scale[i]! + } + return tokens +} + +function readPalette(variant: ThemeVariant): PaletteInput { + if ("palette" in variant && variant.palette) { + const palette = variant.palette + return { + neutral: palette.neutral, + ink: palette.ink, + primary: palette.primary, + accent: palette.accent ?? palette.info, + success: palette.success, + warning: palette.warning, + error: palette.error, + info: palette.info, + interactive: palette.interactive ?? palette.primary, + diffAdd: palette.diffAdd ?? shift(palette.success, { c: 0.55, l: 0.14 }), + diffDelete: palette.diffDelete ?? palette.error, + } + } + if ("seeds" in variant && variant.seeds) { + const seeds = variant.seeds + return { + neutral: seeds.neutral, + ink: seeds.neutral, + primary: seeds.primary, + accent: seeds.info, + success: seeds.success, + warning: seeds.warning, + error: seeds.error, + info: seeds.info, + interactive: seeds.interactive, + diffAdd: seeds.diffAdd, + diffDelete: seeds.diffDelete, + } + } + throw new Error("Theme variant requires `palette` or `seeds`") +} + +/** Build v2 primitive ramps (100 = lightest). Alpha ramps are static in `v2/styles/colors.css`. */ +export function generateV2Primitives(variant: ThemeVariant, isDark: boolean): Record { + const colors = readPalette(variant) + const grey = generateV2NeutralScale(colors.neutral, colors.ink, isDark) + const blue = generateV2HueScale(colors.interactive, isDark) + const green = generateV2HueScale(colors.success, isDark) + const yellow = generateV2HueScale(colors.warning, isDark) + const red = generateV2HueScale(colors.error, isDark) + const purple = generateV2HueScale(colors.accent, isDark) + const pink = generateV2HueScale(colors.info, isDark) + const orange = generateV2HueScale(shift(colors.warning, { h: -22, l: -0.082, c: 0.94 }), isDark) + const cyan = generateV2HueScale(shift(colors.info, { h: -12, l: 0.128, c: 1.12 }), isDark) + + return { + ...V2_PRIMITIVES_DEFAULT, + ...assignHueRamp("grey", grey), + ...assignHueRamp("blue", blue), + ...assignHueRamp("green", green), + ...assignHueRamp("yellow", yellow), + ...assignHueRamp("red", red), + ...assignHueRamp("purple", purple), + ...assignHueRamp("pink", pink), + ...assignHueRamp("orange", orange), + ...assignHueRamp("cyan", cyan), + } +} + +export function resolveThemeVariantV2(variant: ThemeVariant, isDark: boolean): ResolvedV2Theme { + const primitives = generateV2Primitives(variant, isDark) + const semantics = mapV2Semantics(isDark) + const foreground = mapV2Foreground(readPalette(variant).ink, isDark, variant.overrides) + return mergeV2Tokens(primitives, semantics, foreground, variant.v2Overrides ?? {}) +} + +export function resolveThemeV2(theme: DesktopTheme): { light: ResolvedV2Theme; dark: ResolvedV2Theme } { + return { + light: resolveThemeVariantV2(theme.light, false), + dark: resolveThemeVariantV2(theme.dark, true), + } +} + +export function themeV2ToCss(tokens: ResolvedV2Theme): string { + return Object.entries(tokens) + .map(([key, value]) => `--${key}: ${value};`) + .join("\n ") +} diff --git a/packages/ui/src/v2/components/dialog-v2.css b/packages/ui/src/v2/components/dialog-v2.css index e7d0f60f39..be6b157304 100644 --- a/packages/ui/src/v2/components/dialog-v2.css +++ b/packages/ui/src/v2/components/dialog-v2.css @@ -7,7 +7,7 @@ background-color: var(--v2-overlay-simple-overlay-scrim); } -[data-component="dialog"] { +[data-component="dialog-v2"] { position: fixed; inset: 0; z-index: 50; @@ -25,7 +25,7 @@ background: var(--v2-background-bg-layer-01); box-shadow: var(--v2-elevation-overlay); border-radius: 6px; - overflow: visible; + overflow: hidden; pointer-events: auto; [data-slot="dialog-content"] { @@ -37,6 +37,7 @@ max-height: 100%; flex: 1; overflow: auto; + background: transparent; scrollbar-width: none; -ms-overflow-style: none; diff --git a/packages/ui/src/v2/components/dialog-v2.stories.tsx b/packages/ui/src/v2/components/dialog-v2.stories.tsx index 355a87bd97..78d4720c40 100644 --- a/packages/ui/src/v2/components/dialog-v2.stories.tsx +++ b/packages/ui/src/v2/components/dialog-v2.stories.tsx @@ -17,7 +17,7 @@ Dialog content wrapper built on Kobalte's dialog primitive with v2 styling. - Focus trapping and aria attributes provided by Kobalte Dialog. ### Theming/tokens -- Uses \`data-component="dialog"\` and slot attributes. +- Uses \`data-component="dialog-v2"\` and slot attributes. ` export default { diff --git a/packages/ui/src/v2/components/dialog-v2.tsx b/packages/ui/src/v2/components/dialog-v2.tsx index b674efe64d..6a9e66184c 100644 --- a/packages/ui/src/v2/components/dialog-v2.tsx +++ b/packages/ui/src/v2/components/dialog-v2.tsx @@ -7,6 +7,7 @@ export interface DialogProps extends ParentProps { description?: JSXElement action?: JSXElement size?: "normal" | "large" | "x-large" + variant?: "default" | "settings" class?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] fit?: boolean @@ -17,14 +18,19 @@ export function DialogFooter(props: ParentProps) { } export function Dialog(props: DialogProps) { - const [local] = splitProps(props, ["title", "description", "action", "size", "class", "classList", "fit", "children"]) + const [local] = splitProps(props, ["title", "description", "action", "size", "variant", "class", "classList", "fit", "children"]) const title = children(() => local.title) const description = children(() => local.description) const action = children(() => local.action) const hasHeader = () => title() || action() return ( -
    +
    Date: Fri, 5 Jun 2026 00:54:49 +0000 Subject: [PATCH 013/626] chore: generate --- packages/ui/src/theme/v2/foreground.ts | 8 ++------ packages/ui/src/theme/v2/mapping.ts | 4 +--- packages/ui/src/v2/components/dialog-v2.tsx | 12 +++++++++++- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/theme/v2/foreground.ts b/packages/ui/src/theme/v2/foreground.ts index f188e5e4db..609e713097 100644 --- a/packages/ui/src/theme/v2/foreground.ts +++ b/packages/ui/src/theme/v2/foreground.ts @@ -13,12 +13,8 @@ export function mapV2Foreground( }) return { - "v2-text-text-base": isDark - ? blend("#ffffff", body, 0.9) - : shift(body, { l: -0.07, c: 1.04 }), - "v2-text-text-muted": - overrides["text-weak"] ?? - shift(body, { l: isDark ? -0.11 : 0.11, c: 0.9 }), + "v2-text-text-base": isDark ? blend("#ffffff", body, 0.9) : shift(body, { l: -0.07, c: 1.04 }), + "v2-text-text-muted": overrides["text-weak"] ?? shift(body, { l: isDark ? -0.11 : 0.11, c: 0.9 }), "v2-text-text-faint": shift(body, { l: isDark ? -0.2 : 0.21, c: isDark ? 0.78 : 0.72 }), } } diff --git a/packages/ui/src/theme/v2/mapping.ts b/packages/ui/src/theme/v2/mapping.ts index 60b34118c6..e998c43279 100644 --- a/packages/ui/src/theme/v2/mapping.ts +++ b/packages/ui/src/theme/v2/mapping.ts @@ -143,8 +143,6 @@ export function mapV2Semantics(isDark: boolean): Record { return isDark ? dark : light } -export function mergeV2Tokens( - ...layers: Record[] -): Record { +export function mergeV2Tokens(...layers: Record[]): Record { return Object.assign({}, ...layers) } diff --git a/packages/ui/src/v2/components/dialog-v2.tsx b/packages/ui/src/v2/components/dialog-v2.tsx index 6a9e66184c..d4113d0678 100644 --- a/packages/ui/src/v2/components/dialog-v2.tsx +++ b/packages/ui/src/v2/components/dialog-v2.tsx @@ -18,7 +18,17 @@ export function DialogFooter(props: ParentProps) { } export function Dialog(props: DialogProps) { - const [local] = splitProps(props, ["title", "description", "action", "size", "variant", "class", "classList", "fit", "children"]) + const [local] = splitProps(props, [ + "title", + "description", + "action", + "size", + "variant", + "class", + "classList", + "fit", + "children", + ]) const title = children(() => local.title) const description = children(() => local.description) const action = children(() => local.action) From 17ba5539e792648bda4a3925c5c18bcc4606282a Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 5 Jun 2026 03:08:13 +0000 Subject: [PATCH 014/626] sync release versions for v1.16.0 --- bun.lock | 50 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 27 files changed, 51 insertions(+), 51 deletions(-) diff --git a/bun.lock b/bun.lock index 1ead55e870..fca46adc62 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.15.13", + "version": "1.16.0", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -106,7 +106,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -142,7 +142,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -169,7 +169,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -191,7 +191,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -215,7 +215,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -235,7 +235,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.15.13", + "version": "1.16.0", "bin": { "opencode": "./bin/opencode", }, @@ -324,7 +324,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -378,7 +378,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -392,7 +392,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.15.10", + "version": "1.16.0", "dependencies": { "effect": "catalog:", }, @@ -404,7 +404,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -435,7 +435,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -451,7 +451,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -464,7 +464,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -482,7 +482,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.15.13", + "version": "1.16.0", "bin": { "opencode": "./bin/opencode", }, @@ -609,7 +609,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -647,7 +647,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "cross-spawn": "catalog:", }, @@ -662,7 +662,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", @@ -676,7 +676,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -689,7 +689,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", @@ -722,7 +722,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -741,7 +741,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -781,7 +781,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -830,7 +830,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 239eacdd1b..1a4e391842 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.15.13", + "version": "1.16.0", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 6f6cf7f1cb..a8f7327247 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.15.13", + "version": "1.16.0", "type": "module", "license": "MIT", "bin": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index e3da004c61..470a302f02 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.15.13", + "version": "1.16.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index e9645f8695..71bc49a914 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.15.13", + "version": "1.16.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 48d783723f..085e0d3e37 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.15.13", + "version": "1.16.0", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 159f600842..ed1e602725 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.15.13", + "version": "1.16.0", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 1c1064169e..3fd5f94476 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.15.13", + "version": "1.16.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 848a99fe2d..561cb93a23 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.13", + "version": "1.16.0", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index dfdbe69dce..2386003264 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.15.13", + "version": "1.16.0", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 1d0e26dde8..37fa4b2680 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.13", + "version": "1.16.0", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 74671bb5b4..17d1663cb0 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.10", + "version": "1.16.0", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index efa80ec8cd..a265686720 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.15.13", + "version": "1.16.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 83888b41ab..9a4d064002 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.15.13", + "version": "1.16.0", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 243b4be76e..b1d7dc4c42 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.13", + "version": "1.16.0", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index 3aca204c2e..44283b85a2 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.13", + "version": "1.16.0", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index cde08e0352..75144430d4 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.13", + "version": "1.16.0", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index f93a21588c..1c448baf5e 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.15.13", + "version": "1.16.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index b3580468e0..854212fcb3 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.15.13", + "version": "1.16.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 0eb698857f..4f1bd8bad1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.15.13", + "version": "1.16.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 5850181baf..c3668ea8b0 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.15.13", + "version": "1.16.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 9904dc49ab..1330936923 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.15.13", + "version": "1.16.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 4fdbf7d626..92a032d34f 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.15.13", + "version": "1.16.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 4dedc30584..705f5867ef 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.15.13", + "version": "1.16.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 3313fd08aa..6ffe617213 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.15.13", + "version": "1.16.0", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 9609da8966..3a096369f6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.15.13", + "version": "1.16.0", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 1b100e0aa1..266d14b4fa 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.15.13", + "version": "1.16.0", "publisher": "sst-dev", "repository": { "type": "git", From 64dc6d39ab39dee8a220736fd65e171ef476667a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 23:12:17 -0400 Subject: [PATCH 015/626] feat(core): attach global native tools (#30832) --- packages/core/src/location-layer.ts | 4 +- packages/core/src/public/index.ts | 1 + packages/core/src/public/opencode.ts | 8 +- packages/core/src/public/tool.ts | 19 ++ packages/core/src/session/runner/llm.ts | 2 +- packages/core/src/tool/AGENTS.md | 139 +++++++++++++ packages/core/src/tool/application-tools.ts | 51 +++++ packages/core/src/tool/apply-patch.ts | 2 +- packages/core/src/tool/bash.ts | 2 +- packages/core/src/tool/edit.ts | 2 +- packages/core/src/tool/glob.ts | 2 +- packages/core/src/tool/grep.ts | 2 +- packages/core/src/tool/native.ts | 73 +++++++ packages/core/src/tool/question.ts | 2 +- packages/core/src/tool/read.ts | 4 +- .../{tool-registry.ts => tool/registry.ts} | 42 +++- packages/core/src/tool/skill.ts | 2 +- packages/core/src/tool/todowrite.ts | 2 +- packages/core/src/tool/webfetch.ts | 2 +- packages/core/src/tool/websearch.ts | 2 +- packages/core/src/tool/write.ts | 2 +- packages/core/test/application-tools.test.ts | 184 ++++++++++++++++++ packages/core/test/location-layer.test.ts | 40 ++-- packages/core/test/public-opencode.test.ts | 14 +- .../core/test/session-runner-recorded.test.ts | 4 +- .../test/session-runner-tool-registry.test.ts | 4 +- packages/core/test/session-runner.test.ts | 57 +++++- packages/core/test/tool-apply-patch.test.ts | 4 +- packages/core/test/tool-bash.test.ts | 4 +- packages/core/test/tool-edit.test.ts | 4 +- packages/core/test/tool-glob.test.ts | 4 +- packages/core/test/tool-grep.test.ts | 6 +- packages/core/test/tool-question.test.ts | 4 +- packages/core/test/tool-read.test.ts | 4 +- packages/core/test/tool-skill.test.ts | 4 +- packages/core/test/tool-todowrite.test.ts | 4 +- packages/core/test/tool-webfetch.test.ts | 4 +- packages/core/test/tool-websearch.test.ts | 4 +- packages/core/test/tool-write.test.ts | 4 +- 39 files changed, 647 insertions(+), 71 deletions(-) create mode 100644 packages/core/src/public/tool.ts create mode 100644 packages/core/src/tool/AGENTS.md create mode 100644 packages/core/src/tool/application-tools.ts create mode 100644 packages/core/src/tool/native.ts rename packages/core/src/{tool-registry.ts => tool/registry.ts} (80%) create mode 100644 packages/core/test/application-tools.test.ts diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 93c59ef539..b45a4aab75 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -27,7 +27,8 @@ import { RepositoryCache } from "./repository-cache" import { Pty } from "./pty" import { SkillV2 } from "./skill" import { BuiltInTools } from "./tool/builtins" -import { ToolRegistry } from "./tool-registry" +import { ToolRegistry } from "./tool/registry" +import { ApplicationTools } from "./tool/application-tools" import { ToolOutputStore } from "./tool-output-store" import { AppProcess } from "./process" import { Ripgrep } from "./ripgrep" @@ -108,5 +109,6 @@ export class LocationServiceMap extends LayerMap.Service()(" LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)), FetchHttpClient.layer, ToolOutputStore.defaultCleanupLayer, + ApplicationTools.layer, ], }) {} diff --git a/packages/core/src/public/index.ts b/packages/core/src/public/index.ts index 0fd1b9537e..2229039b9a 100644 --- a/packages/core/src/public/index.ts +++ b/packages/core/src/public/index.ts @@ -3,6 +3,7 @@ export { Agent } from "./agent" export { Model } from "./model" export { OpenCode } from "./opencode" export { Session } from "./session" +export { Tool } from "./tool" export { Location } from "./location" export { Prompt } from "../session/prompt" export { AbsolutePath } from "../schema" diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index 38541faa8f..8ec58de4d5 100644 --- a/packages/core/src/public/opencode.ts +++ b/packages/core/src/public/opencode.ts @@ -9,10 +9,13 @@ import { SessionV2 } from "../session" import * as SessionExecutionLocal from "../session/execution/local" import { SessionProjector } from "../session/projector" import { SessionStore } from "../session/store" +import { ApplicationTools } from "../tool/application-tools" import { Session } from "./session" +import { Tool } from "./tool" export interface Interface { readonly sessions: Session.Interface + readonly tools: Tool.Service } /** Intentional public native API for Effect applications embedding OpenCode. */ @@ -28,13 +31,16 @@ const SessionsLayer = SessionV2.layer.pipe( Layer.provide(ProjectV2.defaultLayer), Layer.orDie, ) +const ApplicationToolsLayer = ApplicationTools.layer // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. export const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* SessionV2.Service + const tools = yield* ApplicationTools.Service return Service.of({ + tools: { attach: tools.attach }, sessions: { create: (input) => sessions.create({ @@ -65,6 +71,6 @@ export const layer = Layer.effect( }, }) }), -).pipe(Layer.provide(SessionsLayer)) +).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer))) // TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics. diff --git a/packages/core/src/public/tool.ts b/packages/core/src/public/tool.ts new file mode 100644 index 0000000000..3584393e75 --- /dev/null +++ b/packages/core/src/public/tool.ts @@ -0,0 +1,19 @@ +export * as Tool from "./tool" + +import { Effect, Scope } from "effect" +import type { NativeTool } from "../tool/native" + +export { Failure, make } from "../tool/native" +export type { Any, Content, Context, Executable } from "../tool/native" + +export interface Service { + /** + * Attach same-process tools to this OpenCode instance for the current Scope. + * Location tools with the same name take precedence where they are installed. + * Closing the Scope removes the tools immediately, so calls that have not + * started settling may fail because the tool is no longer available. + */ + readonly attach: ( + tools: Readonly>, + ) => Effect.Effect +} diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index a322b21098..8da27e4c85 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -9,7 +9,7 @@ import { SessionStore } from "../store" import { Service, StepLimitExceededError } from "./index" import { createLLMEventPublisher } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" -import { ToolRegistry } from "../../tool-registry" +import { ToolRegistry } from "../../tool/registry" import { SessionRunnerModel } from "./model" import { Database } from "../../database/database" import { SessionInput } from "../input" diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md new file mode 100644 index 0000000000..ce6a478aa8 --- /dev/null +++ b/packages/core/src/tool/AGENTS.md @@ -0,0 +1,139 @@ +# Core Tool Architecture + +This folder owns Core-native tool definition, contribution, effective lookup, and execution. Keep those concerns distinct even though `ToolRegistry` brings them together at runtime. + +## Current Architecture + +```txt +Public Tool.make NativeTool value ApplicationTools Location built-ins Location ToolRegistry Session runner + │ │ │ │ │ │ + ├─ construct ─────────▶ │ │ │ │ + │ │ │ │ │ │ + │ ├─ scoped attach ─────▶ │ │ │ + │ │ │ │ │ │ + │ │ │ ├─ scoped contributions ──▶ │ + │ │ │ │ │ │ + │ │ ├─ shared current entries ───────────────────────▶ │ + │ │ │ │ │ │ + │ │ │ │ ├─ effective definitions and settlement ──▶ + │ │ │ │ │ │ +``` + +There are three relevant representations: + +- `native.ts` defines the plain Core-native executable value exposed publicly as `Tool.make(...)`. It combines an `@opencode-ai/llm` model-facing definition with a Session-aware handler. +- `application-tools.ts` stores process-scoped application contributions. It owns availability and scoped attachment, but it does not execute tools. +- `registry.ts` is the single execution registry. Each Location owns one registry, its built-in contributions, effective precedence, input/output validation, permissions, and settlement. + +`ToolRegistry.Entry` is intentionally more powerful than the public native tool value. Internal Location tools may use Core-owned capabilities such as `assertPermission`; embedding applications receive only the narrow public execution context. + +## Placement And Layers + +- `ApplicationTools.Service` is process-scoped and must be shared by current and future Locations. +- `ToolRegistry.Service` is Location-scoped because built-in handlers close over Location services such as filesystem, permissions, and tool-output storage. +- `LocationServiceMap` constructs fresh Location services while receiving the shared `ApplicationTools.Service` as a dependency. +- `OpenCode.layer` exposes the same shared application-tool service through `opencode.tools.attach(...)`. +- `ToolRegistry.defaultLayer` creates isolated application-tool state. It is suitable for self-contained consumers and tests, but not when attachments must be shared with a separately constructed `LocationServiceMap`. + +Do not make `ToolRegistry` process-global. Do not move Location resources into `ApplicationTools`. Do not construct independent `ApplicationTools.layer` instances when the caller expects one attachment to appear across Locations. + +## Contribution And Precedence + +Built-in Location tools contribute through `ToolRegistry.contribute(...)`. Application tools attach through `ApplicationTools.attach(...)`, exposed publicly as `opencode.tools.attach(...)`. + +Both contribution mechanisms use `State` scoped transforms: + +- Closing a contribution Scope rebuilds state without that contribution. +- A later same-name application attachment wins while active. +- Closing that later attachment reveals the earlier active application contribution. +- A Location tool always takes precedence over an application tool with the same name. +- Application attachment inputs are captured before registering the replayable transform; later caller mutation must not alter a contribution during an unrelated rebuild. + +Do not introduce another application-specific tool type or registry. Plugins should contribute existing native tools or internal registry entries at the lifetime they actually own. + +## Dynamic Removal Semantics + +Definitions and settlement intentionally resolve the current effective tools independently. There is no provider-turn snapshot, attachment lease, or draining detach. + +```txt +Embedding App ApplicationTools Location ToolRegistry Session Runner + │ │ │ │ + ├─ attach({ opencord_run }) ──▶ │ │ + │ │ │ │ + │ │ ◀─ definitions() ──────────────────┤ + │ │ │ │ + │ ◀─ entries() ────────────┤ │ + │ │ │ │ + │ │ ├─ current effective definitions ──▶ + │ │ │ │ + ├─ attachment Scope closes ───▶ │ │ + │ │ │ │ + │ │ ◀─ settle(opencord_run) ───────────┤ + │ │ │ │ + │ ◀─ current lookup ───────┤ │ + │ │ │ │ + │ │ ├─ Unknown tool ───────────────────▶ + │ │ │ │ +``` + +Consequences of this choice: + +- Closing an attachment Scope revokes the tool immediately for calls that have not started settling. +- A call produced from an earlier advertised definition may fail as unknown. +- If a same-name replacement is currently active, a later call may execute that replacement. +- An execution that already resolved its entry continues with the handler it captured. +- Attachment Scope closure does not wait for already-started executions. Applications whose handlers depend on scoped resources must coordinate graceful shutdown themselves. + +These are deliberate simplifications. Do not add snapshots, semaphores, leases, or deferred finalizers without a concrete requirement for stronger consistency or graceful draining. + +## File Roles + +```txt +tool/ + native.ts plain public/Core-native executable tool value + application-tools.ts process-scoped State-backed application contributions + registry.ts Location-scoped effective lookup, validation, and execution + builtins.ts shipped Location tool layer composition + read.ts, bash.ts, ... individual Location-scoped built-in contributions +``` + +Keep model/provider-neutral tool schemas and output projection in `@opencode-ai/llm`. Keep Session identity, permissions, Location precedence, and settlement in Core. + +## Future Directions + +Tool availability may eventually gain a real third scope, such as Session-specific or plugin-owned contributions: + +```txt + ╭─────────────────╮ + │ Tool definition │ + ╰────────┬────────╯ + ╭────────────────────────────────────────╰╮─ ─ ─ ─ ─ ─ ─ ─ future ─ ─ ─ ─ ─ ─ ─ ─ ╮ + │ │ + ▼ ▼ ▼ +╭───────────────────────╮ ╭────────────────────────╮ ╭───────────────────────╮ +│ Process contributions │ │ Location contributions │ │ Session contributions │ +╰───────────┬───────────╯ ╰────────────┬───────────╯ ╰───────────┬───────────╯ + │ │ │ + │ │ + ╰─────────────────────────────────────────◀─ ─ ─ ─ ─ ─ ─ ─ future ─ ─ ─ ─ ─ ─ ─ ─ ╯ + ╭──────────────────────╮ + │ Effective resolution │ + ╭─────────╰───────────┬──────────╯────────────╮ + │ │ │ + ▼ ▼ + ╭───────────────────────────────╮ ╭─────────────────────────╮ + │ Advertise current definitions │ │ Execute current handler │ + ╰───────────────────────────────╯ ╰─────────────────────────╯ +``` + +Prefer these directions only when a concrete use requires them: + +- **Contextual availability:** Add Session/agent/plugin filtering at effective resolution. Keep tool definitions independent from where they are enabled. +- **Hierarchical overlays:** If a third contribution scope becomes real, consider one registry abstraction with process, Location, and Session overlays rather than adding another special registry service. +- **Plugin tools:** Reuse the existing native tool value for restricted handlers and `ToolRegistry.Entry` for trusted Core-owned capabilities. Choose process or Location contribution lifetime explicitly. +- **Stale-call rejection:** If executing a same-name replacement is unsafe, attach an identity/version to advertised definitions and reject stale calls without retaining removed handlers. +- **Pinned provider turns:** If exact advertisement-to-execution consistency becomes necessary, snapshot effective entries for one provider turn. This weakens immediate revocation. +- **Graceful plugin unload:** If attachment-owned resources must outlive started executions, add explicit execution draining. Keep this separate from whether new calls can discover the tool. +- **Cluster placement:** `ApplicationTools` is process-global, not cluster-global. Cluster-wide contribution and execution ownership require a separate durable design. + +When choosing stronger semantics, state which property matters: immediate revocation, stale-call rejection, exact handler pinning, or graceful resource draining. They are different guarantees and should not arrive as one bundled lifecycle mechanism. diff --git a/packages/core/src/tool/application-tools.ts b/packages/core/src/tool/application-tools.ts new file mode 100644 index 0000000000..97111e9c33 --- /dev/null +++ b/packages/core/src/tool/application-tools.ts @@ -0,0 +1,51 @@ +export * as ApplicationTools from "./application-tools" + +import { Context, Effect, Layer, Scope } from "effect" +import { castDraft, enableMapSet } from "immer" +import { State } from "../state" +import { NativeTool } from "./native" + +type Data = { + readonly entries: Map +} + +type Editor = { + readonly set: (name: string, tool: NativeTool.Any) => void +} + +export interface Interface { + readonly attach: (tools: Readonly>) => Effect.Effect + readonly entries: () => ReadonlyMap +} + +export class Service extends Context.Service()("@opencode/ApplicationTools") {} + +enableMapSet() + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = State.create({ + initial: () => ({ entries: new Map() }), + editor: (draft) => ({ + set: (name, tool) => { + draft.entries.set( + name, + castDraft(tool) as typeof draft.entries extends Map ? Value : never, + ) + }, + }), + }) + + return Service.of({ + attach: Effect.fn("ApplicationTools.attach")(function* (tools) { + const entries = Object.entries(tools) + const transform = yield* state.transform() + yield* transform((editor) => { + for (const [name, tool] of entries) editor.set(name, tool) + }) + }), + entries: () => state.get().entries, + }) + }), +) diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 74b66864b5..78c5b3f136 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -6,7 +6,7 @@ import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { Patch } from "../patch" -import { ToolRegistry } from "../tool-registry" +import { ToolRegistry } from "./registry" export const name = "apply_patch" diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index 0297def11f..408339c87d 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -10,7 +10,7 @@ import { LocationMutation } from "../location-mutation" import { AppProcess } from "../process" import { PositiveInt } from "../schema" import { ToolOutputStore } from "../tool-output-store" -import { ToolRegistry } from "../tool-registry" +import { ToolRegistry } from "./registry" export const name = "bash" export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index c16ce9abbd..64069dc0f2 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -12,7 +12,7 @@ import { Cause, Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" -import { ToolRegistry } from "../tool-registry" +import { ToolRegistry } from "./registry" export const name = "edit" diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index b16308bef8..397489a49f 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -4,7 +4,7 @@ import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { Cause, Effect, Layer, Schema } from "effect" import { FileSystem } from "../filesystem" import { LocationSearch } from "../location-search" -import { ToolRegistry } from "../tool-registry" +import { ToolRegistry } from "./registry" export const name = "glob" diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 6bd20093b8..bee27a0f97 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -5,7 +5,7 @@ import { Cause, Effect, Layer, Schema } from "effect" import { FileSystem } from "../filesystem" import { LocationSearch } from "../location-search" import { Ripgrep } from "../ripgrep" -import { ToolRegistry } from "../tool-registry" +import { ToolRegistry } from "./registry" export const name = "grep" diff --git a/packages/core/src/tool/native.ts b/packages/core/src/tool/native.ts new file mode 100644 index 0000000000..290dbbccf4 --- /dev/null +++ b/packages/core/src/tool/native.ts @@ -0,0 +1,73 @@ +export * as NativeTool from "./native" + +import { Tool, ToolFailure } from "@opencode-ai/llm" +import { Effect, Schema } from "effect" +import type { SessionSchema } from "../session/schema" + +export interface Context { + readonly sessionID: SessionSchema.ID + readonly id: string + readonly name: string +} + +export type SchemaType = Schema.Codec + +export interface Executable, Success extends SchemaType> { + readonly definition: Tool.Tool + readonly execute: ( + parameters: Schema.Schema.Type, + context: Context, + ) => Effect.Effect, ToolFailure> +} + +export type Any = Executable + +export const Failure = ToolFailure +export type Failure = ToolFailure + +export type Content = + | { readonly type: "text"; readonly text: string } + | { + readonly type: "file" + readonly data: string + readonly mime: string + readonly name?: string + } + +export function make, Success extends SchemaType>(config: { + readonly description: string + readonly parameters: Parameters + readonly success: Success + readonly execute: ( + parameters: Schema.Schema.Type, + context: Context, + ) => Effect.Effect, ToolFailure> + readonly toModelOutput?: (input: { + readonly callID: string + readonly parameters: Schema.Schema.Type + readonly output: Success["Encoded"] + }) => ReadonlyArray +}): Executable { + const toModelOutput = config.toModelOutput + return { + definition: Tool.make({ + description: config.description, + parameters: config.parameters, + success: config.success, + toModelOutput: toModelOutput + ? (input) => + toModelOutput(input).map((content) => + content.type === "text" + ? content + : { + type: "file", + source: { type: "data", data: content.data }, + mime: content.mime, + name: content.name, + }, + ) + : undefined, + }), + execute: config.execute, + } +} diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index b4378fe474..559f29300f 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -3,7 +3,7 @@ export * as QuestionTool from "./question" import { Tool, toolText } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import { QuestionV2 } from "../question" -import { ToolRegistry } from "../tool-registry" +import { ToolRegistry } from "./registry" export const name = "question" diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 67f62eb388..6efc75a07d 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -6,7 +6,7 @@ import { FileSystem } from "../filesystem" import { NonNegativeInt, PositiveInt } from "../schema" import { PermissionV2 } from "../permission" import { ToolOutputStore } from "../tool-output-store" -import { ToolRegistry } from "../tool-registry" +import { ToolRegistry } from "./registry" export const name = "read" const LocationInput = Schema.Struct({ @@ -93,7 +93,7 @@ export const layer = Layer.effectDiscard( }), ) export const locationLayer = layer.pipe( - Layer.provideMerge(ToolRegistry.layer), + Layer.provideMerge(ToolRegistry.defaultLayer), Layer.provideMerge(FileSystem.locationLayer), Layer.provideMerge(PermissionV2.locationLayer), Layer.provideMerge(ToolOutputStore.defaultLayer), diff --git a/packages/core/src/tool-registry.ts b/packages/core/src/tool/registry.ts similarity index 80% rename from packages/core/src/tool-registry.ts rename to packages/core/src/tool/registry.ts index 87c22dedb8..056f65d317 100644 --- a/packages/core/src/tool-registry.ts +++ b/packages/core/src/tool/registry.ts @@ -1,4 +1,4 @@ -export * as ToolRegistry from "./tool-registry" +export * as ToolRegistry from "./registry" import { Tool, @@ -13,10 +13,11 @@ import { } from "@opencode-ai/llm" import { Context, Effect, Layer, Schema, Scope } from "effect" import { castDraft, enableMapSet } from "immer" -import { PermissionV2 } from "./permission" -import { State } from "./state" -import { SessionSchema } from "./session/schema" -import type { SessionV2 } from "./session" +import { PermissionV2 } from "../permission" +import { State } from "../state" +import { SessionSchema } from "../session/schema" +import type { SessionV2 } from "../session" +import { ApplicationTools } from "./application-tools" export type ExecuteInput = { readonly sessionID: SessionSchema.ID @@ -86,6 +87,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const permission = yield* PermissionV2.Service + const applications = yield* ApplicationTools.Service const state = State.create({ initial: () => ({ entries: new Map() }), editor: (draft) => ({ @@ -104,19 +106,36 @@ export const layer = Layer.effect( }) const definitions = Effect.fn("ToolRegistry.definitions")(function* () { - return Tool.toDefinitions( - Object.fromEntries(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool])), - ) + const tools = new Map(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool] as const)) + // Location tools own their names. Application tools fill otherwise-unclaimed names. + for (const [name, tool] of applications.entries()) { + if (!tools.has(name)) tools.set(name, tool.definition) + } + return Tool.toDefinitions(Object.fromEntries(tools)) }) + const entry = (name: string): Entry | undefined => { + const local = state.get().entries.get(name) + if (local !== undefined) return local + const tool = applications.entries().get(name) + if (tool === undefined) return + return { + tool: tool.definition, + execute: ({ parameters, sessionID, call }) => + tool.execute(parameters, { sessionID, id: call.id, name: call.name }), + } + } + const invocation = (input: ExecuteInput): Invocation => ({ ...input, // Source needs the durable owning assistant message ID, which the registry does not receive yet. assertPermission: (request) => permission.assert({ ...request, sessionID: input.sessionID }), }) - const settle = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput) { - const entry = state.get().entries.get(input.call.name) + const settleEntry = Effect.fn("ToolRegistry.settleEntry")(function* ( + entry: Entry | undefined, + input: ExecuteInput, + ) { if (!entry) return { result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } } if (!entry.execute && !entry.tool.execute) return { result: { type: "error" as const, value: `Tool has no execute handler: ${input.call.name}` } } @@ -155,6 +174,7 @@ export const layer = Layer.effect( ) }) + const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) => settleEntry(entry(input.call.name), input)) const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) { return (yield* settle(input)).result }) @@ -171,3 +191,5 @@ export const layer = Layer.effect( }) }), ) + +export const defaultLayer = layer.pipe(Layer.provide(ApplicationTools.layer)) diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index bf06b0a3f2..688a76d5f0 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -8,7 +8,7 @@ import { FSUtil } from "../fs-util" import { PluginBoot } from "../plugin/boot" import { SkillV2 } from "../skill" import { ToolOutputStore } from "../tool-output-store" -import { ToolRegistry } from "../tool-registry" +import { ToolRegistry } from "./registry" export const name = "skill" const FILE_LIMIT = 10 diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index de43e01954..729279f6a9 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -3,7 +3,7 @@ export * as TodoWriteTool from "./todowrite" import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { Cause, Effect, Layer, Schema } from "effect" import { SessionTodo } from "../session/todo" -import { ToolRegistry } from "../tool-registry" +import { ToolRegistry } from "./registry" export const name = "todowrite" diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 1f0d71c938..12ee4fd245 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -6,7 +6,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { Parser } from "htmlparser2" import TurndownService from "turndown" import { ToolOutputStore } from "../tool-output-store" -import { ToolRegistry } from "../tool-registry" +import { ToolRegistry } from "./registry" export const name = "webfetch" export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024 diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index f0438dd3ef..68daad49e6 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -7,7 +7,7 @@ import { truthy } from "../flag/flag" import { InstallationVersion } from "../installation/version" import { PositiveInt } from "../schema" import { ToolOutputStore } from "../tool-output-store" -import { ToolRegistry } from "../tool-registry" +import { ToolRegistry } from "./registry" import { checksum } from "../util/encode" export const name = "websearch" diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index af5dab29e7..4a3afa93f3 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -11,7 +11,7 @@ import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { Cause, Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { LocationMutation } from "../location-mutation" -import { ToolRegistry } from "../tool-registry" +import { ToolRegistry } from "./registry" export const name = "write" diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts new file mode 100644 index 0000000000..8b16275bd1 --- /dev/null +++ b/packages/core/test/application-tools.test.ts @@ -0,0 +1,184 @@ +import { describe, expect } from "bun:test" +import { Tool } from "@opencode-ai/core/public" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { SessionV2 } from "@opencode-ai/core/session" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { Effect, Exit, Layer, Schema, Scope } from "effect" +import { testEffect } from "./lib/effect" + +const permission = Layer.mock(PermissionV2.Service, { + assert: () => Effect.void, +}) +const applications = ApplicationTools.layer +const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(applications)) +const it = testEffect(Layer.mergeAll(applications, registry)) + +const sessionID = SessionV2.ID.make("ses_application_tool") +const contextual = (contexts: Tool.Context[]) => + Tool.make({ + description: "Read application context", + parameters: Schema.Struct({ query: Schema.String }), + success: Schema.Struct({ answer: Schema.String }), + execute: ({ query }, context) => + Effect.sync(() => { + contexts.push(context) + return { answer: query.toUpperCase() } + }), + toModelOutput: ({ output }) => [ + { type: "text", text: output.answer }, + { type: "file", data: "aGVsbG8=", mime: "image/png", name: "result.png" }, + ], + }) + +describe("ApplicationTools", () => { + it.effect("advertises and executes a scoped application tool with Session context", () => + Effect.gen(function* () { + const applications = yield* ApplicationTools.Service + const registry = yield* ToolRegistry.Service + const contexts: Tool.Context[] = [] + + yield* applications.attach({ application_context: contextual(contexts) }) + + expect(yield* registry.definitions()).toMatchObject([ + { name: "application_context", description: "Read application context" }, + ]) + expect( + yield* registry.settle({ + sessionID, + call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } }, + }), + ).toEqual({ + result: { + type: "content", + value: [ + { type: "text", text: "HELLO" }, + { type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" }, + ], + }, + output: { + structured: { answer: "HELLO" }, + content: [ + { type: "text", text: "HELLO" }, + { type: "file", source: { type: "data", data: "aGVsbG8=" }, mime: "image/png", name: "result.png" }, + ], + }, + }) + expect(contexts).toEqual([{ sessionID, id: "call-context", name: "application_context" }]) + }), + ) + + it.effect("removes an application tool when its attachment scope closes", () => + Effect.gen(function* () { + const applications = yield* ApplicationTools.Service + const registry = yield* ToolRegistry.Service + const scope = yield* Scope.make() + + yield* applications.attach({ temporary: contextual([]) }).pipe(Scope.provide(scope)) + expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["temporary"]) + + yield* Scope.close(scope, Exit.void) + expect(yield* registry.definitions()).toEqual([]) + }), + ) + + it.effect("removes a tool before settling a call produced from an earlier definition", () => + Effect.gen(function* () { + const applications = yield* ApplicationTools.Service + const registry = yield* ToolRegistry.Service + const attachmentScope = yield* Scope.make() + yield* applications.attach({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope)) + expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"]) + + yield* Scope.close(attachmentScope, Exit.void) + expect( + yield* registry.settle({ + sessionID, + call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } }, + }), + ).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } }) + }), + ) + + it.effect("does not leak an attachment into an already closed scope", () => + Effect.gen(function* () { + const applications = yield* ApplicationTools.Service + const registry = yield* ToolRegistry.Service + const scope = yield* Scope.make() + yield* Scope.close(scope, Exit.void) + + yield* applications.attach({ closed: contextual([]) }).pipe(Scope.provide(scope)) + + expect(yield* registry.definitions()).toEqual([]) + }), + ) + + it.effect("captures the attached record before later State rebuilds", () => + Effect.gen(function* () { + const applications = yield* ApplicationTools.Service + const registry = yield* ToolRegistry.Service + const attached = { stable: contextual([]) } + yield* applications.attach(attached) + Object.assign(attached, { late: contextual([]) }) + + yield* Effect.scoped(applications.attach({ temporary: contextual([]) })) + + expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["stable"]) + }), + ) + + it.effect("settles with the current same-name application tool and restores earlier attachments", () => + Effect.gen(function* () { + const applications = yield* ApplicationTools.Service + const registry = yield* ToolRegistry.Service + const firstContexts: Tool.Context[] = [] + const secondContexts: Tool.Context[] = [] + const scope = yield* Scope.make() + yield* applications.attach({ contextual: contextual(firstContexts) }) + expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"]) + yield* applications.attach({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope)) + + yield* registry.settle({ + sessionID, + call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } }, + }) + yield* Scope.close(scope, Exit.void) + yield* registry.settle({ + sessionID, + call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } }, + }) + + expect(secondContexts).toEqual([{ sessionID, id: "call-second", name: "contextual" }]) + expect(firstContexts).toEqual([{ sessionID, id: "call-first", name: "contextual" }]) + }), + ) + + it.effect("keeps the Location tool when an application tool has the same name", () => + Effect.gen(function* () { + const applications = yield* ApplicationTools.Service + const registry = yield* ToolRegistry.Service + const transform = yield* registry.transform() + const locationContexts: Tool.Context[] = [] + const applicationContexts: Tool.Context[] = [] + const location = contextual(locationContexts) + yield* transform((editor) => + editor.set("shared", { + tool: location.definition, + execute: ({ parameters, sessionID, call }) => + location.execute(parameters, { sessionID, id: call.id, name: call.name }), + }), + ) + yield* applications.attach({ shared: contextual(applicationContexts) }) + + expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["shared"]) + expect( + yield* registry.settle({ + sessionID, + call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } }, + }), + ).toMatchObject({ result: { type: "content" } }) + expect(locationContexts).toEqual([{ sessionID, id: "call-shared", name: "shared" }]) + expect(applicationContexts).toEqual([]) + }), + ) +}) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index baaa27a374..e42d9859c5 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,7 +1,8 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Layer, Schema } from "effect" +import { Tool } from "@opencode-ai/core/public" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { PluginBoot } from "@opencode-ai/core/plugin/boot" @@ -18,19 +19,24 @@ import { Npm } from "../src/npm" import { Project } from "../src/project" import { ProjectReference } from "../src/project-reference" import { LocationSearch } from "../src/location-search" -import { ToolRegistry } from "../src/tool-registry" +import { ToolRegistry } from "../src/tool/registry" +import { ApplicationTools } from "../src/tool/application-tools" +const applicationTools = ApplicationTools.layer const it = testEffect( - LocationServiceMap.layer.pipe( - Layer.provide( - Layer.mergeAll( - Project.defaultLayer, - EventV2.defaultLayer, - Auth.defaultLayer, - Npm.defaultLayer, - ModelsDev.defaultLayer, - FSUtil.defaultLayer, - Global.defaultLayer, + Layer.merge( + applicationTools, + LocationServiceMap.layer.pipe( + Layer.provide( + Layer.mergeAll( + Project.defaultLayer, + EventV2.defaultLayer, + Auth.defaultLayer, + Npm.defaultLayer, + ModelsDev.defaultLayer, + FSUtil.defaultLayer, + Global.defaultLayer, + ), ), ), ), @@ -44,6 +50,14 @@ describe("LocationServiceMap", () => { ).pipe( Effect.flatMap(([blocked, allowed]) => Effect.gen(function* () { + yield* (yield* ApplicationTools.Service).attach({ + application_context: Tool.make({ + description: "Read application context", + parameters: Schema.Struct({}), + success: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }), + }) yield* Effect.promise(() => fs.writeFile( path.join(blocked.path, "opencode.json"), @@ -70,6 +84,7 @@ describe("LocationServiceMap", () => { const blockedState = yield* update(blocked.path) expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false) expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([ + "application_context", "apply_patch", "bash", "edit", @@ -86,6 +101,7 @@ describe("LocationServiceMap", () => { const allowedState = yield* update(allowed.path) expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true) expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([ + "application_context", "apply_patch", "bash", "edit", diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts index 1aa9130fa8..05cc7c3b86 100644 --- a/packages/core/test/public-opencode.test.ts +++ b/packages/core/test/public-opencode.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" -import { Effect } from "effect" -import { OpenCode, Session } from "@opencode-ai/core/public" +import { Effect, Schema } from "effect" +import { OpenCode, Session, Tool } from "@opencode-ai/core/public" import { testEffect } from "./lib/effect" const it = testEffect(OpenCode.layer) @@ -10,6 +10,8 @@ describe("public native OpenCode API", () => { Effect.gen(function* () { const opencode = yield* OpenCode.Service + expect(Object.keys(opencode).sort()).toEqual(["sessions", "tools"]) + expect(Object.keys(opencode.sessions).sort()).toEqual([ "context", "create", @@ -23,6 +25,14 @@ describe("public native OpenCode API", () => { expect(Session.ID.create()).toStartWith("ses_") expect(Session.MessageID.create()).toStartWith("msg_") expect(yield* opencode.sessions.list()).toBeArray() + yield* opencode.tools.attach({ + public_tool: Tool.make({ + description: "Public tool", + parameters: Schema.Struct({}), + success: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }), + }) }), ) }) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 4268859bd1..53a89efa61 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -16,7 +16,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { describe, expect } from "bun:test" @@ -46,7 +46,7 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) +const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.com/v1" }, diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index e4b5eb2903..ff4d6c2446 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from "bun:test" import { Tool, ToolFailure } from "@opencode-ai/llm" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { Effect, Exit, Layer, Schema, Scope } from "effect" import { testEffect } from "./lib/effect" @@ -24,7 +24,7 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) +const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const it = testEffect(Layer.mergeAll(permission, registry)) const echo = Tool.make({ diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index bc476aee14..8607ac33b6 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -29,7 +29,9 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { NativeTool } from "@opencode-ai/core/tool/native" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { ModelV2 } from "@opencode-ai/core/model" @@ -93,7 +95,8 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) +const applications = ApplicationTools.layer +const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(applications)) const echo = Layer.effectDiscard( ToolRegistry.Service.use((registry) => registry.contribute((editor) => { @@ -163,6 +166,7 @@ const it = testEffect( store, client, permission, + applications, registry, echo, models, @@ -414,6 +418,55 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => }) describe("SessionRunnerLLM", () => { + it.effect("advertises and executes a globally attached application tool", () => + Effect.gen(function* () { + yield* setup + const applicationTools = yield* ApplicationTools.Service + const session = yield* SessionV2.Service + const contexts: NativeTool.Context[] = [] + yield* applicationTools.attach({ + application_context: NativeTool.make({ + description: "Read application context", + parameters: Schema.Struct({ query: Schema.String }), + success: Schema.Struct({ answer: Schema.String }), + execute: ({ query }, context) => + Effect.sync(() => { + contexts.push(context) + return { answer: query.toUpperCase() } + }), + }), + }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use application context" }), resume: false }) + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-application", name: "application_context", input: { query: "hello" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [], + ] + + yield* session.resume(sessionID) + + expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context") + expect(contexts).toEqual([{ sessionID, id: "call-application", name: "application_context" }]) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Use application context" }, + { + type: "assistant", + content: [ + { + type: "tool", + id: "call-application", + state: { status: "completed", structured: { answer: "HELLO" } }, + }, + ], + }, + ]) + }), + ) + it.effect("starts a real runner turn after default prompt recording", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index e461335060..89adcd6e61 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -9,7 +9,7 @@ import { LocationMutation } from "@opencode-ai/core/location-mutation" import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" @@ -86,7 +86,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ) const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning)) - const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) + const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const patch = ApplyPatchTool.layer.pipe( Layer.provide(registry), Layer.provide(planning), diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 950debc82a..cb47490f86 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -14,7 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { BashTool } from "@opencode-ai/core/tool/bash" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -113,7 +113,7 @@ const withTool = ( Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) - const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) + const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const bash = BashTool.layer.pipe( Layer.provide(registry), Layer.provide(permission), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 2299408540..a56153d82e 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -10,7 +10,7 @@ import { LocationMutation } from "@opencode-ai/core/location-mutation" import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { EditTool } from "@opencode-ai/core/tool/edit" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" @@ -79,7 +79,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ) const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning)) - const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) + const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const edit = EditTool.layer.pipe( Layer.provide(registry), Layer.provide(planning), diff --git a/packages/core/test/tool-glob.test.ts b/packages/core/test/tool-glob.test.ts index 09bb54c2bb..6e49af3612 100644 --- a/packages/core/test/tool-glob.test.ts +++ b/packages/core/test/tool-glob.test.ts @@ -6,7 +6,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { RelativePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { GlobTool } from "@opencode-ai/core/tool/glob" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { testEffect } from "./lib/effect" const sessionID = SessionV2.ID.make("ses_glob_tool_test") @@ -81,7 +81,7 @@ const search = Layer.succeed( }), ) -const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) +const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const glob = GlobTool.layer.pipe( Layer.provide(registry), Layer.provide(permission), diff --git a/packages/core/test/tool-grep.test.ts b/packages/core/test/tool-grep.test.ts index 4459db4dba..2a038736d8 100644 --- a/packages/core/test/tool-grep.test.ts +++ b/packages/core/test/tool-grep.test.ts @@ -14,7 +14,7 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { GrepTool } from "@opencode-ai/core/tool/grep" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { it as runtimeIt } from "./lib/effect" @@ -86,7 +86,7 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) +const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const grep = GrepTool.layer.pipe( Layer.provide(registry), Layer.provide(filesystem), @@ -140,7 +140,7 @@ function provideLive(directory: string, projectReferences = references({})) { Layer.provide(FSUtil.defaultLayer), Layer.provide(dependencies), ) - const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) + const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const grep = GrepTool.layer.pipe( Layer.provide(registry), Layer.provide(filesystem), diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 5a01a29a87..29ccb84164 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -3,7 +3,7 @@ import { Effect, Exit, Fiber, Layer } from "effect" import { PermissionV2 } from "@opencode-ai/core/permission" import { QuestionV2 } from "@opencode-ai/core/question" import { SessionV2 } from "@opencode-ai/core/session" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { QuestionTool } from "@opencode-ai/core/tool/question" import { testEffect } from "./lib/effect" @@ -23,7 +23,7 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) +const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const question = Layer.succeed( QuestionV2.Service, QuestionV2.Service.of({ diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index c0a56f5001..6d38944391 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ReadTool } from "@opencode-ai/core/tool/read" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { RelativePath } from "@opencode-ai/core/schema" @@ -127,7 +127,7 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) +const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const resources = Layer.succeed( ToolOutputStore.Service, ToolOutputStore.Service.of({ diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 8297478166..fad2544c68 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -10,7 +10,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SkillV2 } from "@opencode-ai/core/skill" import { SkillTool } from "@opencode-ai/core/tool/skill" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" @@ -72,7 +72,7 @@ describe("SkillTool", () => { forAgent: () => Effect.die("unused"), }), ) - const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) + const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const resources = Layer.succeed( ToolOutputStore.Service, ToolOutputStore.Service.of({ diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index 09ab1f2d12..480518b4aa 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -10,7 +10,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionTodo } from "@opencode-ai/core/session/todo" import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { testEffect } from "./lib/effect" const sessionID = SessionV2.ID.make("ses_todowrite_tool_test") @@ -34,7 +34,7 @@ const permission = Layer.succeed( const database = Database.layerFromPath(":memory:") const events = EventV2.layer.pipe(Layer.provide(database)) const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) -const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) +const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(todos)) const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool)) diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index 01d36ec7b1..acc71952e8 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -5,7 +5,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebFetchTool } from "@opencode-ai/core/tool/webfetch" import { testEffect } from "./lib/effect" @@ -48,7 +48,7 @@ const resources = Layer.succeed( cleanup: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) +const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(http), Layer.provide(resources)) const it = testEffect(Layer.mergeAll(registry, permission, http, resources, webfetch)) const fetchWebfetch = WebFetchTool.layer.pipe( diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index b5d06e94d5..3d41ee83d6 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebSearchTool } from "@opencode-ai/core/tool/websearch" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" @@ -96,7 +96,7 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) +const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const websearchConfig = Layer.succeed( WebSearchTool.ConfigService, WebSearchTool.ConfigService.of({ diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 56ba7bb944..5fa34356eb 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -10,7 +10,7 @@ import { LocationMutation } from "@opencode-ai/core/location-mutation" import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { ToolRegistry } from "@opencode-ai/core/tool-registry" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WriteTool } from "@opencode-ai/core/tool/write" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" @@ -67,7 +67,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ) const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning)) - const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) + const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const write = WriteTool.layer.pipe(Layer.provide(registry), Layer.provide(planning), Layer.provide(commits)) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) From 39babcbb531eae51ba61a6b01479da7b0cd2d41b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 5 Jun 2026 03:14:18 +0000 Subject: [PATCH 016/626] chore: generate --- packages/core/src/public/tool.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/public/tool.ts b/packages/core/src/public/tool.ts index 3584393e75..427d18cb35 100644 --- a/packages/core/src/public/tool.ts +++ b/packages/core/src/public/tool.ts @@ -13,7 +13,5 @@ export interface Service { * Closing the Scope removes the tools immediately, so calls that have not * started settling may fail because the tool is no longer available. */ - readonly attach: ( - tools: Readonly>, - ) => Effect.Effect + readonly attach: (tools: Readonly>) => Effect.Effect } From 2859ce6e73a46b44ba50618592e12a9b35c61971 Mon Sep 17 00:00:00 2001 From: Kamesh Sampath Date: Fri, 5 Jun 2026 08:49:55 +0530 Subject: [PATCH 017/626] feat(core): add Snowflake Cortex provider (#29901) Co-authored-by: Cortex Code --- packages/core/src/plugin/provider.ts | 2 + .../src/plugin/provider/snowflake-cortex.ts | 81 ++++++++ .../plugin/provider-snowflake-cortex.test.ts | 185 ++++++++++++++++++ packages/opencode/src/cli/cmd/providers.ts | 19 ++ packages/opencode/src/provider/provider.ts | 88 +++++++++ packages/web/src/content/docs/providers.mdx | 59 ++++++ 6 files changed, 434 insertions(+) create mode 100644 packages/core/src/plugin/provider/snowflake-cortex.ts create mode 100644 packages/core/test/plugin/provider-snowflake-cortex.test.ts diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index eb84a73aca..ea3939b750 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -19,6 +19,7 @@ import { LLMGatewayPlugin } from "./provider/llmgateway" import { MistralPlugin } from "./provider/mistral" import { NvidiaPlugin } from "./provider/nvidia" import { OpenAIPlugin } from "./provider/openai" +import { SnowflakeCortexPlugin } from "./provider/snowflake-cortex" import { OpenAICompatiblePlugin } from "./provider/openai-compatible" import { OpencodePlugin } from "./provider/opencode" import { OpenRouterPlugin } from "./provider/openrouter" @@ -53,6 +54,7 @@ export const ProviderPlugins = [ MistralPlugin, NvidiaPlugin, OpencodePlugin, + SnowflakeCortexPlugin, OpenAICompatiblePlugin, OpenAIPlugin, OpenRouterPlugin, diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts new file mode 100644 index 0000000000..3493b976bd --- /dev/null +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -0,0 +1,81 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise + +// Exported for testing: intercepts Cortex-specific request/response quirks. +export function cortexFetch(upstream: FetchLike = fetch) { + return async (url: string | URL | Request, init?: RequestInit): Promise => { + if (init?.body && typeof init.body === "string") { + try { + const body = JSON.parse(init.body) + if ("max_tokens" in body) { + body.max_completion_tokens = body.max_tokens + delete body.max_tokens + init = { ...init, body: JSON.stringify(body) } + } + } catch {} + } + + const response = await upstream(url, init) + + // Cortex returns 400 "conversation complete" as a normal stop condition + if (!response.ok && response.status === 400) { + try { + const errorData = (await response.clone().json()) as Record + if (String(errorData.message || errorData.error || "").toLowerCase().includes("conversation complete")) { + return new Response( + JSON.stringify({ choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }] }), + { status: 200, headers: new Headers({ "content-type": "application/json" }) }, + ) + } + } catch {} + } + + // Cortex returns role:"" in streaming deltas; the AI SDK schema requires "assistant" + if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) { + const reader = response.body.getReader() + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const stream = new ReadableStream({ + async pull(ctrl) { + const { done, value } = await reader.read() + if (done) { + ctrl.close() + return + } + ctrl.enqueue(encoder.encode(decoder.decode(value, { stream: true }).replace(/"role"\s*:\s*""/g, '"role":"assistant"'))) + }, + cancel() { + reader.cancel() + }, + }) + return new Response(stream, { headers: response.headers, status: response.status }) + } + + return response + } +} + +export const SnowflakeCortexPlugin = PluginV2.define({ + id: PluginV2.ID.make("snowflake-cortex"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return + const pat = + process.env.SNOWFLAKE_CORTEX_PAT ?? + (typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined) + const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined + if (evt.options.includeUsage !== false) evt.options.includeUsage = true + const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) + evt.sdk = mod.createOpenAICompatible({ + ...evt.options, + ...(pat ? { apiKey: pat } : {}), + fetch: cortexFetch(upstream) as typeof fetch, + } as any) + }), + } + }), +}) diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts new file mode 100644 index 0000000000..cec25dbf05 --- /dev/null +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it as bun_it } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { expectPluginRegistered, it, model, withEnv } from "./provider-helper" + +describe("SnowflakeCortexPlugin", () => { + it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => + Effect.sync(() => { + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "snowflake-cortex", + ) + const ids = ProviderPlugins.map((p) => p.id as string) + expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible")) + }), + ) + + it.effect("ignores non-snowflake-cortex providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(SnowflakeCortexPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("creates SDK for snowflake-cortex using SNOWFLAKE_CORTEX_PAT env var", () => + withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(SnowflakeCortexPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("snowflake-cortex", "claude-sonnet-4-6"), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ), + ) + + it.effect("falls back to options.apiKey when SNOWFLAKE_CORTEX_PAT env var is absent", () => + withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(SnowflakeCortexPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("snowflake-cortex", "claude-sonnet-4-6"), + package: "@ai-sdk/openai-compatible", + options: { + name: "snowflake-cortex", + baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", + apiKey: "options-pat", + }, + }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ), + ) + + it.effect("sets includeUsage on the SDK options", () => + withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const captured: Record[] = [] + yield* plugin.add(SnowflakeCortexPlugin) + yield* plugin.add({ + id: PluginV2.ID.make("inspector"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + captured.push({ ...evt.options }) + }), + }), + }) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("snowflake-cortex", "claude-sonnet-4-6"), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }, + {}, + ) + expect(captured[0]?.includeUsage).toBe(true) + }), + ), + ) +}) + +type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise + +describe("cortexFetch", () => { + bun_it("rewrites max_tokens to max_completion_tokens", async () => { + const captured: RequestInit[] = [] + const upstream: FetchLike = async (_url, init) => { + captured.push(init ?? {}) + return new Response("{}", { status: 200 }) + } + await cortexFetch(upstream)("https://test", { + method: "POST", + body: JSON.stringify({ model: "claude-sonnet-4-6", max_tokens: 1024 }), + }) + const body = JSON.parse(captured[0].body as string) + expect(body.max_completion_tokens).toBe(1024) + expect(body.max_tokens).toBeUndefined() + }) + + bun_it("preserves body when max_tokens is absent", async () => { + const captured: RequestInit[] = [] + const upstream: FetchLike = async (_url, init) => { + captured.push(init ?? {}) + return new Response("{}", { status: 200 }) + } + const original = JSON.stringify({ model: "claude-sonnet-4-6", temperature: 0.7 }) + await cortexFetch(upstream)("https://test", { method: "POST", body: original }) + expect(captured[0].body).toBe(original) + }) + + bun_it("treats 400 'conversation complete' as a stop response", async () => { + const upstream: FetchLike = async () => + new Response(JSON.stringify({ message: "Conversation complete" }), { + status: 400, + headers: { "content-type": "application/json" }, + }) + const response = await cortexFetch(upstream)("https://test", {}) + expect(response.status).toBe(200) + const data = (await response.json()) as { choices: { finish_reason: string }[] } + expect(data.choices[0].finish_reason).toBe("stop") + }) + + bun_it("passes through other 400 errors unchanged", async () => { + const upstream: FetchLike = async () => + new Response(JSON.stringify({ message: "Invalid model" }), { + status: 400, + headers: { "content-type": "application/json" }, + }) + const response = await cortexFetch(upstream)("https://test", {}) + expect(response.status).toBe(400) + }) + + bun_it("passes through non-400 errors unchanged", async () => { + const upstream: FetchLike = async () => new Response("Unauthorized", { status: 401 }) + const response = await cortexFetch(upstream)("https://test", {}) + expect(response.status).toBe(401) + }) + + bun_it("handles invalid JSON body gracefully without throwing", async () => { + const captured: RequestInit[] = [] + const upstream: FetchLike = async (_url, init) => { + captured.push(init ?? {}) + return new Response("{}", { status: 200 }) + } + const invalidBody = "{ not json }" + await cortexFetch(upstream)("https://test", { method: "POST", body: invalidBody }) + expect(captured[0].body).toBe(invalidBody) + }) + + bun_it("rewrites role:'' to role:'assistant' in streaming SSE chunks", async () => { + const chunk = `data: {"choices":[{"delta":{"role":"","content":"Hi"},"index":0}]}\n\n` + const upstream: FetchLike = async () => + new Response(new ReadableStream({ start: (ctrl) => { ctrl.enqueue(new TextEncoder().encode(chunk)); ctrl.close() } }), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }) + const response = await cortexFetch(upstream)("https://test", {}) + const text = await response.text() + expect(text).toContain('"role":"assistant"') + expect(text).not.toContain('"role":""') + }) +}) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 560781ef0e..639d868cd8 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -474,6 +474,25 @@ export const ProvidersLoginCommand = effectCmd({ ) } + if (provider === "snowflake-cortex") { + const account = yield* promptValue( + yield* Prompt.text({ + message: "Snowflake Account Identifier", + placeholder: "xy12345.us-east-1", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }), + ) + const pat = yield* promptValue( + yield* Prompt.password({ + message: "Programmatic Access Token (PAT)", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }), + ) + yield* Effect.orDie(authSvc.set(provider, { type: "api", key: pat, metadata: { account } })) + yield* Prompt.outro("Done") + return + } + const key = yield* Prompt.password({ message: "Enter your API key", validate: (x) => (x && x.length > 0 ? undefined : "Required"), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 8522432dff..e329b10192 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -851,6 +851,94 @@ function custom(dep: CustomDep): Record { }, }, }), + "snowflake-cortex": Effect.fnUntraced(function* (input: Info) { + const env = yield* dep.env() + const auth = yield* dep.auth(input.id) + + const account = + env["SNOWFLAKE_ACCOUNT"] ?? + (auth?.type === "api" ? auth.metadata?.account : undefined) ?? + input.options?.account + + const pat = + env["SNOWFLAKE_CORTEX_PAT"] ?? + (auth?.type === "api" ? auth.key : undefined) ?? + input.options?.apiKey + + if (!account || !pat) { + const missing = [!account && "SNOWFLAKE_ACCOUNT", !pat && "SNOWFLAKE_CORTEX_PAT"].filter(Boolean).join(", ") + return { + autoload: false, + async getModel() { + throw new Error( + `Snowflake Cortex: missing credentials (${missing}). Set via env var, opencode auth, or provider options.`, + ) + }, + } + } + + const baseURL = `https://${account}.snowflakecomputing.com/api/v2/cortex/v1` + + return { + autoload: input.source === "config", + options: { + baseURL, + apiKey: pat, + fetch: async (url: RequestInfo | URL, init?: RequestInit) => { + if (init?.body && typeof init.body === "string") { + try { + const body = JSON.parse(init.body) + if ("max_tokens" in body) { + body.max_completion_tokens = body.max_tokens + delete body.max_tokens + init = { ...init, body: JSON.stringify(body) } + } + } catch {} + } + + const response = await fetch(url, init) + + // Cortex returns 400 "conversation complete" as a normal stop condition + if (!response.ok && response.status === 400) { + try { + const errorData = await response.clone().json() + const errorMessage = String(errorData.message || errorData.error || "") + if (errorMessage.toLowerCase().includes("conversation complete")) { + return new Response( + JSON.stringify({ choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }] }), + { status: 200, headers: new Headers({ "content-type": "application/json" }) }, + ) + } + } catch {} + } + + // Cortex returns role:"" in streaming deltas; the AI SDK schema requires "assistant" + if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) { + const reader = response.body.getReader() + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const stream = new ReadableStream({ + async pull(ctrl) { + const { done, value } = await reader.read() + if (done) { + ctrl.close() + return + } + const text = decoder.decode(value, { stream: true }) + ctrl.enqueue(encoder.encode(text.replace(/"role"\s*:\s*""/g, '"role":"assistant"'))) + }, + cancel() { + reader.cancel() + }, + }) + return new Response(stream, { headers: response.headers, status: response.status }) + } + + return response + }, + }, + } + }), } } diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index f5c2160daa..628e2244e0 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1940,6 +1940,65 @@ To use [Scaleway Generative APIs](https://www.scaleway.com/en/docs/generative-ap --- +### Snowflake Cortex + +[Snowflake Cortex](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-rest-api) gives you access to frontier models (Claude, OpenAI GPT-5, and more) via an OpenAI-compatible API. All inference runs within the Snowflake perimeter and is billed in Snowflake credits. For per-model rates, see the [Snowflake Service Consumption Table](https://www.snowflake.com/legal-files/CreditConsumptionTable.pdf). + +Don't have a Snowflake account? [Sign up for a free trial](https://signup.snowflake.com/?utm_source=opencode&utm_medium=docs&utm_campaign=cortex-provider). + +opencode's core workflow for coding, editing files, and running commands relies on tool calling. Only the Claude and OpenAI families within Snowflake Cortex support this. The provider is limited to those families to support the core workflow. + +1. Generate a [Programmatic Access Token (PAT)](https://docs.snowflake.com/en/user-guide/programmatic-access-tokens) in your Snowflake account. + +2. Run the `/connect` command and search for **Snowflake Cortex**. + + ```txt + /connect + ``` + +3. Enter your [account identifier](https://docs.snowflake.com/en/user-guide/admin-account-identifier) when prompted (e.g. `myorg-myaccount` or `xy12345.us-east-1`). + + ```txt + ┌ Snowflake Account Identifier + │ + │ + └ enter + ``` + +4. Enter your **PAT**. + + ```txt + ┌ Programmatic Access Token (PAT) + │ + │ + └ enter + ``` + +5. Run the `/models` command to select a model. + + ```txt + /models + ``` + +Alternatively, set environment variables before starting opencode: + +```bash +export SNOWFLAKE_ACCOUNT=myorg-myaccount +export SNOWFLAKE_CORTEX_PAT=your-pat +``` + +The model catalog is provided automatically. A minimal `opencode.json` is all that's needed: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "model": "snowflake-cortex/claude-sonnet-4-6", + "small_model": "snowflake-cortex/claude-haiku-4-5" +} +``` + +--- + ### Together AI 1. Head over to the [Together AI console](https://api.together.ai), create an account, and click **Add Key**. From c47cb2878130309317593698753f74c6f99c8464 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 5 Jun 2026 03:21:15 +0000 Subject: [PATCH 018/626] chore: generate --- .../core/src/plugin/provider/snowflake-cortex.ts | 13 +++++++++---- .../plugin/provider-snowflake-cortex.test.ts | 16 ++++++++++++---- packages/opencode/src/provider/provider.ts | 9 ++++----- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 3493b976bd..8dcabf26b1 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -24,7 +24,11 @@ export function cortexFetch(upstream: FetchLike = fetch) { if (!response.ok && response.status === 400) { try { const errorData = (await response.clone().json()) as Record - if (String(errorData.message || errorData.error || "").toLowerCase().includes("conversation complete")) { + if ( + String(errorData.message || errorData.error || "") + .toLowerCase() + .includes("conversation complete") + ) { return new Response( JSON.stringify({ choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }] }), { status: 200, headers: new Headers({ "content-type": "application/json" }) }, @@ -45,7 +49,9 @@ export function cortexFetch(upstream: FetchLike = fetch) { ctrl.close() return } - ctrl.enqueue(encoder.encode(decoder.decode(value, { stream: true }).replace(/"role"\s*:\s*""/g, '"role":"assistant"'))) + ctrl.enqueue( + encoder.encode(decoder.decode(value, { stream: true }).replace(/"role"\s*:\s*""/g, '"role":"assistant"')), + ) }, cancel() { reader.cancel() @@ -65,8 +71,7 @@ export const SnowflakeCortexPlugin = PluginV2.define({ "aisdk.sdk": Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return const pat = - process.env.SNOWFLAKE_CORTEX_PAT ?? - (typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined) + process.env.SNOWFLAKE_CORTEX_PAT ?? (typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined) const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined if (evt.options.includeUsage !== false) evt.options.includeUsage = true const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index cec25dbf05..f702945978 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -173,10 +173,18 @@ describe("cortexFetch", () => { bun_it("rewrites role:'' to role:'assistant' in streaming SSE chunks", async () => { const chunk = `data: {"choices":[{"delta":{"role":"","content":"Hi"},"index":0}]}\n\n` const upstream: FetchLike = async () => - new Response(new ReadableStream({ start: (ctrl) => { ctrl.enqueue(new TextEncoder().encode(chunk)); ctrl.close() } }), { - status: 200, - headers: { "content-type": "text/event-stream" }, - }) + new Response( + new ReadableStream({ + start: (ctrl) => { + ctrl.enqueue(new TextEncoder().encode(chunk)) + ctrl.close() + }, + }), + { + status: 200, + headers: { "content-type": "text/event-stream" }, + }, + ) const response = await cortexFetch(upstream)("https://test", {}) const text = await response.text() expect(text).toContain('"role":"assistant"') diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e329b10192..2142f84340 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -860,10 +860,7 @@ function custom(dep: CustomDep): Record { (auth?.type === "api" ? auth.metadata?.account : undefined) ?? input.options?.account - const pat = - env["SNOWFLAKE_CORTEX_PAT"] ?? - (auth?.type === "api" ? auth.key : undefined) ?? - input.options?.apiKey + const pat = env["SNOWFLAKE_CORTEX_PAT"] ?? (auth?.type === "api" ? auth.key : undefined) ?? input.options?.apiKey if (!account || !pat) { const missing = [!account && "SNOWFLAKE_ACCOUNT", !pat && "SNOWFLAKE_CORTEX_PAT"].filter(Boolean).join(", ") @@ -905,7 +902,9 @@ function custom(dep: CustomDep): Record { const errorMessage = String(errorData.message || errorData.error || "") if (errorMessage.toLowerCase().includes("conversation complete")) { return new Response( - JSON.stringify({ choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }] }), + JSON.stringify({ + choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }], + }), { status: 200, headers: new Headers({ "content-type": "application/json" }) }, ) } From 1af8dafd3eb6099cca2ba5eb30548569b5d43d91 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 23:26:43 -0400 Subject: [PATCH 019/626] feat(core): persist v2 session context epochs (#30789) --- CONTEXT.md | 65 +- .../migration.sql | 9 + .../snapshot.json | 1980 +++++++++++++++++ packages/core/src/database/migration.gen.ts | 1 + ...0605003541_add_session_context_snapshot.ts | 21 + packages/core/src/event.ts | 37 +- packages/core/src/instruction-context.ts | 91 + packages/core/src/location-layer.ts | 3 + packages/core/src/session-system-context.ts | 54 - packages/core/src/session/context-epoch.ts | 242 ++ packages/core/src/session/context.ts | 77 +- packages/core/src/session/event.ts | 12 + packages/core/src/session/message-updater.ts | 9 + packages/core/src/session/message.ts | 17 +- packages/core/src/session/projector.ts | 57 +- packages/core/src/session/runner/index.ts | 8 +- packages/core/src/session/runner/llm.ts | 29 +- .../core/src/session/runner/to-llm-message.ts | 2 + packages/core/src/session/sql.ts | 13 + packages/core/src/session/store.ts | 7 + packages/core/src/system-context-builtins.ts | 47 + packages/core/src/system-context-registry.ts | 46 + packages/core/src/system-context.ts | 375 +++- packages/core/test/database-migration.test.ts | 6 +- packages/core/test/event.test.ts | 50 + .../core/test/instruction-context.test.ts | 299 +++ .../core/test/session-runner-message.test.ts | 13 +- .../core/test/session-runner-recorded.test.ts | 4 + packages/core/test/session-runner.test.ts | 509 ++++- .../core/test/session-system-context.test.ts | 70 - .../core/test/system-context-builtins.test.ts | 127 ++ .../core/test/system-context-registry.test.ts | 113 + packages/core/test/system-context.test.ts | 453 ++-- .../llm/src/protocols/anthropic-messages.ts | 56 +- .../test/provider/anthropic-messages.test.ts | 75 +- .../llm/test/provider/golden.recorded.test.ts | 6 +- packages/llm/test/recorded-scenarios.ts | 2 +- packages/llm/test/schema.test.ts | 21 +- .../src/cli/cmd/tui/context/sync-v2.tsx | 10 + .../tui/feature-plugins/system/session-v2.tsx | 3 + .../opencode/test/cli/tui/sync-v2.test.tsx | 49 + packages/sdk/js/src/v2/gen/types.gen.ts | 53 + packages/sdk/openapi.json | 169 ++ specs/v2/schema-changelog.md | 82 + specs/v2/session.md | 60 + 45 files changed, 4886 insertions(+), 546 deletions(-) create mode 100644 packages/core/migration/20260605003541_add_session_context_snapshot/migration.sql create mode 100644 packages/core/migration/20260605003541_add_session_context_snapshot/snapshot.json create mode 100644 packages/core/src/database/migration/20260605003541_add_session_context_snapshot.ts create mode 100644 packages/core/src/instruction-context.ts delete mode 100644 packages/core/src/session-system-context.ts create mode 100644 packages/core/src/session/context-epoch.ts create mode 100644 packages/core/src/system-context-builtins.ts create mode 100644 packages/core/src/system-context-registry.ts create mode 100644 packages/core/test/instruction-context.test.ts delete mode 100644 packages/core/test/session-system-context.test.ts create mode 100644 packages/core/test/system-context-builtins.test.ts create mode 100644 packages/core/test/system-context-registry.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index a391bf95cc..4a3bc6d225 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,12 +8,15 @@ OpenCode sessions preserve durable conversational history while assembling the r The structured collection of contextual facts presented to the model as initial instructions and chronological updates. _Avoid_: System prompt -**Context Component**: -One independently loaded fact within the **System Context**, represented by a stable key and one effectfully loaded baseline/update rendering. +**Context Source**: +One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources. _Avoid_: Prompt fragment +**System Context Registry**: +The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**. + **Mid-Conversation System Message**: -A durable chronological instruction that tells the model the newly effective state of a changed **Context Component**. +A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**. _Avoid_: System update, system notification, raw text diff **Context Epoch**: @@ -23,48 +26,57 @@ The span during which one initially rendered **System Context** remains immutabl The full **System Context** rendered at the start of a **Context Epoch**. _Avoid_: Live system prompt -**Context Checkpoint**: -The durable model-hidden comparison state used to detect which **Context Components** changed since context was last admitted to a provider turn. +**Context Snapshot**: +The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn. **Unavailable Context**: -An expected temporary inability to load a **Context Component** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded. +An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded. **Safe Provider-Turn Boundary**: The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. ## Relationships -- A **System Context** contains one or more **Context Components**. -- A changed **Context Component** may produce one **Mid-Conversation System Message** containing its newly effective state. -- A **Mid-Conversation System Message** persists its originating **Context Component** key and the exact rendered text sent to the model. -- A **Context Checkpoint** advances atomically with the corresponding durable **Mid-Conversation System Message**. -- A **Context Checkpoint** stores one rendered-content hash per stable **Context Component** key so core and plugin-defined components can evolve independently. -- Changes from multiple **Context Components** admitted at one safe boundary combine into one **Mid-Conversation System Message**. +- A **System Context** is an opaque carrier composed from zero or more **Context Sources**. +- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**. +- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state. +- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model. +- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**. +- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key. +- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**. - Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes. - At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**. -- The first provider turn renders the latest **Baseline System Context** and initializes its **Context Checkpoint** without emitting a redundant **Mid-Conversation System Message**. -- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Checkpoint**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history. -- A **Context Checkpoint** is an evolvable component map; a newly registered core or plugin-defined **Context Component** absent from an existing checkpoint emits its current state once at the next **Safe Provider-Turn Boundary**. -- **Context Component** keys are stable and namespaced; duplicate keys fail assembly. Built-in components preserve declaration order and plugin-defined components append in lexicographic key order so rendered context is deterministic. -- Each **Context Component** loader returns its model-visible baseline string and absolute current-state update string from one coherent sample; the update string is hashed for change detection. +- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline. +- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion. +- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history. +- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**. +- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic. +- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed. +- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**. +- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked. +- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context. +- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run. - **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text. -- Ordinary **Context Component** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. -- Nested project instruction files discovered while reading join the effective instructions returned by the instruction service and are admitted durably at the next **Safe Provider-Turn Boundary**. -- A discovered nested project instruction remains active for the session while it stays in the same location and is folded into later **Baseline System Contexts** after compaction. +- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. +- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. +- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote. +- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. -- Plugin-defined **Context Components** register through a scoped replayable registry so plugin hot reload adds and removes components predictably. +- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. +- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. -- **Mid-Conversation System Messages** remain durable model-projection history but are hidden from normal user-facing transcript surfaces. -- The date **Context Component** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. +- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. +- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. - A **Context Epoch** begins with one immutable **Baseline System Context**. - A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**. -- A **Baseline System Context** durably preserves deterministic keyed top-level component strings rather than eagerly joining all text; request assembly lowers them into canonical LLM system parts. +- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache. - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. -- When an effective instruction file changes, its **Mid-Conversation System Message** includes the complete current contents and supersedes the prior version from that source; when it is removed, the message states that it no longer applies. +- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. +- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible. ## Example dialogue @@ -73,5 +85,4 @@ The point immediately before a provider call, after durable input promotion and ## Flagged ambiguities -- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Components**, or narrow its semantics. -- A location change likely starts a new **Context Epoch** so location-dependent instructions and discovery can be rebuilt cleanly, but implementation should verify whether an append-only update is sufficient and meaningfully preserves cache. +- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics. diff --git a/packages/core/migration/20260605003541_add_session_context_snapshot/migration.sql b/packages/core/migration/20260605003541_add_session_context_snapshot/migration.sql new file mode 100644 index 0000000000..ec98751154 --- /dev/null +++ b/packages/core/migration/20260605003541_add_session_context_snapshot/migration.sql @@ -0,0 +1,9 @@ +CREATE TABLE `session_context_epoch` ( + `session_id` text PRIMARY KEY, + `baseline` text NOT NULL, + `snapshot` text NOT NULL, + `baseline_seq` integer NOT NULL, + `replacement_seq` integer, + `revision` integer DEFAULT 0 NOT NULL, + CONSTRAINT `fk_session_context_epoch_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); diff --git a/packages/core/migration/20260605003541_add_session_context_snapshot/snapshot.json b/packages/core/migration/20260605003541_add_session_context_snapshot/snapshot.json new file mode 100644 index 0000000000..6e1cce1361 --- /dev/null +++ b/packages/core/migration/20260605003541_add_session_context_snapshot/snapshot.json @@ -0,0 +1,1980 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "40f7b9b8-83b4-4ea0-a59f-76a489679d88", + "prevIds": ["84c6ad6c-6116-48e1-b973-6fee4593496b"], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "project_directory", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "session_context_epoch", + "entityType": "tables" + }, + { + "name": "session_input", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "snapshot", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "replacement_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "revision", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "admitted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "promoted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_project_directory_project_id_project_id_fk", + "entityType": "fks", + "table": "project_directory" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_epoch_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_epoch" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_input_session_id_session_id_fk", + "entityType": "fks", + "table": "session_input" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["project_id", "directory"], + "nameExplicit": false, + "name": "project_directory_pk", + "entityType": "pks", + "table": "project_directory" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["name"], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_context_epoch_pk", + "table": "session_context_epoch", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_input_pk", + "table": "session_input", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "event_aggregate_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_type_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "action", + "isExpression": false + }, + { + "value": "resource", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "permission_project_action_resource_idx", + "entityType": "indexes", + "table": "permission" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + }, + { + "value": "delivery", + "isExpression": false + }, + { + "value": "admitted_seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_input_session_pending_delivery_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "admitted_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_input_session_admitted_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_input_session_promoted_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_message_session_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_time_created_id_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 59ed2fdbcf..299ff846a5 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -32,5 +32,6 @@ export const migrations = ( import("./migration/20260603141458_session_input_inbox"), import("./migration/20260603160727_jittery_ezekiel_stane"), import("./migration/20260604172448_event_sourced_session_input"), + import("./migration/20260605003541_add_session_context_snapshot"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260605003541_add_session_context_snapshot.ts b/packages/core/src/database/migration/20260605003541_add_session_context_snapshot.ts new file mode 100644 index 0000000000..d1648969dd --- /dev/null +++ b/packages/core/src/database/migration/20260605003541_add_session_context_snapshot.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260605003541_add_session_context_snapshot", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_context_epoch\` ( + \`session_id\` text PRIMARY KEY, + \`baseline\` text NOT NULL, + \`snapshot\` text NOT NULL, + \`baseline_seq\` integer NOT NULL, + \`replacement_seq\` integer, + \`revision\` integer DEFAULT 0 NOT NULL, + CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 57dd587dec..0ad0714e98 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -45,6 +45,8 @@ export type Payload = { readonly version?: number readonly location?: Location.Ref readonly metadata?: Record + /** Internal replay marker for projectors that own non-replicated operational state. */ + readonly replay?: boolean } export type Projector = (event: Payload) => Effect.Effect @@ -137,6 +139,8 @@ export interface PublishOptions { readonly id?: ID readonly metadata?: Record readonly location?: Location.Ref + /** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */ + readonly commit?: (seq: number) => Effect.Effect } export interface Interface { @@ -215,6 +219,7 @@ export const layerWith = (options?: LayerOptions) => readonly ownerID?: string readonly strictOwner?: boolean }, + commit?: (seq: number) => Effect.Effect, ) { return Effect.gen(function* () { const definition = registry.get(event.type) @@ -330,6 +335,7 @@ export const layerWith = (options?: LayerOptions) => for (const projector of list) { yield* projector({ ...event, seq } as Payload) } + if (commit) yield* commit(seq) yield* db .insert(EventSequenceTable) .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }]) @@ -375,11 +381,18 @@ export const layerWith = (options?: LayerOptions) => }) } - function publishEvent(event: Payload) { + function publishEvent(event: Payload, commit?: PublishOptions["commit"]) { return Effect.gen(function* () { const durable = registry.get(event.type)?.sync !== undefined + if (!durable && commit) + return yield* Effect.die( + new InvalidSyncEventError({ + type: event.type, + message: "Local commit hooks require a synchronized event", + }), + ) if (durable) { - const committed = yield* commitSyncEvent(event as Payload) + const committed = yield* commitSyncEvent(event as Payload, undefined, commit) if (committed) { event = { ...event, seq: committed.seq } yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true }) @@ -424,14 +437,17 @@ export const layerWith = (options?: LayerOptions) => (serviceLocation ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID } : undefined) - return yield* publishEvent({ - id: options?.id ?? ID.create(), - ...(options?.metadata ? { metadata: options.metadata } : {}), - type: definition.type, - ...(definition.sync === undefined ? {} : { version: definition.sync.version }), - ...(location ? { location } : {}), - data, - } as Payload) + return yield* publishEvent( + { + id: options?.id ?? ID.create(), + ...(options?.metadata ? { metadata: options.metadata } : {}), + type: definition.type, + ...(definition.sync === undefined ? {} : { version: definition.sync.version }), + ...(location ? { location } : {}), + data, + } as Payload, + options?.commit, + ) }) } @@ -451,6 +467,7 @@ export const layerWith = (options?: LayerOptions) => type: definition.type, version: definition.sync.version, data: definition.decode(event.data), + replay: true, } as Payload const committed = yield* commitSyncEvent(payload, { seq: event.seq, diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts new file mode 100644 index 0000000000..db15ebd4a3 --- /dev/null +++ b/packages/core/src/instruction-context.ts @@ -0,0 +1,91 @@ +export * as InstructionContext from "./instruction-context" + +import { Array, Effect, Layer, Schema } from "effect" +import { isAbsolute, join, relative, sep } from "path" +import { FSUtil } from "./fs-util" +import { Flag } from "./flag/flag" +import { Global } from "./global" +import { Location } from "./location" +import { AbsolutePath } from "./schema" +import { SystemContext } from "./system-context" +import { SystemContextRegistry } from "./system-context-registry" + +class File extends Schema.Class("InstructionContext.File")({ + path: AbsolutePath, + content: Schema.String, +}) {} + +const Files = Schema.Array(File) +const key = SystemContext.Key.make("core/instructions") + +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const location = yield* Location.Service + const registry = yield* SystemContextRegistry.Service + + const source = (value: ReadonlyArray | SystemContext.Unavailable) => + SystemContext.make({ + key, + codec: Schema.toCodecJson(Files), + load: Effect.succeed(value), + baseline: render, + update: (_previous, current) => `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`, + removed: () => "Previously loaded instructions no longer apply.", + }) + + const observe = Effect.fn("InstructionContext.observe")(function* () { + const start = FSUtil.resolve(location.directory) + const stop = FSUtil.resolve(location.project.directory) + const fromProject = relative(stop, start) + const insideProject = + fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject)) + const discovered = new Set( + (Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject + ? [] + : yield* fs.up({ + targets: ["AGENTS.md"], + start, + stop, + }) + ).map(FSUtil.resolve), + ) + const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered]) + const files = yield* Effect.forEach( + paths, + (path) => + fs + .readFileStringSafe(path) + .pipe( + Effect.map((content) => + content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }), + ), + ), + { concurrency: "unbounded" }, + ) + if (files.some((file, index) => file === undefined && discovered.has(paths[index]))) + return SystemContext.unavailable + return files.filter((file): file is File => file !== undefined) + }) + + yield* registry.contribute({ + key, + load: observe().pipe( + Effect.map((files) => + files === SystemContext.unavailable + ? source(files) + : files.length === 0 + ? SystemContext.empty + : source(files), + ), + Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))), + Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))), + ), + }) + }), +) + +function render(files: ReadonlyArray) { + return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") +} diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index b45a4aab75..c1621c404f 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -40,12 +40,14 @@ import { RequestExecutor } from "@opencode-ai/llm/route" import * as SessionRunnerLLM from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" import { SessionRunCoordinator } from "./session/run-coordinator" +import { SystemContextBuiltIns } from "./system-context-builtins" import { FetchHttpClient } from "effect/unstable/http" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { const location = Location.layer(ref) const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer)) + const systemContext = SystemContextBuiltIns.locationLayer const services = Layer.mergeAll( location, Policy.locationLayer, @@ -60,6 +62,7 @@ export class LocationServiceMap extends LayerMap.Service()(" Watcher.locationLayer, Pty.locationLayer, SkillV2.locationLayer, + systemContext, permissionsAndTools, LocationMutation.locationLayer.pipe(Layer.orDie), ).pipe(Layer.provideMerge(location)) diff --git a/packages/core/src/session-system-context.ts b/packages/core/src/session-system-context.ts deleted file mode 100644 index 0ae80c66e3..0000000000 --- a/packages/core/src/session-system-context.ts +++ /dev/null @@ -1,54 +0,0 @@ -export * as SessionSystemContext from "./session-system-context" - -import { Context, DateTime, Effect, Layer } from "effect" -import { Location } from "./location" -import { SystemContext } from "./system-context" - -export interface Interface { - readonly load: () => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/SessionSystemContext") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const location = yield* Location.Service - const environment = [ - "", - ` Working directory: ${location.directory}`, - ` Workspace root folder: ${location.project.directory}`, - ` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`, - ` Platform: ${process.platform}`, - "", - ].join("\n") - const context = SystemContext.struct({ - environment: SystemContext.value({ - key: SystemContext.Key.make("core/environment"), - load: Effect.succeed({ - baseline: ["Here is some useful information about the environment you are running in:", environment].join( - "\n", - ), - update: ["The environment you are running in is now:", environment].join("\n"), - }), - }), - date: SystemContext.value({ - key: SystemContext.Key.make("core/date"), - load: DateTime.nowAsDate.pipe( - Effect.map((date) => ({ - baseline: `Today's date: ${date.toDateString()}`, - update: `Today's date is now: ${date.toDateString()}`, - })), - ), - }), - }) - - return Service.of({ - load: Effect.fn("SessionSystemContext.load")(function* () { - return yield* SystemContext.load(context) - }), - }) - }), -) - -export const locationLayer = layer diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts new file mode 100644 index 0000000000..22ce8d5d40 --- /dev/null +++ b/packages/core/src/session/context-epoch.ts @@ -0,0 +1,242 @@ +export * as SessionContextEpoch from "./context-epoch" + +import { and, eq, isNull, lt, or, sql } from "drizzle-orm" +import { DateTime, Effect, Schema } from "effect" +import type { Database } from "../database/database" +import { EventV2 } from "../event" +import { Location } from "../location" +import { SystemContext } from "../system-context" +import { SystemContextRegistry } from "../system-context-registry" +import { SessionEvent } from "./event" +import { SessionInput } from "./input" +import { SessionMessageID } from "./message-id" +import { SessionSchema } from "./schema" +import { SessionContextEpochTable, SessionTable } from "./sql" + +type DatabaseService = Database.Interface["db"] + +class RevisionMismatch extends Error {} +class LocationMismatch extends Error {} + +const retryRevisionMismatch = (attempt: () => Effect.Effect): Effect.Effect => + attempt().pipe( + Effect.catchDefect((defect) => + defect instanceof RevisionMismatch + ? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt))) + : Effect.die(defect), + ), + ) + +interface Prepared { + readonly baseline: string + readonly baselineSeq: number +} + +export function initialize( + db: DatabaseService, + context: SystemContextRegistry.Interface, + sessionID: SessionSchema.ID, + location: Location.Ref, +): Effect.Effect { + return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location)).pipe( + Effect.withSpan("SessionContextEpoch.initialize"), + ) +} + +export function prepare( + db: DatabaseService, + events: EventV2.Interface, + context: SystemContextRegistry.Interface, + sessionID: SessionSchema.ID, + location: Location.Ref, +): Effect.Effect { + return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location)).pipe( + Effect.withSpan("SessionContextEpoch.prepare"), + ) +} + +const prepareOnce = Effect.fnUntraced(function* ( + db: DatabaseService, + events: EventV2.Interface, + context: SystemContextRegistry.Interface, + sessionID: SessionSchema.ID, + location: Location.Ref, +) { + const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" }) + if (!stored) { + const generation = yield* SystemContext.initialize(value) + const baselineSeq = yield* insert(db, sessionID, location, generation) + return { baseline: generation.baseline, baselineSeq } + } + + const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(Effect.orDie) + const result = + stored.replacement_seq === null + ? yield* SystemContext.reconcile(value, snapshot) + : yield* SystemContext.replace(value, snapshot) + if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } + if (result._tag === "ReplacementReady") { + const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID)) + yield* replace(db, sessionID, stored.revision, replacementSeq, result.generation) + return { baseline: result.generation.baseline, baselineSeq: replacementSeq } + } + + yield* events.publish( + SessionEvent.ContextUpdated, + { sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text }, + { commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) }, + ) + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } +}) + +const initializeOnce = Effect.fnUntraced(function* ( + db: DatabaseService, + context: SystemContextRegistry.Interface, + sessionID: SessionSchema.ID, + location: Location.Ref, +) { + if (yield* exists(db, sessionID)) return + const generation = yield* context.load().pipe(Effect.flatMap(SystemContext.initialize)) + const baselineSeq = yield* insert(db, sessionID, location, generation) + return { baseline: generation.baseline, baselineSeq } +}) + +const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + return ( + (yield* db + .select({ sessionID: SessionContextEpochTable.session_id }) + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie)) !== undefined + ) +}) + +const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + return yield* db + .select() + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) +}) + +export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + seq: number, +) { + return yield* db + .update(SessionContextEpochTable) + .set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` }) + .where( + and( + eq(SessionContextEpochTable.session_id, sessionID), + lt(SessionContextEpochTable.baseline_seq, seq), + or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)), + ), + ) + .run() + .pipe(Effect.orDie) +}) + +export const reset = Effect.fn("SessionContextEpoch.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + yield* db.delete(SessionContextEpochTable).where(eq(SessionContextEpochTable.session_id, sessionID)).run().pipe(Effect.orDie) +}) + +const insert = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + location: Location.Ref, + generation: SystemContext.Generation, +) { + return yield* db + .transaction( + () => + Effect.gen(function* () { + const placed = yield* db + .select({ sessionID: SessionTable.id }) + .from(SessionTable) + .where( + and( + eq(SessionTable.id, sessionID), + eq(SessionTable.directory, location.directory), + location.workspaceID === undefined + ? isNull(SessionTable.workspace_id) + : eq(SessionTable.workspace_id, location.workspaceID), + ), + ) + .get() + .pipe(Effect.orDie) + if (!placed) return yield* Effect.die(new LocationMismatch()) + const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) + yield* db + .insert(SessionContextEpochTable) + .values({ + session_id: sessionID, + baseline: generation.baseline, + snapshot: generation.snapshot, + baseline_seq: baselineSeq, + revision: 0, + }) + .onConflictDoNothing() + .returning({ sessionID: SessionContextEpochTable.session_id }) + .get() + .pipe( + Effect.orDie, + Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))), + ) + return baselineSeq + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) +}) + +const replace = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + expectedRevision: number, + baselineSeq: number, + generation: SystemContext.Generation, +) { + const updated = yield* db + .update(SessionContextEpochTable) + .set({ + baseline: generation.baseline, + snapshot: generation.snapshot, + baseline_seq: baselineSeq, + replacement_seq: null, + revision: expectedRevision + 1, + }) + .where( + and(eq(SessionContextEpochTable.session_id, sessionID), eq(SessionContextEpochTable.revision, expectedRevision)), + ) + .returning({ revision: SessionContextEpochTable.revision }) + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die(new RevisionMismatch()) +}) + +const advance = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + expectedRevision: number, + snapshot: SystemContext.Snapshot, +) { + const updated = yield* db + .update(SessionContextEpochTable) + .set({ snapshot, revision: expectedRevision + 1 }) + .where( + and( + eq(SessionContextEpochTable.session_id, sessionID), + eq(SessionContextEpochTable.revision, expectedRevision), + isNull(SessionContextEpochTable.replacement_seq), + ), + ) + .returning({ revision: SessionContextEpochTable.revision }) + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die(new RevisionMismatch()) +}) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index 458f768032..3148dbb8fe 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -1,47 +1,92 @@ -import { and, asc, desc, eq, gt, gte, or } from "drizzle-orm" +import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm" import { Effect, Schema } from "effect" import { Database } from "../database/database" import { MessageDecodeError } from "./error" import { SessionMessage } from "./message" import { SessionSchema } from "./schema" -import { SessionMessageTable } from "./sql" +import { SessionContextEpochTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] const decode = Schema.decodeUnknownEffect(SessionMessage.Message) -export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { - const compaction = yield* db - .select() +const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + return yield* db + .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) .orderBy(desc(SessionMessageTable.seq)) .limit(1) .get() .pipe(Effect.orDie) - const rows = yield* db +}) + +const messageRows = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + compaction: { readonly seq: number } | undefined, + baselineSeq?: number, +) { + return yield* db .select() .from(SessionMessageTable) .where( and( eq(SessionMessageTable.session_id, sessionID), - compaction ? or(gte(SessionMessageTable.seq, compaction.seq)) : undefined, + compaction + ? or( + gte(SessionMessageTable.seq, compaction.seq), + baselineSeq === undefined + ? undefined + : and(eq(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)), + ) + : undefined, + baselineSeq === undefined + ? undefined + : or(ne(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)), ), ) .orderBy(asc(SessionMessageTable.seq)) .all() .pipe(Effect.orDie) - return yield* Effect.forEach(rows, (row) => - decode({ ...row.data, id: row.id, type: row.type }).pipe( - Effect.mapError( - () => - new MessageDecodeError({ - sessionID: SessionSchema.ID.make(row.session_id), - messageID: SessionMessage.ID.make(row.id), - }), - ), +}) + +const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) => + decode({ ...row.data, id: row.id, type: row.type }).pipe( + Effect.mapError( + () => + new MessageDecodeError({ + sessionID: SessionSchema.ID.make(row.session_id), + messageID: SessionMessage.ID.make(row.id), + }), ), ) + +export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + const [epoch, compaction] = yield* Effect.all( + [ + db + .select({ baselineSeq: SessionContextEpochTable.baseline_seq }) + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie), + latestCompaction(db, sessionID), + ], + { concurrency: "unbounded" }, + ) + return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow) +}) + +export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + baselineSeq: number, +) { + return yield* Effect.forEach( + yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq), + decodeMessageRow, + ) }) export * as SessionContext from "./context" diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index a85e807d24..048ec6c624 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -119,6 +119,17 @@ export namespace PromptLifecycle { export type Promoted = typeof Promoted.Type } +export const ContextUpdated = EventV2.define({ + type: "session.next.context.updated", + ...options, + schema: { + ...Base, + messageID: SessionMessageID.ID, + text: Schema.String, + }, +}) +export type ContextUpdated = typeof ContextUpdated.Type + export const Synthetic = EventV2.define({ type: "session.next.synthetic", ...options, @@ -444,6 +455,7 @@ const DurableDefinitions = [ Prompted, PromptLifecycle.Admitted, PromptLifecycle.Promoted, + ContextUpdated, Synthetic, Shell.Started, Shell.Ended, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index a7a3383205..38f52c21c4 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -159,6 +159,15 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.next.prompt.admitted": () => Effect.void, "session.next.prompt.promoted": () => Effect.void, + "session.next.context.updated": (event) => + adapter.appendMessage( + new SessionMessage.System({ + id: event.data.messageID, + type: "system", + text: event.data.text, + time: { created: event.data.timestamp }, + }), + ), "session.next.synthetic": (event) => { return adapter.appendMessage( new SessionMessage.Synthetic({ diff --git a/packages/core/src/session/message.ts b/packages/core/src/session/message.ts index 7f62236bef..98360cd936 100644 --- a/packages/core/src/session/message.ts +++ b/packages/core/src/session/message.ts @@ -51,6 +51,12 @@ export class Synthetic extends Schema.Class("Session.Message.Syntheti type: Schema.Literal("synthetic"), }) {} +export class System extends Schema.Class("Session.Message.System")({ + ...Base, + type: Schema.Literal("system"), + text: SessionEvent.ContextUpdated.data.fields.text, +}) {} + export class Shell extends Schema.Class("Session.Message.Shell")({ ...Base, type: Schema.Literal("shell"), @@ -170,7 +176,16 @@ export class Compaction extends Schema.Class("Session.Message.Compac ...Base, }) {} -export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction]) +export const Message = Schema.Union([ + AgentSwitched, + ModelSwitched, + User, + Synthetic, + System, + Shell, + Assistant, + Compaction, +]) .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "Session.Message" }) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index e780d7ceaa..42053ce733 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -11,6 +11,7 @@ import { SessionMessage } from "./message" import { SessionMessageUpdater } from "./message-updater" import { SessionInput } from "./input" import { WorkspaceV2 } from "../workspace" +import { SessionContextEpoch } from "./context-epoch" import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql" import type { DeepMutable } from "../schema" @@ -259,17 +260,20 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie), ) yield* events.project(SessionEvent.Moved, (event) => - db - .update(SessionTable) - .set({ - directory: event.data.location.directory, - path: event.data.subdirectory, - workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, - time_updated: DateTime.toEpochMillis(event.data.timestamp), - }) - .where(eq(SessionTable.id, event.data.sessionID)) - .run() - .pipe(Effect.orDie), + Effect.gen(function* () { + yield* db + .update(SessionTable) + .set({ + directory: event.data.location.directory, + path: event.data.subdirectory, + workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, + time_updated: DateTime.toEpochMillis(event.data.timestamp), + }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) + yield* SessionContextEpoch.reset(db, event.data.sessionID) + }), ) yield* events.project(SessionV1.Event.Deleted, (event) => db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), @@ -352,12 +356,18 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie, Effect.andThen(run(db, event))), ) yield* events.project(SessionEvent.ModelSwitched, (event) => - db - .update(SessionTable) - .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) - .where(eq(SessionTable.id, event.data.sessionID)) - .run() - .pipe(Effect.orDie, Effect.andThen(run(db, event))), + Effect.gen(function* () { + yield* db + .update(SessionTable) + .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) + yield* run(db, event) + if (event.seq === undefined) + return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq) + }), ) yield* events.project(SessionEvent.Prompted, (event) => Effect.gen(function* () { @@ -413,6 +423,12 @@ export const layer = Layer.effectDiscard( ) }), ) + yield* events.project(SessionEvent.ContextUpdated, (event) => { + if (!event.replay || event.seq === undefined) return run(db, event) + return run(db, event).pipe( + Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)), + ) + }) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) @@ -432,7 +448,12 @@ export const layer = Layer.effectDiscard( // yield* events.project(SessionEvent.Retried, (event) => run(db, event)) yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Compaction.Delta, (event) => run(db, event)) - yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event)) + yield* events.project(SessionEvent.Compaction.Ended, (event) => { + if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + return run(db, event).pipe( + Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)), + ) + }) }), ) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index fe17284b8a..cfdc6d8943 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -5,6 +5,7 @@ import { Context, Effect, Schema } from "effect" import { SessionSchema } from "../schema" import type { MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" +import type { SystemContext } from "../../system-context" export class StepLimitExceededError extends Schema.TaggedErrorClass()( "SessionRunner.StepLimitExceededError", @@ -14,7 +15,12 @@ export class StepLimitExceededError extends Schema.TaggedErrorClass Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) const runTurn = Effect.fn("SessionRunner.runTurn")(function* ( - session: SessionSchema.Info, + sessionID: SessionSchema.ID, promotion: "steer" | "queue" | undefined, ) { + const session = yield* getSession(sessionID) + const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id, session.location) const model = yield* models.resolve(session) const toolFibers = yield* FiberSet.make() let needsContinuation = false @@ -140,9 +144,14 @@ export const layer = Layer.effect( yield* SessionInput.promoteSteers(db, events, session.id, cutoff) } } - yield* failInterruptedTools(session.id) - const context = yield* getContext(session.id) - const request = LLM.request({ model, messages: toLLMMessages(context, model), tools: yield* tools.definitions() }) + const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id, session.location)) + const context = yield* store.runnerContext(session.id, system.baselineSeq) + const request = LLM.request({ + model, + system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [], + messages: toLLMMessages(context, model), + tools: yield* tools.definitions(), + }) const publisher = createLLMEventPublisher(events, { sessionID: session.id, agent: session.agent ?? "build", @@ -235,16 +244,16 @@ export const layer = Layer.effect( readonly sessionID: SessionSchema.ID readonly force?: boolean }) { - const session = yield* getSession(input.sessionID) const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") if (input.force !== true && !hasSteer && !hasQueue) return + yield* failInterruptedTools(input.sessionID) let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined let openActivity = input.force === true || hasSteer || hasQueue while (openActivity) { let needsContinuation = true for (let step = 0; step < MAX_STEPS; step++) { - needsContinuation = yield* runTurn(session, promotion) + needsContinuation = yield* runTurn(input.sessionID, promotion) promotion = "steer" if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") if (!needsContinuation) break diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 75c8fce6a5..cca399790f 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -111,6 +111,8 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] ] case "synthetic": return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })] + case "system": + return [Message.system(message.text)] case "shell": return [ Message.make({ diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 27f22bd4bf..7cd9fbf9af 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -11,6 +11,7 @@ import type { SessionSchema } from "./schema" import type { MessageID, PartID, SessionV1 } from "../v1/session" import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" +import type { SystemContext } from "../system-context" type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> type V1MessageData = Omit @@ -161,3 +162,15 @@ export const SessionInputTable = sqliteTable( uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq), ], ) + +export const SessionContextEpochTable = sqliteTable("session_context_epoch", { + session_id: text() + .$type() + .primaryKey() + .references(() => SessionTable.id, { onDelete: "cascade" }), + baseline: text().notNull(), + snapshot: text({ mode: "json" }).notNull().$type(), + baseline_seq: integer().notNull(), + replacement_seq: integer(), + revision: integer().notNull().default(0), +}) diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 8660202da9..6eb7052539 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -13,6 +13,10 @@ import { fromRow } from "./info" export interface Interface { readonly get: (sessionID: SessionSchema.ID) => Effect.Effect readonly context: (sessionID: SessionSchema.ID) => Effect.Effect + readonly runnerContext: ( + sessionID: SessionSchema.ID, + baselineSeq: number, + ) => Effect.Effect readonly message: ( messageID: SessionMessage.ID, ) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined> @@ -34,6 +38,9 @@ export const layer = Layer.effect( context: Effect.fn("SessionStore.context")(function* (sessionID) { return yield* SessionContext.load(db, sessionID) }), + runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID, baselineSeq) { + return yield* SessionContext.loadForRunner(db, sessionID, baselineSeq) + }), message: Effect.fn("SessionStore.message")(function* (messageID) { const row = yield* db .select() diff --git a/packages/core/src/system-context-builtins.ts b/packages/core/src/system-context-builtins.ts new file mode 100644 index 0000000000..8e74788e75 --- /dev/null +++ b/packages/core/src/system-context-builtins.ts @@ -0,0 +1,47 @@ +export * as SystemContextBuiltIns from "./system-context-builtins" + +import { DateTime, Effect, Layer, Schema } from "effect" +import { InstructionContext } from "./instruction-context" +import { Location } from "./location" +import { SystemContext } from "./system-context" +import { SystemContextRegistry } from "./system-context-registry" + +const builtIns = Layer.effectDiscard( + Effect.gen(function* () { + const location = yield* Location.Service + const registry = yield* SystemContextRegistry.Service + const environment = [ + "", + ` Working directory: ${location.directory}`, + ` Workspace root folder: ${location.project.directory}`, + ` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`, + ` Platform: ${process.platform}`, + "", + ].join("\n") + const context = SystemContext.combine([ + SystemContext.make({ + key: SystemContext.Key.make("core/environment"), + codec: Schema.toCodecJson(Schema.String), + load: Effect.succeed(environment), + baseline: (environment) => + ["Here is some useful information about the environment you are running in:", environment].join("\n"), + update: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"), + }), + SystemContext.make({ + key: SystemContext.Key.make("core/date"), + codec: Schema.toCodecJson(Schema.String), + load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())), + baseline: (date) => `Today's date: ${date}`, + update: (_previous, date) => `Today's date is now: ${date}`, + }), + ]) + + yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) }) + }), +) + +export const layer = Layer.mergeAll(builtIns, InstructionContext.layer).pipe( + Layer.provideMerge(SystemContextRegistry.layer), +) + +export const locationLayer = layer diff --git a/packages/core/src/system-context-registry.ts b/packages/core/src/system-context-registry.ts new file mode 100644 index 0000000000..3e28f30c6e --- /dev/null +++ b/packages/core/src/system-context-registry.ts @@ -0,0 +1,46 @@ +export * as SystemContextRegistry from "./system-context-registry" + +import { Context, Effect, Layer, Ref, Scope } from "effect" +import { SystemContext } from "./system-context" + +export interface Contribution { + readonly key: SystemContext.Key + readonly load: Effect.Effect +} + +export interface Interface { + readonly contribute: (contribution: Contribution) => Effect.Effect + readonly load: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/SystemContextRegistry") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const contributions = yield* Ref.make>([]) + + return Service.of({ + contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) { + yield* Effect.acquireRelease( + Ref.modify(contributions, (current) => { + if (current.some((item) => item.key === contribution.key)) return [false, current] + return [true, [...current, contribution]] + }).pipe( + Effect.flatMap((added) => + added ? Effect.void : Effect.die(`Duplicate system context contribution key: ${contribution.key}`), + ), + Effect.as(contribution), + ), + (entry) => Ref.update(contributions, (current) => current.filter((item) => item !== entry)), + ) + }), + load: Effect.fn("SystemContextRegistry.load")(function* () { + const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) + return SystemContext.combine( + yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }), + ) + }), + }) + }), +) diff --git a/packages/core/src/system-context.ts b/packages/core/src/system-context.ts index c40f995ed2..739305bfeb 100644 --- a/packages/core/src/system-context.ts +++ b/packages/core/src/system-context.ts @@ -1,66 +1,89 @@ export * as SystemContext from "./system-context" -import { Effect, Schema } from "effect" -import { Hash } from "./util/hash" +import { Effect, Option, Schema } from "effect" +/** + * Models privileged system context as independently refreshable typed sources. + * + * `Source` describes how to observe, compare, and render one value. `make` + * closes over `A`, producing an opaque `SystemContext` that composes uniformly + * with contexts built from other value types. Interpreters observe the composed + * context once, then produce a durable structured + * `Snapshot` alongside the exact model-visible baseline or update text. + * + * Returning `unavailable` means observation failed temporarily. It differs from + * removing a source from the context: refresh preserves the admitted snapshot, + * and replacement waits rather than silently constructing an incomplete baseline. + * + * @module + */ + +/** Stable namespaced identity for one independently refreshable context source. */ export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._/-]*$/)).pipe( Schema.brand("SystemContext.Key"), ) export type Key = typeof Key.Type +/** Indicates that a source could not be observed without treating it as removed. */ export const unavailable = Symbol.for("@opencode/SystemContext.Unavailable") export type Unavailable = typeof unavailable -export interface Value { - /** Full component text rendered into a new epoch baseline. */ +/** Defines one typed source before its value type is hidden by `make`. */ +export interface Source { + readonly key: Key + readonly codec: Schema.Codec + readonly load: Effect.Effect + readonly baseline: (current: A) => string + readonly update: (previous: A, current: A) => string + readonly removed?: (previous: A) => string +} + +const ContextTypeId: unique symbol = Symbol.for("@opencode/SystemContext") + +/** Opaque carrier for composable system context sources. */ +export interface SystemContext { + readonly [ContextTypeId]: ReadonlyArray +} + +/** Durable comparison state for one admitted source. */ +export const SourceSnapshot = Schema.Struct({ + value: Schema.Json, + removed: Schema.optional(Schema.NonEmptyString), +}) +export type SourceSnapshot = typeof SourceSnapshot.Type + +/** Durable structured comparison state for one active context generation. */ +export const Snapshot = Schema.Record(Key, SourceSnapshot) +export type Snapshot = Readonly> + +export interface Generation { readonly baseline: string - /** Absolute current-state text emitted when this component changes. */ - readonly update: string + readonly snapshot: Snapshot } -export interface Component { - readonly key: Key - readonly load: Effect.Effect -} - -export interface SystemContext { - readonly components: ReadonlyArray> -} - -export interface AvailableEntry extends Value { - readonly _tag: "Available" - readonly key: Key - readonly hash: string -} - -export interface UnavailableEntry { - readonly _tag: "Unavailable" - readonly key: Key -} - -export type Entry = AvailableEntry | UnavailableEntry - -export interface Snapshot { - readonly entries: ReadonlyArray -} - -export interface Part { - readonly key: Key +export interface Updated { + readonly _tag: "Updated" readonly text: string + readonly snapshot: Snapshot } -export type Checkpoint = Readonly> - -export interface Initialized { - readonly baseline: ReadonlyArray - readonly checkpoint: Checkpoint +export interface ReplacementReady { + readonly _tag: "ReplacementReady" + readonly generation: Generation } -export interface Refreshed { - readonly changes: ReadonlyArray - readonly checkpoint: Checkpoint +export interface ReplacementBlocked { + readonly _tag: "ReplacementBlocked" } +export type ReplacementResult = ReplacementReady | ReplacementBlocked +export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult + +export class InitializationBlocked extends Schema.TaggedErrorClass()( + "SystemContext.InitializationBlocked", + { keys: Schema.Array(Key) }, +) {} + export class DuplicateKeyError extends Schema.TaggedErrorClass()("SystemContext.DuplicateKeyError", { key: Key, }) { @@ -69,73 +92,225 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass(component: Component): Component => component - -export function struct(components: Readonly>>): SystemContext { - const values = Object.values(components) - assertUniqueKeys(values) - return { components: values } +interface PackedSource { + readonly key: Key + readonly load: Effect.Effect } -export const load = (context: SystemContext) => - Effect.sync(() => assertUniqueKeys(context.components)).pipe( - Effect.andThen( - Effect.forEach(context.components, (component) => - component.load.pipe( - Effect.map( - (result): Entry => - result === unavailable - ? { _tag: "Unavailable", key: component.key } - : { _tag: "Available", key: component.key, ...result, hash: Hash.sha256(result.update) }, - ), +interface Loaded { + readonly baseline: () => Rendered + readonly compare: (previous: Schema.Json) => Compared +} + +interface Rendered { + readonly text: string + readonly snapshot: SourceSnapshot +} + +type Compared = + | { readonly _tag: "Incompatible" } + | { readonly _tag: "Unchanged" } + | { readonly _tag: "Updated"; readonly render: () => Rendered } + +interface AvailableEntry extends Loaded { + readonly _tag: "Available" + readonly key: Key +} + +interface UnavailableEntry { + readonly _tag: "Unavailable" + readonly key: Key +} + +type Entry = AvailableEntry | UnavailableEntry + +/** The identity context. */ +export const empty = context([]) + +/** Closes a typed source into a context that composes with differently typed sources. */ +export function make(source: Source): SystemContext { + const decode = Schema.decodeUnknownOption(source.codec) + const encode = Schema.encodeSync(source.codec) + const equivalent = Schema.toEquivalence(source.codec) + return context([ + { + key: source.key, + load: source.load.pipe( + Effect.map((value) => { + if (isUnavailable(value)) return value + const snapshot = (): SourceSnapshot => ({ + value: encode(value), + ...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}), + }) + return { + baseline: (): Rendered => ({ + text: requireText(source.key, "baseline", source.baseline(value)), + snapshot: snapshot(), + }), + compare: (previous): Compared => + Option.match(decode(previous), { + onNone: (): Compared => ({ _tag: "Incompatible" }), + onSome: (decoded): Compared => + equivalent(decoded, value) + ? { _tag: "Unchanged" } + : { + _tag: "Updated", + render: () => ({ + text: requireText(source.key, "update", source.update(decoded, value)), + snapshot: snapshot(), + }), + }, + }), + } + }), + ), + }, + ]) +} + +/** Combines contexts in order and rejects duplicate source keys immediately. */ +export function combine(values: ReadonlyArray): SystemContext { + const sources = values.flatMap((value) => value[ContextTypeId]) + assertUniqueKeys(sources) + return context(sources) +} + +const observe = (value: SystemContext) => + Effect.forEach( + value[ContextTypeId], + (source) => + source.load.pipe( + Effect.map( + (result): Entry => + result === unavailable + ? { _tag: "Unavailable", key: source.key } + : { _tag: "Available", key: source.key, ...result }, ), ), - ), - Effect.map((entries): Snapshot => ({ entries })), + { concurrency: "unbounded" }, ) -export function initialize(snapshot: Snapshot): Initialized { - return { - baseline: snapshot.entries.flatMap((entry) => - entry._tag === "Available" ? [{ key: entry.key, text: entry.baseline }] : [], - ), - checkpoint: nextCheckpoint(snapshot, {}), - } -} - -export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed { - return { - changes: snapshot.entries.flatMap((entry) => - entry._tag === "Available" && getCheckpoint(previous, entry.key) !== entry.hash - ? [{ key: entry.key, text: entry.update }] - : [], - ), - checkpoint: nextCheckpoint(snapshot, previous), - } -} - -export function render(parts: ReadonlyArray) { - return parts.map((part) => part.text).join("\n\n") -} - -function nextCheckpoint(snapshot: Snapshot, previous: Checkpoint) { - return Object.fromEntries( - snapshot.entries.flatMap((entry) => { - if (entry._tag === "Available") return [[entry.key, entry.hash]] - const hash = getCheckpoint(previous, entry.key) - return hash === undefined ? [] : [[entry.key, hash]] +/** Creates the immutable baseline and durable snapshot for a new generation. */ +export function initialize(value: SystemContext): Effect.Effect { + return observe(value).pipe( + Effect.flatMap((entries) => { + const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : [])) + if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable }) + return Effect.succeed(initializeObservation(entries)) }), ) } -function getCheckpoint(checkpoint: Checkpoint, key: Key) { - return Object.hasOwn(checkpoint, key) ? checkpoint[key] : undefined -} - -function assertUniqueKeys(components: ReadonlyArray>) { - const keys = new Set() - for (const component of components) { - if (keys.has(component.key)) throw new DuplicateKeyError({ key: component.key }) - keys.add(component.key) +function initializeObservation(entries: ReadonlyArray): Generation { + const available = entries.filter((entry): entry is AvailableEntry => entry._tag === "Available") + const rendered = available.map((entry) => [entry.key, entry.baseline()] as const) + return { + baseline: render(rendered.map(([, result]) => result.text)), + snapshot: Object.fromEntries(rendered.map(([key, result]) => [key, result.snapshot])), + } +} + +/** Reconciles current source values with one active generation. */ +export function reconcile(value: SystemContext, previous: Snapshot): Effect.Effect { + return observe(value).pipe( + Effect.map((entries): ReconcileResult => { + const result = reconcileObservation(entries, previous) + if (result._tag === "Unchanged" || result._tag === "Updated") return result + return replaceObservation(entries, previous) + }), + ) +} + +function reconcileObservation( + entries: ReadonlyArray, + previous: Snapshot, +): { readonly _tag: "Unchanged" } | Updated | { readonly _tag: "Replace" } { + const keys = new Set(entries.map((entry) => entry.key)) + const comparisons = new Map() + for (const entry of entries) { + if (entry._tag === "Unavailable") continue + const stored = getSnapshot(previous, entry.key) + if (!stored) continue + const compared = entry.compare(stored.value) + if (compared._tag === "Incompatible") return { _tag: "Replace" } + comparisons.set(entry.key, compared) + } + for (const key of Object.keys(previous).sort()) { + if (keys.has(Key.make(key))) continue + if (previous[key].removed === undefined) return { _tag: "Replace" } + } + + const snapshot: Record = {} + const updates: string[] = [] + for (const entry of entries) { + const stored = getSnapshot(previous, entry.key) + if (entry._tag === "Unavailable") { + if (stored) snapshot[entry.key] = stored + continue + } + if (!stored) { + const rendered = entry.baseline() + updates.push(rendered.text) + snapshot[entry.key] = rendered.snapshot + continue + } + const compared = comparisons.get(entry.key) + if (!compared || compared._tag === "Incompatible") + throw new Error(`Missing comparison for system context source ${entry.key}`) + if (compared._tag === "Unchanged") { + snapshot[entry.key] = stored + continue + } + const rendered = compared.render() + updates.push(rendered.text) + snapshot[entry.key] = rendered.snapshot + } + for (const key of Object.keys(previous).sort()) { + if (keys.has(Key.make(key))) continue + const removed = previous[key].removed + if (removed === undefined) throw new Error(`Missing removal rendering for system context source ${key}`) + updates.push(removed) + } + if (updates.length === 0) return { _tag: "Unchanged" } + return { _tag: "Updated", text: render(updates), snapshot } +} + +/** Creates a complete replacement generation or blocks while admitted context is unavailable. */ +export function replace(value: SystemContext, previous: Snapshot): Effect.Effect { + return observe(value).pipe(Effect.map((entries) => replaceObservation(entries, previous))) +} + +function replaceObservation(entries: ReadonlyArray, previous: Snapshot): ReplacementResult { + if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined)) + return { _tag: "ReplacementBlocked" } + return { _tag: "ReplacementReady", generation: initializeObservation(entries) } +} + +function context(sources: ReadonlyArray): SystemContext { + return { [ContextTypeId]: sources } +} + +function render(parts: ReadonlyArray) { + return parts.join("\n\n") +} + +function getSnapshot(snapshot: Snapshot, key: Key) { + return Object.hasOwn(snapshot, key) ? snapshot[key] : undefined +} + +function isUnavailable(value: unknown): value is Unavailable { + return value === unavailable +} + +function requireText(key: Key, kind: string, text: string) { + if (text.length === 0) throw new Error(`System context source ${key} rendered an empty ${kind}`) + return text +} + +function assertUniqueKeys(sources: ReadonlyArray) { + const keys = new Set() + for (const source of sources) { + if (keys.has(source.key)) throw new DuplicateKeyError({ key: source.key }) + keys.add(source.key) } } diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 6b0f1d2692..52c1e920ad 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -7,6 +7,7 @@ import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { Effect, Layer } from "effect" import { eq, inArray, sql } from "drizzle-orm" import { DatabaseMigration } from "@opencode-ai/core/database/migration" +import { migrations } from "@opencode-ai/core/database/migration.gen" import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage" import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths" import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order" @@ -63,7 +64,10 @@ describe("DatabaseMigration", () => { expect( yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`), ).toEqual({ name: "session_input" }) - expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 30 }) + expect( + yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`), + ).toEqual({ name: "session_context_epoch" }) + expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) expect( yield* db.all( sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`, diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index c91d34fcfa..cd8ba69253 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -190,6 +190,56 @@ describe("EventV2", () => { }), ) + it.effect("commits local operational state inside a new synchronized event transaction", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + const aggregateID = EventV2.ID.create() + yield* events.project(SyncMessage, () => Effect.sync(() => received.push("projector"))) + + yield* events.publish( + SyncMessage, + { id: aggregateID, text: "hello" }, + { commit: (seq) => Effect.sync(() => received.push(`commit:${seq}`)) }, + ) + + expect(received).toEqual(["projector", "commit:0"]) + }), + ) + + it.effect("rolls back the synchronized event and projector when the local commit fails", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + yield* db.run("CREATE TABLE IF NOT EXISTS event_commit_probe (value text NOT NULL)") + yield* db.run("DELETE FROM event_commit_probe") + yield* events.project(SyncMessage, () => + db.run("INSERT INTO event_commit_probe (value) VALUES ('projected')").pipe(Effect.orDie, Effect.asVoid), + ) + + const exit = yield* events + .publish(SyncMessage, { id: aggregateID, text: "hello" }, { commit: () => Effect.die("commit failed") }) + .pipe(Effect.exit) + + expect(String(exit)).toContain("commit failed") + expect(yield* db.all("SELECT value FROM event_commit_probe")).toEqual([]) + expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([]) + expect( + yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(), + ).toEqual([]) + }), + ) + + it.effect("rejects local commit hooks on live-only events", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit) + + expect(String(exit)).toContain("Local commit hooks require a synchronized event") + }), + ) + it.effect("runs projectors before publishing to streams", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts new file mode 100644 index 0000000000..88f97ed2bd --- /dev/null +++ b/packages/core/test/instruction-context.test.ts @@ -0,0 +1,299 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import fs from "fs/promises" +import path from "path" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { InstructionContext } from "@opencode-ai/core/instruction-context" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SystemContext } from "@opencode-ai/core/system-context" +import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" +import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect(Layer.empty) + +describe("InstructionContext", () => { + it.live("loads global and upward project AGENTS.md files as one aggregate context", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + const directory = path.join(project, "packages", "core") + const outside = path.join(tmp.path, "AGENTS.md") + const globalFile = path.join(global, "AGENTS.md") + const projectFile = path.join(project, "AGENTS.md") + const packageFile = path.join(directory, "AGENTS.md") + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(outside, "outside") + await fs.writeFile(globalFile, "global") + await fs.writeFile(projectFile, "project") + await fs.writeFile(packageFile, "package") + }) + + const load = SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(FSUtil.defaultLayer), + Effect.provide(Global.layerWith({ config: global })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(project) }, + ), + ), + ), + ), + ) + + const initialized = yield* SystemContext.initialize(yield* load) + expect(initialized.baseline).toBe( + [ + `Instructions from: ${globalFile}\nglobal`, + `Instructions from: ${packageFile}\npackage`, + `Instructions from: ${projectFile}\nproject`, + ].join("\n\n"), + ) + expect(initialized.baseline).not.toContain("outside") + + yield* Effect.promise(() => fs.writeFile(packageFile, "changed")) + expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toMatchObject({ + _tag: "Updated", + text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`), + }) + + yield* Effect.promise(() => fs.rm(packageFile)) + const partial = yield* SystemContext.reconcile(yield* load, initialized.snapshot) + expect(partial).toEqual({ + _tag: "Updated", + text: [ + "These instructions replace all previously loaded ambient instructions.", + `Instructions from: ${globalFile}\nglobal`, + `Instructions from: ${projectFile}\nproject`, + ].join("\n\n"), + snapshot: expect.any(Object), + }) + + yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)])) + expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toEqual({ + _tag: "Updated", + text: "Previously loaded instructions no longer apply.", + snapshot: {}, + }) + }), + ), + ), + ) + + it.live("keeps an empty AGENTS.md as available context", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const file = path.join(tmp.path, "AGENTS.md") + yield* Effect.promise(() => fs.writeFile(file, "")) + const context = yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(FSUtil.defaultLayer), + Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })), + ), + ), + ) + + expect((yield* SystemContext.initialize(context)).baseline).toBe(`Instructions from: ${file}\n`) + }), + ), + ), + ) + + it.effect("preserves admitted instructions while observation is unavailable", () => + Effect.gen(function* () { + const failingFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }), + ), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)) + const context = yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(failingFS), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), + ), + ) + + expect( + yield* SystemContext.reconcile(context, { + "core/instructions": { + value: [{ path: "/repo/AGENTS.md", content: "old" }], + removed: "Previously loaded instructions no longer apply.", + }, + }), + ).toEqual({ _tag: "Unchanged" }) + }), + ) + + it.effect("preserves admitted instructions when a discovered file disappears before read", () => + Effect.gen(function* () { + const file = AbsolutePath.make("/repo/AGENTS.md") + const racingFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ + ...fs, + up: () => Effect.succeed([file]), + readFileStringSafe: () => Effect.succeed(undefined), + }), + ), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)) + const context = yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(racingFS), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), + ), + ) + + expect( + yield* SystemContext.reconcile(context, { + "core/instructions": { + value: [{ path: file, content: "old" }], + removed: "Previously loaded instructions no longer apply.", + }, + }), + ).toEqual({ _tag: "Unchanged" }) + }), + ) + + it.effect("canonicalizes upward discovery boundaries", () => + Effect.gen(function* () { + let observed: { targets: string[]; start: string; stop?: string } | undefined + const observingFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ + ...fs, + up: (options) => + Effect.sync(() => { + observed = options + return [] + }), + }), + ), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)) + + yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(observingFS), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make("/repo/") }, + { projectDirectory: AbsolutePath.make("/repo") }, + ), + ), + ), + ), + ) + + expect(observed).toEqual({ targets: ["AGENTS.md"], start: FSUtil.resolve("/repo"), stop: FSUtil.resolve("/repo") }) + }), + ) + + it.effect("honors the project instruction opt-out", () => + Effect.gen(function* () { + const previous = process.env.OPENCODE_DISABLE_PROJECT_CONFIG + let scanned = false + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1" + + yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide( + Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)), + ), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG + else process.env.OPENCODE_DISABLE_PROJECT_CONFIG = previous + }), + ), + ) + + expect(scanned).toBe(false) + }), + ) + + it.effect("does not discover project instructions outside the canonical project root", () => + Effect.gen(function* () { + let scanned = false + yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide( + Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)), + ), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make("/outside") }, + { projectDirectory: AbsolutePath.make("/repo") }, + ), + ), + ), + ), + ) + + expect(scanned).toBe(false) + }), + ) +}) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 775e744d84..47b018fa12 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -32,6 +32,12 @@ describe("toLLMMessages", () => { model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, time: { created }, }), + new SessionMessage.System({ + id: id("system"), + type: "system", + text: "Updated context\n\nOther context", + time: { created }, + }), new SessionMessage.User({ id: id("user"), type: "user", @@ -67,8 +73,9 @@ describe("toLLMMessages", () => { model, ) - expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"]) - expect(messages[0]).toEqual( + expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"]) + expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context")) + expect(messages[1]).toEqual( Message.make({ id: id("user"), role: "user", @@ -79,7 +86,7 @@ describe("toLLMMessages", () => { metadata: { agents: [{ name: "build" }], references: [reference] }, }), ) - expect(messages.slice(1).map((message) => message.content)).toEqual([ + expect(messages.slice(2).map((message) => message.content)).toEqual([ [{ type: "text", text: "Synthetic context" }], [{ type: "text", text: "Shell command: pwd\n\n/project" }], [{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }], diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 53a89efa61..376b2e3452 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -19,6 +19,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" +import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" @@ -55,6 +56,7 @@ const model = OpenAIChat.route }) .model({ id: "gpt-4o-mini" }) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const systemContext = SystemContextRegistry.layer const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(database), Layer.provide(store), @@ -62,6 +64,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(client), Layer.provide(registry), Layer.provide(models), + Layer.provide(systemContext), ) const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) const execution = Layer.effect( @@ -88,6 +91,7 @@ const it = testEffect( permission, registry, models, + systemContext, runner, coordinator, execution, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 8607ac33b6..160884402e 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -32,11 +32,18 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { NativeTool } from "@opencode-ai/core/tool/native" -import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { + SessionContextEpochTable, + SessionInputTable, + SessionMessageTable, + SessionTable, +} from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" +import { SystemContext } from "@opencode-ai/core/system-context" +import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { Cause, DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect" +import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" @@ -54,6 +61,7 @@ let streamStarted: Deferred.Deferred | undefined let streamFailure: LLMError | undefined let toolExecutionGate: Deferred.Deferred | undefined let toolExecutionsStarted: Deferred.Deferred | undefined +let toolExecutionsReady = 5 let activeToolExecutions = 0 let maxActiveToolExecutions = 0 const client = Layer.succeed( @@ -82,6 +90,7 @@ const client = Layer.succeed( }), ) const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) +const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route }) const authorizations: ToolRegistry.AuthorizeInput[] = [] const executions: string[] = [] const permission = Layer.succeed( @@ -115,7 +124,7 @@ const echo = Layer.effectDiscard( executions.push(text) activeToolExecutions++ maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) - if (activeToolExecutions === 5 && toolExecutionsStarted) { + if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) { yield* Deferred.succeed(toolExecutionsStarted, undefined) } if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) @@ -134,7 +143,43 @@ const echo = Layer.effectDiscard( }), ), ).pipe(Layer.provide(registry)) -const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const models = SessionRunnerModel.layerWith((session) => + Effect.succeed(session.model?.id === "replacement" ? replacementModel : model), +) +const systemContextKey = SystemContext.Key.make("test/context") +let systemBaseline = "Initial context" +let systemRemoved = false +let systemUnavailable = false +let systemLoadHook = Effect.void +const systemContext = Layer.effectDiscard( + SystemContextRegistry.Service.pipe( + Effect.flatMap((registry) => + registry.contribute({ + key: systemContextKey, + load: Effect.sync(() => + SystemContext.combine( + systemRemoved + ? [] + : [ + SystemContext.make({ + key: systemContextKey, + codec: Schema.toCodecJson(Schema.String), + load: systemLoadHook.pipe( + Effect.andThen( + Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)), + ), + ), + baseline: String, + update: (_previous, current) => current, + removed: () => "System context source removed: test/context", + }), + ], + ), + ), + }), + ), + ), +).pipe(Layer.provideMerge(SystemContextRegistry.layer)) const runner = SessionRunnerLLM.layer.pipe( Layer.provide(database), Layer.provide(store), @@ -142,6 +187,7 @@ const runner = SessionRunnerLLM.layer.pipe( Layer.provide(client), Layer.provide(registry), Layer.provide(models), + Layer.provide(systemContext), ) const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) const execution = Layer.effect( @@ -170,6 +216,7 @@ const it = testEffect( registry, echo, models, + systemContext, runner, coordinator, execution, @@ -200,6 +247,10 @@ const insertSession = (id: SessionV2.ID) => const setup = Effect.gen(function* () { const { db } = yield* Database.Service response = [] + systemBaseline = "Initial context" + systemRemoved = false + systemUnavailable = false + systemLoadHook = Effect.void responses = undefined streamFailure = undefined responseStream = undefined @@ -207,6 +258,7 @@ const setup = Effect.gen(function* () { streamStarted = undefined toolExecutionGate = undefined toolExecutionsStarted = undefined + toolExecutionsReady = 5 activeToolExecutions = 0 maxActiveToolExecutions = 0 yield* db @@ -511,6 +563,411 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("retries the first provider turn after system context becomes available", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const { db } = yield* Database.Service + const messageID = SessionMessage.ID.create() + systemUnavailable = true + yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + requests.length = 0 + + const exit = yield* session.resume(sessionID).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.InitializationBlocked) + expect(requests).toHaveLength(0) + expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true) + expect( + yield* db + .select() + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get(), + ).toBeUndefined() + + systemUnavailable = false + yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) }) + yield* (yield* SessionRunCoordinator.Service).awaitIdle(sessionID) + + expect(requests).toHaveLength(1) + expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"]) + }), + ) + + it.effect("requires a complete new baseline after a Session moves", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + requests.length = 0 + response = [] + yield* session.resume(sessionID) + + yield* events.publish(SessionEvent.Moved, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + location: { directory: AbsolutePath.make("/moved") }, + }) + expect( + yield* db + .select() + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get(), + ).toBeUndefined() + + systemUnavailable = true + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + const exit = yield* session.resume(sessionID).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.InitializationBlocked) + expect(requests).toHaveLength(1) + expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true) + }), + ) + + it.effect("does not create a source Location epoch after a concurrent Session move", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + let moved = false + systemLoadHook = Effect.suspend(() => { + if (moved) return Effect.void + moved = true + return events + .publish(SessionEvent.Moved, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + location: { directory: AbsolutePath.make("/moved") }, + }) + .pipe(Effect.asVoid) + }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + expect(Exit.isFailure(yield* session.resume(sessionID).pipe(Effect.exit))).toBe(true) + expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true) + expect( + yield* db + .select() + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get(), + ).toBeUndefined() + expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved")) + }), + ) + + it.effect("reuses one durable baseline after the context producer changes", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + systemBaseline = "Changed context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context"], + ["Initial context"], + ]) + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) + expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }]) + expect(yield* session.messages({ sessionID })).toHaveLength(3) + const { db } = yield* Database.Service + expect( + yield* db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.type, "session.next.context.updated.1")) + .all() + .pipe(Effect.orDie), + ).toHaveLength(1) + yield* replaySessionProjection(sessionID) + expect(yield* session.messages({ sessionID })).toHaveLength(3) + }), + ) + + it.effect("admits removed context as a chronological System message", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + systemRemoved = true + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) + expect(requests[1]?.messages.at(-1)?.content).toEqual([ + { type: "text", text: "System context source removed: test/context" }, + ]) + expect(yield* session.messages({ sessionID })).toHaveLength(3) + }), + ) + + it.effect("replaces the baseline lazily after a model switch and drops prior System updates", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + systemBaseline = "Changed context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, + }) + systemBaseline = "Replacement context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context"], + ["Initial context"], + ["Replacement context"], + ]) + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) + expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"]) + expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([ + "user", + "user", + "model-switched", + "user", + ]) + yield* replaySessionProjection(sessionID) + expect(yield* session.messages({ sessionID })).toHaveLength(5) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false }) + yield* session.resume(sessionID) + }), + ) + + it.effect("defers replacement while admitted context is temporarily unavailable", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, + }) + systemUnavailable = true + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + systemUnavailable = false + systemBaseline = "Replacement context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context"], + ["Initial context"], + ["Replacement context"], + ]) + }), + ) + + it.effect("advances a pending replacement to the latest invalidation boundary", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + response = [] + yield* session.resume(sessionID) + + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") }, + }) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(2), + model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") }, + }) + const latest = yield* SessionInput.latestSeq(db, sessionID) + + expect( + yield* db + .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie), + ).toEqual({ replacementSeq: latest }) + }), + ) + + it.effect("retries epoch preparation until observation-time invalidations settle", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + response = [] + yield* session.resume(sessionID) + + requests.length = 0 + systemBaseline = "Changed context" + let invalidations = 0 + systemLoadHook = Effect.suspend(() => { + if (invalidations === 4) return Effect.void + invalidations++ + return events + .publish(SessionEvent.ModelSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(invalidations), + model: { id: ModelV2.ID.make(`replacement-${invalidations}`), providerID: ProviderV2.ID.make("fake") }, + }) + .pipe(Effect.asVoid) + }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + + yield* session.resume(sessionID) + + expect(invalidations).toBe(4) + expect(requests).toHaveLength(1) + expect(requests[0]?.system.map((part) => part.text)).toEqual(["Changed context"]) + }), + ) + + it.effect("replays retained context projections while replacement is pending", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + systemBaseline = "Changed context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, + }) + + yield* replaySessionProjection(sessionID) + systemBaseline = "Replacement context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.resume(sessionID) + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Replacement context"]) + }), + ) + + it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.Compaction.Started, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + reason: "manual", + }) + yield* events.publish(SessionEvent.Compaction.Ended, { + sessionID, + timestamp: DateTime.makeUnsafe(2), + text: "summary", + }) + systemBaseline = "Replacement context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context"], + ["Replacement context"], + ]) + yield* replaySessionProjection(sessionID) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.resume(sessionID) + }), + ) + + it.effect("preserves effective System updates while compaction replacement is blocked", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + systemBaseline = "Changed context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.Compaction.Started, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + reason: "manual", + }) + yield* events.publish(SessionEvent.Compaction.Ended, { + sessionID, + timestamp: DateTime.makeUnsafe(2), + text: "summary", + }) + systemUnavailable = true + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"]) + expect( + requests + .at(-1) + ?.messages.some( + (message) => + message.role === "system" && + message.content[0]?.type === "text" && + message.content[0].text === "Changed context", + ), + ).toBe(true) + }), + ) + it.effect("projects reasoning and tool events without executing or continuing tools", () => Effect.gen(function* () { yield* setup @@ -667,6 +1124,50 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("reloads a model switch before a tool-driven continuation turn", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false }) + + requests.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], + ] + toolExecutionGate = yield* Deferred.make() + toolExecutionsStarted = yield* Deferred.make() + toolExecutionsReady = 1 + const run = yield* Effect.forkChild(session.resume(sessionID)) + yield* Deferred.await(toolExecutionsStarted) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(1), + model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, + }) + systemBaseline = "Replacement context" + yield* Deferred.succeed(toolExecutionGate, undefined) + yield* Fiber.join(run) + + expect(requests.map((request) => request.model)).toEqual([model, replacementModel]) + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context"], + ["Replacement context"], + ]) + }), + ) + it.effect("restores durable reasoning provider metadata in a second-turn request", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/session-system-context.test.ts b/packages/core/test/session-system-context.test.ts deleted file mode 100644 index a2c979d069..0000000000 --- a/packages/core/test/session-system-context.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import * as TestClock from "effect/testing/TestClock" -import { Location } from "@opencode-ai/core/location" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { SessionSystemContext } from "@opencode-ai/core/session-system-context" -import { SystemContext } from "@opencode-ai/core/system-context" -import { location } from "./fixture/location" -import { testEffect } from "./lib/effect" - -const directory = AbsolutePath.make("/repo/packages/core") -const projectDirectory = AbsolutePath.make("/repo") -const timestamp = Date.parse("2026-06-03T12:00:00.000Z") -const localDate = (time: number) => new Date(time).toDateString() -const it = testEffect( - SessionSystemContext.locationLayer.pipe( - Layer.provide( - Layer.succeed( - Location.Service, - Location.Service.of( - location({ directory }, { projectDirectory, vcs: { type: "git", store: AbsolutePath.make("/repo/.git") } }), - ), - ), - ), - ), -) - -describe("SessionSystemContext", () => { - it.effect("loads location-scoped environment and host-local date context", () => - Effect.gen(function* () { - yield* TestClock.setTime(timestamp) - const context = yield* SessionSystemContext.Service - const initialized = SystemContext.initialize(yield* context.load()) - - expect(initialized.baseline).toEqual([ - { - key: SystemContext.Key.make("core/environment"), - text: [ - "Here is some useful information about the environment you are running in:", - "", - ` Working directory: ${directory}`, - ` Workspace root folder: ${projectDirectory}`, - " Is directory a git repo: yes", - ` Platform: ${process.platform}`, - "", - ].join("\n"), - }, - { key: SystemContext.Key.make("core/date"), text: `Today's date: ${localDate(timestamp)}` }, - ]) - }), - ) - - it.effect("refreshes the date without repeating unchanged environment context", () => - Effect.gen(function* () { - yield* TestClock.setTime(timestamp) - const context = yield* SessionSystemContext.Service - const initialized = SystemContext.initialize(yield* context.load()) - - yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000) - const refreshed = SystemContext.refresh(yield* context.load(), initialized.checkpoint) - - expect(refreshed.changes).toEqual([ - { - key: SystemContext.Key.make("core/date"), - text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`, - }, - ]) - }), - ) -}) diff --git a/packages/core/test/system-context-builtins.test.ts b/packages/core/test/system-context-builtins.test.ts new file mode 100644 index 0000000000..6ec9ca41ee --- /dev/null +++ b/packages/core/test/system-context-builtins.test.ts @@ -0,0 +1,127 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import * as TestClock from "effect/testing/TestClock" +import { Location } from "@opencode-ai/core/location" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SystemContext } from "@opencode-ai/core/system-context" +import { SystemContextBuiltIns } from "@opencode-ai/core/system-context-builtins" +import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" +import { location } from "./fixture/location" +import { testEffect } from "./lib/effect" + +const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core")) +const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo")) +const instructionFile = FSUtil.resolve("/repo/AGENTS.md") +const timestamp = Date.parse("2026-06-03T12:00:00.000Z") +const localDate = (time: number) => new Date(time).toDateString() +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory }, + { projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } }, + ), + ), +) +const it = testEffect( + SystemContextBuiltIns.locationLayer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Global.layerWith({ config: "/global" })), + Layer.provide(locationLayer), + ), +) +const instructionFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ + ...fs, + up: () => Effect.succeed([instructionFile]), + readFileStringSafe: (path) => Effect.succeed(path === instructionFile ? "Be precise." : undefined), + }), + ), + ), +).pipe(Layer.provide(FSUtil.defaultLayer)) +const itWithInstructions = testEffect( + SystemContextBuiltIns.locationLayer.pipe( + Layer.provide(instructionFS), + Layer.provide(Global.layerWith({ config: "/global" })), + Layer.provide(locationLayer), + ), +) + +describe("SystemContextBuiltIns", () => { + it.effect("loads location-scoped environment and host-local date context", () => + Effect.gen(function* () { + yield* TestClock.setTime(timestamp) + const context = yield* SystemContextRegistry.Service + const initialized = yield* SystemContext.initialize(yield* context.load()) + + expect(initialized.baseline).toBe( + [ + "Here is some useful information about the environment you are running in:", + "", + ` Working directory: ${directory}`, + ` Workspace root folder: ${projectDirectory}`, + " Is directory a git repo: yes", + ` Platform: ${process.platform}`, + "", + "", + `Today's date: ${localDate(timestamp)}`, + ].join("\n"), + ) + }), + ) + + it.effect("reconciles the date without repeating unchanged environment context", () => + Effect.gen(function* () { + yield* TestClock.setTime(timestamp) + const context = yield* SystemContextRegistry.Service + const initialized = yield* SystemContext.initialize(yield* context.load()) + + yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000) + const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot) + + expect(refreshed).toMatchObject({ + _tag: "Updated", + text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`, + }) + }), + ) + + it.effect("does not update again within the same local calendar day", () => + Effect.gen(function* () { + yield* TestClock.setTime(timestamp) + const context = yield* SystemContextRegistry.Service + const initialized = yield* SystemContext.initialize(yield* context.load()) + + yield* TestClock.setTime(timestamp + 60 * 60 * 1000) + expect(yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)).toEqual({ _tag: "Unchanged" }) + }), + ) + + itWithInstructions.effect("composes ambient instructions after built-in context", () => + Effect.gen(function* () { + yield* TestClock.setTime(timestamp) + const context = yield* SystemContextRegistry.Service + + expect((yield* SystemContext.initialize(yield* context.load())).baseline).toBe( + [ + "Here is some useful information about the environment you are running in:", + "", + ` Working directory: ${directory}`, + ` Workspace root folder: ${projectDirectory}`, + " Is directory a git repo: yes", + ` Platform: ${process.platform}`, + "", + "", + `Today's date: ${localDate(timestamp)}`, + "", + `Instructions from: ${instructionFile}\nBe precise.`, + ].join("\n"), + ) + }), + ) +}) diff --git a/packages/core/test/system-context-registry.test.ts b/packages/core/test/system-context-registry.test.ts new file mode 100644 index 0000000000..9625276f1e --- /dev/null +++ b/packages/core/test/system-context-registry.test.ts @@ -0,0 +1,113 @@ +import { describe, expect } from "bun:test" +import { Cause, Effect, Exit, Schema, Scope } from "effect" +import { SystemContext } from "@opencode-ai/core/system-context" +import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" +import { testEffect } from "./lib/effect" + +const contribution = (key: string, text: string, sourceKey = key) => ({ + key: SystemContext.Key.make(key), + load: Effect.succeed( + SystemContext.make({ + key: SystemContext.Key.make(sourceKey), + codec: Schema.toCodecJson(Schema.String), + load: Effect.succeed(text), + baseline: String, + update: (_previous, current) => current, + }), + ), +}) + +const it = testEffect(SystemContextRegistry.layer) + +describe("SystemContextRegistry", () => { + it.effect("loads empty system context when there are no contributions", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + + expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} }) + }), + ) + + it.effect("loads scoped contributions in stable key order", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + yield* registry.contribute(contribution("test/second", "second")) + yield* registry.contribute(contribution("test/first", "first")) + + expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond") + }), + ) + + it.effect("re-evaluates contribution producers on each load", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + let loads = 0 + yield* registry.contribute({ + key: SystemContext.Key.make("test/dynamic"), + load: Effect.sync(() => { + loads++ + return SystemContext.empty + }), + }) + + yield* registry.load() + yield* registry.load() + + expect(loads).toBe(2) + }), + ) + + it.effect("propagates contribution producer failures", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + const failure = new Error("contribution failed") + yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) }) + + const exit = yield* registry.load().pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(failure) + }), + ) + + it.effect("rejects duplicate source keys from separate contributions", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + yield* registry.contribute(contribution("test/first", "first", "test/duplicate")) + yield* registry.contribute(contribution("test/second", "second", "test/duplicate")) + + const exit = yield* registry.load().pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.DuplicateKeyError) + expect(Cause.squash(exit.cause)).toMatchObject({ key: SystemContext.Key.make("test/duplicate") }) + } + }), + ) + + it.effect("rejects duplicate contribution keys", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + yield* registry.contribute(contribution("test/duplicate", "first")) + + const exit = yield* registry.contribute(contribution("test/duplicate", "second", "test/other")).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context contribution key") + }), + ) + + it.effect("removes a contribution when its owning scope closes", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + const scope = yield* Scope.make() + yield* registry.contribute(contribution("test/scoped", "scoped")).pipe(Scope.provide(scope)) + + expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped") + + yield* Scope.close(scope, Exit.void) + expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} }) + }), + ) +}) diff --git a/packages/core/test/system-context.test.ts b/packages/core/test/system-context.test.ts index e15843e63b..a20f8977b3 100644 --- a/packages/core/test/system-context.test.ts +++ b/packages/core/test/system-context.test.ts @@ -1,188 +1,307 @@ -import { describe, expect, test } from "bun:test" -import { Effect, Schema } from "effect" +import { describe, expect } from "bun:test" +import { Cause, Effect, Exit, Schema } from "effect" import { SystemContext } from "@opencode-ai/core/system-context" -import { Hash } from "@opencode-ai/core/util/hash" +import { it } from "./lib/effect" const key = SystemContext.Key.make +const stringContext = (input: { + key: string + value: string | SystemContext.Unavailable + baseline?: (value: string) => string + update?: (previous: string, current: string) => string + removed?: (value: string) => string +}) => + SystemContext.make({ + key: key(input.key), + codec: Schema.toCodecJson(Schema.String), + load: Effect.succeed(input.value), + baseline: input.baseline ?? String, + update: input.update ?? ((_previous, current) => current), + removed: input.removed, + }) describe("SystemContext", () => { - test("loads one coherent sample and initializes a deterministic baseline", async () => { - let loads = 0 - const context = SystemContext.struct({ - date: SystemContext.value({ + it.effect("stores the canonical JSON encoding of the loaded value", () => + Effect.gen(function* () { + const context = SystemContext.make({ key: key("core/date"), + codec: Schema.toCodecJson(Schema.DateFromString), + load: Effect.succeed(new Date("2026-06-03T12:00:00.000Z")), + baseline: (date) => date.toISOString(), + update: (_previous, date) => date.toISOString(), + removed: () => "Date removed", + }) + + expect((yield* SystemContext.initialize(context)).snapshot["core/date"].value).toBe("2026-06-03T12:00:00.000Z") + }), + ) + + it.effect("loads once and initializes a baseline with a structured snapshot", () => + Effect.gen(function* () { + let loads = 0 + const context = SystemContext.combine([ + SystemContext.make({ + key: key("core/date"), + codec: Schema.toCodecJson(Schema.String), + load: Effect.sync(() => { + loads++ + return "2026-06-03" + }), + baseline: (date) => `Today's date is ${date}.`, + update: (previous, current) => `The date changed from ${previous} to ${current}.`, + removed: () => "The date was removed.", + }), + stringContext({ key: "core/location", value: "/repo", baseline: (value) => `Directory: ${value}` }), + ]) + + expect(yield* SystemContext.initialize(context)).toEqual({ + baseline: "Today's date is 2026-06-03.\n\nDirectory: /repo", + snapshot: { + "core/date": { value: "2026-06-03", removed: "The date was removed." }, + "core/location": { value: "/repo" }, + }, + }) + expect(loads).toBe(1) + }), + ) + + it.effect("renders updates only after a structured value changes", () => + Effect.gen(function* () { + const previous = { + "core/date": { value: "2026-06-03", removed: "The date was removed." }, + "core/location": { value: "/repo", removed: "Removed: /repo" }, + } + const changed = SystemContext.combine([ + stringContext({ + key: "core/date", + value: "2026-06-04", + update: (before, current) => `The date changed from ${before} to ${current}.`, + removed: () => "The date was removed.", + }), + stringContext({ key: "core/location", value: "/repo" }), + ]) + + expect(yield* SystemContext.reconcile(changed, previous)).toEqual({ + _tag: "Updated", + text: "The date changed from 2026-06-03 to 2026-06-04.", + snapshot: { + "core/date": { value: "2026-06-04", removed: "The date was removed." }, + "core/location": { value: "/repo", removed: "Removed: /repo" }, + }, + }) + + expect( + yield* SystemContext.reconcile( + SystemContext.combine([ + stringContext({ key: "core/date", value: "2026-06-03", removed: () => "The date was removed." }), + stringContext({ key: "core/location", value: "/repo" }), + ]), + previous, + ), + ).toEqual({ _tag: "Unchanged" }) + }), + ) + + it.effect("uses the baseline for a newly added source", () => + Effect.gen(function* () { + const context = stringContext({ + key: "core/skills", + value: "effect", + baseline: (skill) => `Available skill: ${skill}`, + }) + + expect(yield* SystemContext.reconcile(context, {})).toEqual({ + _tag: "Updated", + text: "Available skill: effect", + snapshot: { "core/skills": { value: "effect" } }, + }) + }), + ) + + it.effect("retains admitted snapshots while a source is temporarily unavailable", () => + Effect.gen(function* () { + const previous = { "core/remote": { value: "instructions", removed: "Instructions removed" } } + const context = stringContext({ key: "core/remote", value: SystemContext.unavailable }) + + expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" }) + expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" }) + expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "ReplacementReady" }) + }), + ) + + it.effect("blocks initialization while a source is unavailable", () => + Effect.gen(function* () { + const exit = yield* SystemContext.initialize( + stringContext({ key: "core/remote", value: SystemContext.unavailable }), + ).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) + expect(Cause.squash(exit.cause)).toEqual( + new SystemContext.InitializationBlocked({ keys: [key("core/remote")] }), + ) + }), + ) + + it.effect("emits the previously stored removal message", () => + Effect.gen(function* () { + expect( + yield* SystemContext.reconcile(SystemContext.empty, { + "core/instructions": { value: "contents", removed: "Instructions removed; stop applying them." }, + }), + ).toEqual({ + _tag: "Updated", + text: "Instructions removed; stop applying them.", + snapshot: {}, + }) + }), + ) + + it.effect("requests replacement when a source without removal text disappears", () => + Effect.gen(function* () { + expect( + yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } }), + ).toMatchObject({ + _tag: "ReplacementReady", + }) + }), + ) + + it.effect("renders multiple removals in stable key order", () => + Effect.gen(function* () { + expect( + yield* SystemContext.reconcile(SystemContext.empty, { + "core/z": { value: "z", removed: "Removed z" }, + "core/a": { value: "a", removed: "Removed a" }, + }), + ).toMatchObject({ _tag: "Updated", text: "Removed a\n\nRemoved z" }) + }), + ) + + it.effect("rejects empty model-visible renderings", () => + Effect.gen(function* () { + const exit = yield* SystemContext.initialize( + stringContext({ key: "core/empty", value: "value", baseline: () => "" }), + ).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("rendered an empty baseline") + }), + ) + + it.effect("requests replacement when a stored value no longer decodes", () => + Effect.gen(function* () { + expect( + yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), { + "core/date": { value: 42, removed: "Date removed" }, + }), + ).toMatchObject({ _tag: "ReplacementReady" }) + }), + ) + + it.effect("replaces from one coherent source observation", () => + Effect.gen(function* () { + let loads = 0 + const context = SystemContext.make({ + key: key("core/date"), + codec: Schema.toCodecJson(Schema.String), load: Effect.sync(() => { loads++ - return { baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." } + return "2026-06-04" }), - }), - location: SystemContext.value({ - key: key("core/location"), - load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }), - }), - }) + baseline: String, + update: (_previous, current) => current, + }) - const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context))) + expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({ + _tag: "ReplacementReady", + generation: { baseline: "2026-06-04" }, + }) + expect(loads).toBe(1) + }), + ) - expect(loads).toBe(1) - expect(initialized).toEqual({ - baseline: [ - { key: key("core/date"), text: "Today's date is 2026-06-03." }, - { key: key("core/location"), text: "Working directory: /repo" }, - ], - checkpoint: { - "core/date": Hash.sha256("The current date is 2026-06-03."), - "core/location": Hash.sha256("The working directory is /repo."), - }, - }) - }) + it.effect("does not render discarded updates while replacing", () => + Effect.gen(function* () { + let updates = 0 + const context = SystemContext.combine([ + stringContext({ + key: "core/date", + value: "2026-06-04", + update: () => { + updates++ + return "updated" + }, + }), + stringContext({ key: "core/location", value: "/repo" }), + ]) - test("emits changed and newly registered components in declaration order", async () => { - const context = SystemContext.struct({ - date: SystemContext.value({ - key: key("core/date"), - load: Effect.succeed({ baseline: "Today's date is 2026-06-04.", update: "The current date is 2026-06-04." }), - }), - location: SystemContext.value({ - key: key("core/location"), - load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }), - }), - skills: SystemContext.value({ - key: key("core/skills"), - load: Effect.succeed({ baseline: "Available skills: effect", update: "Available skills: effect" }), - }), - }) + expect( + yield* SystemContext.reconcile(context, { + "core/date": { value: "2026-06-03" }, + "core/location": { value: 42 }, + }), + ).toMatchObject({ _tag: "ReplacementReady" }) + expect(updates).toBe(0) + }), + ) - const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), { - "core/date": Hash.sha256("The current date is 2026-06-03."), - "core/location": Hash.sha256("The working directory is /repo."), - }) + it.effect("blocks an incompatible replacement while another admitted source is unavailable", () => + Effect.gen(function* () { + const previous = { + "core/date": { value: 42, removed: "Date removed" }, + "core/remote": { value: "instructions", removed: "Instructions removed" }, + } + const context = SystemContext.combine([ + stringContext({ key: "core/date", value: "2026-06-04" }), + stringContext({ key: "core/remote", value: SystemContext.unavailable }), + ]) - expect(refreshed).toEqual({ - changes: [ - { key: key("core/date"), text: "The current date is 2026-06-04." }, - { key: key("core/skills"), text: "Available skills: effect" }, - ], - checkpoint: { - "core/date": Hash.sha256("The current date is 2026-06-04."), - "core/location": Hash.sha256("The working directory is /repo."), - "core/skills": Hash.sha256("Available skills: effect"), - }, - }) - expect(SystemContext.render(refreshed.changes)).toBe("The current date is 2026-06-04.\n\nAvailable skills: effect") - }) + expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "ReplacementBlocked" }) + expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" }) + }), + ) - test("omits unavailable initial context and admits it after its first successful load", async () => { - let available = false - const context = SystemContext.struct({ - remote: SystemContext.value({ - key: key("core/remote-instructions"), - load: Effect.sync(() => - available - ? { baseline: "Remote instructions: available", update: "Remote instructions are now available." } - : SystemContext.unavailable, - ), - }), - }) + it.effect("rejects duplicate source keys", () => + Effect.sync(() => { + expect(() => + SystemContext.combine([ + stringContext({ key: "core/date", value: "one" }), + stringContext({ key: "core/date", value: "two" }), + ]), + ).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") })) + }), + ) - const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context))) - available = true - const refreshed = SystemContext.refresh( - await Effect.runPromise(SystemContext.load(context)), - initialized.checkpoint, - ) + it.effect("combines contexts in order", () => + Effect.gen(function* () { + expect( + (yield* SystemContext.initialize( + SystemContext.combine([ + stringContext({ key: "core/date", value: "date" }), + stringContext({ key: "core/location", value: "location" }), + ]), + )).baseline, + ).toBe("date\n\nlocation") + }), + ) - expect(initialized).toEqual({ baseline: [], checkpoint: {} }) - expect(refreshed.changes).toEqual([ - { key: key("core/remote-instructions"), text: "Remote instructions are now available." }, - ]) - }) + it.effect("requires namespaced source keys", () => + Effect.sync(() => { + const decodeKey = Schema.decodeUnknownSync(SystemContext.Key) - test("retains an existing checkpoint while context is unavailable", async () => { - const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") } - const context = SystemContext.struct({ - remote: SystemContext.value({ - key: key("core/remote-instructions"), - load: Effect.succeed(SystemContext.unavailable), - }), - }) + expect(decodeKey("core/date")).toBe(key("core/date")) + expect(() => decodeKey("date")).toThrow() + }), + ) - const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous) + it.effect("requires namespaced durable snapshot keys", () => + Effect.sync(() => { + const decodeSnapshot = Schema.decodeUnknownSync(SystemContext.Snapshot) - expect(refreshed).toEqual({ changes: [], checkpoint: previous }) - }) - - test("drops checkpoints for removed components", async () => { - const context = SystemContext.struct({ - date: SystemContext.value({ - key: key("core/date"), - load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }), - }), - }) - - const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), { - "core/date": Hash.sha256("The current date is 2026-06-03."), - "plugin/removed": Hash.sha256("Removed plugin context"), - }) - - expect(refreshed).toEqual({ - changes: [], - checkpoint: { "core/date": Hash.sha256("The current date is 2026-06-03.") }, - }) - }) - - test("ignores inherited checkpoint properties", async () => { - const context = SystemContext.struct({ - date: SystemContext.value({ - key: key("core/date"), - load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }), - }), - }) - const previous = Object.create({ - "core/date": Hash.sha256("The current date is 2026-06-03."), - }) as SystemContext.Checkpoint - - const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous) - - expect(refreshed.changes).toEqual([{ key: key("core/date"), text: "The current date is 2026-06-03." }]) - expect(Object.hasOwn(refreshed.checkpoint, "core/date")).toBe(true) - }) - - test("preserves unexpected loader failures", async () => { - const context = SystemContext.struct({ - broken: SystemContext.value({ - key: key("plugin/broken"), - load: Effect.fail("broken loader"), - }), - }) - - await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBe("broken loader") - }) - - test("rejects duplicate component keys", () => { - expect(() => - SystemContext.struct({ - one: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "one", update: "one" }) }), - two: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "two", update: "two" }) }), - }), - ).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") })) - }) - - test("rejects duplicate component keys at the interpreter boundary", async () => { - const component = SystemContext.value({ - key: key("core/date"), - load: Effect.succeed({ baseline: "date", update: "date" }), - }) - const context: SystemContext.SystemContext = { components: [component, component] } - - await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBeInstanceOf(SystemContext.DuplicateKeyError) - }) - - test("requires namespaced component keys", () => { - const decode = Schema.decodeUnknownSync(SystemContext.Key) - - expect(decode("core/date")).toBe(key("core/date")) - expect(() => decode("date")).toThrow() - expect(() => decode("core/")).toThrow() - }) + expect(Object.keys(decodeSnapshot({ "core/date": { value: "date" } }))).toEqual(["core/date"]) + expect(() => decodeSnapshot({ date: { value: "date" } })).toThrow() + expect(() => decodeSnapshot({ "core/date": { value: "date", removed: "" } })).toThrow() + }), + ) }) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 330089ae95..d543e659ca 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -351,38 +351,29 @@ const endsInServerToolUse = (message: LLMRequest["messages"][number]) => { return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true } -const endsInLocalToolUse = (message: LLMRequest["messages"][number]) => { - const last = message.content.at(-1) - return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted !== true -} - -const validateNativeSystemUpdate = Effect.fn("AnthropicMessages.validateNativeSystemUpdate")(function* ( - messages: LLMRequest["messages"], - index: number, -) { +const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => { const previous = messages[index - 1] const next = messages[index + 1] - if (!previous) - return yield* invalid( - "Anthropic Messages chronological system updates cannot be the first message; use LLMRequest.system", - ) - if (previous.role === "system") - return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive") - if (endsInLocalToolUse(previous)) - return yield* invalid( - "Anthropic Messages chronological system updates cannot appear between a local tool call and its tool result", - ) - if (previous.role !== "user" && previous.role !== "tool" && !endsInServerToolUse(previous)) - return yield* invalid( - "Anthropic Messages chronological system updates must follow a user message, tool result, or assistant server tool use", - ) - if (next?.role === "system") - return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive") - if (next && next.role !== "assistant") - return yield* invalid( - "Anthropic Messages chronological system updates must end the messages array or immediately precede an assistant message", - ) -}) + return ( + previous !== undefined && + previous.role !== "system" && + (previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) && + next?.role !== "system" && + (next === undefined || next.role === "assistant") + ) +} + +const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number) => { + const pending = new Set() + for (const message of messages.slice(0, index)) { + for (const part of message.content) { + if (message.role === "assistant" && part.type === "tool-call" && part.providerExecuted !== true) + pending.add(part.id) + if (message.role === "tool" && part.type === "tool-result") pending.delete(part.id) + } + } + return pending.size > 0 +} const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* ( message: LLMRequest["messages"][number], @@ -407,8 +398,9 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( for (const [index, message] of request.messages.entries()) { if (message.role === "system") { - if (supportsNativeSystemUpdates(request)) { - yield* validateNativeSystemUpdate(request.messages, index) + if (splitsLocalToolResults(request.messages, index)) + return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result") + if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) { messages.push(yield* lowerNativeSystemUpdate(message, breakpoints)) continue } diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 50fced0de3..1cd8f4dd9e 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -125,26 +125,65 @@ describe("Anthropic Messages route", () => { }), ) - it.effect("rejects invalid native chronological system update placement", () => + it.effect("falls back for unsupported native chronological system update placement", () => Effect.gen(function* () { - const placementError = (messages: Parameters[0]["messages"]) => - LLMClient.prepare(LLM.request({ model: opus48, messages, cache: "none" })).pipe(Effect.flip) + expect( + (yield* LLMClient.prepare( + LLM.request({ + model: opus48, + messages: [Message.assistant("Plain."), Message.system("After plain assistant.")], + cache: "none", + }), + )).body.messages, + ).toEqual([ + { role: "assistant", content: [{ type: "text", text: "Plain." }] }, + { + role: "user", + content: [{ type: "text", text: "\nAfter plain assistant.\n" }], + }, + ]) + expect( + (yield* LLMClient.prepare( + LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }), + )).body.messages, + ).toEqual([{ role: "user", content: [{ type: "text", text: "\nFirst.\n" }] }]) + expect( + (yield* LLMClient.prepare( + LLM.request({ + model: opus48, + messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")], + cache: "none", + }), + )).body.messages, + ).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "Before." }, + { type: "text", text: "\nOne.\n" }, + { type: "text", text: "\nTwo.\n" }, + ], + }, + ]) + }), + ) - expect((yield* placementError([Message.system("First.")])).message).toContain("cannot be the first message") - expect( - (yield* placementError([Message.user("Before."), Message.system("One."), Message.system("Two.")])).message, - ).toContain("cannot be consecutive") - expect( - (yield* placementError([Message.assistant("Plain."), Message.system("After plain assistant.")])).message, - ).toContain("must follow a user message, tool result, or assistant server tool use") - expect( - (yield* placementError([ - Message.user("Use the tool."), - Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), - Message.system("Too early."), - Message.tool({ id: "call_1", name: "lookup", result: "Done." }), - ])).message, - ).toContain("cannot appear between a local tool call and its tool result") + it.effect("rejects a system update between a local tool call and its result", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + model: opus48, + messages: [ + Message.user("Use the tool."), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.system("Too early."), + Message.tool({ id: "call_1", name: "lookup", result: "Done." }), + ], + cache: "none", + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("system updates cannot split a local tool call from its tool result") }), ) diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index 79d8589cf8..9b95f678fd 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -123,7 +123,11 @@ describeRecordedGoldenScenarios([ prefix: "gemini", model: gemini, requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], - scenarios: [{ id: "text", maxTokens: 80 }, "tool-call", { id: "image", maxTokens: 160 }], + scenarios: [ + { id: "text", maxTokens: 80 }, + "tool-call", + { id: "image", maxTokens: 160 }, + ], }, { name: "xAI Grok 3 Mini", diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 1fc01ae76e..545a3b983a 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -1,5 +1,5 @@ import { expect } from "bun:test" -import { Effect, Schema, Stream } from "effect" +import { Effect, Schema } from "effect" import { LLM, LLMEvent, diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index ff1bc448b9..84ceb0e5d8 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIResponses from "../src/protocols/openai-responses" -import { ContentPart, LLMEvent, LLMRequest, Model, ModelID, ProviderID, Usage } from "../src/schema" +import { + ContentPart, + LLMEvent, + LLMRequest, + Model, + ModelID, + ProviderID, + Usage, +} from "../src/schema" import { ProviderShared } from "../src/protocols/shared" const model = new Model({ @@ -43,17 +51,6 @@ describe("llm schema", () => { expect(decoded.model.route.id).toBe("openai-responses") }) - test("decodes chronological system messages", () => { - const decoded = decodeLLMRequest({ - model, - system: [], - messages: [{ role: "system", content: [{ type: "text", text: "Operator update." }] }], - tools: [], - }) - - expect(decoded.messages[0]).toMatchObject({ role: "system", content: [{ type: "text", text: "Operator update." }] }) - }) - test("rejects invalid event type", () => { expect(() => decodeLLMEvent({ type: "bogus" })).toThrow() }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index be13d2007d..31ff7d7478 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -175,6 +175,16 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( }) }) break + case "session.next.context.updated": + update(event.properties.sessionID, (draft) => { + prepend(draft, { + id: event.properties.messageID, + type: "system", + text: event.properties.text, + time: { created: event.properties.timestamp }, + }) + }) + break case "session.next.synthetic": update(event.properties.sessionID, (draft) => { prepend(draft, { diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 9f2008d2b5..2e54a7365d 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -104,6 +104,9 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { <> + + <> + diff --git a/packages/opencode/test/cli/tui/sync-v2.test.tsx b/packages/opencode/test/cli/tui/sync-v2.test.tsx index 1b995d2943..b37d86a703 100644 --- a/packages/opencode/test/cli/tui/sync-v2.test.tsx +++ b/packages/opencode/test/cli/tui/sync-v2.test.tsx @@ -261,6 +261,55 @@ test("sync v2 renders a promoted prompt when admission was missed", async () => } }) +test("sync v2 projects live context updates with their message ID", async () => { + const events = createEventSource() + const calls = createFetch() + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await mounted + emitTwice(events, { + id: "evt_context_1", + type: "session.next.context.updated", + properties: { + sessionID: "session-1", + messageID: "msg_context_1", + timestamp: 1, + text: "Updated context", + }, + }) + + await wait(() => sync.session.message.fromSession("session-1").length === 1) + expect(sync.session.message.fromSession("session-1")[0]).toMatchObject({ + id: "msg_context_1", + type: "system", + text: "Updated context", + }) + } finally { + app.renderer.destroy() + } +}) + test("sync v2 preserves live events while snapshot hydration is in flight", async () => { const events = createEventSource() const response = Promise.withResolvers() diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f3a9cca90a..69ab21185f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -21,6 +21,7 @@ export type Event = | EventSessionNextPrompted | EventSessionNextPromptAdmitted | EventSessionNextPromptPromoted + | EventSessionNextContextUpdated | EventSessionNextSynthetic | EventSessionNextShellStarted | EventSessionNextShellEnded @@ -867,6 +868,16 @@ export type GlobalEvent = { timeCreated: number } } + | { + id: string + type: "session.next.context.updated" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } + } | { id: string type: "session.next.synthetic" @@ -1615,6 +1626,7 @@ export type GlobalEvent = { | SyncEventSessionNextPrompted | SyncEventSessionNextPromptAdmitted | SyncEventSessionNextPromptPromoted + | SyncEventSessionNextContextUpdated | SyncEventSessionNextSynthetic | SyncEventSessionNextShellStarted | SyncEventSessionNextShellEnded @@ -3259,6 +3271,23 @@ export type SyncEventSessionNextPromptPromoted = { } } +export type SyncEventSessionNextContextUpdated = { + type: "sync" + id: string + syncEvent: { + type: "session.next.context.updated.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } + } +} + export type SyncEventSessionNextSynthetic = { type: "sync" id: string @@ -3822,6 +3851,18 @@ export type SessionMessageSynthetic = { type: "synthetic" } +export type SessionMessageSystem = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "system" + text: string +} + export type SessionMessageShell = { id: string metadata?: { @@ -3980,6 +4021,7 @@ export type SessionMessage = | SessionMessageModelSwitched | SessionMessageUser | SessionMessageSynthetic + | SessionMessageSystem | SessionMessageShell | SessionMessageAssistant | SessionMessageCompaction @@ -4339,6 +4381,17 @@ export type EventSessionNextPromptPromoted = { } } +export type EventSessionNextContextUpdated = { + id: string + type: "session.next.context.updated" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + export type EventSessionNextSynthetic = { id: string type: "session.next.synthetic" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0a3d5fac38..6e350a71e2 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -12026,6 +12026,9 @@ { "$ref": "#/components/schemas/EventSessionNextPromptPromoted" }, + { + "$ref": "#/components/schemas/EventSessionNextContextUpdated" + }, { "$ref": "#/components/schemas/EventSessionNextSynthetic" }, @@ -14621,6 +14624,42 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.context.updated"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -17142,6 +17181,9 @@ { "$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted" }, + { + "$ref": "#/components/schemas/SyncEventSessionNextContextUpdated" + }, { "$ref": "#/components/schemas/SyncEventSessionNextSynthetic" }, @@ -21870,6 +21912,63 @@ "required": ["type", "id", "syncEvent"], "additionalProperties": false }, + "SyncEventSessionNextContextUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "syncEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["session.next.context.updated.1"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + } + }, + "required": ["type", "id", "syncEvent"], + "additionalProperties": false + }, "SyncEventSessionNextSynthetic": { "type": "object", "properties": { @@ -23634,6 +23733,37 @@ "required": ["id", "time", "sessionID", "text", "type"], "additionalProperties": false }, + "SessionMessageSystem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["system"] + }, + "text": { + "type": "string" + } + }, + "required": ["id", "time", "type", "text"], + "additionalProperties": false + }, "SessionMessageShell": { "type": "object", "properties": { @@ -24071,6 +24201,9 @@ { "$ref": "#/components/schemas/SessionMessageSynthetic" }, + { + "$ref": "#/components/schemas/SessionMessageSystem" + }, { "$ref": "#/components/schemas/SessionMessageShell" }, @@ -25174,6 +25307,42 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventSessionNextContextUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.context.updated"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSessionNextSynthetic": { "type": "object", "properties": { diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 33a619b222..5c5980a2ed 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -686,3 +686,85 @@ Compatibility: - Foreground V2 bash execution is unchanged. - Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics. + +## 2026-06-04: Add Durable Session Context Snapshots + +Affected schema: + +- Add `session_context_epoch` for one active immutable baseline string, structured JSON snapshot, and baseline sequence per Session. + +Change: + +- Lazily initialize one durable Context Epoch snapshot at the first safe provider-turn boundary. +- Lower its exact baseline string through `LLMRequest.system` for every provider turn in the epoch. +- Reuse the stored baseline verbatim after restart or producer changes instead of resampling privileged initial context. +- Compare later observations against an overwriteable codec-encoded structured snapshot rather than rendered-text hashes. +- Expose admitted chronological context as first-class `system` Session messages while keeping the active baseline in bounded context state. + +Compatibility: + +- The unpublished Context Epoch schema is consolidated into one database migration; baseline and structured snapshots are operational state rather than synchronized event history. +- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes. +- Chronological context updates, replacement epochs after compaction or model switches, project instructions, skills guidance, and plugin transforms remain follow-up slices. + +## 2026-06-04: Admit Chronological Session Context Updates + +Affected schema: + +- Add synchronized `session.next.context.updated.1` Session events containing a durable System-message ID and only exact combined model-visible text. +- Add `session_context_epoch.revision` for transactional structured-snapshot advancement. +- Add the first-class `system` Session message projection for chronological context updates. + +Change: + +- Reconcile Location-scoped Context Sources at each safe provider-turn boundary using one coherent observation. +- Keep the stored baseline immutable while admitting changed source renderings as chronological `Message.system(...)` history. +- Advance the overwriteable structured snapshot atomically with the rendered System-message event. +- Emit the previously stored model-meaningful removal rendering when a source is removed. +- Reject chronological system updates that would split a local tool call from its result across provider protocols; use wrapped user fallback when Anthropic native system-update placement is unsupported. + +Compatibility: + +- The synchronized event log retains only text actually shown to the model, not internal structured snapshots. +- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes. +- Replacement epochs after compaction or model switches, skills guidance, and plugin-defined context remain follow-up slices. + +## 2026-06-04: Replace Session Context Epochs Lazily + +Affected schema: + +- Add nullable `session_context_epoch.replacement_seq` for idempotent lazy replacement requests. + +Change: + +- Mark the active Context Epoch for replacement after a model switch or completed compaction projection. +- Persist the triggering aggregate sequence so same-target replay cannot reopen an already-settled replacement. +- Render and overwrite the fresh immutable baseline and structured snapshot lazily at the next safe provider-turn boundary. +- Exclude chronological System messages from earlier epochs when assembling active provider history. + +Compatibility: + +- Baseline replacement is bounded operational state and does not add permanent synchronized events. +- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes. +- Compaction execution, skills guidance, and plugin-defined context remain follow-up slices. + +## 2026-06-05: Register Ambient System Context Producers + +Affected schema: + +- No database schema changes. + +Change: + +- Replace the Session-specific context loader with a Location-scoped registry of stable-keyed scoped context producers. +- Register environment/date and ambient instruction producers independently, then evaluate producers concurrently in stable contribution-key order. +- Directly discover and read global plus upward project `AGENTS.md` files at each safe provider-turn boundary. +- Preserve admitted instructions across transient scan/read failures and block first-epoch initialization while any context source is unavailable. +- Retry Context Epoch preparation until stable after optimistic revision mismatches. +- Clear the active Context Epoch when a Session moves so the destination initializes a complete baseline before promoting more input. +- Fence Context Epoch initialization against the authoritative Session Location so a concurrent old-Location runner cannot recreate stale privileged context after a move. +- Canonicalize ambient instruction traversal boundaries, honor `OPENCODE_DISABLE_PROJECT_CONFIG`, and make non-empty aggregate updates explicitly supersede previously loaded instructions. + +Compatibility: + +- Watcher-backed per-file `Refreshable` instruction observations, configured sources, nested discovery, and plugin-defined context remain follow-up slices. diff --git a/specs/v2/session.md b/specs/v2/session.md index 56384e78bd..00f7c2ba72 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -37,6 +37,66 @@ The local runner issues one explicit `llm.stream(request)` per provider turn, pr Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted. +## Context Epochs + +V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch owns one immutable baseline plus a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, and ambient global/upward-project `AGENTS.md` files are the initial registered sources. + +The first complete observation initializes the epoch before any pending prompt becomes model-visible. If initial context is temporarily unavailable, execution stops while the prompt remains pending and retryable. On later provider turns, the runner promotes eligible input first, then reconciles current sources at the safe boundary. Changed context becomes one durable chronological System message, and its event commit advances the epoch snapshot atomically. + +```text +Client Runner System Context Registry Context Epoch Store Session History LLM + │ │ │ │ │ │ + ├─ Admit prompt ─────────────────────────────────────────────────────────────────────────────────────────────▶ │ + │ │ │ │ │ │ + │ ├─ Observe initial context ────────────▶ │ │ │ + │ │ │ │ │ │ + │ ◀─ Complete baseline or unavailable ───┤ │ │ │ + │ │ │ │ │ │ + │ ├─ Initialize missing epoch ───────────────────────────────────────▶ │ │ + │ │ │ │ │ │ + │ ├─ Promote eligible input ─────────────────────────────────────────────────────────────────▶ │ + │ │ │ │ │ │ + │ ├─ Reconcile at safe boundary ─────────▶ │ │ │ + │ │ │ │ │ │ + │ ◀─ Unchanged or chronological update ──┤ │ │ │ + │ │ │ │ │ │ + │ ├─ Advance snapshot atomically with update ────────────────────────▶ │ │ + │ │ │ │ │ │ + │ ├─ Baseline + chronological history ─────────────────────────────────────────────────────────────────────────▶ +``` + +Model switches and completed compactions request lazy baseline replacement. A Session move clears the epoch so the destination Location must initialize a complete baseline before promoting more input. Epoch creation is fenced against the authoritative Session Location, preventing an old-Location runner from recreating stale privileged context after a concurrent move. + +```text +Session Epoch + │ │ + ├─ initialize complete baseline ──▶ + │ │ + │ ├─────────────────────────────────╮ + │ │ reconcile chronological update │ + │ ◀─────────────────────────────────╯ + │ │ + ├─ request replacement ───────────▶ + │ │ + │ ├─────────────────────────────────────╮ + │ │ replace after complete observation │ + │ ◀─────────────────────────────────────╯ + │ │ + ├─ clear after Location move ─────▶ +``` + +Ambient project discovery canonicalizes and contains traversal within the project root and honors `OPENCODE_DISABLE_PROJECT_CONFIG`. An unavailable observation preserves the previously admitted value. A confirmed partial instruction removal emits the complete remaining aggregate with explicit supersession text; removing the final instruction emits a revocation message. + +Current Context Epoch follow-ups: + +- Add configured, remote, and nested instruction sources with explicit precedence and removal semantics. +- Add durable post-crash activity recovery for promoted or provider-dispatched work. +- Integrate actual automatic/context-pressure compaction with epoch replacement. +- Add operational metrics for observation latency, unavailable sources, contention, baseline size, and chronological-update growth. +- Consider watcher-backed per-file caching only if measurements show direct safe-boundary observation is too expensive. +- Expose plugin-defined Context Sources only after plugin reload and scoped cleanup semantics are designed. +- Add clustered Session execution ownership and stale-runtime fencing. + Provider timeout, retry, and watchdog policy is intentionally deferred. The runner does not impose a universal provider-stream inactivity or absolute timeout. A future slice should design configurable policy around provider behavior, durable failure reporting, and local drain-chain release rather than hardcoding one default for every provider. Inbox delivery is explicit: From 8c0edca175d450212031fce754978459a0ec0b82 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 5 Jun 2026 03:28:04 +0000 Subject: [PATCH 020/626] chore: generate --- packages/core/src/instruction-context.ts | 3 ++- packages/core/src/session/context-epoch.ts | 11 +++++++++-- packages/core/src/session/runner/llm.ts | 3 ++- packages/core/test/instruction-context.test.ts | 16 +++++++--------- .../llm/test/provider/golden.recorded.test.ts | 6 +----- packages/llm/test/schema.test.ts | 10 +--------- 6 files changed, 22 insertions(+), 27 deletions(-) diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index db15ebd4a3..f34a016da2 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -31,7 +31,8 @@ export const layer = Layer.effectDiscard( codec: Schema.toCodecJson(Files), load: Effect.succeed(value), baseline: render, - update: (_previous, current) => `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`, + update: (_previous, current) => + `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`, removed: () => "Previously loaded instructions no longer apply.", }) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 22ce8d5d40..21fbacc8b4 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -141,8 +141,15 @@ export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacem .pipe(Effect.orDie) }) -export const reset = Effect.fn("SessionContextEpoch.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { - yield* db.delete(SessionContextEpochTable).where(eq(SessionContextEpochTable.session_id, sessionID)).run().pipe(Effect.orDie) +export const reset = Effect.fn("SessionContextEpoch.reset")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, +) { + yield* db + .delete(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .run() + .pipe(Effect.orDie) }) const insert = Effect.fnUntraced(function* ( diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 0f1880205d..f4b62e09fc 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -144,7 +144,8 @@ export const layer = Layer.effect( yield* SessionInput.promoteSteers(db, events, session.id, cutoff) } } - const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id, session.location)) + const system = + initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id, session.location)) const context = yield* store.runnerContext(session.id, system.baselineSeq) const request = LLM.request({ model, diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index 88f97ed2bd..bd2035ff3e 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -219,16 +219,17 @@ describe("InstructionContext", () => { Layer.succeed( Location.Service, Location.Service.of( - location( - { directory: AbsolutePath.make("/repo/") }, - { projectDirectory: AbsolutePath.make("/repo") }, - ), + location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }), ), ), ), ) - expect(observed).toEqual({ targets: ["AGENTS.md"], start: FSUtil.resolve("/repo"), stop: FSUtil.resolve("/repo") }) + expect(observed).toEqual({ + targets: ["AGENTS.md"], + start: FSUtil.resolve("/repo"), + stop: FSUtil.resolve("/repo"), + }) }), ) @@ -284,10 +285,7 @@ describe("InstructionContext", () => { Layer.succeed( Location.Service, Location.Service.of( - location( - { directory: AbsolutePath.make("/outside") }, - { projectDirectory: AbsolutePath.make("/repo") }, - ), + location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }), ), ), ), diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index 9b95f678fd..79d8589cf8 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -123,11 +123,7 @@ describeRecordedGoldenScenarios([ prefix: "gemini", model: gemini, requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], - scenarios: [ - { id: "text", maxTokens: 80 }, - "tool-call", - { id: "image", maxTokens: 160 }, - ], + scenarios: [{ id: "text", maxTokens: 80 }, "tool-call", { id: "image", maxTokens: 160 }], }, { name: "xAI Grok 3 Mini", diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index 84ceb0e5d8..3c6628c2e5 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -2,15 +2,7 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIResponses from "../src/protocols/openai-responses" -import { - ContentPart, - LLMEvent, - LLMRequest, - Model, - ModelID, - ProviderID, - Usage, -} from "../src/schema" +import { ContentPart, LLMEvent, LLMRequest, Model, ModelID, ProviderID, Usage } from "../src/schema" import { ProviderShared } from "../src/protocols/shared" const model = new Model({ From 3003867c2559b0a21cb46f6af03aae4558dbfcf0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 23:40:52 -0400 Subject: [PATCH 021/626] feat(tui): allow backgrounding synchronous subagents (#30488) --- packages/core/src/background-job.ts | 90 ++++++++++++++++- packages/core/test/fixture/tmpdir.ts | 3 +- packages/opencode/src/background/job.ts | 2 + packages/opencode/src/cli/cmd/run.ts | 2 + packages/opencode/src/cli/cmd/run/footer.ts | 4 + .../opencode/src/cli/cmd/run/footer.view.tsx | 36 +++++++ .../src/cli/cmd/run/runtime.lifecycle.ts | 4 + packages/opencode/src/cli/cmd/run/runtime.ts | 9 ++ .../opencode/src/cli/cmd/run/subagent-data.ts | 2 + packages/opencode/src/cli/cmd/run/types.ts | 2 + .../src/cli/cmd/tui/config/keybind.ts | 2 + .../src/cli/cmd/tui/routes/session/index.tsx | 46 +++++++++ .../instance/httpapi/groups/experimental.ts | 15 +++ .../instance/httpapi/handlers/experimental.ts | 21 ++++ .../server/routes/instance/httpapi/server.ts | 2 + .../src/server/shared/workspace-routing.ts | 4 +- packages/opencode/src/session/run-state.ts | 1 - packages/opencode/src/tool/task.ts | 59 ++++++++---- packages/opencode/test/background/job.test.ts | 53 +++++++++- .../test/cli/run/footer.view.test.tsx | 4 +- .../test/server/httpapi-exercise/index.ts | 11 +++ .../test/server/session-actions.test.ts | 19 ++++ .../test/server/workspace-routing.test.ts | 5 + packages/opencode/test/tool/task.test.ts | 96 ++++++++++++++++++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 38 ++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 32 +++++++ 26 files changed, 527 insertions(+), 35 deletions(-) diff --git a/packages/core/src/background-job.ts b/packages/core/src/background-job.ts index f463273309..35724eb8fd 100644 --- a/packages/core/src/background-job.ts +++ b/packages/core/src/background-job.ts @@ -25,6 +25,9 @@ type Active = { pending: number next: number output?: { sequence: number; text: string } + tail: Deferred.Deferred + promoted: Deferred.Deferred + onPromote?: Effect.Effect } type State = { @@ -38,15 +41,31 @@ type FinishResult = { scope?: Scope.Closeable } +type PromoteResult = { + info?: Info + promoted?: Deferred.Deferred + onPromote?: Effect.Effect +} + type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object } -type ExtendResult = { extended: false } | { extended: true; scope: Scope.Closeable; token: object; sequence: number } +type ExtendResult = + | { extended: false } + | { + extended: true + previous: Deferred.Deferred + scope: Scope.Closeable + tail: Deferred.Deferred + token: object + sequence: number + } export type StartInput = { id?: string type: string title?: string metadata?: Record + onPromote?: Effect.Effect run: Effect.Effect } @@ -71,6 +90,8 @@ export interface Interface { readonly start: (input: StartInput) => Effect.Effect readonly extend: (input: ExtendInput) => Effect.Effect readonly wait: (input: WaitInput) => Effect.Effect + readonly waitForPromotion: (id: string) => Effect.Effect + readonly promote: (id: string) => Effect.Effect readonly cancel: (id: string) => Effect.Effect } @@ -128,6 +149,7 @@ export const make = Effect.gen(function* () { : "error" const next = { ...job, + onPromote: undefined, pending: 0, output, info: { @@ -182,6 +204,8 @@ export const make = Effect.gen(function* () { const id = input.id ?? Identifier.ascending("job") const started_at = yield* Clock.currentTimeMillis const done = yield* Deferred.make() + const promoted = yield* Deferred.make() + const tail = yield* Deferred.make() const result = yield* SynchronizedRef.modifyEffect( state.jobs, Effect.fnUntraced(function* (jobs) { @@ -205,6 +229,9 @@ export const make = Effect.gen(function* () { token, pending: 1, next: 1, + tail, + promoted, + onPromote: input.onPromote, } return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [ StartResult, @@ -212,7 +239,14 @@ export const make = Effect.gen(function* () { ] }), ) - if ("scope" in result) yield* fork(result.scope, id, result.token, 0, restore(input.run)) + if ("scope" in result) + yield* fork( + result.scope, + id, + result.token, + 0, + restore(input.run).pipe(Effect.ensuring(Deferred.succeed(tail, undefined))), + ) return result.info }), ) @@ -221,23 +255,34 @@ export const make = Effect.gen(function* () { const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) { return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { + const tail = yield* Deferred.make() const result = yield* SynchronizedRef.modify( state.jobs, (jobs): readonly [ExtendResult, Map] => { const job = jobs.get(input.id) if (!job || job.info.status !== "running") return [{ extended: false }, jobs] return [ - { extended: true, scope: job.scope, token: job.token, sequence: job.next }, + { extended: true, previous: job.tail, scope: job.scope, tail, token: job.token, sequence: job.next }, new Map(jobs).set(input.id, { ...job, pending: job.pending + 1, next: job.next + 1, + tail, }), ] }, ) if (!result.extended) return false - yield* fork(result.scope, input.id, result.token, result.sequence, restore(input.run)) + yield* fork( + result.scope, + input.id, + result.token, + result.sequence, + Deferred.await(result.previous).pipe( + Effect.andThen(restore(input.run)), + Effect.ensuring(Deferred.succeed(result.tail, undefined)), + ), + ) return true }), ) @@ -254,6 +299,40 @@ export const make = Effect.gen(function* () { return { info: snapshot(job), timedOut: true } }) + const waitForPromotion: Interface["waitForPromotion"] = Effect.fn("BackgroundJob.waitForPromotion")(function* (id) { + const job = (yield* SynchronizedRef.get(state.jobs)).get(id) + if (!job || job.info.status !== "running") return yield* Effect.never + if (job.info.metadata?.background === true) return snapshot(job) + return yield* Deferred.await(job.promoted) + }) + + const promote: Interface["promote"] = Effect.fn("BackgroundJob.promote")(function* (id) { + const result = yield* SynchronizedRef.modifyEffect( + state.jobs, + Effect.fnUntraced(function* (jobs) { + const job = jobs.get(id) + if (!job || job.info.status !== "running") return [{}, jobs] as readonly [PromoteResult, Map] + if (job.info.metadata?.background === true) + return [{ info: snapshot(job) }, jobs] as readonly [PromoteResult, Map] + const next = { + ...job, + onPromote: undefined, + info: { + ...job.info, + metadata: { ...job.info.metadata, background: true }, + }, + } + return [ + { info: snapshot(next), onPromote: job.onPromote, promoted: job.promoted }, + new Map(jobs).set(id, next), + ] as readonly [PromoteResult, Map] + }), + ) + if (result.info && result.promoted) yield* Deferred.succeed(result.promoted, result.info).pipe(Effect.ignore) + if (result.onPromote) yield* result.onPromote.pipe(Effect.ignore) + return result.info + }) + const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) { const completed_at = yield* Clock.currentTimeMillis const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map] => { @@ -262,6 +341,7 @@ export const make = Effect.gen(function* () { if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs] const next = { ...job, + onPromote: undefined, pending: 0, info: { ...job.info, @@ -276,7 +356,7 @@ export const make = Effect.gen(function* () { return result.info }) - return Service.of({ list, get, start, extend, wait, cancel }) + return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel }) }) export const layer = Layer.effect(Service, make) diff --git a/packages/core/test/fixture/tmpdir.ts b/packages/core/test/fixture/tmpdir.ts index 7ee31a002b..81d5d01667 100644 --- a/packages/core/test/fixture/tmpdir.ts +++ b/packages/core/test/fixture/tmpdir.ts @@ -12,12 +12,13 @@ export const tmpdir = async () => { } } -async function remove(dir: string, retries = 10): Promise { +async function remove(dir: string, retries = 30): Promise { try { await fs.rm(dir, { recursive: true, force: true }) } catch (error) { if (retries === 0 || !error || typeof error !== "object" || !("code" in error) || error.code !== "EBUSY") throw error + Bun.gc(true) await Bun.sleep(100) return remove(dir, retries - 1) } diff --git a/packages/opencode/src/background/job.ts b/packages/opencode/src/background/job.ts index 4bc3bde883..4d888eb77c 100644 --- a/packages/opencode/src/background/job.ts +++ b/packages/opencode/src/background/job.ts @@ -24,6 +24,8 @@ export const layer = Layer.effect( start: (input) => InstanceState.useEffect(state, (jobs) => jobs.start(input)), extend: (input) => InstanceState.useEffect(state, (jobs) => jobs.extend(input)), wait: (input) => InstanceState.useEffect(state, (jobs) => jobs.wait(input)), + waitForPromotion: (id) => InstanceState.useEffect(state, (jobs) => jobs.waitForPromotion(id)), + promote: (id) => InstanceState.useEffect(state, (jobs) => jobs.promote(id)), cancel: (id) => InstanceState.useEffect(state, (jobs) => jobs.cancel(id)), }) }), diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 8a79885a45..e9e6091ff1 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -816,6 +816,7 @@ export const RunCommand = effectCmd({ initialInput, createSession: createFreshSession, thinking, + backgroundSubagents: flags.experimentalBackgroundSubagents, demo: args.demo, }) } catch (error) { @@ -849,6 +850,7 @@ export const RunCommand = effectCmd({ files, initialInput, thinking, + backgroundSubagents: flags.experimentalBackgroundSubagents, demo: args.demo, }) } catch (error) { diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 8062d4bef6..4d5274b20c 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -84,6 +84,7 @@ type RunFooterOptions = { theme: RunTheme keymap: Keymap tuiConfig: RunTuiConfig + backgroundSubagents: boolean diffStyle: RunDiffStyle onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise @@ -92,6 +93,7 @@ type RunFooterOptions = { onModelSelect?: (model: NonNullable) => CycleResult | void | Promise onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise onInterrupt?: () => void + onBackground?: () => void onExit?: () => void onSubagentSelect?: (sessionID: string | undefined) => void treeSitterClient?: TreeSitterClient @@ -294,6 +296,7 @@ export class RunFooter implements FooterApi { theme: options.theme, diffStyle: options.diffStyle, tuiConfig: options.tuiConfig, + backgroundSubagents: options.backgroundSubagents, history: options.history, agent: options.agentLabel, onSubmit: footer.handlePrompt, @@ -302,6 +305,7 @@ export class RunFooter implements FooterApi { onQuestionReject: footer.handleQuestionReject, onCycle: footer.handleCycle, onInterrupt: footer.handleInterrupt, + onBackground: options.onBackground, onInputClear: footer.handleInputClear, onExitRequest: footer.handleExit, onRequestExit: footer.setRequestExitHandler, diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index 5d5083ede2..edc40441a8 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -86,6 +86,7 @@ type RunFooterViewProps = { theme?: RunTheme diffStyle?: RunDiffStyle tuiConfig: RunTuiConfig + backgroundSubagents: boolean history?: RunPrompt[] agent: string onSubmit: (input: RunPrompt) => boolean @@ -94,6 +95,7 @@ type RunFooterViewProps = { onQuestionReject: (input: QuestionReject) => void | Promise onCycle: () => void onInterrupt: () => boolean + onBackground?: () => void onInputClear: () => void onExitRequest?: () => boolean onRequestExit?: (fn: (() => boolean) | undefined) => void @@ -158,6 +160,9 @@ export function RunFooterView(props: RunFooterViewProps) { label: count === 1 ? "agent" : "agents", } }) + const foregroundSubagents = createMemo( + () => props.backgroundSubagents && tabs().some((item) => item.status === "running" && !item.background), + ) const queuedIndicator = createMemo(() => { const count = queuedPrompts().length if (count === 0) return @@ -214,6 +219,15 @@ export function RunFooterView(props: RunFooterViewProps) { props.tuiConfig, ) ?? "", ) + const backgroundShortcut = useKeymapSelector( + (keymap: OpenTuiKeymap) => + formatKeyBindings( + keymap + .getCommandBindings({ visibility: "registered", commands: ["session.background"] }) + .get("session.background"), + props.tuiConfig, + ) ?? "", + ) const hints = createMemo(() => hintFlags(term().width)) const busy = createMemo(() => props.state().phase === "running") const armed = createMemo(() => props.state().interrupt > 0) @@ -375,6 +389,21 @@ export function RunFooterView(props: RunFooterViewProps) { ], })) + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents(), + priority: 1, + commands: [ + { + name: "session.background", + title: "Background subagents", + category: "Session", + run: () => props.onBackground?.(), + }, + ], + bindings: props.tuiConfig.keybinds.get("session.background"), + })) + useBindings(() => ({ mode: OPENCODE_BASE_MODE, enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0, @@ -774,6 +803,13 @@ export function RunFooterView(props: RunFooterViewProps) { )} + + + + {backgroundShortcut()}{" "} + background + + {(info) => ( diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index 389f868ee8..333be6ea9b 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts @@ -63,6 +63,7 @@ export type LifecycleInput = { model: RunInput["model"] variant: string | undefined tuiConfig: RunTuiConfig + backgroundSubagents: boolean onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise onQuestionReject: (input: QuestionReject) => void | Promise @@ -70,6 +71,7 @@ export type LifecycleInput = { onModelSelect?: (model: NonNullable) => CycleResult | void | Promise onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise onInterrupt?: () => void + onBackground?: () => void onSubagentSelect?: (sessionID: string | undefined) => void } @@ -237,6 +239,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { model: state.model, variant: state.activeVariant, tuiConfig, + backgroundSubagents: input.backgroundSubagents, onPermissionReply: async (next) => { if (state.demo?.permission(next)) { return @@ -372,6 +375,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { state.aborting = false }) }, + onBackground: () => { + if (!hasSession(input, state)) return + void ctx.sdk.experimental.session.background({ sessionID: state.sessionID }).catch(() => {}) + }, onSubagentSelect: (sessionID) => { state.selectSubagent?.(sessionID) log?.write("subagent.select", { @@ -794,6 +801,7 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promisec", "Compact the session"), session_toggle_timestamps: keybind("none", "Toggle message timestamps"), session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"), @@ -291,6 +292,7 @@ export const CommandMap = { session_share: "session.share", session_unshare: "session.unshare", session_interrupt: "session.interrupt", + session_background: "session.background", session_compact: "session.compact", session_toggle_timestamps: "session.toggle.timestamps", session_toggle_generic_tool_output: "session.toggle.generic_tool_output", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 4c5b4eef94..368f9f5105 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -198,6 +198,17 @@ export function Session() { .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) + const foregroundTasks = createMemo(() => + messages().flatMap((message) => + (sync.data.part[message.id] ?? []).filter( + (part): part is ToolPart => + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + part.state.metadata?.background !== true, + ), + ), + ) const permissions = createMemo(() => { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.permission[x.id] ?? []) @@ -1008,6 +1019,20 @@ export function Session() { dialog.clear() }, }, + { + title: "Background subagents", + value: "session.background", + category: "Session", + hidden: true, + enabled: foregroundTasks().length > 0, + run: () => { + void sdk.client.experimental.session.background({ + sessionID: route.sessionID, + workspace: project.workspace.current(), + }) + dialog.clear() + }, + }, { title: "Go to child session", value: "session.child.first", @@ -1088,6 +1113,13 @@ export function Session() { bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands), })) + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: foregroundTasks().length > 0, + priority: 1, + bindings: tuiConfig.keybinds.get("session.background"), + })) + const revertInfo = createMemo(() => session()?.revert) const revertMessageID = createMemo(() => revertInfo()?.messageID) @@ -1453,6 +1485,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las }) const childShortcut = useCommandShortcut("session.child.first") + const backgroundShortcut = useCommandShortcut("session.background") return ( <> @@ -1476,6 +1509,19 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las {childShortcut()} view subagents + + x.type === "tool" && + x.tool === "task" && + x.state.status === "running" && + x.state.metadata?.background !== true, + )} + > + · + {backgroundShortcut()} + background + diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 83491400a1..378e0b7217 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -2,6 +2,7 @@ import { AccountID, OrgID } from "@/account/schema" import { MCP } from "@/mcp" import { Session } from "@/session/session" +import { SessionID } from "@/session/schema" import { Worktree } from "@/worktree" import { NonNegativeInt } from "@opencode-ai/core/schema" import { Schema } from "effect" @@ -91,6 +92,7 @@ export const ExperimentalPaths = { worktree: "/experimental/worktree", worktreeReset: "/experimental/worktree/reset", session: "/experimental/session", + sessionBackground: "/experimental/session/:sessionID/background", resource: "/experimental/resource", } as const @@ -215,6 +217,19 @@ export const ExperimentalApi = HttpApi.make("experimental") "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", }), ), + HttpApiEndpoint.post("sessionBackground", ExperimentalPaths.sessionBackground, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + success: described(Schema.Boolean, "Backgrounded subagents"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.session.background", + summary: "Background subagents", + description: + "Detach any synchronous subagents currently blocking the session and continue them in the background.", + }), + ), HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index e995c21602..57d6464d90 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -1,10 +1,13 @@ import { Account } from "@/account/account" import { Agent } from "@/agent/agent" +import { BackgroundJob } from "@/background/job" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" +import { RuntimeFlags } from "@/effect/runtime-flags" import { MCP } from "@/mcp" import { Project } from "@/project/project" import { Session } from "@/session/session" +import type { SessionID } from "@/session/schema" import { ToolJsonSchema } from "@/tool/json-schema" import { ToolRegistry } from "@/tool/registry" import { Worktree } from "@/worktree" @@ -30,6 +33,8 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const registry = yield* ToolRegistry.Service const worktreeSvc = yield* Worktree.Service const sessions = yield* Session.Service + const background = yield* BackgroundJob.Service + const flags = yield* RuntimeFlags.Service const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { const [state, groups] = yield* Effect.all( @@ -146,6 +151,21 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper }) }) + const sessionBackground = Effect.fn("ExperimentalHttpApi.sessionBackground")(function* (ctx: { + params: { sessionID: SessionID } + }) { + if (!flags.experimentalBackgroundSubagents) return false + const jobs = (yield* background.list()).filter( + (job) => + job.type === "task" && + job.status === "running" && + job.metadata?.parentSessionId === ctx.params.sessionID && + job.metadata.background !== true, + ) + const promoted = yield* Effect.forEach(jobs, (job) => background.promote(job.id), { concurrency: "unbounded" }) + return promoted.some((job) => job !== undefined) + }) + const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { return yield* mcp.resources() }) @@ -161,6 +181,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper .handle("worktreeRemove", worktreeRemove) .handle("worktreeReset", worktreeReset) .handle("session", session) + .handle("sessionBackground", sessionBackground) .handle("resource", resource) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 5c1945a588..4f9d69c8ee 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -13,6 +13,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" +import { BackgroundJob } from "@/background/job" import { Config } from "@/config/config" import { Command } from "@/command" import * as Observability from "@opencode-ai/core/effect/observability" @@ -214,6 +215,7 @@ export function createRoutes( Account.defaultLayer, Agent.defaultLayer, Auth.defaultLayer, + BackgroundJob.defaultLayer, Command.defaultLayer, Config.defaultLayer, Format.defaultLayer, diff --git a/packages/opencode/src/server/shared/workspace-routing.ts b/packages/opencode/src/server/shared/workspace-routing.ts index 366c455dd6..0edfb2b305 100644 --- a/packages/opencode/src/server/shared/workspace-routing.ts +++ b/packages/opencode/src/server/shared/workspace-routing.ts @@ -20,7 +20,9 @@ export function isLocalWorkspaceRoute(method: string, path: string) { export function getWorkspaceRouteSessionID(url: URL) { if (url.pathname === "/session/status") return null - const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] + const id = + url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] ?? + url.pathname.match(/^\/experimental\/session\/([^/]+)\/background$/)?.[1] if (!id) return null return SessionID.make(id) diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 76f2e738a0..9ac171f564 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -4,7 +4,6 @@ import { Runner } from "@/effect/runner" import { BackgroundJob } from "@/background/job" import { Effect, Latch, Layer, Scope, Context } from "effect" import { Session } from "./session" -import { MessageV2 } from "./message-v2" import { SessionID } from "./schema" import { SessionStatus } from "./status" diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 8f2d9d8e8f..1ac9d4d9ad 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -220,6 +220,17 @@ export const TaskTool = Tool.define( .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true })) }) + const notify = Effect.fn("TaskTool.notifyBackgroundResult")(function* (jobID: string) { + yield* background.wait({ id: jobID }).pipe( + Effect.flatMap((result) => { + if (result.info?.status === "completed") return inject("completed", result.info.output ?? "") + if (result.info?.status === "error") return inject("error", result.info.error ?? "") + return Effect.void + }), + Effect.forkIn(scope, { startImmediately: true }), + ) + }) + if (yield* background.extend({ id: nextSession.id, run: runTask() })) { return { title: params.description, @@ -237,27 +248,27 @@ export const TaskTool = Tool.define( } } - if (runInBackground) { - const info = yield* background.start({ - id: nextSession.id, - type: id, - title: params.description, - metadata, - run: runTask(), - }) - yield* background.wait({ id: info.id }).pipe( - Effect.flatMap((result) => { - if (result.info?.status === "completed") return inject("completed", result.info.output ?? "") - if (result.info?.status === "error") return inject("error", result.info.error ?? "") - return Effect.void + const info = yield* background.start({ + id: nextSession.id, + type: id, + title: params.description, + metadata, + onPromote: Effect.all([ + ctx.metadata({ + title: params.description, + metadata: { ...metadata, background: true, jobId: nextSession.id }, }), - Effect.forkIn(scope, { startImmediately: true }), - ) + notify(nextSession.id), + ]), + run: runTask().pipe(Effect.onInterrupt(() => ops.cancel(nextSession.id))), + }) + function backgroundResult() { return { title: params.description, metadata: { ...metadata, + background: true, jobId: info.id, }, output: renderOutput({ @@ -269,6 +280,11 @@ export const TaskTool = Tool.define( } } + if (runInBackground) { + yield* notify(info.id) + return backgroundResult() + } + const runCancel = yield* EffectBridge.make() const cancel = ops.cancel(nextSession.id) @@ -282,16 +298,23 @@ export const TaskTool = Tool.define( }), () => Effect.gen(function* () { - const text = yield* runTask() + const result = yield* Effect.raceFirst( + background.wait({ id: nextSession.id }).pipe(Effect.map((waited) => waited.info)), + background.waitForPromotion(nextSession.id), + ) + if (result?.metadata?.background === true) return backgroundResult() + if (result?.status === "error") return yield* Effect.fail(new Error(result.error ?? "Task failed")) + if (result?.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled")) return { title: params.description, metadata, - output: renderOutput({ sessionID: nextSession.id, state: "completed", text }), + output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }), } }), (_, exit) => Effect.gen(function* () { - if (Exit.hasInterrupts(exit)) yield* cancel + if (Exit.hasInterrupts(exit)) + yield* Effect.all([cancel, background.cancel(nextSession.id)], { discard: true }) }).pipe( Effect.ensuring( Effect.sync(() => { diff --git a/packages/opencode/test/background/job.test.ts b/packages/opencode/test/background/job.test.ts index 667f9f5db6..dbcb484dc6 100644 --- a/packages/opencode/test/background/job.test.ts +++ b/packages/opencode/test/background/job.test.ts @@ -99,6 +99,31 @@ describe("background.job", () => { }), ) + it.instance("runs extensions after earlier work completes", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const first = yield* Deferred.make() + const order: string[] = [] + const job = yield* jobs.start({ + type: "test", + run: Effect.sync(() => order.push("start")).pipe(Effect.andThen(Deferred.await(first)), Effect.as("first")), + }) + + expect( + yield* jobs.extend({ + id: job.id, + run: Effect.sync(() => order.push("extend")).pipe(Effect.as("second")), + }), + ).toBe(true) + yield* Effect.yieldNow + expect(order).toEqual(["start"]) + + yield* Deferred.succeed(first, undefined) + expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("second") + expect(order).toEqual(["start", "extend"]) + }), + ) + it.instance("rejects extensions after a job completes", () => Effect.gen(function* () { const jobs = yield* BackgroundJob.Service @@ -160,25 +185,47 @@ describe("background.job", () => { Effect.gen(function* () { const jobs = yield* BackgroundJob.Service const interrupted = yield* Deferred.make() - const extendedInterrupted = yield* Deferred.make() const job = yield* jobs.start({ type: "test", run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))), }) yield* jobs.extend({ id: job.id, - run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(extendedInterrupted, undefined))), + run: Effect.never, }) const cancelled = yield* jobs.cancel(job.id) expect(cancelled?.status).toBe("cancelled") yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second")) - yield* Deferred.await(extendedInterrupted).pipe(Effect.timeout("1 second")) expect((yield* jobs.get(job.id))?.status).toBe("cancelled") }), ) + it.instance("promotes running jobs without interrupting them", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const latch = yield* Deferred.make() + const promoted = yield* Deferred.make() + const job = yield* jobs.start({ + type: "test", + metadata: { parentSessionId: "parent" }, + onPromote: Deferred.succeed(promoted, undefined).pipe(Effect.asVoid), + run: Deferred.await(latch).pipe(Effect.as("done")), + }) + + const info = yield* jobs.promote(job.id) + + expect(info?.status).toBe("running") + expect(info?.metadata?.background).toBe(true) + yield* Deferred.await(promoted) + expect((yield* jobs.get(job.id))?.status).toBe("running") + + yield* Deferred.succeed(latch, undefined) + expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("done") + }), + ) + it.instance("returns immutable snapshots", () => Effect.gen(function* () { const jobs = yield* BackgroundJob.Service diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index e23318c568..2c53f1f8b4 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -185,6 +185,7 @@ async function renderFooter( subagent={subagents} theme={RUN_THEME_FALLBACK} tuiConfig={config} + backgroundSubagents={true} agent="opencode" onSubmit={input.onSubmit ?? (() => true)} onPermissionReply={() => {}} @@ -613,6 +614,7 @@ test("direct footer shows editable prompts and additional queued work while runn ]} theme={RUN_THEME_FALLBACK} tuiConfig={tuiConfig} + backgroundSubagents={true} agent="opencode" onSubmit={() => true} onPermissionReply={() => {}} @@ -647,7 +649,7 @@ test("direct footer shows editable prompts and additional queued work while runn try { await app.renderOnce() - expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • 1 queued ctrl+x q") + expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • ctrl+b background • 1 queued ctrl+x q") expect(app.captureCharFrame()).toContain("2 queued") expect(app.captureCharFrame()).not.toContain("to view") expect(app.captureCharFrame()).not.toContain("edit/remove") diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index ba55b21a4e..634b605227 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -568,6 +568,17 @@ const scenarios: Scenario[] = [ .get("/experimental/session", "experimental.session.list") .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) .json(200, array), + http.protected + .post("/experimental/session/{sessionID}/background", "experimental.session.background") + .mutating() + .seeded((ctx) => ctx.session({ title: "Background route owner" })) + .at((ctx) => ({ + path: route("/experimental/session/{sessionID}/background", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json(200, (body) => { + check(body === false, "background route should be a no-op without running subagents") + }), http.protected.get("/experimental/resource", "experimental.resource.list").json(), http.protected .post("/sync/history", "sync.history.list") diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 093ec43c03..4c1c772342 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -90,4 +90,23 @@ describe("session action routes", () => { }), { git: true }, ) + + it.instance( + "experimental background route is a no-op without synchronous subagents", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) => + SessionNs.use.remove(created.id).pipe(Effect.ignore), + ) + + const res = yield* requestInDirectory(`/experimental/session/${session.id}/background`, test.directory, { + method: "POST", + }) + + expect(res.status).toBe(200) + expect(yield* res.json).toBe(false) + }), + { git: true }, + ) }) diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts index a921ae2774..9ae0f3e632 100644 --- a/packages/opencode/test/server/workspace-routing.test.ts +++ b/packages/opencode/test/server/workspace-routing.test.ts @@ -41,6 +41,11 @@ describe("getWorkspaceRouteSessionID", () => { expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_xyz")) }) + test("extracts session ID from experimental background path", () => { + const url = new URL("http://localhost/experimental/session/ses_bg/background") + expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_bg")) + }) + test("returns null for /session/status", () => { const url = new URL("http://localhost/session/status") expect(getWorkspaceRouteSessionID(url)).toBeNull() diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 6a9bd766f6..6a9604a01e 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,14 +1,13 @@ import { afterEach, describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" -import { Effect, Exit, Fiber, Layer } from "effect" +import { Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" import { Config } from "@/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Session } from "@/session/session" -import { MessageV2 } from "../../src/session/message-v2" import type { SessionPrompt } from "../../src/session/prompt" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionRunState } from "@/session/run-state" @@ -484,6 +483,72 @@ describe("tool.task", () => { }), ) + it.instance("promotes a running foreground task without restarting it", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const ready = yield* Deferred.make() + const done = yield* Deferred.make() + const injected = yield* Deferred.make() + let runs = 0 + const promptOps: TaskPromptOps = { + cancel: () => Effect.void, + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => { + if (input.sessionID === chat.id) { + return Deferred.succeed(injected, input).pipe(Effect.as(reply(input, "injected"))) + } + return Effect.gen(function* () { + runs += 1 + yield* Deferred.succeed(ready, undefined) + yield* Deferred.await(done) + return reply(input, "background done") + }) + }, + } + + const fiber = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.forkChild) + + yield* Deferred.await(ready) + const job = (yield* jobs.list())[0] + expect(job).toBeDefined() + if (!job) throw new Error("task job not found") + expect(job.metadata?.parentSessionId).toBe(chat.id) + yield* jobs.promote(job.id) + + const result = yield* Fiber.join(fiber) + expect(result.metadata.background).toBe(true) + expect(result.output).toContain(`state="running"`) + expect((yield* jobs.get(result.metadata.sessionId))?.status).toBe("running") + expect(runs).toBe(1) + + yield* Deferred.succeed(done, undefined) + expect((yield* jobs.wait({ id: result.metadata.sessionId })).info?.output).toBe("background done") + expect((yield* Deferred.await(injected)).parts[0]?.type).toBe("text") + expect(runs).toBe(1) + }), + ) + background.instance("execute launches background tasks without waiting for completion", () => Effect.gen(function* () { const jobs = yield* BackgroundJob.Service @@ -576,14 +641,14 @@ describe("tool.task", () => { context, ) - expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([ - { type: "text", text: "also inspect cancellation" }, - ]) expect(result.metadata.sessionId).toBe(started.metadata.sessionId) expect(result.metadata.background).toBe(true) expect(result.output).toContain("Background task updated") first.resolve() expect((yield* jobs.get(started.metadata.sessionId))?.status).toBe("running") + expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([ + { type: "text", text: "also inspect cancellation" }, + ]) second.resolve() const waited = yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 }) @@ -784,6 +849,27 @@ describe("tool.task", () => { }), ) + it.instance("cancelling a child run cancels its own pre-runner task job", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const runState = yield* SessionRunState.Service + const sessions = yield* Session.Service + const { chat } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "child" }) + + yield* jobs.start({ + id: child.id, + type: "task", + metadata: { parentSessionId: chat.id, sessionId: child.id }, + run: Effect.never, + }) + + yield* runState.cancel(child.id) + + expect((yield* jobs.get(child.id))?.status).toBe("cancelled") + }), + ) + it.instance("cancelling a parent run recursively cancels descendant background tasks", () => Effect.gen(function* () { const jobs = yield* BackgroundJob.Service diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 7fc5166d7a..420e52d671 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -44,6 +44,8 @@ import type { ExperimentalProjectCopyRemoveResponses, ExperimentalResourceListErrors, ExperimentalResourceListResponses, + ExperimentalSessionBackgroundErrors, + ExperimentalSessionBackgroundResponses, ExperimentalSessionListErrors, ExperimentalSessionListResponses, ExperimentalWorkspaceAdapterListErrors, @@ -733,6 +735,42 @@ export class Session extends HeyApiClient { ...params, }) } + + /** + * Background subagents + * + * Detach any synchronous subagents currently blocking the session and continue them in the background. + */ + public background( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalSessionBackgroundResponses, + ExperimentalSessionBackgroundErrors, + ThrowOnError + >({ + url: "/experimental/session/{sessionID}/background", + ...options, + ...params, + }) + } } export class Resource extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 69ab21185f..4d71b3c52a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5850,6 +5850,38 @@ export type ExperimentalSessionListResponses = { export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] +export type ExperimentalSessionBackgroundData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/session/{sessionID}/background" +} + +export type ExperimentalSessionBackgroundErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type ExperimentalSessionBackgroundError = + ExperimentalSessionBackgroundErrors[keyof ExperimentalSessionBackgroundErrors] + +export type ExperimentalSessionBackgroundResponses = { + /** + * Backgrounded subagents + */ + 200: boolean +} + +export type ExperimentalSessionBackgroundResponse = + ExperimentalSessionBackgroundResponses[keyof ExperimentalSessionBackgroundResponses] + export type ExperimentalResourceListData = { body?: never path?: never From 5426478e46fd5c61ed7eafeb3a9b3808e31a4259 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:41:30 +0800 Subject: [PATCH 022/626] fix(app): improve tab handling (#30669) --- packages/app/src/components/titlebar.tsx | 246 +++++++++--------- packages/app/src/context/directory-sync.ts | 11 +- packages/app/src/context/global.tsx | 1 + packages/app/src/context/layout-helpers.ts | 38 +++ packages/app/src/context/layout.test.ts | 2 +- packages/app/src/context/layout.tsx | 64 ++--- packages/app/src/context/server-sync.tsx | 33 +-- packages/app/src/context/settings.tsx | 4 - packages/app/src/pages/directory-layout.tsx | 2 +- packages/app/src/pages/layout/helpers.ts | 2 +- .../pages/session/use-session-commands.tsx | 37 +-- packages/ui/src/components/session-review.tsx | 1 - 12 files changed, 238 insertions(+), 203 deletions(-) create mode 100644 packages/app/src/context/layout-helpers.ts diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index c2af727387..2169878633 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js" +import { createEffect, createMemo, createResource, For, Match, Show, startTransition, Switch, untrack } from "solid-js" import { createStore, produce } from "solid-js/store" import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -9,7 +9,7 @@ import { useTheme } from "@opencode-ai/ui/theme/context" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout" +import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -17,7 +17,6 @@ import { useSettings } from "@/context/settings" import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" import { useServerSync } from "@/context/server-sync" -import { decodeDirectory } from "@/pages/directory-layout" import { iife } from "@opencode-ai/core/util/iife" import { base64Encode } from "@opencode-ai/core/util/encode" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" @@ -29,6 +28,9 @@ import { SESSION_TABS_REMOVED_EVENT, type SessionTabsRemovedDetail, } from "@/components/titlebar-session-events" +import { Persist, persisted } from "@/utils/persist" +import { useGlobal } from "@/context/global" +import { decode64 } from "@/utils/base64" type TauriDesktopWindow = { startDragging?: () => Promise @@ -243,6 +245,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const serverSync = useServerSync() const navigate = useNavigate() const homeMatch = useMatch(() => "/") + const layout = useLayout() const newSessionHref = () => { if (params.dir) return `/${params.dir}/session` @@ -253,41 +256,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { return `/${base64Encode(project.worktree)}/session` } - type Tab = { dir: string; sessionId: string; href: string } + type SessionTab = { type: "session"; dirBase64: string; sessionId: string } + type Tab = SessionTab + + const tabHref = (tab: Tab) => { + if (tab.type === "session") { + return makeSessionHref(tab.dirBase64, tab.sessionId) + } + return "/" + } const [tabsStore, tabsStoreActions] = iife(() => { - const [store, setStore] = createStore( - iife(() => { - if (!params.dir || !params.id) return [] - return [ - { - dir: decodeDirectory(params.dir) ?? "", - sessionId: params.id, - href: makeSessionHref(params.dir, params.id), - }, - ] - }), - ) + const [store, setStore] = persisted(Persist.global("tabs"), createStore([])) const actions = { - addTab: (tab: Tab) => { + addSessionTab: (tab: Omit) => { setStore( produce((tabs) => { - if (tabs.some((t) => t.href === tab.href)) return + if (tabs.some((t) => t.type === "session" && tabHref(t) === tabHref({ type: "session", ...tab }))) + return - tabs.push(tab) + tabs.push({ type: "session", ...tab }) }), ) }, - removeTab: (href: string) => { + removeTab: (index: number) => { + if (index < 0) return void startTransition(() => { setStore( produce((tabs) => { - const index = tabs.findIndex((t) => t.href === href) - if (index === -1) return + const nextTab = tabs[index + 1] ?? tabs[index - 1] tabs.splice(index, 1) - const nextTab = tabs[index] ?? tabs[tabs.length - 1] - if (nextTab) navigate(nextTab.href) + if (nextTab) navigate(tabHref(nextTab)) else navigate("/") }), ) @@ -299,23 +299,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { produce((tabs) => { const sessionIDs = new Set(input.sessionIDs) const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined - const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1 + const currentIndex = currentHref + ? tabs.findIndex((tab) => tab.type === "session" && tabHref(tab) === currentHref) + : -1 + const currentTab = tabs[currentIndex] const removedCurrent = - currentIndex !== -1 && - tabs[currentIndex]?.dir === input.directory && - sessionIDs.has(tabs[currentIndex]?.sessionId ?? "") + currentTab?.type === "session" && + atob(currentTab.dirBase64) === input.directory && + sessionIDs.has(currentTab.sessionId) for (let i = tabs.length - 1; i >= 0; i--) { const tab = tabs[i] - if (!tab) continue - if (tab.dir !== input.directory) continue + if (!tab || tab.type !== "session") continue + if (atob(tab.dirBase64) !== input.directory) continue if (!sessionIDs.has(tab.sessionId)) continue tabs.splice(i, 1) } if (!removedCurrent) return - const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1] - if (nextTab) navigate(nextTab.href) + const nextTab = + tabs.slice(currentIndex).find((tab) => tab.type === "session") ?? + tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session") + if (nextTab) navigate(tabHref(nextTab)) else navigate("/") }), ) @@ -326,55 +331,54 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { return [store, actions] }) + const matchRoute = (route: LayoutRoute) => { + if (route.type === "home") return + if (route.type === "dir-new-sesssion") { + } + if (route.type === "session") { + const main = tabsStore.find((s) => s.type === "session" && s.sessionId === route.sessionId) + if (main) return main + const sync = serverSync.createDirSyncContext(route.dir) + const session = sync.session.get(route.sessionId) + if (session?.parentID) { + const parentID = session.parentID + const parent = tabsStore.find((s) => s.type === "session" && s.sessionId === parentID) + if (parent) return parent + } + } + } + + const currentTab = () => matchRoute(layout.route()) + + createEffect(() => { + const route = layout.route() + const tab = currentTab() + if (tab) return + + if (route.type === "session") { + const sync = serverSync.createDirSyncContext(route.dir) + const session = sync.session.get(route.sessionId) + if (!session) return + const sessionId = session.parentID ?? session.id + tabsStoreActions.addSessionTab({ + dirBase64: route.dirBase64, + sessionId, + }) + } + }) + makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => { const detail = readSessionTabsRemovedDetail(event) if (!detail) return tabsStoreActions.removeSessions(detail) }) - createEffect(() => { - const params = useParams() - if (!(params.dir && params.id)) return - - tabsStoreActions.addTab({ - dir: decodeDirectory(params.dir) ?? "", - sessionId: params.id, - href: makeSessionHref(params.dir, params.id), - }) - }) - - const projects = createMemo(() => layout.projects.list()) - const projectByID = createMemo( - () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), - ) - - const currentSessionTab = () => { - if (!params.dir || !params.id) return - const href = makeSessionHref(params.dir, params.id) - return tabsStore.find((tab) => tab.href === href) - } - - const closeCurrentSessionTab = () => { - const tab = currentSessionTab() - if (!tab) return false - tabsStoreActions.removeTab(tab.href) - return true - } - - const closeNewSessionTab = () => { - if (!(params.dir && !params.id)) return false - const last = tabsStore[tabsStore.length - 1] - if (last) navigate(last.href) - else navigate("/") - return true - } - const openNewTab = () => navigate(newSessionHref()) - const closeActiveTab = () => closeCurrentSessionTab() || closeNewSessionTab() + command.register("tabs", () => { + const current = currentTab() - command.register(() => { - const commands = [ + return [ { id: "tab.new", category: "tab", @@ -383,13 +387,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { hidden: true, onSelect: openNewTab, }, - { + current && { id: "tab.close", category: "tab", title: language.t("command.tab.close"), keybind: "mod+w", hidden: true, - onSelect: closeActiveTab, + onSelect: () => { + tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab)) + }, }, { id: `tab.prev`, @@ -398,14 +404,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { keybind: `mod+option+ArrowLeft`, hidden: true, onSelect: () => { - let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href) + let index = tabsStore.findIndex((tab) => tab === currentTab()) if (index === -1) return index -= 1 if (index === -1) index = tabsStore.length - 1 const next = tabsStore[index] - if (next) navigate(next.href) + if (next) navigate(tabHref(next)) }, }, { @@ -415,14 +421,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { keybind: `mod+option+ArrowRight`, hidden: true, onSelect: () => { - let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href) + let index = tabsStore.findIndex((tab) => tab === currentTab()) if (index === -1) return index += 1 if (index === tabsStore.length) index = 0 const next = tabsStore[index] - if (next) navigate(next.href) + if (next) navigate(tabHref(next)) }, }, ...Array.from({ length: 9 }, (_, i) => { @@ -437,26 +443,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { hidden: true, onSelect: () => { const tab = tabsStore[index] - if (tab) navigate(tab.href) + if (tab) navigate(tabHref(tab)) }, } }), - ] - - return commands - }) - - const tabsEnriched = iife(() => { - const base = mapArray( - () => tabsStore, - (tab) => { - const sync = serverSync.createDirSyncContext(tab.dir) - const session = sync.session.get(tab.sessionId) - return session ? { ...tab, info: session } : null - }, - ) - - return () => base().flatMap((s) => (s ? [s] : [])) + ].filter((v) => v !== undefined) }) return ( @@ -483,19 +474,18 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
    - + {(tab, i) => ( <> {i() !== 0 && (
    )} tabsStoreActions.removeTab(tab.href)} + href={tabHref(tab)} + directory={decode64(tab.dirBase64)!} + sessionId={tab.sessionId} + onClose={() => tabsStoreActions.removeTab(i())} + active={currentTab() === tab} /> )} @@ -519,7 +509,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { navigate(tabsEnriched().at(-1)?.href ?? "/")} + onClose={() => navigate(tabsStore.at(-1) ? tabHref(tabsStore.at(-1)!) : "/")} />
    @@ -746,38 +736,58 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) { function TabNavItem(props: { href: string - title: string - project?: LocalProject directory: string - sessionId: string + sessionId?: string hideClose?: boolean onClose: () => void + active?: boolean }) { - const match = useMatch(() => props.href) - const isActive = () => !!match() const closeTab = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() props.onClose() } + const global = useGlobal() + const serverCtx = global.createServerCtx(global.servers.default()) + + const [session] = createResource( + () => { + const dirSyncCtx = serverCtx.sync.createDirSyncContext(props.directory) + return props.sessionId ? ([props.sessionId, dirSyncCtx] as const) : undefined + }, + async ([sessionId, dirSyncCtx]) => { + await dirSyncCtx.session.sync(sessionId).catch(() => {}) + return dirSyncCtx.session.get(sessionId) + }, + ) + return (
    { if (event.button !== 1) return closeTab(event) }} > - - - - - {props.title} - + + {(session) => { + const layout = useLayout() + const project = createMemo(() => projectForSession(session(), layout.projects.list())) + + return ( + + + + + {session().title} + + ) + }} +
    void, input: Opti }) } -export const createDirSyncContext = (directory: string, serverSync: ReturnType) => { - const serverSDK = useServerSDK() +export const createDirSyncContext = ( + directory: string, + serverSync: ReturnType, + serverSDK: ReturnType = useServerSDK(), +) => { const client = serverSDK.createClient({ directory, throwOnError: true }) type Child = ReturnType<(typeof serverSync)["child"]> diff --git a/packages/app/src/context/global.tsx b/packages/app/src/context/global.tsx index 77673bacb8..3f04aa6805 100644 --- a/packages/app/src/context/global.tsx +++ b/packages/app/src/context/global.tsx @@ -73,6 +73,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( servers: { list: allServers, health: serverHealth, + default: () => allServers().find((s) => ServerConnection.key(s) === props.defaultServer) ?? allServers()[0]!, }, settings: { server: { diff --git a/packages/app/src/context/layout-helpers.ts b/packages/app/src/context/layout-helpers.ts new file mode 100644 index 0000000000..e2c5ce1c3c --- /dev/null +++ b/packages/app/src/context/layout-helpers.ts @@ -0,0 +1,38 @@ +import type { Accessor } from "solid-js" + +export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) { + touch(key) + seed(key) + return key +} + +export function createSessionKeyReader(sessionKey: string | Accessor, ensure: (key: string) => void) { + const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey + return () => { + const value = key() + ensure(value) + return value + } +} + +export function pruneSessionKeys(input: { + keep?: string + max: number + used: Map + view: string[] + tabs: string[] +}) { + if (!input.keep) return [] + + const keys = new Set([...input.view, ...input.tabs]) + if (keys.size <= input.max) return [] + + const score = (key: string) => { + if (key === input.keep) return Number.MAX_SAFE_INTEGER + return input.used.get(key) ?? 0 + } + + return Array.from(keys) + .sort((a, b) => score(b) - score(a)) + .slice(input.max) +} diff --git a/packages/app/src/context/layout.test.ts b/packages/app/src/context/layout.test.ts index 582d5edbd2..895c0e85e2 100644 --- a/packages/app/src/context/layout.test.ts +++ b/packages/app/src/context/layout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { createRoot, createSignal } from "solid-js" -import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout" +import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" describe("layout session-key helpers", () => { test("couples touch and scroll seed in order", () => { diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 4dc611f0ad..a13891b6d2 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -1,10 +1,11 @@ import { createStore, produce } from "solid-js/store" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" +import { useLocation } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" import { makeEventListener } from "@solid-primitives/event-listener" import { useServerSync } from "./server-sync" import { useServerSDK } from "./server-sdk" -import { useServer } from "./server" +import { ServerConnection, useServer } from "./server" import { usePlatform } from "./platform" import { Project } from "@opencode-ai/sdk/v2" import { Persist, persisted, removePersisted } from "@/utils/persist" @@ -13,6 +14,9 @@ import { same } from "@/utils/same" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createPathHelpers } from "./file/path" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" +import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" + +export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } export type { ProjectAvatarVariant } @@ -69,42 +73,10 @@ export type LocalProject = Partial & { worktree: string; expanded: bool export type ReviewDiffStyle = "unified" | "split" -export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) { - touch(key) - seed(key) - return key -} - -export function createSessionKeyReader(sessionKey: string | Accessor, ensure: (key: string) => void) { - const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey - return () => { - const value = key() - ensure(value) - return value - } -} - -export function pruneSessionKeys(input: { - keep?: string - max: number - used: Map - view: string[] - tabs: string[] -}) { - if (!input.keep) return [] - - const keys = new Set([...input.view, ...input.tabs]) - if (keys.size <= input.max) return [] - - const score = (key: string) => { - if (key === input.keep) return Number.MAX_SAFE_INTEGER - return input.used.get(key) ?? 0 - } - - return Array.from(keys) - .sort((a, b) => score(b) - score(a)) - .slice(input.max) -} +export type LayoutRoute = + | { type: "home" } + | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } + | { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key } function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { const all = current?.all ?? [] @@ -146,6 +118,21 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { } } +const currentRoute = (pathname: string): LayoutRoute => { + const parts = pathname.split("/").filter(Boolean) + if (parts.length === 0) return { type: "home" } + + const dirBase64 = parts[0] + const dir = decode64(dirBase64) + if (!dir) return { type: "home" } + + if (parts[1] !== "session") return { type: "home" } + + const id = parts[2] + if (id) return { type: "session", dir, dirBase64, sessionId: id } + return { type: "dir-new-sesssion", dir, dirBase64 } +} + export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -153,6 +140,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const serverSync = useServerSync() const server = useServer() const platform = usePlatform() + const location = useLocation() + const route = createMemo(() => currentRoute(location.pathname)) const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) @@ -557,6 +546,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }) return { + route, ready, handoff: { tabs: createMemo(() => store.handoff?.tabs), diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 823a965dcf..04364a0ff9 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,17 +1,7 @@ import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" -import { - batch, - createContext, - createEffect, - getOwner, - onCleanup, - onMount, - type ParentProps, - untrack, - useContext, -} from "solid-js" +import { batch, getOwner, onCleanup, onMount, untrack } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import type { InitError } from "../pages/error" @@ -86,7 +76,7 @@ function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: Path } export type QueryOptionsApi = ReturnType -export function createServerSyncContext(_serverSDK?: ServerSDK) { +export function createServerSyncContextInner(_serverSDK?: ServerSDK) { const serverSDK: ServerSDK = _serverSDK ?? useServerSDK() const language = useLanguage() const owner = getOwner() @@ -476,6 +466,17 @@ export function createServerSyncContext(_serverSDK?: ServerSDK) { } } +export function createServerSyncContext(_serverSDK?: ServerSDK) { + const inner = createServerSyncContextInner(_serverSDK) + return Object.assign(inner, { + createDirSyncContext: createRefCountMap( + (dir) => createDirSyncContext(dir, inner, _serverSDK), + (dir) => inner.disableMcp(dir), + directoryKey, + ), + }) +} + export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({ name: "ServerSync", init: (props: { server?: ServerConnection.Any }) => { @@ -487,13 +488,7 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) const ctx = global.createServerCtx(conn) - return Object.assign(ctx.sync, { - createDirSyncContext: createRefCountMap( - (dir) => createDirSyncContext(dir, ctx.sync), - (dir) => ctx.sync.disableMcp(dir), - directoryKey, - ), - }) + return ctx.sync }, }) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index cad0c60553..84288d31a5 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -159,10 +159,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont init: () => { const [store, setStore, _, ready] = persisted("settings.v3", createStore(defaultSettings)) - createEffect(() => { - console.log("settings", { ready: ready() }) - }) - createEffect(() => { if (typeof document === "undefined") return const root = document.documentElement diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index efa40db103..2d0dfd81dc 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -26,7 +26,7 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { createResource( () => params.id, - (id) => sync.session.sync(id), + (id) => sync.session.sync(id).catch(() => {}), ) return ( diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 1e05199a25..7c91705337 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -67,7 +67,7 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url export function projectForSession( session: Session, projects: T[], - byID: Map, + byID: Map = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))), ) { const direct = byID.get(session.projectID) if (direct) return direct diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 73307c1089..c9f73bd482 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -418,23 +418,26 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }), ] - const fileCmds = () => [ - fileCommand({ - id: "file.open", - title: language.t("command.file.open"), - description: language.t("palette.search.placeholder"), - keybind: "mod+k,mod+p", - slash: "open", - onSelect: openFile, - }), - fileCommand({ - id: "tab.close", - title: language.t("command.tab.close"), - keybind: "mod+w", - disabled: !closableTab(), - onSelect: closeTab, - }), - ] + const fileCmds = () => { + const tab = closableTab() + return [ + fileCommand({ + id: "file.open", + title: language.t("command.file.open"), + description: language.t("palette.search.placeholder"), + keybind: "mod+k,mod+p", + slash: "open", + onSelect: openFile, + }), + tab && + fileCommand({ + id: "tab.close", + title: language.t("command.tab.close"), + keybind: "mod+w", + onSelect: closeTab, + }), + ].filter((v) => !!v) + } const contextCmds = () => [ contextCommand({ diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 08514d4ae5..ebdd5d939c 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -390,7 +390,6 @@ export const SessionReview = (props: SessionReviewProps) => { {(file) => { - console.log({ file }) const diff = () => itemsMap()[file] // binary files have empty diffs that we can't render From 0a364330627e95aa723ff70959467ca62b13bf5b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 5 Jun 2026 03:42:49 +0000 Subject: [PATCH 023/626] chore: generate --- packages/sdk/openapi.json | 71 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6e350a71e2..62296d37fc 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1568,6 +1568,77 @@ ] } }, + "/experimental/session/{sessionID}/background": { + "post": { + "tags": ["experimental"], + "operationId": "experimental.session.background", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Backgrounded subagents", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Backgrounded subagents" + } + } + } + }, + "400": { + "description": "BadRequest | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/effect_HttpApiError_BadRequest" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + } + }, + "description": "Detach any synchronous subagents currently blocking the session and continue them in the background.", + "summary": "Background subagents", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.session.background({\n ...\n})" + } + ] + } + }, "/experimental/resource": { "get": { "tags": ["experimental"], From b37589074575047fa9670b6d7e99d53495b5163e Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:28:43 -0500 Subject: [PATCH 024/626] fix(tui): prioritize models slash autocomplete (#30848) --- packages/opencode/src/cli/cmd/tui/app.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 0cb5a08b27..3fbffde183 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -633,6 +633,8 @@ function App(props: { onSnapshot?: () => Promise }) { suggested: true, category: "Agent", slashName: "models", + // Bias /mo toward /models over /move without changing global fuzzy scoring. + slashAliases: ["mo"], run: () => { dialog.replace(() => ) }, From 3cf1cef7fed55a314b01635a9a2388ef9f257e5e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:56:06 -0400 Subject: [PATCH 025/626] fix(tui): route permission replies to session directory (#30851) Co-authored-by: opencode-agent[bot] --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 5 ++++- .../opencode/src/cli/cmd/tui/routes/session/permission.tsx | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 368f9f5105..27ee66c7bd 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1285,7 +1285,10 @@ export function Session() { 0}> - + 0}> Date: Fri, 5 Jun 2026 01:30:19 -0400 Subject: [PATCH 026/626] fix(cli): harden daemon lifecycle (#30844) --- packages/cli/src/services/daemon.ts | 150 ++++++++++++++++++---------- 1 file changed, 95 insertions(+), 55 deletions(-) diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts index 58c56ac4f0..bbd21a6fd7 100644 --- a/packages/cli/src/services/daemon.ts +++ b/packages/cli/src/services/daemon.ts @@ -1,9 +1,10 @@ import { Global } from "@opencode-ai/core/global" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { ServerAuth } from "@opencode-ai/server/auth" import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect" import { HttpServer } from "effect/unstable/http" -import { randomBytes } from "crypto" +import { randomBytes, randomUUID } from "crypto" import path from "path" export interface Interface { @@ -17,6 +18,18 @@ export interface Interface { export class Service extends Context.Service()("@opencode/cli/Daemon") {} +const Registration = Schema.Struct({ + id: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + url: Schema.String, + pid: Schema.Int.check(Schema.isGreaterThan(0)), +}) +type Registration = typeof Registration.Type + +function sameRegistration(left: Registration, right: Registration) { + return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid +} + export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -24,9 +37,7 @@ export const layer = Layer.effect( const directory = Global.Path.state const file = path.join(directory, "server.json") const passwordFile = path.join(directory, "password") - const decodeRegistration = Schema.decodeUnknownEffect( - Schema.fromJsonString(Schema.Struct({ url: Schema.String, pid: Schema.Number })), - ) + const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) const password = Effect.fn("cli.daemon.password")(function* (value?: string) { const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined))) @@ -53,42 +64,15 @@ export const layer = Layer.effect( const healthy = Effect.fnUntraced(function* () { const info = yield* registration() const client = yield* createClient(info.url) - const response = yield* Effect.tryPromise(() => client.v2.health.get()) + const response = yield* Effect.tryPromise(() => client.v2.health.get({ signal: AbortSignal.timeout(2_000) })) if (response.data?.healthy === true) return info return yield* Effect.fail(new Error("Registered server is not healthy")) }) - const start = Effect.fn("cli.daemon.start")(function* () { - const existing = yield* healthy().pipe(Effect.option) - const found = Option.getOrUndefined(existing) - if (found) return found.url - - yield* Effect.sync(() => { - const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" - Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }).unref() - }) - - return yield* healthy().pipe( - Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), - Effect.map((info) => info.url), - Effect.mapError(() => new Error("Failed to start server")), - ) - }) - - const client = Effect.fn("cli.daemon.client")(function* () { - return yield* createClient(yield* start()) - }) - - const status = Effect.fn("cli.daemon.status")(function* () { - const existing = yield* healthy().pipe(Effect.option) - const found = Option.getOrUndefined(existing) - if (found) return found.url - yield* fs.remove(file).pipe(Effect.ignore) - return undefined + const compatible = Effect.fnUntraced(function* () { + const info = yield* healthy() + if (info.version === InstallationVersion) return info + return yield* Effect.fail(new Error("Registered server version does not match the client")) }) const signal = (pid: number, signal: NodeJS.Signals) => @@ -102,36 +86,92 @@ export const layer = Layer.effect( return yield* Effect.fail(new Error(`Server process ${pid} is still running`)) }) + const stopProcess = Effect.fnUntraced(function* (info: Registration) { + const current = yield* healthy().pipe(Effect.option) + if (Option.isNone(current) || !sameRegistration(current.value, info)) return + + yield* signal(info.pid, "SIGTERM") + const stopped = yield* awaitStopped(info.pid).pipe( + Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), + Effect.option, + ) + if (Option.isSome(stopped)) return + + const latest = yield* healthy().pipe(Effect.option) + if (Option.isNone(latest) || !sameRegistration(latest.value, info)) return + yield* signal(info.pid, "SIGKILL") + yield* awaitStopped(info.pid).pipe( + Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), + ) + }) + + const start = Effect.fn("cli.daemon.start")(function* () { + const existing = yield* healthy().pipe(Effect.option) + const found = Option.getOrUndefined(existing) + if (found?.version === InstallationVersion) return found.url + if (found) yield* stopProcess(found).pipe(Effect.ignore) + + yield* Effect.sync(() => { + const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" + Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).unref() + }) + + return yield* compatible().pipe( + Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), + Effect.map((info) => info.url), + Effect.mapError(() => new Error("Failed to start server")), + ) + }) + + const client = Effect.fn("cli.daemon.client")(function* () { + return yield* createClient(yield* start()) + }) + + const status = Effect.fn("cli.daemon.status")(function* () { + const existing = yield* healthy().pipe(Effect.option) + const found = Option.getOrUndefined(existing) + if (found?.version === InstallationVersion) return found.url + if (found) return undefined + yield* fs.remove(file).pipe(Effect.ignore) + return undefined + }) + const stop = Effect.fn("cli.daemon.stop")(function* () { const existing = yield* healthy().pipe(Effect.option) // A stale registration may point at a PID that has since been reused by // another process. Only signal the PID after authenticating the server. if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore) - const pid = existing.value.pid - yield* signal(pid, "SIGTERM") - const stopped = yield* awaitStopped(pid).pipe( - Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), - Effect.option, - ) - if (Option.isNone(stopped)) { - yield* signal(pid, "SIGKILL") - yield* awaitStopped(pid).pipe( - Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), - ) - } + yield* stopProcess(existing.value) yield* fs.remove(file).pipe(Effect.ignore) }) const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) { - const temp = file + ".tmp" + const id = randomUUID() + const temp = file + "." + id + ".tmp" yield* fs.makeDirectory(directory, { recursive: true }) - yield* fs.writeFileString(temp, JSON.stringify({ url: HttpServer.formatAddress(address), pid: process.pid }), { - mode: 0o600, - }) + yield* fs.writeFileString( + temp, + JSON.stringify({ id, version: InstallationVersion, url: HttpServer.formatAddress(address), pid: process.pid }), + { mode: 0o600 }, + ) yield* fs.rename(temp, file) - // The metadata file represents this live listener, not persistent config. - // Scope shutdown removes it when the server exits normally. - yield* Effect.addFinalizer(() => fs.remove(file).pipe(Effect.ignore)) + yield* registration() + .pipe( + Effect.flatMap((info) => (info.id === id ? Effect.void : signal(process.pid, "SIGTERM"))), + Effect.catch(() => signal(process.pid, "SIGTERM")), + Effect.repeat(Schedule.spaced("10 seconds")), + Effect.forkScoped, + ) + yield* Effect.addFinalizer(() => + registration().pipe( + Effect.flatMap((info) => (info.id === id ? fs.remove(file) : Effect.void)), + Effect.ignore, + ), + ) }) return Service.of({ client, start, status, stop, password, register }) From 7ae856a9e97130f664f6f11fa5871a2795de9902 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 5 Jun 2026 05:31:34 +0000 Subject: [PATCH 027/626] chore: generate --- packages/cli/src/services/daemon.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts index bbd21a6fd7..bbd628b433 100644 --- a/packages/cli/src/services/daemon.ts +++ b/packages/cli/src/services/daemon.ts @@ -159,13 +159,12 @@ export const layer = Layer.effect( { mode: 0o600 }, ) yield* fs.rename(temp, file) - yield* registration() - .pipe( - Effect.flatMap((info) => (info.id === id ? Effect.void : signal(process.pid, "SIGTERM"))), - Effect.catch(() => signal(process.pid, "SIGTERM")), - Effect.repeat(Schedule.spaced("10 seconds")), - Effect.forkScoped, - ) + yield* registration().pipe( + Effect.flatMap((info) => (info.id === id ? Effect.void : signal(process.pid, "SIGTERM"))), + Effect.catch(() => signal(process.pid, "SIGTERM")), + Effect.repeat(Schedule.spaced("10 seconds")), + Effect.forkScoped, + ) yield* Effect.addFinalizer(() => registration().pipe( Effect.flatMap((info) => (info.id === id ? fs.remove(file) : Effect.void)), From 7f33576f465a6fc89badd9994a34088bafb33aa7 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:30:02 +1000 Subject: [PATCH 028/626] feat(app): improve desktop multi-server support (#30678) Co-authored-by: Brendan Allan --- .../regression/prompt-thinking-level.spec.ts | 3 +- .../session-list-path-loading.spec.ts | 5 +- .../session-timeline-collapse-state.spec.ts | 11 +- .../session-timeline-context-resize.spec.ts | 7 +- .../app/e2e/smoke/session-timeline.spec.ts | 12 +- packages/app/e2e/utils/waits.ts | 11 + packages/app/src/app.tsx | 71 +-- .../src/components/dialog-edit-project.tsx | 18 +- .../src/components/dialog-select-server.tsx | 9 +- .../components/prompt-input/submit.test.ts | 1 + .../app/src/components/prompt-input/submit.ts | 19 +- .../app/src/components/settings-general.tsx | 4 +- .../src/components/settings-v2/general.tsx | 12 +- .../src/components/settings-v2/providers.tsx | 20 +- packages/app/src/components/titlebar.tsx | 170 +++---- packages/app/src/context/comments.tsx | 9 +- packages/app/src/context/directory-sync.ts | 9 +- packages/app/src/context/file.tsx | 7 +- packages/app/src/context/file/view-cache.ts | 9 +- .../src/context/global-sync/bootstrap.test.ts | 15 +- .../app/src/context/global-sync/bootstrap.ts | 48 +- .../context/global-sync/child-store.test.ts | 21 +- .../src/context/global-sync/child-store.ts | 15 +- .../global-sync/session-prefetch.test.ts | 42 +- .../context/global-sync/session-prefetch.ts | 37 +- packages/app/src/context/global.tsx | 150 +----- packages/app/src/context/language.tsx | 1 + packages/app/src/context/layout.tsx | 34 +- packages/app/src/context/local.tsx | 15 +- packages/app/src/context/models.tsx | 1 + packages/app/src/context/notification.tsx | 3 +- packages/app/src/context/permission.tsx | 3 +- packages/app/src/context/prompt.tsx | 9 +- packages/app/src/context/server-sdk.test.ts | 14 + packages/app/src/context/server-sdk.tsx | 33 +- packages/app/src/context/server-sync.tsx | 48 +- packages/app/src/context/server.test.ts | 72 ++- packages/app/src/context/server.tsx | 171 ++++--- packages/app/src/context/settings.tsx | 1 + packages/app/src/context/tabs.tsx | 140 ++++++ packages/app/src/context/terminal.test.ts | 48 +- packages/app/src/context/terminal.tsx | 61 +-- packages/app/src/entry.tsx | 1 + packages/app/src/pages/home.tsx | 443 +++++++++++------- packages/app/src/pages/layout.tsx | 48 +- packages/app/src/pages/layout/helpers.test.ts | 86 ++++ packages/app/src/pages/layout/helpers.ts | 40 +- .../src/pages/layout/project-avatar-state.ts | 6 +- packages/app/src/pages/session.tsx | 12 +- .../composer/session-question-dock.tsx | 12 +- .../app/src/pages/session/session-layout.ts | 12 +- .../app/src/pages/session/terminal-panel.tsx | 6 +- packages/app/src/utils/persist.test.ts | 26 + packages/app/src/utils/persist.ts | 31 +- packages/app/src/utils/server-health.test.ts | 25 + packages/app/src/utils/server-health.ts | 7 +- packages/app/src/utils/server-scope.test.ts | 57 +++ packages/app/src/utils/server-scope.ts | 70 +++ packages/app/src/utils/worktree.test.ts | 36 +- packages/app/src/utils/worktree.ts | 45 +- packages/desktop/src/main/server.ts | 1 + packages/ui/src/context/helper.tsx | 11 +- 62 files changed, 1523 insertions(+), 841 deletions(-) create mode 100644 packages/app/e2e/utils/waits.ts create mode 100644 packages/app/src/context/server-sdk.test.ts create mode 100644 packages/app/src/context/tabs.tsx create mode 100644 packages/app/src/utils/server-scope.test.ts create mode 100644 packages/app/src/utils/server-scope.ts diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts index d3a42071d1..d12684c143 100644 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" const directory = "C:/OpenCode/PromptThinkingLevelRegression" const projectID = "proj_prompt_thinking_level_regression" @@ -56,7 +57,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => { const composer = page.locator('[data-component="session-composer"]') const input = composer.locator('[data-component="prompt-input"]') const control = composer.locator('[data-component="prompt-variant-control"]') - await expect(composer).toBeVisible() + await expectAppVisible(composer) await idleComposer(page) await expect(control).toBeHidden() diff --git a/packages/app/e2e/regression/session-list-path-loading.spec.ts b/packages/app/e2e/regression/session-list-path-loading.spec.ts index 1dbc0575f1..4a3855122a 100644 --- a/packages/app/e2e/regression/session-list-path-loading.spec.ts +++ b/packages/app/e2e/regression/session-list-path-loading.spec.ts @@ -1,6 +1,7 @@ -import { expect, test } from "@playwright/test" +import { test } from "@playwright/test" import { fixture, pageMessages } from "../smoke/session-timeline.fixture" import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" test("shows loaded sessions before the directory path request resolves", async ({ page }) => { await mockOpenCodeServer(page, { @@ -33,7 +34,7 @@ test("shows loaded sessions before the directory path request resolves", async ( await page.goto("/") try { - await expect(page.getByText(fixture.expected.sourceTitle).first()).toBeVisible({ timeout: 5_000 }) + await expectAppVisible(page.getByText(fixture.expected.sourceTitle).first()) } finally { releasePath() } diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 88b140a61d..8b31b8cc27 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Locator, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/TimelineStateRegression" const projectID = "proj_timeline_state_regression" @@ -106,10 +107,10 @@ test.describe("regression: session timeline local row state", () => { await configurePage(page) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expect(page.getByRole("heading", { name: title })).toBeVisible() + await expectSessionTitle(page, title) const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() - await expect(wrapper).toBeVisible() + await expectAppVisible(wrapper) await expectExpanded(wrapper, true) await wrapper.evaluate((element) => { @@ -142,11 +143,11 @@ test.describe("regression: session timeline local row state", () => { await configurePage(page) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expect(page.getByRole("heading", { name: title })).toBeVisible() + await expectSessionTitle(page, title) const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() - await expect(wrapper).toBeVisible() - await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible() + await expectAppVisible(wrapper) + await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first()) await markDiffProbe(page) events.push({ diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index dc72e24f0d..d2b1827573 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/ContextResizeRegression" const projectID = "proj_context_resize_regression" @@ -23,9 +24,9 @@ test.describe("regression: session timeline context group resize", () => { await configurePage(page) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expect(page.getByRole("heading", { name: title })).toBeVisible() - await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible() - await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible() + await expectSessionTitle(page, title) + await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()) + await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()) await settle(page) const samples = await sampleExpansion(page) diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index af413ffffb..5d7425ea5b 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -3,6 +3,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { fixture, pageMessages } from "./session-timeline.fixture" import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" import { mockOpenCodeServer } from "../utils/mock-server" +import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits" const forbiddenText = ["Load details", "Show earlier steps"] @@ -411,18 +412,21 @@ function expectCompleteScroll( async function selectHomeProject(page: Page, projectName: string) { await page.goto("/") - await page + const row = page .locator('[data-component="home-project-row"]') .filter({ hasText: new RegExp(projectName, "i") }) - .click() + .first() + await expectAppVisible(row) + await row.click() + await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT }) await expect(page).toHaveURL(/\/$/) } async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) { await page.goto(`/${base64Encode(directory)}/session/${sessionId}`) - await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible() + await expectSessionTitle(page, expectedTitle) } async function expectSessionReady(page: Page) { - await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible() + await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i })) } diff --git a/packages/app/e2e/utils/waits.ts b/packages/app/e2e/utils/waits.ts new file mode 100644 index 0000000000..8a47815674 --- /dev/null +++ b/packages/app/e2e/utils/waits.ts @@ -0,0 +1,11 @@ +import { expect, type Locator, type Page } from "@playwright/test" + +export const APP_READY_TIMEOUT = 30_000 + +export async function expectAppVisible(locator: Locator) { + await expect(locator).toBeVisible({ timeout: APP_READY_TIMEOUT }) +} + +export async function expectSessionTitle(page: Page, title: string) { + await expectAppVisible(page.getByRole("heading", { name: title })) +} diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 915c8ec530..8e5d445f4c 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -25,7 +25,6 @@ import { onCleanup, type ParentProps, Show, - Suspense, } from "solid-js" import { Dynamic } from "solid-js/web" import { CommandProvider } from "@/context/command" @@ -44,6 +43,7 @@ import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider, useSettings } from "@/context/settings" import { TerminalProvider } from "@/context/terminal" +import { TabsProvider } from "@/context/tabs" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" @@ -211,26 +211,21 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { Effect.runPromise, ), ) + const checking = createMemo( + () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state), + ) return ( -
    } > - {/* - -
    - } - >*/} - {checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest} { @@ -246,8 +241,7 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { > {props.children} - {/**/} - + ) } @@ -310,32 +304,41 @@ function ServerKey(props: ParentProps) { export function AppInterface(props: { children?: JSX.Element defaultServer: ServerConnection.Key + canonicalLocalServer?: ServerConnection.Key servers?: Array router?: Component disableHealthCheck?: boolean }) { return ( - - + + - - - - - {routerProps.children}} - > - - - } /> - - - - - - - + ( + + + + + + {routerProps.children} + + + + + + )} + > + + + } /> + + + diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 21677b72e3..29ee4dfeab 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -6,21 +6,23 @@ import { useMutation } from "@tanstack/solid-query" import { Icon } from "@opencode-ai/ui/icon" import { createMemo, For, Show } from "solid-js" import { createStore } from "solid-js/store" -import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" import { type LocalProject, getAvatarColors } from "@/context/layout" import { getFilename } from "@opencode-ai/core/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" import { getProjectAvatarSource } from "@/pages/layout/helpers" +import { ServerConnection } from "@/context/server" +import { useGlobal } from "@/context/global" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const -export function DialogEditProject(props: { project: LocalProject }) { +export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) { const dialog = useDialog() - const serverSDK = useServerSDK() - const serverSync = useServerSync() + const global = useGlobal() const language = useLanguage() + const serverCtx = createMemo(() => global.createServerCtx(props.server)) + const serverSDK = () => serverCtx().sdk + const serverSync = () => serverCtx().sync const folderName = createMemo(() => getFilename(props.project.worktree)) const defaultName = createMemo(() => props.project.name || folderName()) @@ -78,19 +80,19 @@ export function DialogEditProject(props: { project: LocalProject }) { const start = store.startup.trim() if (props.project.id && props.project.id !== "global") { - await serverSDK.client.project.update({ + await serverSDK().client.project.update({ projectID: props.project.id, directory: props.project.worktree, name, icon: { color: store.color || "", override: store.iconOverride || "" }, commands: { start }, }) - serverSync.project.icon(props.project.worktree, store.iconOverride || undefined) + serverSync().project.icon(props.project.worktree, store.iconOverride || undefined) dialog.close() return } - serverSync.project.meta(props.project.worktree, { + serverSync().project.meta(props.project.worktree, { name, icon: { color: store.color || undefined, override: store.iconOverride || undefined }, commands: { start: start || undefined }, diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 6f43a6d46f..90a644097b 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -18,6 +18,7 @@ import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { useSettings } from "@/context/settings" +import { useTabs } from "@/context/tabs" const DEFAULT_USERNAME = "opencode" @@ -191,6 +192,7 @@ export function DialogSelectServer() { export function useServerManagementController(options: { onSelect?: () => void } = {}) { const navigate = useNavigate() const server = useServer() + const tabs = useTabs() const global = useGlobal() const platform = usePlatform() const language = useLanguage() @@ -311,12 +313,14 @@ export function useServerManagementController(options: { onSelect?: () => void } })) const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { + const originalKey = ServerConnection.key(original) const active = server.key + tabs.removeServer(originalKey) const newConn = server.add(next) if (!newConn) return - const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active + const nextActive = active === originalKey ? ServerConnection.key(newConn) : active if (nextActive) server.setActive(nextActive) - server.remove(ServerConnection.key(original)) + server.remove(originalKey) } const items = createMemo(() => { @@ -501,6 +505,7 @@ export function useServerManagementController(options: { onSelect?: () => void } }) async function handleRemove(url: ServerConnection.Key) { + tabs.removeServer(url) server.remove(url) if ((await platform.getDefaultServer?.()) === url) { void platform.setDefaultServer?.(null) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index b1b289a276..092731a9ea 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -127,6 +127,7 @@ beforeAll(async () => { mock.module("@/context/sdk", () => ({ useSDK: () => { const sdk = { + scope: "local", directory: "/repo/main", client: rootClient, url: "http://localhost:4096", diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 004427d0c8..7614bb5eab 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -18,6 +18,7 @@ import { Worktree as WorktreeState } from "@/utils/worktree" import { buildRequestParts } from "./build-request-parts" import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" +import { ScopedKey } from "@/utils/server-scope" type PendingPrompt = { abort: AbortController @@ -212,6 +213,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const layout = useLayout() const language = useLanguage() const params = useParams() + const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID) const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { @@ -232,11 +234,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.onAbort?.() - const queued = pending.get(sessionID) + const key = pendingKey(sessionID) + const queued = pending.get(key) if (queued) { queued.abort.abort() queued.cleanup() - pending.delete(sessionID) + pending.delete(key) return Promise.resolve() } return sdk.client.session @@ -341,7 +344,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) return } - WorktreeState.pending(createdWorktree.directory) + WorktreeState.pending(sdk.scope, createdWorktree.directory) sessionDirectory = createdWorktree.directory } @@ -500,7 +503,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { clearInput() const waitForWorktree = async () => { - const worktree = WorktreeState.get(sessionDirectory) + const worktree = WorktreeState.get(sdk.scope, sessionDirectory) if (!worktree || worktree.status !== "pending") return true if (sessionDirectory === projectDirectory) { @@ -517,7 +520,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { restoreInput() } - pending.set(session.id, { abort: controller, cleanup }) + pending.set(pendingKey(session.id), { abort: controller, cleanup }) const abortWait = new Promise>>((resolve) => { if (controller.signal.aborted) { @@ -544,11 +547,11 @@ export function createPromptSubmit(input: PromptSubmitInput) { }, timeoutMs) }) - const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => { + const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(() => { if (timer.id === undefined) return clearTimeout(timer.id) }) - pending.delete(session.id) + pending.delete(pendingKey(session.id)) if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) return true @@ -563,7 +566,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { optimisticBusy: sessionDirectory === projectDirectory, before: waitForWorktree, }).catch((err) => { - pending.delete(session.id) + pending.delete(pendingKey(session.id)) if (sessionDirectory === projectDirectory) { sync.set("session_status", session.id, { type: "idle" }) } diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 3970f5c210..b5e8ffa721 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -178,11 +178,11 @@ export const SettingsGeneral: Component = () => { const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const serverSync = useServerSync() - const globalSdk = useServerSDK() + const serverSdk = useServerSDK() const [shells] = createResource( () => - globalSdk.client.pty + serverSdk.client.pty .shells() .then((res) => res.data ?? []) .catch(() => [] as ShellOption[]), diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 113710d588..9bc5141ba6 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -179,12 +179,12 @@ export const SettingsGeneralV2: Component = () => { const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) - const globalSync = useServerSync() - const globalSdk = useServerSDK() + const serverSync = useServerSync() + const serverSdk = useServerSDK() const [shells] = createResource( () => - globalSdk.client.pty + serverSdk.client.pty .shells() .then((res) => res.data ?? []) .catch(() => [] as ShellOption[]), @@ -208,11 +208,11 @@ export const SettingsGeneralV2: Component = () => { }) const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } - const currentShell = createMemo(() => globalSync.data.config.shell ?? "") + const currentShell = createMemo(() => serverSync.data.config.shell ?? "") const shellOptions = createMemo(() => { const list = shells.latest - const current = globalSync.data.config.shell + const current = serverSync.data.config.shell const nameCounts = new Map() for (const s of list) { @@ -347,7 +347,7 @@ export const SettingsGeneralV2: Component = () => { onSelect={(option) => { if (!option) return if (option.value === currentShell()) return - globalSync.updateConfig({ shell: option.value }) + serverSync.updateConfig({ shell: option.value }) }} /> diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index 397166be75..f5ad6e9762 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -33,8 +33,8 @@ const PROVIDER_ICON_SIZE = 16 export const SettingsProvidersV2: Component = () => { const dialog = useDialog() const language = useLanguage() - const globalSDK = useServerSDK() - const globalSync = useServerSync() + const serverSdk = useServerSDK() + const serverSync = useServerSync() const providers = useProviders() const connected = createMemo(() => { @@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component = () => { const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const isConfigCustom = (providerID: string) => { - const provider = globalSync.data.config.provider?.[providerID] + const provider = serverSync.data.config.provider?.[providerID] if (!provider) return false if (provider.npm !== "@ai-sdk/openai-compatible") return false if (!provider.models || Object.keys(provider.models).length === 0) return false @@ -85,11 +85,11 @@ export const SettingsProvidersV2: Component = () => { } const disableProvider = async (providerID: string, name: string) => { - const before = globalSync.data.config.disabled_providers ?? [] + const before = serverSync.data.config.disabled_providers ?? [] const next = before.includes(providerID) ? before : [...before, providerID] - globalSync.set("config", "disabled_providers", next) + serverSync.set("config", "disabled_providers", next) - await globalSync + await serverSync .updateConfig({ disabled_providers: next }) .then(() => { showToast({ @@ -100,7 +100,7 @@ export const SettingsProvidersV2: Component = () => { }) }) .catch((err: unknown) => { - globalSync.set("config", "disabled_providers", before) + serverSync.set("config", "disabled_providers", before) const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) }) @@ -108,14 +108,14 @@ export const SettingsProvidersV2: Component = () => { const disconnect = async (providerID: string, name: string) => { if (isConfigCustom(providerID)) { - await globalSDK.client.auth.remove({ providerID }).catch(() => undefined) + await serverSdk.client.auth.remove({ providerID }).catch(() => undefined) await disableProvider(providerID, name) return } - await globalSDK.client.auth + await serverSdk.client.auth .remove({ providerID }) .then(async () => { - await globalSDK.client.global.dispose() + await serverSdk.client.global.dispose() showToast({ variant: "success", icon: "circle-check", diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 2169878633..36c7e46b62 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,5 +1,5 @@ import { createEffect, createMemo, createResource, For, Match, Show, startTransition, Switch, untrack } from "solid-js" -import { createStore, produce } from "solid-js/store" +import { createStore } from "solid-js/store" import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" @@ -17,20 +17,16 @@ import { useSettings } from "@/context/settings" import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" import { useServerSync } from "@/context/server-sync" -import { iife } from "@opencode-ai/core/util/iife" import { base64Encode } from "@opencode-ai/core/util/encode" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { makeEventListener } from "@solid-primitives/event-listener" -import { - readSessionTabsRemovedDetail, - SESSION_TABS_REMOVED_EVENT, - type SessionTabsRemovedDetail, -} from "@/components/titlebar-session-events" -import { Persist, persisted } from "@/utils/persist" +import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { useGlobal } from "@/context/global" import { decode64 } from "@/utils/base64" +import { ServerConnection, useServer } from "@/context/server" +import { tabHref, useTabs, type Tab } from "@/context/tabs" type TauriDesktopWindow = { startDragging?: () => Promise @@ -58,8 +54,6 @@ const v2TitlebarHeight = 36 const minTitlebarZoom = 0.25 const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. -const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}` - export type TitlebarUpdate = { version: () => string | undefined installing: () => boolean @@ -73,6 +67,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const language = useLanguage() const settings = useSettings() const theme = useTheme() + const server = useServer() const navigate = useNavigate() const location = useLocation() const params = useParams() @@ -256,93 +251,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { return `/${base64Encode(project.worktree)}/session` } - type SessionTab = { type: "session"; dirBase64: string; sessionId: string } - type Tab = SessionTab - - const tabHref = (tab: Tab) => { - if (tab.type === "session") { - return makeSessionHref(tab.dirBase64, tab.sessionId) + const tabs = useTabs() + const tabsStore = tabs.store + const tabsStoreActions = tabs + const navigateTab = (tab: Tab) => { + const href = tabHref(tab) + if (tab.server === server.key) { + navigate(href) + return } - return "/" + void startTransition(() => { + server.setActive(tab.server) + navigate(href) + }) } - const [tabsStore, tabsStoreActions] = iife(() => { - const [store, setStore] = persisted(Persist.global("tabs"), createStore([])) - - const actions = { - addSessionTab: (tab: Omit) => { - setStore( - produce((tabs) => { - if (tabs.some((t) => t.type === "session" && tabHref(t) === tabHref({ type: "session", ...tab }))) - return - - tabs.push({ type: "session", ...tab }) - }), - ) - }, - removeTab: (index: number) => { - if (index < 0) return - void startTransition(() => { - setStore( - produce((tabs) => { - const nextTab = tabs[index + 1] ?? tabs[index - 1] - tabs.splice(index, 1) - if (nextTab) navigate(tabHref(nextTab)) - else navigate("/") - }), - ) - }) - }, - removeSessions: (input: SessionTabsRemovedDetail) => { - void startTransition(() => { - setStore( - produce((tabs) => { - const sessionIDs = new Set(input.sessionIDs) - const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined - const currentIndex = currentHref - ? tabs.findIndex((tab) => tab.type === "session" && tabHref(tab) === currentHref) - : -1 - const currentTab = tabs[currentIndex] - const removedCurrent = - currentTab?.type === "session" && - atob(currentTab.dirBase64) === input.directory && - sessionIDs.has(currentTab.sessionId) - - for (let i = tabs.length - 1; i >= 0; i--) { - const tab = tabs[i] - if (!tab || tab.type !== "session") continue - if (atob(tab.dirBase64) !== input.directory) continue - if (!sessionIDs.has(tab.sessionId)) continue - tabs.splice(i, 1) - } - - if (!removedCurrent) return - const nextTab = - tabs.slice(currentIndex).find((tab) => tab.type === "session") ?? - tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session") - if (nextTab) navigate(tabHref(nextTab)) - else navigate("/") - }), - ) - }) - }, - } - - return [store, actions] - }) - const matchRoute = (route: LayoutRoute) => { if (route.type === "home") return if (route.type === "dir-new-sesssion") { } if (route.type === "session") { - const main = tabsStore.find((s) => s.type === "session" && s.sessionId === route.sessionId) + const main = tabsStore.find( + (item) => + item.type === "session" && item.server === route.server && item.sessionId === route.sessionId, + ) if (main) return main const sync = serverSync.createDirSyncContext(route.dir) const session = sync.session.get(route.sessionId) if (session?.parentID) { const parentID = session.parentID - const parent = tabsStore.find((s) => s.type === "session" && s.sessionId === parentID) + const parent = tabsStore.find( + (item) => item.type === "session" && item.server === route.server && item.sessionId === parentID, + ) if (parent) return parent } } @@ -352,6 +292,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { createEffect(() => { const route = layout.route() + if (!tabs.ready()) return const tab = currentTab() if (tab) return @@ -360,10 +301,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const session = sync.session.get(route.sessionId) if (!session) return const sessionId = session.parentID ?? session.id - tabsStoreActions.addSessionTab({ + const next = { + server: route.server ?? server.key, dirBase64: route.dirBase64, sessionId, - }) + } + tabsStoreActions.addSessionTab(next) } }) @@ -411,7 +354,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { if (index === -1) index = tabsStore.length - 1 const next = tabsStore[index] - if (next) navigate(tabHref(next)) + if (next) navigateTab(next) }, }, { @@ -428,7 +371,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { if (index === tabsStore.length) index = 0 const next = tabsStore[index] - if (next) navigate(tabHref(next)) + if (next) navigateTab(next) }, }, ...Array.from({ length: 9 }, (_, i) => { @@ -443,7 +386,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { hidden: true, onSelect: () => { const tab = tabsStore[index] - if (tab) navigate(tabHref(tab)) + if (tab) navigateTab(tab) }, } }), @@ -482,10 +425,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { )} navigateTab(tab)} onClose={() => tabsStoreActions.removeTab(i())} active={currentTab() === tab} + activeServer={tab.server === server.key} /> )} @@ -509,7 +455,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { navigate(tabsStore.at(-1) ? tabHref(tabsStore.at(-1)!) : "/")} + onClose={() => { + const tab = tabsStore.at(-1) + if (tab) navigateTab(tab) + else navigate("/") + }} />
    @@ -736,11 +686,14 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) { function TabNavItem(props: { href: string + server: ServerConnection.Key directory: string sessionId?: string hideClose?: boolean onClose: () => void + onNavigate: () => void active?: boolean + activeServer: boolean }) { const closeTab = (event: MouseEvent) => { event.preventDefault() @@ -748,17 +701,23 @@ function TabNavItem(props: { props.onClose() } const global = useGlobal() - const serverCtx = global.createServerCtx(global.servers.default()) + const serverCtx = createMemo(() => { + const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) + if (conn) return global.createServerCtx(conn) + }) + const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory)) const [session] = createResource( () => { - const dirSyncCtx = serverCtx.sync.createDirSyncContext(props.directory) - return props.sessionId ? ([props.sessionId, dirSyncCtx] as const) : undefined + const ctx = dirSyncCtx() + if (!ctx || !props.sessionId) return + return [props.sessionId, ctx] as const }, async ([sessionId, dirSyncCtx]) => { await dirSyncCtx.session.sync(sessionId).catch(() => {}) return dirSyncCtx.session.get(sessionId) }, + { initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined }, ) return ( @@ -770,18 +729,26 @@ function TabNavItem(props: { closeTab(event) }} > - + {(session) => { - const layout = useLayout() - const project = createMemo(() => projectForSession(session(), layout.projects.list())) + const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) return ( { + event.preventDefault() + props.onNavigate() + }} class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base" > - + {session().title} @@ -809,10 +776,15 @@ function TabNavItem(props: { ) } -function ProjectTabAvatar(props: { project?: LocalProject; directory: string; sessionId: string }) { +function ProjectTabAvatar(props: { + project?: LocalProject + directory: string + sessionId: string + activeServer: boolean +}) { const directory = () => props.directory const sessionId = () => props.sessionId - const state = useSessionTabAvatarState(directory, sessionId) + const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) return ( ({ comments: {}, }), @@ -200,11 +202,12 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont gate: false, init: () => { const params = useParams() + const serverSDK = useServerSDK() const cache = createScopedCache( (key) => { const decoded = decodeSessionKey(key) return createRoot((dispose) => ({ - value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id), + value: createCommentSession(serverSDK.scope, decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id), dispose, })) }, diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 0443bbc34c..e8221fd0b0 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -276,7 +276,7 @@ export const createDirSyncContext = ( const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => { if (sessionIDs.length === 0) return - clearSessionPrefetch(directory, sessionIDs) + clearSessionPrefetch(serverSDK.scope, directory, sessionIDs) for (const sessionID of sessionIDs) { serverSync.todo.set(sessionID, undefined) } @@ -348,6 +348,7 @@ export const createDirSyncContext = ( setMeta("cursor", key, next.cursor) setMeta("complete", key, next.complete) setSessionPrefetch({ + scope: serverSDK.scope, directory: input.directory, sessionID: input.sessionID, limit: message.length, @@ -438,7 +439,7 @@ export const createDirSyncContext = ( touch(directory, setStore, sessionID) - const seeded = getSessionPrefetch(directory, sessionID) + const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID) if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { batch(() => { setMeta("limit", key, seeded.limit) @@ -449,10 +450,10 @@ export const createDirSyncContext = ( } return runInflight(inflight, key, async () => { - const pending = getSessionPrefetchPromise(directory, sessionID) + const pending = getSessionPrefetchPromise(serverSDK.scope, directory, sessionID) if (pending) { await pending - const seeded = getSessionPrefetch(directory, sessionID) + const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID) if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { batch(() => { setMeta("limit", key, seeded.limit) diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 49173c49d6..a4088810aa 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -21,6 +21,8 @@ import { touchFileContent, } from "./file/content-cache" import { createFileViewCache } from "./file/view-cache" +import { useServerSDK } from "./server-sdk" +import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" import { createFileTreeStore } from "./file/tree-store" import { invalidateFromWatcher } from "./file/watcher" import { @@ -56,12 +58,13 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const sdk = useSDK() useSync() const params = useParams() + const serverSDK = useServerSDK() const language = useLanguage() const layout = useLayout() const scope = createMemo(() => sdk.directory) const path = createPathHelpers(scope) - const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = layout.tabs(() => SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id))) const inflight = new Map>() const [store, setStore] = createStore<{ @@ -107,7 +110,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ }) }) - const viewCache = createFileViewCache() + const viewCache = createFileViewCache(serverSDK.scope) const view = createMemo(() => viewCache.load(scope(), params.id)) const ensure = (file: string) => { diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts index 4c060174ab..87b859dea8 100644 --- a/packages/app/src/context/file/view-cache.ts +++ b/packages/app/src/context/file/view-cache.ts @@ -3,6 +3,7 @@ import { createStore, produce } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import { createScopedCache } from "@/utils/scoped-cache" import type { FileViewState, SelectedLineRange } from "./types" +import type { ServerScope } from "@/utils/server-scope" const WORKSPACE_KEY = "__workspace__" const MAX_FILE_VIEW_SESSIONS = 20 @@ -33,11 +34,11 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected ) } -function createViewSession(dir: string, id: string | undefined) { +function createViewSession(scope: ServerScope, dir: string, id: string | undefined) { const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` const [view, setView, _, ready] = persisted( - Persist.scoped(dir, id, "file-view", [legacyViewKey]), + Persist.serverScoped(scope, dir, id, "file-view", [legacyViewKey]), createStore<{ file: Record }>({ @@ -119,14 +120,14 @@ function createViewSession(dir: string, id: string | undefined) { } } -export function createFileViewCache() { +export function createFileViewCache(scope: ServerScope) { const cache = createScopedCache( (key) => { const split = key.lastIndexOf("\n") const dir = split >= 0 ? key.slice(0, split) : key const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY return createRoot((dispose) => ({ - value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id), + value: createViewSession(scope, dir, id === WORKSPACE_KEY ? undefined : id), dispose, })) }, diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 8117e6139d..48e49cc07a 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -3,8 +3,9 @@ import { createStore } from "solid-js/store" import { QueryClient } from "@tanstack/solid-query" import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context" -import { bootstrapDirectory } from "./bootstrap" +import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap" import type { State, VcsCache } from "./types" +import { ServerScope } from "@/utils/server-scope" const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse @@ -45,6 +46,7 @@ describe("bootstrapDirectory", () => { await bootstrapDirectory({ directory: "/project", + scope: ServerScope.local, mcp: false, global: { config: {} satisfies Config, @@ -89,3 +91,14 @@ describe("bootstrapDirectory", () => { expect(mcpReads).toEqual([]) }) }) + +describe("query keys", () => { + test("partitions identical directories by server scope", () => { + const client = {} as OpencodeClient + const remote = "https://debian.example" as typeof ServerScope.local + + expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"]) + expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) + expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual(["https://debian.example", null, "providers"]) + }) +}) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 0ded34fe28..bac9c69280 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -20,6 +20,7 @@ import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" import { loadMcpQuery } from "../server-sync" import { NormalizedProviderListResponse } from "@opencode-ai/ui/context" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" type GlobalStore = { ready: boolean @@ -59,8 +60,8 @@ function errors(list: PromiseSettledResult[]) { const providerRev = new Map() -export function clearProviderRev(directory: string) { - providerRev.delete(directory) +export function clearProviderRev(scope: ServerScope, directory: string) { + providerRev.delete(ScopedKey.from(scope, directory)) } function runAll(list: Array<() => Promise>) { @@ -83,15 +84,15 @@ function showErrors(input: { }) } -export const loadGlobalConfigQuery = (sdk: OpencodeClient) => +export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) => queryOptions({ - queryKey: ["config"], + queryKey: [scope, "config"], queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)), }) -export const loadProjectsQuery = (sdk: OpencodeClient) => +export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) => queryOptions({ - queryKey: ["project"], + queryKey: [scope, "project"], queryFn: () => retry(() => sdk.project.list().then((x) => { @@ -106,6 +107,7 @@ export const loadProjectsQuery = (sdk: OpencodeClient) => export async function bootstrapGlobal(input: { serverSDK: OpencodeClient + scope: ServerScope requestFailedTitle: string translate: (key: string, vars?: Record) => string formatMoreCount: (count: number) => string @@ -113,12 +115,12 @@ export async function bootstrapGlobal(input: { queryClient: QueryClient }) { const slow = [ - () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.serverSDK)), - () => input.queryClient.fetchQuery(loadProvidersQuery(null, input.serverSDK)), - () => input.queryClient.fetchQuery(loadPathQuery(null, input.serverSDK)), + () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)), + () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)), + () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)), () => input.queryClient - .fetchQuery(loadProjectsQuery(input.serverSDK)) + .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK)) .then((data) => input.setGlobalStore("project", data)), ] await runAll(slow) @@ -178,26 +180,27 @@ function warmSessions(input: { ).then(() => undefined) } -export const loadProvidersQuery = (directory: string | null, sdk: OpencodeClient) => +export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => queryOptions({ - queryKey: [directory, "providers"], + queryKey: [scope, directory, "providers"], queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))), }) -export const loadAgentsQuery = (directory: string | null, sdk: OpencodeClient) => +export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => queryOptions({ - queryKey: [directory, "agents"], + queryKey: [scope, directory, "agents"], queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))), }) -export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) => +export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => queryOptions({ - queryKey: [directory, "path"], + queryKey: [scope, directory, "path"], queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), }) export async function bootstrapDirectory(input: { directory: string + scope: ServerScope mcp: boolean sdk: OpencodeClient store: Store @@ -223,14 +226,15 @@ export async function bootstrapDirectory(input: { } if (loading) input.setStore("status", "partial") - const rev = (providerRev.get(input.directory) ?? 0) + 1 - providerRev.set(input.directory, rev) + const revKey = ScopedKey.from(input.scope, input.directory) + const rev = (providerRev.get(revKey) ?? 0) + 1 + providerRev.set(revKey, rev) ;(async () => { const slow = [ () => Promise.resolve(input.loadSessions(input.directory)), () => input.queryClient - .ensureQueryData(loadAgentsQuery(input.directory, input.sdk)) + .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk)) .then((data) => input.setStore("agent", data)), () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), @@ -239,7 +243,7 @@ export async function bootstrapDirectory(input: { (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), !seededPath && (() => - input.queryClient.ensureQueryData(loadPathQuery(input.directory, input.sdk)).then((data) => { + input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => { const next = projectID(data.directory ?? input.directory, input.global.project) if (next) input.setStore("project", next) })), @@ -305,9 +309,9 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), - input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))), + input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), () => - input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => { + input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { const project = getFilename(input.directory) showToast({ variant: "error", diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index c0ad67cc24..5d61f1099d 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -4,9 +4,16 @@ import { createStore } from "solid-js/store" import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import type { State } from "./types" import type { QueryOptionsApi } from "../server-sync" +import { ServerScope } from "@/utils/server-scope" let createChildStoreManager: typeof import("./child-store").createChildStoreManager const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = [] +const persist: typeof import("@/utils/persist").persisted = (_target, store) => [ + store[0], + store[1], + null, + Object.assign(() => true, { promise: undefined }), +] const child = () => createStore({} as State) const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse @@ -42,12 +49,6 @@ function createOwner(callback: (owner: Owner) => void) { } beforeAll(async () => { - mock.module("@/utils/persist", () => ({ - Persist: { - workspace: (...parts: string[]) => parts.join(":"), - }, - persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true], - })) mock.module("@tanstack/solid-query", () => ({ useQuery: (options: () => { queryKey?: unknown[]; enabled?: boolean }) => { querySingles.push(options) @@ -80,6 +81,8 @@ describe("createChildStoreManager", () => { const manager = createChildStoreManager({ owner, + scope: ServerScope.local, + persist, isBooting: () => false, isLoadingSessions: () => false, onBootstrap() {}, @@ -109,6 +112,8 @@ describe("createChildStoreManager", () => { const dispose = createOwner((owner) => { manager = createChildStoreManager({ owner, + scope: ServerScope.local, + persist, isBooting: () => false, isLoadingSessions: () => false, onBootstrap(directory) { @@ -140,6 +145,8 @@ describe("createChildStoreManager", () => { const dispose = createOwner((owner) => { manager = createChildStoreManager({ owner, + scope: ServerScope.local, + persist, isBooting: () => false, isLoadingSessions: () => false, onBootstrap() {}, @@ -171,6 +178,8 @@ describe("createChildStoreManager", () => { const dispose = createOwner((owner) => { manager = createChildStoreManager({ owner, + scope: ServerScope.local, + persist, isBooting: () => false, isLoadingSessions: () => false, onBootstrap() {}, diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 22bd262715..e9ab8fa2a3 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -18,9 +18,12 @@ import { useQuery } from "@tanstack/solid-query" import { QueryOptionsApi } from "../server-sync" import { directoryKey, type DirectoryKey } from "./utils" import { NormalizedProviderListResponse } from "@opencode-ai/ui/context" +import type { ServerScope } from "@/utils/server-scope" export function createChildStoreManager(input: { owner: Owner + scope: ServerScope + persist: typeof persisted isBooting: (directory: string) => boolean isLoadingSessions: (directory: string) => boolean onBootstrap: (directory: string) => void @@ -147,8 +150,8 @@ export function createChildStoreManager(input: { if (!key) console.error("No directory provided") if (!children[key]) { const vcs = runWithOwner(input.owner, () => - persisted( - Persist.workspace(directory, "vcs", ["vcs.v1"]), + input.persist( + Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]), createStore({ value: undefined as VcsInfo | undefined }), ), ) @@ -157,8 +160,8 @@ export function createChildStoreManager(input: { vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) const meta = runWithOwner(input.owner, () => - persisted( - Persist.workspace(directory, "project", ["project.v1"]), + input.persist( + Persist.serverWorkspace(input.scope, directory, "project", ["project.v1"]), createStore({ value: undefined as ProjectMeta | undefined }), ), ) @@ -166,8 +169,8 @@ export function createChildStoreManager(input: { metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] }) const icon = runWithOwner(input.owner, () => - persisted( - Persist.workspace(directory, "icon", ["icon.v1"]), + input.persist( + Persist.serverWorkspace(input.scope, directory, "icon", ["icon.v1"]), createStore({ value: undefined as string | undefined }), ), ) diff --git a/packages/app/src/context/global-sync/session-prefetch.test.ts b/packages/app/src/context/global-sync/session-prefetch.test.ts index 1ce8334698..89ba0a9a11 100644 --- a/packages/app/src/context/global-sync/session-prefetch.test.ts +++ b/packages/app/src/context/global-sync/session-prefetch.test.ts @@ -7,14 +7,18 @@ import { setSessionPrefetch, shouldSkipSessionPrefetch, } from "./session-prefetch" +import { ServerScope } from "@/utils/server-scope" + +const scope = ServerScope.local describe("session prefetch", () => { test("stores and clears message metadata by directory", () => { - clearSessionPrefetch("/tmp/a", ["ses_1"]) - clearSessionPrefetch("/tmp/b", ["ses_1"]) + clearSessionPrefetch(scope, "/tmp/a", ["ses_1"]) + clearSessionPrefetch(scope, "/tmp/b", ["ses_1"]) setSessionPrefetch({ directory: "/tmp/a", + scope, sessionID: "ses_1", limit: 200, cursor: "abc", @@ -22,21 +26,22 @@ describe("session prefetch", () => { at: 123, }) - expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 }) - expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined() + expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 }) + expect(getSessionPrefetch(scope, "/tmp/b", "ses_1")).toBeUndefined() - clearSessionPrefetch("/tmp/a", ["ses_1"]) + clearSessionPrefetch(scope, "/tmp/a", ["ses_1"]) - expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined() + expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toBeUndefined() }) test("dedupes inflight work", async () => { - clearSessionPrefetch("/tmp/c", ["ses_2"]) + clearSessionPrefetch(scope, "/tmp/c", ["ses_2"]) let calls = 0 const run = () => runSessionPrefetch({ directory: "/tmp/c", + scope, sessionID: "ses_2", task: async () => { calls += 1 @@ -52,15 +57,24 @@ describe("session prefetch", () => { }) test("clears a whole directory", () => { - setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 }) - setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 }) - setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 }) + setSessionPrefetch({ scope, directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 }) + setSessionPrefetch({ scope, directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 }) + setSessionPrefetch({ scope, directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 }) - clearSessionPrefetchDirectory("/tmp/d") + clearSessionPrefetchDirectory(scope, "/tmp/d") - expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined() - expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined() - expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 }) + expect(getSessionPrefetch(scope, "/tmp/d", "ses_1")).toBeUndefined() + expect(getSessionPrefetch(scope, "/tmp/d", "ses_2")).toBeUndefined() + expect(getSessionPrefetch(scope, "/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 }) + }) + + test("isolates identical directories and sessions by server scope", () => { + const remote = "https://debian.example" as ServerScope + setSessionPrefetch({ scope, directory: "/repo", sessionID: "ses_1", limit: 10, complete: true, at: 1 }) + setSessionPrefetch({ scope: remote, directory: "/repo", sessionID: "ses_1", limit: 20, complete: true, at: 2 }) + + expect(getSessionPrefetch(scope, "/repo", "ses_1")?.limit).toBe(10) + expect(getSessionPrefetch(remote, "/repo", "ses_1")?.limit).toBe(20) }) test("refreshes stale first-page prefetched history", () => { diff --git a/packages/app/src/context/global-sync/session-prefetch.ts b/packages/app/src/context/global-sync/session-prefetch.ts index 608561f855..d2c3d86f4e 100644 --- a/packages/app/src/context/global-sync/session-prefetch.ts +++ b/packages/app/src/context/global-sync/session-prefetch.ts @@ -1,4 +1,6 @@ -const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}` +import { ScopedKey, type ServerScope } from "@/utils/server-scope" + +const key = (scope: ServerScope, directory: string, sessionID: string) => ScopedKey.from(scope, directory, sessionID) export const SESSION_PREFETCH_TTL = 15_000 @@ -27,28 +29,32 @@ const rev = new Map() const version = (id: string) => rev.get(id) ?? 0 -export function getSessionPrefetch(directory: string, sessionID: string) { - return cache.get(key(directory, sessionID)) +export function getSessionPrefetch(scope: ServerScope, directory: string, sessionID: string) { + return cache.get(key(scope, directory, sessionID)) } -export function getSessionPrefetchPromise(directory: string, sessionID: string) { - return inflight.get(key(directory, sessionID)) +export function getSessionPrefetchPromise(scope: ServerScope, directory: string, sessionID: string) { + return inflight.get(key(scope, directory, sessionID)) } -export function clearSessionPrefetchInflight() { - inflight.clear() +export function clearSessionPrefetchInflight(scope: ServerScope) { + const prefix = ScopedKey.prefix(scope) + for (const id of inflight.keys()) { + if (id.startsWith(prefix)) inflight.delete(id) + } } -export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) { - return version(key(directory, sessionID)) === value +export function isSessionPrefetchCurrent(scope: ServerScope, directory: string, sessionID: string, value: number) { + return version(key(scope, directory, sessionID)) === value } export function runSessionPrefetch(input: { directory: string + scope: ServerScope sessionID: string task: (value: number) => Promise }) { - const id = key(input.directory, input.sessionID) + const id = key(input.scope, input.directory, input.sessionID) const pending = inflight.get(id) if (pending) return pending @@ -64,13 +70,14 @@ export function runSessionPrefetch(input: { export function setSessionPrefetch(input: { directory: string + scope: ServerScope sessionID: string limit: number cursor?: string complete: boolean at?: number }) { - cache.set(key(input.directory, input.sessionID), { + cache.set(key(input.scope, input.directory, input.sessionID), { limit: input.limit, cursor: input.cursor, complete: input.complete, @@ -78,18 +85,18 @@ export function setSessionPrefetch(input: { }) } -export function clearSessionPrefetch(directory: string, sessionIDs: Iterable) { +export function clearSessionPrefetch(scope: ServerScope, directory: string, sessionIDs: Iterable) { for (const sessionID of sessionIDs) { if (!sessionID) continue - const id = key(directory, sessionID) + const id = key(scope, directory, sessionID) rev.set(id, version(id) + 1) cache.delete(id) inflight.delete(id) } } -export function clearSessionPrefetchDirectory(directory: string) { - const prefix = `${directory}\n` +export function clearSessionPrefetchDirectory(scope: ServerScope, directory: string) { + const prefix = ScopedKey.prefix(scope, directory) const keys = new Set([...cache.keys(), ...inflight.keys()]) for (const id of keys) { if (!id.startsWith(prefix)) continue diff --git a/packages/app/src/context/global.tsx b/packages/app/src/context/global.tsx index 3f04aa6805..b096daa079 100644 --- a/packages/app/src/context/global.tsx +++ b/packages/app/src/context/global.tsx @@ -1,17 +1,17 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { createEffect, createMemo, createRoot } from "solid-js" import { createStore } from "solid-js/store" -import { ServerConnection, useServer } from "./server" +import { createServerProjects, ServerConnection, useServer } from "./server" import { useServerHealth } from "@/utils/server-health" -import { QueryClient } from "@tanstack/solid-query" import { createServerSdkContext } from "./server-sdk" import { createServerSyncContext } from "./server-sync" import { getOwner } from "solid-js/web" -import { Persist, persisted } from "@/utils/persist" +import { QueryClient } from "@tanstack/solid-query" +import type { ServerScope } from "@/utils/server-scope" export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({ name: "Global", - init: (props: { defaultServer: ServerConnection.Key; servers?: Array }) => { + init: () => { const server = useServer() const serverHealth = useServerHealth( () => server.list, @@ -23,8 +23,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( }, }) - const serversAndProjects = createServersAndProjectStore() - const settingsServer = createMemo(() => { const list = server.list return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0] @@ -43,18 +41,25 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( const owner = getOwner() + const ensureServerCtx = (conn: ServerConnection.Any) => { + const key = ServerConnection.key(conn) + const existing = serverCtxs.get(key) + if (existing) return existing.serverCtx + const root = createRoot((dispose) => { + const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key)) + return { dispose, serverCtx } + }, owner as any) + serverCtxs.set(key, root) + return root.serverCtx + } + createMemo(() => { for (const conn of server.list) { - const key = ServerConnection.key(conn) - if (!serverCtxs.has(key)) { - const root = createRoot((dispose) => { - const serverCtx = createServerCtx(conn, serversAndProjects) - return { dispose, serverCtx } - }, owner as any) - serverCtxs.set(key, root) - } + ensureServerCtx(conn) } + }) + createEffect(() => { for (const [key] of serverCtxs) { if (!server.list.find((conn) => ServerConnection.key(conn) === key)) { const { dispose } = serverCtxs.get(key)! @@ -64,16 +69,10 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( } }) - const allServers = createMemo( - (): Array => - resolveServerList({ stored: serversAndProjects.store.list, props: props.servers }), - ) - return { servers: { - list: allServers, + list: () => server.list, health: serverHealth, - default: () => allServers().find((s) => ServerConnection.key(s) === props.defaultServer) ?? allServers()[0]!, }, settings: { server: { @@ -87,33 +86,16 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( }, }, createServerCtx(conn: ServerConnection.Any) { - const key = ServerConnection.key(conn) - const ctx = serverCtxs.get(key) - if (!ctx) return createServerCtx(conn, serversAndProjects) - return ctx.serverCtx + return ensureServerCtx(conn) }, } }, }) -type StoredProject = { worktree: string; expanded: boolean } -type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http - -const createServersAndProjectStore = () => { - const [store, setStore, _, ready] = persisted( - Persist.global("server", ["server.v3"]), - createStore({ - list: [] as StoredServer[], - projects: {} as Record, - lastProject: {} as Record, - }), - ) - return { store, setStore, ready } -} - function createServerCtx( conn: ServerConnection.Any, - { store, setStore }: ReturnType, + scope: ServerScope, + projects: ReturnType, ) { const queryClient = new QueryClient({ defaultOptions: { @@ -124,13 +106,9 @@ function createServerCtx( }, }, }) - - const sdk = createServerSdkContext(conn) + const sdk = createServerSdkContext(conn, scope) const sync = createServerSyncContext(sdk) - const key = ServerConnection.key(conn) - const storeKey = projectsKey(key) - function enrich(project: { worktree: string; expanded: boolean }) { const [childStore] = sync.child(project.worktree, { bootstrap: false }) const projectID = childStore.project @@ -148,7 +126,7 @@ function createServerCtx( return base } - const projectsList = createMemo(() => (store.projects[storeKey] ?? []).map(enrich)) + const projectsList = createMemo(() => projects.list().map(enrich)) const isLocal = (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url)) @@ -159,45 +137,8 @@ function createServerCtx( sync, isLocal, projects: { + ...projects, list: projectsList, - open(directory: string) { - const current = store.projects[storeKey] ?? [] - if (current.find((x) => x.worktree === directory)) return - setStore("projects", storeKey, [{ worktree: directory, expanded: true }, ...current]) - }, - close(directory: string) { - const current = store.projects[storeKey] ?? [] - setStore( - "projects", - storeKey, - current.filter((x) => x.worktree !== directory), - ) - }, - expand(directory: string) { - const current = store.projects[storeKey] ?? [] - const index = current.findIndex((x) => x.worktree === directory) - if (index !== -1) setStore("projects", storeKey, index, "expanded", true) - }, - collapse(directory: string) { - const current = store.projects[storeKey] ?? [] - const index = current.findIndex((x) => x.worktree === directory) - if (index !== -1) setStore("projects", storeKey, index, "expanded", false) - }, - move(directory: string, toIndex: number) { - const current = store.projects[storeKey] ?? [] - const fromIndex = current.findIndex((x) => x.worktree === directory) - if (fromIndex === -1 || fromIndex === toIndex) return - const result = [...current] - const [item] = result.splice(fromIndex, 1) - result.splice(toIndex, 0, item) - setStore("projects", storeKey, result) - }, - last() { - return store.lastProject[storeKey] - }, - touch(directory: string) { - setStore("lastProject", storeKey, directory) - }, }, } } @@ -208,42 +149,3 @@ function isLocalHost(url: string) { const host = url.replace(/^https?:\/\//, "").split(":")[0] if (host === "localhost" || host === "127.0.0.1") return "local" } - -function projectsKey(key: ServerConnection.Key) { - if (key === "sidecar") return "local" - if (isLocalHost(key)) return "local" - return key -} - -export function resolveServerList(input: { - props?: Array - stored: StoredServer[] -}): Array { - const deduped = new Map( - input.props?.map((v) => [ServerConnection.key(v), v]) ?? [], - ) - - for (const value of input.stored) { - const conn: ServerConnection.Http = - typeof value === "string" - ? { - type: "http" as const, - http: { url: value }, - } - : "http" in value - ? value - : { type: "http", http: value } - const key = ServerConnection.key(conn) - - const existing = deduped.get(key) - if (existing) - deduped.set(key, { - ...existing, - ...conn, - http: { ...existing.http, ...conn.http }, - }) - else deduped.set(key, conn) - } - - return [...deduped.values()] -} diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index 533b805053..6b2eb314c8 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -198,6 +198,7 @@ if (warm !== "en") void loadDict(warm) export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ name: "Language", + gate: false, init: (props: { locale?: Locale }) => { const initial = props.locale ?? readStoredLocale() ?? detectLocale() const [store, setStore, _, ready] = persisted( diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index a13891b6d2..49d04df546 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -14,6 +14,7 @@ import { same } from "@/utils/same" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createPathHelpers } from "./file/path" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" +import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -64,6 +65,7 @@ type SessionView = { } type TabHandoff = { + scope: ServerScope dir: string id: string at: number @@ -87,7 +89,7 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): } const sessionPath = (key: string) => { - const dir = key.split("/")[0] + const dir = SessionStateKey.route(key).split("/")[0] if (!dir) return const root = decode64(dir) if (!root) return @@ -135,13 +137,18 @@ const currentRoute = (pathname: string): LayoutRoute => { export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", + gate: false, init: () => { - const globalSdk = useServerSDK() + const serverSdk = useServerSDK() const serverSync = useServerSync() const server = useServer() const platform = usePlatform() const location = useLocation() - const route = createMemo(() => currentRoute(location.pathname)) + const route = createMemo(() => { + const value = currentRoute(location.pathname) + if (value.type === "home") return value + return { ...value, server: server.key } + }) const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) @@ -186,7 +193,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } })() - const sessionTabs = value.sessionTabs + const sessionTabs = migrateLegacySessionStateKeys(value.sessionTabs) + const sessionView = migrateLegacySessionStateKeys(value.sessionView) const migratedSessionTabs = (() => { if (!isRecord(sessionTabs)) return sessionTabs @@ -215,7 +223,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree && - migratedSessionTabs === sessionTabs + migratedSessionTabs === value.sessionTabs && + sessionView === value.sessionView ) { return value } @@ -226,10 +235,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: migratedReview, fileTree: migratedFileTree, sessionTabs: migratedSessionTabs, + sessionView, } } - const target = Persist.global("layout", ["layout.v6"]) + const target = Persist.serverGlobal(serverSdk.scope, "layout", ["layout.v6"]) const [store, setStore, _, ready] = persisted( { ...target, migrate }, createStore({ @@ -282,15 +292,19 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const dropSessionState = (keys: string[]) => { for (const key of keys) { - const parts = key.split("/") + const scope = SessionStateKey.scope(key) + const parts = SessionStateKey.route(key).split("/") const dir = parts[0] const session = parts[1] if (!dir) continue for (const entry of SESSION_STATE_KEYS) { - const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key) + const target = session + ? Persist.serverSession(scope, dir, session, entry.key) + : Persist.serverWorkspace(scope, dir, entry.key) void removePersisted(target, platform) + if (scope !== ServerScope.local) continue const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}` void removePersisted({ key: legacyKey }, platform) } @@ -515,7 +529,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( continue } - void globalSdk.client.project + void serverSdk.client.project .update({ projectID: project.id, directory: worktree, icon: { color } }) .catch(() => { if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) @@ -551,7 +565,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( handoff: { tabs: createMemo(() => store.handoff?.tabs), setTabs(dir: string, id: string) { - setStore("handoff", "tabs", { dir, id, at: Date.now() }) + setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() }) }, clearTabs() { if (!store.handoff?.tabs) return diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index c0ae79d695..8bc58a7c26 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -9,6 +9,8 @@ import { Persist, persisted } from "@/utils/persist" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { useSDK } from "./sdk" import { useSync } from "./sync" +import { useServerSDK } from "./server-sdk" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" export type ModelKey = { providerID: string; modelID: string; variant?: string } @@ -25,7 +27,7 @@ type Saved = { const WORKSPACE_KEY = "__workspace__" const handoff = new Map() -const handoffKey = (dir: string, id: string) => `${dir}\n${id}` +const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id) const migrate = (value: unknown) => { if (!value || typeof value !== "object") return { session: {} } @@ -57,6 +59,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const params = useParams() const sdk = useSDK() const sync = useSync() + const serverSDK = useServerSDK() const providers = useProviders() const models = useModels() @@ -66,7 +69,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const [saved, setSaved] = persisted( { - ...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]), + ...Persist.serverWorkspace(serverSDK.scope, sdk.directory, "model-selection", ["model-selection.v1"]), migrate, }, createStore({ @@ -121,14 +124,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const scope = createMemo(() => { const session = id() if (!session) return store.draft - return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session)) + return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk.directory, session)) }) createEffect(() => { const session = id() if (!session) return - const key = handoffKey(sdk.directory, session) + const key = handoffKey(serverSDK.scope, sdk.directory, session) const next = handoff.get(key) if (!next) return if (saved.session[session] !== undefined) { @@ -377,7 +380,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return } - handoff.set(handoffKey(dir, session), next) + handoff.set(handoffKey(serverSDK.scope, dir, session), next) setStore("draft", undefined) }, restore(msg: { sessionID: string; agent: string; model: ModelKey }) { @@ -385,7 +388,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (!session) return if (msg.sessionID !== session) return if (saved.session[session] !== undefined) return - if (handoff.has(handoffKey(sdk.directory, session))) return + if (handoff.has(handoffKey(serverSDK.scope, sdk.directory, session))) return setSaved("session", session, { agent: msg.agent, diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index 12ec8371ad..21816ab12d 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -24,6 +24,7 @@ function modelKey(model: ModelKey) { export const { use: useModels, provider: ModelsProvider } = createSimpleContext({ name: "Models", + gate: false, init: () => { const providers = useProviders() diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 5b6982425a..130c4a262b 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -107,6 +107,7 @@ function buildNotificationIndex(list: Notification[]) { export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ name: "Notification", + gate: false, init: () => { const params = useParams() const serverSDK = useServerSDK() @@ -124,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const currentSession = createMemo(() => params.id) const [store, setStore, _, ready] = persisted( - Persist.global("notification", ["notification.v1"]), + Persist.serverGlobal(serverSDK.scope, "notification", ["notification.v1"]), createStore({ list: [] as Notification[], }), diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index af55f3d26f..f22df01945 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -46,6 +46,7 @@ function hasPermissionPromptRules(permission: unknown) { export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ name: "Permission", + gate: false, init: () => { const params = useParams() const serverSDK = useServerSDK() @@ -60,7 +61,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const [store, setStore, _, ready] = persisted( { - ...Persist.global("permission", ["permission.v3"]), + ...Persist.serverGlobal(serverSDK.scope, "permission", ["permission.v3"]), migrate(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return value diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index dffb798310..b0dbca221e 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -5,6 +5,8 @@ import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" +import { useServerSDK } from "./server-sdk" +import type { ServerScope } from "@/utils/server-scope" interface PartBase { content: string @@ -161,11 +163,11 @@ type PromptCacheEntry = { dispose: VoidFunction } -function createPromptSession(dir: string, id: string | undefined) { +function createPromptSession(scope: ServerScope, dir: string, id: string | undefined) { const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2` const [store, setStore, _, ready] = persisted( - Persist.scoped(dir, id, "prompt", [legacy]), + Persist.serverScoped(scope, dir, id, "prompt", [legacy]), createStore<{ prompt: Prompt cursor?: number @@ -229,6 +231,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( gate: false, init: () => { const params = useParams() + const serverSDK = useServerSDK() const cache = new Map() const disposeAll = () => { @@ -262,7 +265,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const entry = createRoot( (dispose) => ({ - value: createPromptSession(dir, id), + value: createPromptSession(serverSDK.scope, dir, id), dispose, }), owner, diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts new file mode 100644 index 0000000000..6d09569764 --- /dev/null +++ b/packages/app/src/context/server-sdk.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test" +import { resumeStreamAfterPageShow } from "./server-sdk" + +describe("resumeStreamAfterPageShow", () => { + test("restarts a stream only after a back-forward cache restore", () => { + let starts = 0 + const start = () => starts++ + + resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start) + resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start) + + expect(starts).toBe(1) + }) +}) diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 98fdbfa891..ab736c7382 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -9,11 +9,19 @@ import { usePlatform } from "./platform" import { ServerConnection, useServer } from "./server" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" +import { ServerScope } from "@/utils/server-scope" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" -export function createServerSdkContext(server: ServerConnection.Any) { +const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true + +export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { + if (!event.persisted) return + start() +} + +export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope) { const platform = usePlatform() const abort = new AbortController() @@ -96,11 +104,10 @@ export function createServerSdkContext(server: ServerConnection.Any) { let streamErrorLogged = false const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - const aborted = isAbortError - let attempt: AbortController | undefined let run: Promise | undefined let started = false + let generation = 0 const HEARTBEAT_TIMEOUT_MS = 15_000 let lastEventAt = Date.now() let heartbeat: ReturnType | undefined @@ -120,9 +127,12 @@ export function createServerSdkContext(server: ServerConnection.Any) { const start = () => { if (started) return run started = true - run = (async () => { + const active = ++generation + const previous = run + const current = (async () => { + if (previous) await previous // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit - while (!abort.signal.aborted && started) { + while (!abort.signal.aborted && started && generation === active) { attempt = new AbortController() lastEventAt = Date.now() const onAbort = () => { @@ -133,7 +143,7 @@ export function createServerSdkContext(server: ServerConnection.Any) { const events = await eventSdk.global.event({ signal: attempt.signal, onSseError: (error) => { - if (aborted(error)) return + if (isStreamClosed(error, attempt?.signal)) return if (streamErrorLogged) return streamErrorLogged = true console.error("[global-sdk] event stream error", { @@ -176,7 +186,7 @@ export function createServerSdkContext(server: ServerConnection.Any) { await wait(0) } } catch (error) { - if (!aborted(error) && !streamErrorLogged) { + if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) { streamErrorLogged = true console.error("[global-sdk] event stream failed", { url: server.http.url, @@ -190,23 +200,28 @@ export function createServerSdkContext(server: ServerConnection.Any) { clearHeartbeat() } - if (abort.signal.aborted || !started) return + if (abort.signal.aborted || !started || generation !== active) return await wait(RECONNECT_DELAY_MS) } })().finally(() => { + if (run !== current) return run = undefined flush() }) + run = current return run } const stop = () => { started = false + generation++ attempt?.abort() clearHeartbeat() } onMount(() => { + makeEventListener(window, "pagehide", stop) + makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) makeEventListener(document, "visibilitychange", () => { if (document.visibilityState !== "visible") return if (!started) return @@ -228,6 +243,7 @@ export function createServerSdkContext(server: ServerConnection.Any) { }) return { + scope, url: server.http.url, client: sdk, event: { @@ -282,6 +298,7 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDK) { onCleanup(unsub) return { + scope: serverSDK.scope, directory, client, event: emitter, diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 04364a0ff9..e05cdc6cbb 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -34,6 +34,8 @@ import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerConnection, useServer } from "./server" import { retry } from "@opencode-ai/core/util/retry" +import type { ServerScope } from "@/utils/server-scope" +import { persisted } from "@/utils/persist" type GlobalStore = { ready: boolean @@ -49,29 +51,30 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } -export const loadMcpQuery = (directory: string, sdk: OpencodeClient) => +export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => queryOptions({ - queryKey: [directory, "mcp"] as const, + queryKey: [scope, directory, "mcp"] as const, queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), }) -export const loadLspQuery = (directory: string, sdk: OpencodeClient) => +export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => queryOptions({ - queryKey: [directory, "lsp"] as const, + queryKey: [scope, directory, "lsp"] as const, queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), }) -function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) { +function makeQueryOptionsApi(scope: ServerScope, serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) { return { - globalConfig: () => loadGlobalConfigQuery(serverSDK()), - projects: () => loadProjectsQuery(serverSDK()), + globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()), + projects: () => loadProjectsQuery(scope, serverSDK()), providers: (directory: PathKey | null) => - loadProvidersQuery(directory, directory === null ? serverSDK() : sdkFor(directory)), - path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? serverSDK() : sdkFor(directory)), - agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)), - mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)), - lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)), - sessions: (directory: PathKey) => ({ queryKey: [directory, "loadSessions"] as const }), + loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), + path: (directory: PathKey | null) => + loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), + agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), + mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), + lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), + sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), } } export type QueryOptionsApi = ReturnType @@ -99,7 +102,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { return sdk } - const queryOptionsApi = makeQueryOptionsApi(() => serverSDK.client, sdkFor) + const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor) const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], @@ -156,10 +159,11 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { }) as typeof setGlobalStore const bootstrap = useQuery(() => ({ - queryKey: ["bootstrap"], + queryKey: [serverSDK.scope, "bootstrap"], queryFn: async () => { await bootstrapGlobal({ serverSDK: serverSDK.client, + scope: serverSDK.scope, requestFailedTitle: language.t("common.requestFailed"), translate: language.t, formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), @@ -198,12 +202,14 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { const queue = createRefreshQueue({ paused, key: directoryKey, - bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), + bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }), bootstrapInstance, }) const children = createChildStoreManager({ owner, + scope: serverSDK.scope, + persist: persisted, isBooting: (directory) => booting.has(directory), isLoadingSessions: (directory) => sessionLoads.has(directory), onBootstrap: (directory) => { @@ -227,8 +233,8 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { queue.clear(key) sessionMeta.delete(key) sdkCache.delete(key) - clearProviderRev(key) - clearSessionPrefetchDirectory(key) + clearProviderRev(serverSDK.scope, key) + clearSessionPrefetchDirectory(serverSDK.scope, key) }, translate: language.t, queryOptions: queryOptionsApi, @@ -328,6 +334,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { const sdk = sdkFor(directory) await bootstrapDirectory({ directory, + scope: serverSDK.scope, mcp: children.mcp(key), global: { config: globalStore.config, @@ -439,8 +446,8 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { bootstrap.refetch() // Invalidate all provider queries so newly configured custom providers // appear immediately in the available provider list across all directories. - queryClient.invalidateQueries({ queryKey: [null, "providers"] }) - queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === "providers" }) + queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] }) + queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers" }) }, })) @@ -479,6 +486,7 @@ export function createServerSyncContext(_serverSDK?: ServerSDK) { export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({ name: "ServerSync", + gate: false, init: (props: { server?: ServerConnection.Any }) => { const global = useGlobal() const language = useLanguage() diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts index 1fa35247c8..26ad0022c2 100644 --- a/packages/app/src/context/server.test.ts +++ b/packages/app/src/context/server.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test" -import { resolveServerList, ServerConnection } from "./server" +import { createRoot, createSignal } from "solid-js" +import { createStore } from "solid-js/store" +import { createServerProjects, migrateCanonicalLocalServerState, resolveServerList, ServerConnection } from "./server" +import { ServerScope } from "@/utils/server-scope" describe("resolveServerList", () => { test("lets startup auth_token credentials override a persisted same-url server", () => { @@ -51,3 +54,70 @@ describe("resolveServerList", () => { expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined() }) }) + +describe("createServerProjects", () => { + test("keeps active and explicit server buckets in one reactive store", () => { + createRoot((dispose) => { + const [scope] = createSignal(ServerScope.local) + const [store, setStore] = createStore({ projects: {}, lastProject: {} }) + const active = createServerProjects({ scope, store, setStore }) + const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore }) + + remote.open("/repo") + expect(remote.list()).toEqual([{ worktree: "/repo", expanded: true }]) + expect(active.list()).toEqual([]) + + const adopted = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore }) + expect(adopted.list()).toEqual([{ worktree: "/repo", expanded: true }]) + + adopted.close("/repo") + expect(remote.list()).toEqual([]) + dispose() + }) + }) +}) + +describe("migrateCanonicalLocalServerState", () => { + test("moves an existing canonical web bucket into local scope", () => { + expect( + migrateCanonicalLocalServerState( + { + list: [], + projects: { "https://opencode.example.com": [{ worktree: "/remote", expanded: true }] }, + lastProject: { "https://opencode.example.com": "/remote" }, + }, + ServerConnection.Key.make("https://opencode.example.com"), + ), + ).toEqual({ + list: [], + projects: { local: [{ worktree: "/remote", expanded: true }] }, + lastProject: { local: "/remote" }, + }) + }) + + test("preserves existing local state while merging a canonical web bucket", () => { + expect( + migrateCanonicalLocalServerState( + { + projects: { + local: [{ worktree: "/local", expanded: false }], + "https://opencode.example.com": [ + { worktree: "/local", expanded: true }, + { worktree: "/remote", expanded: true }, + ], + }, + lastProject: { local: "/local", "https://opencode.example.com": "/remote" }, + }, + ServerConnection.Key.make("https://opencode.example.com"), + ), + ).toEqual({ + projects: { + local: [ + { worktree: "/local", expanded: false }, + { worktree: "/remote", expanded: true }, + ], + }, + lastProject: { local: "/local" }, + }) + }) +}) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 8532c8249e..9e3b892d43 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,10 +1,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { type Accessor, batch, createMemo } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" +import { ServerScope } from "@/utils/server-scope" type StoredProject = { worktree: string; expanded: boolean } type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http +type ServerProjectState = { projects: Record; lastProject: Record } const HEALTH_POLL_INTERVAL_MS = 10_000 export function normalizeServerUrl(input: string) { @@ -20,18 +22,91 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "") } -function projectsKey(key: ServerConnection.Key) { - if (!key) return "" - if (key === "sidecar") return "local" - if (isLocalHost(key)) return "local" - return key -} - function isLocalHost(url: string) { const host = url.replace(/^https?:\/\//, "").split(":")[0] if (host === "localhost" || host === "127.0.0.1") return "local" } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) { + if (!canonicalLocalServer || canonicalLocalServer === "local") return value + if (!isRecord(value)) return value + const projects = isRecord(value.projects) ? value.projects : undefined + const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined + const previousProjects = projects?.[canonicalLocalServer] + const previousLastProject = lastProject?.[canonicalLocalServer] + if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value + + const next = { ...value } + if (projects && Array.isArray(previousProjects)) { + const local = Array.isArray(projects.local) ? projects.local : [] + const worktrees = new Set( + local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])), + ) + const migrated = previousProjects.filter((project) => { + if (!isRecord(project) || typeof project.worktree !== "string") return true + if (worktrees.has(project.worktree)) return false + worktrees.add(project.worktree) + return true + }) + const nextProjects: Record = { ...projects, local: [...local, ...migrated] } + delete nextProjects[canonicalLocalServer] + next.projects = nextProjects + } + if (lastProject && typeof previousLastProject === "string") { + const nextLastProject = { ...lastProject } + if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject + delete nextLastProject[canonicalLocalServer] + next.lastProject = nextLastProject + } + return next +} + +export function createServerProjects(input: { + scope: Accessor + store: Store + setStore: SetStoreFunction +}) { + const setStore = input.setStore as unknown as SetStoreFunction + const current = () => input.store.projects[input.scope()] ?? [] + return { + list: current, + open(directory: string) { + const scope = input.scope() + if (current().some((project) => project.worktree === directory)) return + setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()]) + }, + close(directory: string) { + setStore("projects", input.scope(), current().filter((project) => project.worktree !== directory)) + }, + expand(directory: string) { + const index = current().findIndex((project) => project.worktree === directory) + if (index !== -1) setStore("projects", input.scope(), index, "expanded", true) + }, + collapse(directory: string) { + const index = current().findIndex((project) => project.worktree === directory) + if (index !== -1) setStore("projects", input.scope(), index, "expanded", false) + }, + move(directory: string, toIndex: number) { + const fromIndex = current().findIndex((project) => project.worktree === directory) + if (fromIndex === -1 || fromIndex === toIndex) return + const next = [...current()] + const [item] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, item) + setStore("projects", input.scope(), next) + }, + last() { + return input.store.lastProject[input.scope()] + }, + touch(directory: string) { + setStore("lastProject", input.scope(), directory) + }, + } +} + export function resolveServerList(input: { props?: Array stored: StoredServer[] @@ -127,9 +202,17 @@ export namespace ServerConnection { export const { use: useServer, provider: ServerProvider } = createSimpleContext({ name: "Server", - init: (props: { defaultServer: ServerConnection.Key; servers?: Array }) => { + gate: true, + init: (props: { + defaultServer: ServerConnection.Key + canonicalLocalServer?: ServerConnection.Key + servers?: Array + }) => { const [store, setStore, _, ready] = persisted( - Persist.global("server", ["server.v3"]), + { + ...Persist.global("server", ["server.v3"]), + migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer), + }, createStore({ list: [] as StoredServer[], projects: {} as Record, @@ -180,8 +263,16 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const isReady = createMemo(() => ready() && !!state.active) - const origin = createMemo(() => projectsKey(state.active)) - const projectsList = createMemo(() => store.projects[origin()] ?? []) + const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer) + const projects = createServerProjects({ scope, store, setStore }) + const projectStores = new Map>() + const projectsForServer = (key: ServerConnection.Key) => { + const existing = projectStores.get(key) + if (existing) return existing + const next = createServerProjects({ scope: () => scope(key), store, setStore }) + projectStores.set(key, next) + return next + } const current: Accessor = createMemo( () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], ) @@ -208,60 +299,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( setActive, add, remove, + scope, projects: { - list: projectsList, - open(directory: string) { - const key = origin() - if (!key) return - const current = store.projects[key] ?? [] - if (current.find((x) => x.worktree === directory)) return - setStore("projects", key, [{ worktree: directory, expanded: true }, ...current]) - }, - close(directory: string) { - const key = origin() - if (!key) return - const current = store.projects[key] ?? [] - setStore( - "projects", - key, - current.filter((x) => x.worktree !== directory), - ) - }, - expand(directory: string) { - const key = origin() - if (!key) return - const current = store.projects[key] ?? [] - const index = current.findIndex((x) => x.worktree === directory) - if (index !== -1) setStore("projects", key, index, "expanded", true) - }, - collapse(directory: string) { - const key = origin() - if (!key) return - const current = store.projects[key] ?? [] - const index = current.findIndex((x) => x.worktree === directory) - if (index !== -1) setStore("projects", key, index, "expanded", false) - }, - move(directory: string, toIndex: number) { - const key = origin() - if (!key) return - const current = store.projects[key] ?? [] - const fromIndex = current.findIndex((x) => x.worktree === directory) - if (fromIndex === -1 || fromIndex === toIndex) return - const result = [...current] - const [item] = result.splice(fromIndex, 1) - result.splice(toIndex, 0, item) - setStore("projects", key, result) - }, - last() { - const key = origin() - if (!key) return - return store.lastProject[key] - }, - touch(directory: string) { - const key = origin() - if (!key) return - setStore("lastProject", key, directory) - }, + ...projects, + forServer: projectsForServer, }, } }, diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 84288d31a5..ca04ff6864 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -156,6 +156,7 @@ function withFallback(read: () => T | undefined, fallback: T) { export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ name: "Settings", + gate: false, init: () => { const [store, setStore, _, ready] = persisted("settings.v3", createStore(defaultSettings)) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx new file mode 100644 index 0000000000..32c1146646 --- /dev/null +++ b/packages/app/src/context/tabs.tsx @@ -0,0 +1,140 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { createStore, produce } from "solid-js/store" +import { Persist, persisted } from "@/utils/persist" +import { ServerConnection, useServer } from "./server" +import { createEffect, startTransition } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" + +export type SessionTab = { + type: "session" + server: ServerConnection.Key + dirBase64: string + sessionId: string +} + +export type Tab = SessionTab + +export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}` +export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}` + +export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ + name: "Tabs", + gate: false, + init: () => { + const server = useServer() + const fallback = server.key + const [store, setStore, _, ready] = persisted( + { + ...Persist.global("tabs"), + migrate: (value: unknown) => { + if (!Array.isArray(value)) return value + return value.map((tab) => { + if (!tab || typeof tab !== "object" || "server" in tab) return tab + return { ...tab, server: fallback } + }) + }, + }, + createStore([]), + ) + + const params = useParams() + const navigate = useNavigate() + + const closing = new Set() + + createEffect(() => { + if (!ready()) return + const servers = new Set(server.list.map(ServerConnection.key)) + if (store.every((tab) => servers.has(tab.server))) return + setStore((tabs) => tabs.filter((tab) => servers.has(tab.server))) + }) + + const navigateTab = (tab: Tab) => { + const href = tabHref(tab) + if (tab.server === server.key) { + navigate(href) + return + } + void startTransition(() => { + server.setActive(tab.server) + navigate(href) + }) + } + + const actions = { + addSessionTab: (tab: Omit) => { + const next = { type: "session" as const, ...tab } + if (closing.has(tabKey(next))) return + setStore( + produce((tabs) => { + if (tabs.some((item) => tabKey(item) === tabKey(next))) return + tabs.push(next) + }), + ) + }, + removeTab: (index: number) => { + const tab = store[index] + if (!tab) return + const key = tabKey(tab) + const nextTab = store[index + 1] ?? store[index - 1] + closing.add(key) + void startTransition(() => { + setStore( + produce((tabs) => { + tabs.splice(index, 1) + }), + ) + if (nextTab) navigateTab(nextTab) + else navigate("/") + }).finally(() => closing.delete(key)) + }, + removeServer(key: ServerConnection.Key) { + setStore((tabs) => tabs.filter((tab) => tab.server !== key)) + if (server.key === key) navigate("/") + }, + removeSessions: (input: SessionTabsRemovedDetail) => { + void startTransition(() => { + setStore( + produce((tabs) => { + const sessionIDs = new Set(input.sessionIDs) + const currentHref = + params.dir && params.id + ? tabHref({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id }) + : undefined + const currentIndex = currentHref + ? tabs.findIndex( + (tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref, + ) + : -1 + const currentTab = tabs[currentIndex] + const removedCurrent = + currentTab?.type === "session" && + currentTab.server === server.key && + atob(currentTab.dirBase64) === input.directory && + sessionIDs.has(currentTab.sessionId) + + for (let i = tabs.length - 1; i >= 0; i--) { + const tab = tabs[i] + if (!tab || tab.type !== "session") continue + if (tab.server !== server.key) continue + if (atob(tab.dirBase64) !== input.directory) continue + if (!sessionIDs.has(tab.sessionId)) continue + tabs.splice(i, 1) + } + + if (!removedCurrent) return + const nextTab = + tabs.slice(currentIndex).find((tab) => tab.type === "session") ?? + tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session") + if (nextTab) navigateTab(nextTab) + else navigate("/") + }), + ) + }) + }, + } + + return { ...actions, store, ready } + }, +}) diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 5bca1b4b7e..4f16953c79 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -1,9 +1,7 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" +import { ServerScope } from "@/utils/server-scope" -type ServerKey = Parameters[1] - -let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string -let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope +let getWorkspaceTerminalCacheKey: typeof import("./terminal").getWorkspaceTerminalCacheKey let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[] let migrateTerminalState: (value: unknown) => unknown @@ -20,53 +18,19 @@ beforeAll(async () => { })) const mod = await import("./terminal") getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey - getTerminalServerScope = mod.getTerminalServerScope getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys migrateTerminalState = mod.migrateTerminalState }) describe("getWorkspaceTerminalCacheKey", () => { test("uses workspace-only directory cache key", () => { - expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__") + expect(String(getWorkspaceTerminalCacheKey("/repo"))).toBe("local\u0000/repo\u0000__workspace__") }) test("can include a server scope", () => { - expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("wsl:Debian:/repo:__workspace__") - }) -}) - -describe("getTerminalServerScope", () => { - test("preserves local server keys", () => { - expect( - getTerminalServerScope( - { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } }, - "sidecar" as ServerKey, - ), - ).toBeUndefined() - expect( - getTerminalServerScope( - { type: "http", http: { url: "http://localhost:4096" } }, - "http://localhost:4096" as ServerKey, - ), - ).toBeUndefined() - expect( - getTerminalServerScope({ type: "http", http: { url: "http://[::1]:4096" } }, "http://[::1]:4096" as ServerKey), - ).toBeUndefined() - }) - - test("scopes non-local server keys", () => { - expect( - getTerminalServerScope( - { type: "sidecar", variant: "wsl", distro: "Debian", http: { url: "http://127.0.0.1:4096" } }, - "wsl:Debian" as ServerKey, - ), - ).toBe("wsl:Debian" as ServerKey) - expect( - getTerminalServerScope( - { type: "http", http: { url: "https://example.com" } }, - "https://example.com" as ServerKey, - ), - ).toBe("https://example.com" as ServerKey) + expect(String(getWorkspaceTerminalCacheKey("/repo", "ssh:debian" as ServerScope))).toBe( + "ssh:debian\u0000/repo\u0000__workspace__", + ) }) }) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index f6751c3f0e..f15b76000e 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -4,9 +4,10 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" import type { Platform } from "./platform" -import { ServerConnection, useServer } from "./server" +import { useServer } from "./server" import { defaultTitle, titleNumber } from "./terminal-title" import { Persist, persisted, removePersisted } from "@/utils/persist" +import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" export type LocalPTY = { id: string @@ -83,29 +84,8 @@ export function migrateTerminalState(value: unknown) { } } -export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) { - if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}` - return `${dir}:${WORKSPACE_KEY}` -} - -export function getTerminalServerScope(conn: ServerConnection.Any | undefined, key: ServerConnection.Key) { - if (!conn) return - if (conn.type === "sidecar" && conn.variant === "base") return - if (conn.type === "http") { - try { - const url = new URL(conn.http.url) - if ( - url.hostname === "localhost" || - url.hostname === "127.0.0.1" || - url.hostname === "::1" || - url.hostname === "[::1]" - ) - return - } catch { - return key - } - } - return key +export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScopeValue = ServerScope.local) { + return ScopedKey.from(scope, dir, WORKSPACE_KEY) } export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) { @@ -132,16 +112,25 @@ const trimTerminal = (pty: LocalPTY) => { } } -export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) { +function terminalPersistTarget(scope: ServerScopeValue, dir: string, legacy?: string[]) { + return Persist.serverWorkspace(scope, dir, "terminal", legacy) +} + +export function clearWorkspaceTerminals( + dir: string, + sessionIDs?: string[], + platform?: Platform, + scope: ServerScopeValue = ServerScope.local, +) { const key = getWorkspaceTerminalCacheKey(dir, scope) for (const cache of caches) { const entry = cache.get(key) entry?.value.clear() } - void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform) + void removePersisted(terminalPersistTarget(scope, dir), platform) - if (scope) return + if (scope !== ServerScope.local) return const legacy = new Set(getLegacyTerminalStorageKeys(dir)) for (const id of sessionIDs ?? []) { for (const key of getLegacyTerminalStorageKeys(dir, id)) { @@ -156,14 +145,14 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat function createWorkspaceTerminalSession( sdk: ReturnType, dir: string, + scope: ServerScopeValue, legacySessionID?: string, - scope?: string, ) { - const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID) + const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : [] const [store, setStore, _, ready] = persisted( { - ...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy), + ...terminalPersistTarget(scope, dir, legacy), migrate: migrateTerminalState, }, createStore<{ @@ -388,9 +377,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont const server = useServer() const params = useParams() const cache = new Map() - const scope = createMemo(() => { - return getTerminalServerScope(server.current, server.key) - }) + const scope = server.scope() caches.add(cache) onCleanup(() => caches.delete(cache)) @@ -414,7 +401,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } } - const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => { + const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: ServerScopeValue) => { // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. const key = getWorkspaceTerminalCacheKey(dir, serverScope) const existing = cache.get(key) @@ -425,7 +412,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } const entry = createRoot((dispose) => ({ - value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope), + value: createWorkspaceTerminalSession(sdk, dir, serverScope, legacySessionID), dispose, })) @@ -434,11 +421,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope())) + const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope)) createEffect( on( - () => ({ dir: params.dir, id: params.id, scope: scope() }), + () => ({ dir: params.dir, id: params.id, scope }), (next, prev) => { if (!prev?.dir) return if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 5115f0348a..c41c1bb4b6 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -170,6 +170,7 @@ if (root instanceof HTMLElement) { diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index cf100bf014..2ecaa3979d 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -1,5 +1,5 @@ import type { Session } from "@opencode-ai/sdk/v2/client" -import { createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js" +import { batch, createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createStore } from "solid-js/store" import { useQuery } from "@tanstack/solid-query" @@ -26,7 +26,18 @@ import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" -import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers" +import { + closeHomeProject, + displayName, + getProjectAvatarSource, + homeProjectDirectories, + homeProjectNavigation, + homeSessionServerStatus, + type HomeProjectSelection, + projectForSession, + sortedRootSessions, + toggleHomeProjectSelection, +} from "@/pages/layout/helpers" import { sessionTitle } from "@/utils/session-title" import { pathKey } from "@/utils/path-key" import { messageAgentColor } from "@/utils/agent" @@ -51,6 +62,8 @@ type HomeSessionRecord = { projectName: string } +type HomeSessionSync = Pick, "child"> + type HomeSessionGroup = { id: "today" | "yesterday" | "older" title: string @@ -65,8 +78,10 @@ const HOME_SEARCH_RESULT_TITLE = const HOME_SEARCH_RESULT_META = "min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]" +let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined + function buildHomeSessionRecords(input: { - sync: ReturnType + sync: Pick, "child"> projectDirectories: () => string[] projects: () => LocalProject[] projectByID: () => Map @@ -95,6 +110,44 @@ function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) { return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query) } +function createHomeSessionStatus(input: { + record: () => HomeSessionRecord + sync: () => HomeSessionSync + activeServer: () => boolean +}) { + const notification = useNotification() + const permission = usePermission() + const sessionStore = createMemo(() => input.sync().child(input.record().session.directory, { bootstrap: false })[0]) + const unseenCount = createMemo(() => (input.activeServer() ? notification.session.unseenCount(input.record().session.id) : 0)) + const hasError = createMemo(() => input.activeServer() && notification.session.unseenHasError(input.record().session.id)) + const hasPermissions = createMemo( + () => + input.activeServer() && + !!sessionPermissionRequest(sessionStore().session, sessionStore().permission, input.record().session.id, (item) => { + return !permission.autoResponds(item, input.record().session.directory) + }), + ) + const serverStatus = createMemo(() => + homeSessionServerStatus(input.activeServer(), () => ({ + working: sessionStore().session_working(input.record().session.id), + tint: messageAgentColor(sessionStore().message[input.record().session.id], sessionStore().agent), + })), + ) + const isWorking = createMemo(() => { + if (hasPermissions()) return false + return serverStatus().working + }) + const tint = createMemo(() => serverStatus().tint) + return { + unseenCount, + hasError, + hasPermissions, + isWorking, + tint, + show: createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0), + } +} + function homeSessionSearchKey(record: HomeSessionRecord) { return `${pathKey(record.session.directory)}:${record.session.id}` } @@ -122,12 +175,27 @@ function HomeDesign() { let focusSessionSearch: (() => void) | undefined const [state, setState] = createStore({ search: "", - project: undefined as string | undefined, + selection: { server: server.key } as HomeProjectSelection, searchFocused: false, }) - const projects = createMemo(() => layout.projects.list()) - const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project)) + const focusedServer = createMemo( + () => global.servers.list().find((conn) => ServerConnection.key(conn) === state.selection.server) ?? server.current, + ) + const focusedServerCtx = createMemo(() => { + const conn = focusedServer() + if (!conn) return + return global.createServerCtx(conn) + }) + const focusedSync = () => focusedServerCtx()?.sync ?? sync + const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list()) + const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory)) + const newSessionProject = createMemo( + () => + selectedProject() ?? + projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ?? + projects()[0], + ) const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])] const projectDirectories = createMemo(() => { const project = selectedProject() @@ -136,9 +204,9 @@ function HomeDesign() { }) const search = createMemo(() => state.search.trim()) const sessionLoad = useQuery(() => ({ - queryKey: ["home", "sessions", ...projectDirectories()] as const, + queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const, queryFn: async () => { - await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory))) + await Promise.all(projectDirectories().map((directory) => focusedSync().project.loadSessions(directory))) return null }, })) @@ -148,7 +216,7 @@ function HomeDesign() { ) const allRecords = createMemo(() => buildHomeSessionRecords({ - sync, + sync: focusedSync(), projectDirectories, projects, projectByID, @@ -163,6 +231,13 @@ function HomeDesign() { const searchOpen = createMemo(() => state.searchFocused && search().length > 0) const groups = createMemo(() => groupSessions(records(), language)) + function setSelection(next: HomeProjectSelection) { + batch(() => { + if (state.selection.server !== next.server) setState("selection", "server", next.server) + if (state.selection.directory !== next.directory) setState("selection", "directory", next.directory) + }) + } + function closeSearch() { setState("search", "") setState("searchFocused", false) @@ -183,47 +258,76 @@ function HomeDesign() { }, ]) - function selectProject(directory: string) { - if (!projects().some((project) => project.worktree === directory)) return - setState("project", state.project === directory ? undefined : directory) + createEffect(() => { + const list = global.servers.list() + if (list.some((conn) => ServerConnection.key(conn) === state.selection.server)) return + const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] + if (conn) setSelection({ server: ServerConnection.key(conn) }) + }) + + createEffect(() => { + const pending = pendingHomeNavigation + if (!pending || pending.server !== server.key) return + pendingHomeNavigation = undefined + navigate(pending.href) + }) + + function focusServer(conn: ServerConnection.Any) { + setSelection({ server: ServerConnection.key(conn) }) } - function addProject(conn: ServerConnection.Any, directory: string) { - const server = global.createServerCtx(conn) - server.projects.open(directory) - server.projects.touch(directory) - setState("project", directory) + function selectProject(conn: ServerConnection.Any, directory: string) { + const key = ServerConnection.key(conn) + if (!global.createServerCtx(conn).projects.list().some((project) => project.worktree === directory)) return + setSelection(toggleHomeProjectSelection(state.selection, key, directory)) + } + + function addProjects(conn: ServerConnection.Any, directories: string[]) { + const directory = directories[0] + if (!directory) return + const ctx = global.createServerCtx(conn) + directories.forEach(ctx.projects.open) + ctx.projects.touch(directory) + setSelection({ server: ServerConnection.key(conn), directory }) } function openNewSession() { - const project = selectedProject() - if (!project) { - const conn = server.current - if (conn) void chooseProject(conn) + const conn = focusedServer() + const project = newSessionProject() + if (!conn || !project) return + openProjectNewSession(conn, project.worktree) + } + + function navigateOnServer(conn: ServerConnection.Any, href: string) { + const next = homeProjectNavigation(server.key, ServerConnection.key(conn), href) + if (!next.server) { + navigate(next.href) return } - layout.projects.open(project.worktree) - server.projects.touch(project.worktree) - navigate(`/${base64Encode(project.worktree)}/session`) + pendingHomeNavigation = next + server.setActive(next.server) } - function openProjectNewSession(directory: string) { - layout.projects.open(directory) - server.projects.touch(directory) - navigate(`/${base64Encode(directory)}/session`) + function openProjectNewSession(conn: ServerConnection.Any, directory: string) { + const ctx = global.createServerCtx(conn) + ctx.projects.open(directory) + ctx.projects.touch(directory) + navigateOnServer(conn, `/${base64Encode(directory)}/session`) } - function editProject(project: LocalProject) { + function editProject(conn: ServerConnection.Any, project: LocalProject) { void import("@/components/dialog-edit-project").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } - function unseenCount(project: LocalProject) { + function unseenCount(conn: ServerConnection.Any, project: LocalProject) { + if (ServerConnection.key(conn) !== server.key) return 0 return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0) } - function clearNotifications(project: LocalProject) { + function clearNotifications(conn: ServerConnection.Any, project: LocalProject) { + if (ServerConnection.key(conn) !== server.key) return directories(project) .filter((directory) => notification.project.unseenCount(directory) > 0) .forEach((directory) => notification.project.markViewed(directory)) @@ -231,19 +335,18 @@ function HomeDesign() { function openSession(session: Session) { const project = projectForSession(session, projects(), projectByID()) - layout.projects.open(project?.worktree ?? session.directory) - server.projects.touch(project?.worktree ?? session.directory) - navigate(`/${base64Encode(session.directory)}/session/${session.id}`) + const conn = focusedServer() + if (!conn) return + const directory = project?.worktree ?? session.directory + const ctx = global.createServerCtx(conn) + ctx.projects.open(directory) + ctx.projects.touch(directory) + navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`) } async function chooseProject(conn: ServerConnection.Any) { function resolve(result: string | string[] | null) { - if (Array.isArray(result)) { - result.forEach((r) => addProject(conn, r)) - if (result[0]) setState("project", result[0]) - return - } - if (result) addProject(conn, result) + addProjects(conn, homeProjectDirectories(result)) } const server = global.createServerCtx(conn) @@ -274,14 +377,20 @@ function HomeDesign() {
    void chooseProject(conn)} editProject={editProject} - closeProject={(directory) => { - layout.projects.close(directory) - if (state.project === directory) setState("project", undefined) + closeProject={(conn, directory) => { + const next = closeHomeProject( + state.selection, + ServerConnection.key(conn), + global.createServerCtx(conn).projects, + directory, + ) + if (next) setSelection(next) }} clearNotifications={clearNotifications} unseenCount={unseenCount} @@ -297,6 +406,8 @@ function HomeDesign() { open={searchOpen()} loading={sessionLoad.isLoading} results={searchResults()} + sync={focusedSync()} + activeServer={state.selection.server === server.key} noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })} bindFocus={(focus) => { focusSessionSearch = focus @@ -316,7 +427,10 @@ function HomeDesign() { when={groups().length > 0} fallback={
    - +
    } > @@ -325,11 +439,18 @@ function HomeDesign() {
    - {(record) => } + {(record) => ( + + )}
    @@ -347,14 +468,15 @@ function HomeDesign() { function HomeProjectColumn(props: { projects: LocalProject[] - selected?: string - selectProject: (directory: string) => void - openNewSession: (directory: string) => void + selected: HomeProjectSelection + focusServer: (server: ServerConnection.Any) => void + selectProject: (server: ServerConnection.Any, directory: string) => void + openNewSession: (server: ServerConnection.Any, directory: string) => void chooseProject: (server: ServerConnection.Any) => void - editProject: (project: LocalProject) => void - closeProject: (directory: string) => void - clearNotifications: (project: LocalProject) => void - unseenCount: (project: LocalProject) => number + editProject: (server: ServerConnection.Any, project: LocalProject) => void + closeProject: (server: ServerConnection.Any, directory: string) => void + clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void + unseenCount: (server: ServerConnection.Any, project: LocalProject) => number openSettings: () => void openHelp: () => void language: ReturnType @@ -378,40 +500,44 @@ function HomeProjectColumn(props: {
    1} - fallback={ props.chooseProject(global.servers.list()[0]!)} />} + fallback={} > {(item) => { const key = ServerConnection.key(item) const healthy = () => !!global.servers.health[key]?.healthy - const [state, setState] = createStore({ open: true }) const serverCtx = global.createServerCtx(item) return (
    - - +
    + + } + aria-label={props.language.t("home.project.add")} + onClick={() => props.chooseProject(item)} + /> +
    +
    props.chooseProject(item)} />
    @@ -442,61 +568,49 @@ function HomeProjectColumn(props: { } function HomeProjectList(props: { + server: ServerConnection.Any projects: LocalProject[] - selected?: string - selectProject: (directory: string) => void - openNewSession: (directory: string) => void - chooseProject: () => void - editProject: (project: LocalProject) => void - closeProject: (directory: string) => void - clearNotifications: (project: LocalProject) => void - unseenCount: (project: LocalProject) => number + selected: HomeProjectSelection + selectProject: (server: ServerConnection.Any, directory: string) => void + openNewSession: (server: ServerConnection.Any, directory: string) => void + editProject: (server: ServerConnection.Any, project: LocalProject) => void + closeProject: (server: ServerConnection.Any, directory: string) => void + clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void + unseenCount: (server: ServerConnection.Any, project: LocalProject) => number language: ReturnType }) { return ( - 0} - fallback={ - - } - > -
    - - {(project) => ( - - )} - -
    -
    +
    + + {(project) => ( + + )} + +
    ) } function HomeProjectRow(props: { project: LocalProject + server: ServerConnection.Any selected: boolean unseenCount: number - selectProject: (directory: string) => void - openNewSession: (directory: string) => void - editProject: (project: LocalProject) => void - closeProject: (directory: string) => void - clearNotifications: (project: LocalProject) => void + selectProject: (server: ServerConnection.Any, directory: string) => void + openNewSession: (server: ServerConnection.Any, directory: string) => void + editProject: (server: ServerConnection.Any, project: LocalProject) => void + closeProject: (server: ServerConnection.Any, directory: string) => void + clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void language: ReturnType }) { const [state, setState] = createStore({ menuOpen: false }) @@ -508,7 +622,7 @@ function HomeProjectRow(props: { class={`${HOME_PROJECT_NAV_ROW} pr-16`} data-selected={props.selected ? "" : undefined} aria-current={props.selected ? "page" : undefined} - onClick={() => props.selectProject(props.project.worktree)} + onClick={() => props.selectProject(props.server, props.project.worktree)} > {displayName(props.project)} @@ -523,7 +637,7 @@ function HomeProjectRow(props: { size="small" icon={} aria-label={props.language.t("command.session.new")} - onClick={() => props.openNewSession(props.project.worktree)} + onClick={() => props.openNewSession(props.server, props.project.worktree)} /> - props.openNewSession(props.project.worktree)}> + props.openNewSession(props.server, props.project.worktree)}> {props.language.t("command.session.new")} - props.editProject(props.project)}> + props.editProject(props.server, props.project)}> {props.language.t("common.edit")} - props.clearNotifications(props.project)}> + props.clearNotifications(props.server, props.project)}> {props.language.t("sidebar.project.clearNotifications")} - props.closeProject(props.project.worktree)}> + props.closeProject(props.server, props.project.worktree)}> {props.language.t("common.close")} @@ -580,6 +694,8 @@ function HomeSessionSearch(props: { open: boolean loading: boolean results: HomeSessionRecord[] + sync: HomeSessionSync + activeServer: boolean noResultsLabel: string bindFocus: (focus: () => void) => void onInput: (value: string) => void @@ -694,6 +810,8 @@ function HomeSessionSearch(props: { {(record) => ( setStore("active", homeSessionSearchKey(record))} onSelect={(session) => props.onSelect(session)} @@ -778,29 +896,14 @@ function HomeSessionSearch(props: { function HomeSessionSearchResultRow(props: { record: HomeSessionRecord + sync: HomeSessionSync + activeServer: boolean selected: boolean onHighlight: () => void onSelect: (session: Session) => void }) { - const globalSync = useServerSync() - const notification = useNotification() - const permission = usePermission() - const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false }) + const status = createHomeSessionStatus({ record: () => props.record, sync: () => props.sync, activeServer: () => props.activeServer }) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) - const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id)) - const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id)) - const hasPermissions = createMemo( - () => - !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => { - return !permission.autoResponds(item, props.record.session.directory) - }), - ) - const isWorking = createMemo(() => { - if (hasPermissions()) return false - return sessionStore.session_working(props.record.session.id) - }) - const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent)) - const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0) const key = () => homeSessionSearchKey(props.record) @@ -820,7 +923,7 @@ function HomeSessionSearchResultRow(props: { onClick={() => props.onSelect(props.record.session)} > @@ -829,19 +932,19 @@ function HomeSessionSearchResultRow(props: { >
    - + - +
    - +
    - 0}> + 0}>
    @@ -884,26 +987,14 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi ) } -function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) { - const globalSync = useServerSync() - const notification = useNotification() - const permission = usePermission() - const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false }) +function HomeSessionRow(props: { + record: HomeSessionRecord + sync: HomeSessionSync + activeServer: boolean + openSession: (session: Session) => void +}) { + const status = createHomeSessionStatus({ record: () => props.record, sync: () => props.sync, activeServer: () => props.activeServer }) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) - const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id)) - const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id)) - const hasPermissions = createMemo( - () => - !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => { - return !permission.autoResponds(item, props.record.session.directory) - }), - ) - const isWorking = createMemo(() => { - if (hasPermissions()) return false - return sessionStore.session_working(props.record.session.id) - }) - const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent)) - const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0) return (