Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 0552458fba refactor(core): resolve database and websearch config through Effect Config 2026-07-02 09:51:39 -04:00
41 changed files with 453 additions and 2010 deletions
+27 -60
View File
@@ -220,54 +220,24 @@ const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16I
Effect.map((value) => value.data),
)
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint4_17Input = {
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
readonly limit?: Endpoint4_17Request["query"]["limit"]
readonly after?: Endpoint4_17Request["query"]["after"]
}
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
type Endpoint4_18Input = {
readonly sessionID: Endpoint4_18Request["params"]["sessionID"]
readonly key: Endpoint4_18Request["params"]["key"]
readonly value: Endpoint4_18Request["payload"]["value"]
}
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
raw["session.context.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
type Endpoint4_19Input = {
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
readonly key: Endpoint4_19Request["params"]["key"]
}
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly limit?: Endpoint4_20Request["query"]["limit"]
readonly after?: Endpoint4_20Request["query"]["after"]
}
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
raw["session.history"]({
params: { sessionID: input["sessionID"] },
query: { limit: input["limit"], after: input["after"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly after?: Endpoint4_21Request["query"]["after"]
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint4_18Input = {
readonly sessionID: Endpoint4_18Request["params"]["sessionID"]
readonly after?: Endpoint4_18Request["query"]["after"]
}
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
Stream.unwrap(
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
Effect.mapError(mapClientError),
@@ -275,22 +245,22 @@ const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21I
),
)
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] }
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_24Input = {
readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
readonly messageID: Endpoint4_24Request["params"]["messageID"]
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly messageID: Endpoint4_21Request["params"]["messageID"]
}
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -314,14 +284,11 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
revertClear: Endpoint4_14(raw),
revertCommit: Endpoint4_15(raw),
context: Endpoint4_16(raw),
listContextEntries: Endpoint4_17(raw),
putContextEntry: Endpoint4_18(raw),
removeContextEntry: Endpoint4_19(raw),
history: Endpoint4_20(raw),
events: Endpoint4_21(raw),
interrupt: Endpoint4_22(raw),
background: Endpoint4_23(raw),
message: Endpoint4_24(raw),
history: Endpoint4_17(raw),
events: Endpoint4_18(raw),
interrupt: Endpoint4_19(raw),
background: Endpoint4_20(raw),
message: Endpoint4_21(raw),
})
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
-40
View File
@@ -39,12 +39,6 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionListContextEntriesInput,
SessionListContextEntriesOutput,
SessionPutContextEntryInput,
SessionPutContextEntryOutput,
SessionRemoveContextEntryInput,
SessionRemoveContextEntryOutput,
SessionHistoryInput,
SessionHistoryOutput,
SessionEventsInput,
@@ -550,40 +544,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
listContextEntries: (input: SessionListContextEntriesInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionListContextEntriesOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
putContextEntry: (input: SessionPutContextEntryInput, requestOptions?: RequestOptions) =>
request<SessionPutContextEntryOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry/${encodeURIComponent(input.key)}`,
body: { value: input["value"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
removeContextEntry: (input: SessionRemoveContextEntryInput, requestOptions?: RequestOptions) =>
request<SessionRemoveContextEntryOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry/${encodeURIComponent(input.key)}`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
history: (input: SessionHistoryInput, requestOptions?: RequestOptions) =>
request<SessionHistoryOutput>(
{
-21
View File
@@ -809,27 +809,6 @@ export type SessionContextOutput = {
>
}["data"]
export type SessionListContextEntriesInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionListContextEntriesOutput = {
readonly data: ReadonlyArray<{ readonly key: string; readonly value: JsonValue }>
}["data"]
export type SessionPutContextEntryInput = {
readonly sessionID: { readonly sessionID: string; readonly key: string }["sessionID"]
readonly key: { readonly sessionID: string; readonly key: string }["key"]
readonly value: { readonly value: JsonValue }["value"]
}
export type SessionPutContextEntryOutput = void
export type SessionRemoveContextEntryInput = {
readonly sessionID: { readonly sessionID: string; readonly key: string }["sessionID"]
readonly key: { readonly sessionID: string; readonly key: string }["key"]
}
export type SessionRemoveContextEntryOutput = void
export type SessionHistoryInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"]
+48 -222
View File
@@ -1,10 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "22e57fed-b9b8-4e94-a3b4-f94bece680a8",
"prevIds": [
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
],
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
"ddl": [
{
"name": "workspace",
@@ -62,10 +60,6 @@
"name": "session_context_epoch",
"entityType": "tables"
},
{
"name": "session_context_entry",
"entityType": "tables"
},
{
"name": "session_input",
"entityType": "tables"
@@ -926,56 +920,6 @@
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "text",
"notNull": false,
@@ -1537,13 +1481,9 @@
"table": "session_share"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1552,13 +1492,9 @@
"table": "workspace"
},
{
"columns": [
"active_account_id"
],
"columns": ["active_account_id"],
"tableTo": "account",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@@ -1567,13 +1503,9 @@
"table": "account_state"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"tableTo": "event_sequence",
"columnsTo": [
"aggregate_id"
],
"columnsTo": ["aggregate_id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1582,13 +1514,9 @@
"table": "event"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1597,13 +1525,9 @@
"table": "permission"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1612,13 +1536,9 @@
"table": "project_directory"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1627,13 +1547,9 @@
"table": "message"
},
{
"columns": [
"message_id"
],
"columns": ["message_id"],
"tableTo": "message",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1642,13 +1558,9 @@
"table": "part"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1657,28 +1569,9 @@
"table": "session_context_epoch"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_context_entry_session_id_session_id_fk",
"entityType": "fks",
"table": "session_context_entry"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1687,13 +1580,9 @@
"table": "session_input"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1702,13 +1591,9 @@
"table": "session_message"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1717,13 +1602,9 @@
"table": "session"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1732,13 +1613,9 @@
"table": "todo"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1747,184 +1624,133 @@
"table": "session_share"
},
{
"columns": [
"email",
"url"
],
"columns": ["email", "url"],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": [
"project_id",
"directory"
],
"columns": ["project_id", "directory"],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": [
"session_id",
"key"
],
"nameExplicit": false,
"name": "session_context_entry_pk",
"entityType": "pks",
"table": "session_context_entry"
},
{
"columns": [
"session_id",
"position"
],
"columns": ["session_id", "position"],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
"name"
],
"columns": ["name"],
"nameExplicit": false,
"name": "data_migration_pk",
"table": "data_migration",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"nameExplicit": false,
"name": "session_context_epoch_pk",
"table": "session_context_epoch",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_input_pk",
"table": "session_input",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
@@ -2242,4 +2068,4 @@
}
],
"renames": []
}
}
+22 -12
View File
@@ -2,9 +2,9 @@ export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { layer as sqliteLayer } from "#sqlite"
import { Context, Effect, Layer } from "effect"
import { Config, Context, Effect, Layer } from "effect"
import { Global } from "../global"
import { Flag } from "../flag/flag"
import { truthyConfig } from "../flag/flag"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
@@ -40,18 +40,28 @@ export function layerFromPath(filename: string) {
return layer.pipe(Layer.provide(sqliteLayer({ filename })))
}
export function path() {
if (Flag.OPENCODE_DB) {
if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return join(Global.Path.data, Flag.OPENCODE_DB)
function resolvePath(input: { readonly file: string | undefined; readonly disableChannelDb: boolean }) {
if (input.file) {
if (input.file === ":memory:" || isAbsolute(input.file)) return input.file
return join(Global.Path.data, input.file)
}
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
if (["latest", "beta", "prod"].includes(InstallationChannel) || input.disableChannelDb)
return join(Global.Path.data, "opencode.db")
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
}
export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] })
/**
* The database placement every consumer shares, resolved through Effect
* Config so tests and tooling can override it with a ConfigProvider. Used by
* the layer below and by external tooling such as `opencode db`.
*/
export const configuredPath = Effect.gen(function* () {
const file = yield* Config.string("OPENCODE_DB").pipe(Config.withDefault(undefined))
const disableChannelDb = yield* truthyConfig("OPENCODE_DISABLE_CHANNEL_DB")
return resolvePath({ file, disableChannelDb })
}).pipe(Effect.orDie)
// Placement is resolved when the layer is built, not at module import.
const configuredLayer = Layer.unwrap(Effect.map(configuredPath, layerFromPath))
export const node = makeGlobalNode({ service: Service, layer: configuredLayer, deps: [] })
-1
View File
@@ -40,6 +40,5 @@ export const migrations = (
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260702134641_add_session_context_entry"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,21 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260702134641_add_session_context_entry",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_context_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
})
},
} satisfies DatabaseMigration.Migration
-11
View File
@@ -154,17 +154,6 @@ export default {
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_context_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_input\` (
\`id\` text PRIMARY KEY,
+17 -4
View File
@@ -1,10 +1,24 @@
import { Config } from "effect"
export function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "true" || value === "1"
function truthyValue(value: string) {
const lower = value.toLowerCase()
return lower === "true" || lower === "1"
}
export function truthy(key: string) {
const value = process.env[key]
return value !== undefined && truthyValue(value)
}
/**
* The `truthy` grammar read through Effect Config, so layer builds can be
* steered by a ConfigProvider. Missing or malformed values are false, exactly
* like `truthy`; strict boolean decoding would turn a stray env value into a
* startup defect.
*/
export const truthyConfig = (name: string) =>
Config.string(name).pipe(Config.map(truthyValue), Config.withDefault(false))
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
const fff = process.env["OPENCODE_DISABLE_FFF"]
@@ -39,7 +53,6 @@ export const Flag = {
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
OPENCODE_DB: process.env["OPENCODE_DB"],
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
-2
View File
@@ -38,7 +38,6 @@ import { SkillGuidance } from "./skill/guidance"
import { Snapshot } from "./snapshot"
import { InstructionContext } from "./instruction-context"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { SessionContextEntry } from "./session/context-entry"
import { SessionInstructions } from "./session/instructions"
import { BuiltInTools } from "./tool/builtins"
import { McpTool } from "./tool/mcp"
@@ -82,7 +81,6 @@ export const locationServices = LayerNode.group([
SkillGuidance.node,
ReferenceGuidance.node,
SessionTodo.node,
SessionContextEntry.node,
QuestionV2.node,
Generate.node,
ReadToolFileSystem.node,
+52 -33
View File
@@ -3,7 +3,7 @@ export * as PluginInternal from "./internal"
import { makeLocationNode } from "../effect/app-node"
import { httpClient } from "../effect/app-node-platform"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Layer, Scope } from "effect"
import { Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
@@ -27,7 +27,6 @@ import { PluginV2 } from "../plugin"
import { PluginRuntime } from "../plugin/runtime"
import { PermissionV2 } from "../permission"
import { Reference } from "../reference"
import { Ripgrep } from "../ripgrep"
import { Shell } from "../shell"
import { SkillV2 } from "../skill"
import { State } from "../state"
@@ -41,7 +40,6 @@ import { ProviderPlugins } from "./provider"
import { SdkPlugins } from "./sdk"
import { SkillPlugin } from "./skill"
import { VariantPlugin } from "./variant"
import { GlobTool } from "../tool/glob"
import { ShellTool } from "../tool/shell"
import { SubagentTool } from "../tool/subagent"
@@ -63,7 +61,6 @@ export type Requirements =
| PermissionV2.Service
| PluginRuntime.Service
| Reference.Service
| Ripgrep.Service
| Shell.Service
| SkillV2.Service
| Tools.Service
@@ -79,35 +76,59 @@ export function define<R>(plugin: Plugin<R>) {
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const plugin = yield* PluginV2.Service
const sdkPlugins = yield* SdkPlugins.Service
const services = Context.mergeAll(
Context.make(Catalog.Service, yield* Catalog.Service),
Context.make(CommandV2.Service, yield* CommandV2.Service),
Context.make(Integration.Service, yield* Integration.Service),
Context.make(AgentV2.Service, yield* AgentV2.Service),
Context.make(Config.Service, yield* Config.Service),
Context.make(Location.Service, yield* Location.Service),
Context.make(ModelsDev.Service, yield* ModelsDev.Service),
Context.make(Npm.Service, yield* Npm.Service),
Context.make(EventV2.Service, yield* EventV2.Service),
Context.make(FSUtil.Service, yield* FSUtil.Service),
Context.make(FileSystem.Service, yield* FileSystem.Service),
Context.make(Global.Service, yield* Global.Service),
Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient),
Context.make(LocationMutation.Service, yield* LocationMutation.Service),
Context.make(PermissionV2.Service, yield* PermissionV2.Service),
Context.make(SkillV2.Service, yield* SkillV2.Service),
Context.make(Reference.Service, yield* Reference.Service),
Context.make(Ripgrep.Service, yield* Ripgrep.Service),
Context.make(Shell.Service, yield* Shell.Service),
Context.make(Tools.Service, yield* Tools.Service),
Context.make(PluginRuntime.Service, yield* PluginRuntime.Service),
)
const add = (input: Plugin<Requirements | Scope.Scope>) =>
plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) =>
input.effect(context).pipe(Effect.provide(services)),
)
const integration = yield* Integration.Service
const agents = yield* AgentV2.Service
const config = yield* Config.Service
const location = yield* Location.Service
const modelsDev = yield* ModelsDev.Service
const npm = yield* Npm.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const filesystem = yield* FileSystem.Service
const global = yield* Global.Service
const http = yield* HttpClient.HttpClient
const mutation = yield* LocationMutation.Service
const permission = yield* PermissionV2.Service
const skill = yield* SkillV2.Service
const reference = yield* Reference.Service
const shell = yield* Shell.Service
const tools = yield* Tools.Service
const runtime = yield* PluginRuntime.Service
const add = <R>(input: Plugin<R>) => {
const loaded = {
id: input.id,
effect: (context: PluginContext) =>
input
.effect(context)
.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(CommandV2.Service, commands),
Effect.provideService(Integration.Service, integration),
Effect.provideService(AgentV2.Service, agents),
Effect.provideService(Config.Service, config),
Effect.provideService(Location.Service, location),
Effect.provideService(ModelsDev.Service, modelsDev),
Effect.provideService(Npm.Service, npm),
Effect.provideService(EventV2.Service, events),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(FileSystem.Service, filesystem),
Effect.provideService(Global.Service, global),
Effect.provideService(HttpClient.HttpClient, http),
Effect.provideService(LocationMutation.Service, mutation),
Effect.provideService(PermissionV2.Service, permission),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, reference),
Effect.provideService(Shell.Service, shell),
Effect.provideService(Tools.Service, tools),
Effect.provideService(PluginRuntime.Service, runtime),
),
}
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
}
yield* State.batch(
Effect.gen(function* () {
@@ -117,7 +138,6 @@ const layer = Layer.effectDiscard(
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(GlobTool.Plugin)
yield* add(ShellTool.Plugin)
yield* add(SubagentTool.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
@@ -155,7 +175,6 @@ export const node = makeLocationNode({
PermissionV2.node,
SkillV2.node,
Reference.node,
Ripgrep.node,
Shell.node,
ToolRegistry.toolsNode,
PluginRuntime.node,
-106
View File
@@ -1,106 +0,0 @@
export * as SessionContextEntry from "./context-entry"
import { and, asc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { SessionContextEntry } from "@opencode-ai/schema/session-context-entry"
import { Database } from "../database/database"
import { makeLocationNode } from "../effect/app-node"
import { SystemContext } from "../system-context/index"
import { SessionSchema } from "./schema"
import { SessionContextEntryTable } from "./sql"
export const Key = SessionContextEntry.Key
export type Key = typeof Key.Type
export const Info = SessionContextEntry.Info
export type Info = typeof Info.Type
export interface Interface {
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
readonly put: (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) => Effect.Effect<void>
readonly remove: (input: { readonly sessionID: SessionSchema.ID; readonly key: Key }) => Effect.Effect<void>
/** Produces one SystemContext source per stored entry, keyed `api/<key>`. */
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<SystemContext.SystemContext>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionContextEntry") {}
const renderValue = (value: Schema.Json) => (typeof value === "string" ? value : JSON.stringify(value, null, 2))
const renderBlock = (key: Key, value: Schema.Json) =>
[`<context key="${key}">`, renderValue(value), "</context>"].join("\n")
// Rendering stays mechanism-neutral: the model sees session context, not how
// it was attached. Only chronological updates and removals carry narration.
const source = (entry: Info) =>
SystemContext.make({
key: SystemContext.Key.make(`api/${entry.key}`),
codec: Schema.toCodecJson(Schema.Json),
load: Effect.succeed(entry.value),
baseline: (value) => renderBlock(entry.key, value),
update: (_previous, value) =>
[
`The context under "${entry.key}" changed and supersedes the previous value:`,
renderBlock(entry.key, value),
].join("\n"),
removed: () => `The context under "${entry.key}" no longer applies. Disregard it.`,
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const list = Effect.fn("SessionContextEntry.list")(function* (sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
.from(SessionContextEntryTable)
.where(eq(SessionContextEntryTable.session_id, sessionID))
.orderBy(asc(SessionContextEntryTable.key))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ key: row.key, value: row.value }))
})
const put = Effect.fn("SessionContextEntry.put")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) {
yield* db
.insert(SessionContextEntryTable)
.values({ session_id: input.sessionID, key: input.key, value: input.value })
.onConflictDoUpdate({
target: [SessionContextEntryTable.session_id, SessionContextEntryTable.key],
set: { value: input.value, time_updated: Date.now() },
})
.run()
.pipe(Effect.orDie)
})
const remove = Effect.fn("SessionContextEntry.remove")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
}) {
yield* db
.delete(SessionContextEntryTable)
.where(
and(eq(SessionContextEntryTable.session_id, input.sessionID), eq(SessionContextEntryTable.key, input.key)),
)
.run()
.pipe(Effect.orDie)
})
const load = Effect.fn("SessionContextEntry.load")(function* (sessionID: SessionSchema.ID) {
const entries = yield* list(sessionID)
return SystemContext.combine(entries.map(source))
})
return Service.of({ list, put, remove, load })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Database.node] })
+14 -20
View File
@@ -23,7 +23,6 @@ import { InstructionContext } from "../../instruction-context"
import { SkillGuidance } from "../../skill/guidance"
import { ReferenceGuidance } from "../../reference/guidance"
import { McpGuidance } from "../../mcp/guidance"
import { SessionContextEntry } from "../context-entry"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { SessionContextCheckpoint } from "../context-checkpoint"
@@ -109,7 +108,6 @@ const layer = Layer.effect(
const skillGuidance = yield* SkillGuidance.Service
const referenceGuidance = yield* ReferenceGuidance.Service
const mcpGuidance = yield* McpGuidance.Service
const contextEntries = yield* SessionContextEntry.Service
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
@@ -173,7 +171,7 @@ const layer = Layer.effect(
const continueAfterOverflowCompaction = (step: number) =>
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
const loadSystemContext = (agent: AgentV2.Selection) =>
Effect.all(
[
builtins.load(),
@@ -181,7 +179,6 @@ const layer = Layer.effect(
skillGuidance.load(agent),
referenceGuidance.load(),
mcpGuidance.load(agent),
contextEntries.load(sessionID),
],
{ concurrency: "unbounded" },
).pipe(Effect.map(SystemContext.combine))
@@ -198,12 +195,7 @@ const layer = Layer.effect(
const agent = yield* agents.select(session.agent)
// Establish what the model knows before admitting what the user said, so
// a blocked first turn leaves pending inputs untouched.
const checkpoint = yield* SessionContextCheckpoint.prepare(
db,
events,
loadSystemContext(agent, session.id),
session.id,
)
const checkpoint = yield* SessionContextCheckpoint.prepare(db, events, loadSystemContext(agent), session.id)
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
let needsContinuation = false
let currentStep = step
@@ -445,15 +437,18 @@ const layer = Layer.effect(
Effect.gen(function* () {
const failure =
Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined
yield* events.publish(SessionEvent.ExecutionSettled, {
sessionID: input.sessionID,
timestamp: yield* DateTime.now,
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure",
error:
failure !== undefined
? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) }
: undefined,
})
yield* events.publish(
SessionEvent.ExecutionSettled,
{
sessionID: input.sessionID,
timestamp: yield* DateTime.now,
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure",
error:
failure !== undefined
? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) }
: undefined,
},
)
}).pipe(
Effect.catchCause(() => Effect.void),
Effect.asVoid,
@@ -484,7 +479,6 @@ export const node = makeLocationNode({
SkillGuidance.node,
ReferenceGuidance.node,
McpGuidance.node,
SessionContextEntry.node,
SessionCompaction.node,
SessionTitle.node,
Config.node,
-15
View File
@@ -14,7 +14,6 @@ import { Timestamps } from "../database/schema.sql"
import type { SystemContext } from "../system-context/index"
import { AgentV2 } from "../agent"
import type { Revert } from "@opencode-ai/schema/revert"
import type { Schema } from "effect"
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
@@ -166,20 +165,6 @@ export const SessionInputTable = sqliteTable(
],
)
export const SessionContextEntryTable = sqliteTable(
"session_context_entry",
{
session_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
key: text().notNull(),
value: text({ mode: "json" }).notNull().$type<Schema.Json>(),
...Timestamps,
},
(table) => [primaryKey({ columns: [table.session_id, table.key] })],
)
export const SessionContextCheckpointTable = sqliteTable("session_context_epoch", {
session_id: text()
.$type<SessionSchema.ID>()
+2
View File
@@ -4,6 +4,7 @@ import { makeLocationNode } from "../effect/app-node"
import { Context, Layer } from "effect"
import { ApplyPatchTool } from "./apply-patch"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
import { GrepTool } from "./grep"
import { QuestionTool } from "./question"
import { ReadTool } from "./read"
@@ -37,6 +38,7 @@ export const node = makeLocationNode({
deps: [
ApplyPatchTool.node,
EditTool.node,
GlobTool.node,
GrepTool.node,
QuestionTool.node,
ReadTool.node,
+15 -7
View File
@@ -1,15 +1,17 @@
export * as GlobTool from "./glob"
import { ToolFailure } from "@opencode-ai/llm"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect"
import { Effect, Layer, Schema } from "effect"
import path from "path"
import { makeLocationNode } from "../effect/app-node"
import { FileSystem } from "../filesystem"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "glob"
@@ -33,14 +35,14 @@ export const toModelOutput = (output: ModelOutput) => {
}
/** Glob leaf that defaults its filesystem root to the active Location. */
export const Plugin = {
id: "core-glob-tool",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const permission = yield* PermissionV2.Service
yield* ctx.tool
yield* tools
.register({
[name]: Tool.make({
description:
@@ -94,4 +96,10 @@ export const Plugin = {
})
.pipe(Effect.orDie)
}),
}
)
export const node = makeLocationNode({
name: "tool/glob",
layer,
deps: [ToolRegistry.node, Ripgrep.node, Location.node, PermissionV2.node],
})
+34 -14
View File
@@ -1,11 +1,11 @@
export * as WebSearchTool from "./websearch"
import { ToolFailure } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { Config, Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { makeLocationNode } from "../effect/app-node"
import { LayerNodePlatform } from "../effect/app-node-platform"
import { truthy } from "../flag/flag"
import { truthyConfig } from "../flag/flag"
import { InstallationVersion } from "../installation/version"
import { PositiveInt } from "../schema"
import { PermissionV2 } from "../permission"
@@ -69,19 +69,39 @@ export interface Config {
export class ConfigService extends Context.Service<ConfigService, Config>()("@opencode/v2/WebSearchConfig") {}
/** Isolates the retained product environment contract from the generic tool implementation. */
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
ConfigService.of({
provider:
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
? process.env.OPENCODE_WEBSEARCH_PROVIDER
: undefined,
enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"),
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
exaApiKey: process.env.EXA_API_KEY,
parallelApiKey: process.env.PARALLEL_API_KEY,
/**
* Isolates the retained product environment contract from the generic tool
* implementation. Reads through Effect `Config` when the layer is built, so
* tests can override values with a `ConfigProvider` instead of mutating
* `process.env`. Parsing keeps the legacy lenient grammar: missing or
* malformed values disable rather than fail, because this layer builds at
* Location boot where a defect would surface as opaque session failures.
*/
export const defaultConfigLayer = Layer.effect(
ConfigService,
Effect.gen(function* () {
const env = yield* Config.all({
provider: Config.string("OPENCODE_WEBSEARCH_PROVIDER").pipe(Config.withDefault(undefined)),
experimental: truthyConfig("OPENCODE_EXPERIMENTAL"),
enableExa: truthyConfig("OPENCODE_ENABLE_EXA"),
experimentalExa: truthyConfig("OPENCODE_EXPERIMENTAL_EXA"),
enableParallel: truthyConfig("OPENCODE_ENABLE_PARALLEL"),
experimentalParallel: truthyConfig("OPENCODE_EXPERIMENTAL_PARALLEL"),
exaApiKey: Config.string("EXA_API_KEY").pipe(Config.withDefault(undefined)),
parallelApiKey: Config.string("PARALLEL_API_KEY").pipe(Config.withDefault(undefined)),
})
const provider = env.provider !== undefined && Schema.is(Provider)(env.provider) ? env.provider : undefined
if (env.provider !== undefined && provider === undefined)
yield* Effect.logWarning("ignoring invalid OPENCODE_WEBSEARCH_PROVIDER", { value: env.provider })
return ConfigService.of({
provider,
enableExa: env.experimental || env.enableExa || env.experimentalExa,
enableParallel: env.enableParallel || env.experimentalParallel,
exaApiKey: env.exaApiKey,
parallelApiKey: env.parallelApiKey,
})
}),
)
).pipe(Layer.orDie)
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { ConfigProvider, Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { Global } from "@opencode-ai/core/global"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { tmpdir } from "./fixture/tmpdir"
const resolve = (env: Record<string, string>) =>
Effect.runPromise(Effect.provide(Database.configuredPath, ConfigProvider.layer(ConfigProvider.fromUnknown(env))))
describe("Database placement", () => {
test("resolves explicit files, relative names, and the channel default", async () => {
expect(await resolve({ OPENCODE_DB: ":memory:" })).toBe(":memory:")
expect(await resolve({ OPENCODE_DB: "/tmp/explicit.db" })).toBe("/tmp/explicit.db")
expect(await resolve({ OPENCODE_DB: "relative.db" })).toBe(path.join(Global.Path.data, "relative.db"))
expect(await resolve({ OPENCODE_DISABLE_CHANNEL_DB: "true" })).toBe(path.join(Global.Path.data, "opencode.db"))
})
test("reads placement from the active ConfigProvider when the layer is built", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "config-seam.sqlite")
// The preload sets OPENCODE_DB=":memory:" in the process environment, so a
// database appearing at this path proves the layer reads through the
// replaced ConfigProvider rather than the environment snapshot.
await Effect.runPromise(
Layer.build(
LayerNode.compile(LayerNode.group([Database.node])).pipe(
Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_DB: file }))),
),
).pipe(Effect.scoped, Effect.asVoid),
)
expect(await Bun.file(file).exists()).toBe(true)
})
})
+3 -2
View File
@@ -24,7 +24,9 @@ import { EventV2 } from "../src/event"
import { Reference } from "../src/reference"
import { ToolRegistry } from "../src/tool/registry"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])),
)
describe("LocationServiceMap", () => {
it.live("reuses cached services for constructed and decoded location refs", () =>
@@ -73,7 +75,6 @@ describe("LocationServiceMap", () => {
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "glob")
yield* waitForTool(registry, "shell")
yield* waitForTool(registry, "subagent")
return {
-54
View File
@@ -50,7 +50,6 @@ import {
SessionMessageTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
import { SessionContextEntry } from "@opencode-ai/core/session/context-entry"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
@@ -271,7 +270,6 @@ const it = testEffect(
SessionRunnerModel.node,
SystemContextBuiltIns.node,
InstructionContext.node,
SessionContextEntry.node,
SkillGuidance.node,
ReferenceGuidance.node,
Config.node,
@@ -1093,58 +1091,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("renders API context entries through the belief lifecycle", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const contextEntries = yield* SessionContextEntry.Service
yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
// String values render verbatim inside the tagged block at baseline.
expect(requests[0]?.system.map((part) => part.text)).toEqual([
defaultSystem,
["Initial context", "", '<context key="deploy-target">', "production", "</context>"].join("\n"),
])
// Non-string JSON pretty-prints; the change narrates as a System update.
yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
expect(requests[1]?.messages.at(1)?.content).toEqual([
{
type: "text",
text: [
'The context under "deploy-target" changed and supersedes the previous value:',
'<context key="deploy-target">',
"{",
' "region": "us-east-1"',
"}",
"</context>",
].join("\n"),
},
])
expect(yield* contextEntries.list(sessionID)).toEqual([{ key: "deploy-target", value: { region: "us-east-1" } }])
// Deleting the row announces removal through the stored removal text.
yield* contextEntries.remove({ sessionID, key: "deploy-target" })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"])
expect(requests[2]?.messages.at(-2)?.content).toEqual([
{ type: "text", text: 'The context under "deploy-target" no longer applies. Disregard it.' },
])
expect(yield* contextEntries.list(sessionID)).toEqual([])
}),
)
it.effect("keeps the baseline and chronological System updates after a model switch", () =>
Effect.gen(function* () {
yield* setup
+52 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { ConfigProvider, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@@ -47,6 +47,57 @@ describe("WebSearchTool provider selection", () => {
})
})
const readDefaultConfig = (env: Record<string, string>) =>
Effect.provide(
WebSearchTool.ConfigService,
WebSearchTool.defaultConfigLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(env)))),
)
describe("WebSearchTool default config", () => {
test("decodes an empty environment to defaults", async () => {
expect(await Effect.runPromise(readDefaultConfig({}))).toEqual({
provider: undefined,
enableExa: false,
enableParallel: false,
exaApiKey: undefined,
parallelApiKey: undefined,
})
})
test("decodes provider, truthy flags, and credentials from the active ConfigProvider", async () => {
expect(
await Effect.runPromise(
readDefaultConfig({
OPENCODE_WEBSEARCH_PROVIDER: "parallel",
OPENCODE_ENABLE_EXA: "1",
OPENCODE_EXPERIMENTAL_PARALLEL: "true",
EXA_API_KEY: "exa-key",
PARALLEL_API_KEY: "parallel-key",
}),
),
).toEqual({
provider: "parallel",
enableExa: true,
enableParallel: true,
exaApiKey: "exa-key",
parallelApiKey: "parallel-key",
})
})
test("keeps the legacy lenient truthiness grammar", async () => {
const decoded = await Effect.runPromise(
readDefaultConfig({ OPENCODE_ENABLE_EXA: "TRUE", OPENCODE_ENABLE_PARALLEL: "banana" }),
)
expect(decoded.enableExa).toBe(true)
expect(decoded.enableParallel).toBe(false)
})
test("ignores an invalid provider instead of failing the Location layer", async () => {
const decoded = await Effect.runPromise(readDefaultConfig({ OPENCODE_WEBSEARCH_PROVIDER: "bing" }))
expect(decoded.provider).toBeUndefined()
})
})
describe("WebSearchTool MCP response parser", () => {
test("parses plain JSON-RPC responses", async () => {
expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results")
@@ -1,170 +0,0 @@
# Simulation Implementation Phases
Status: implementation plan for `specs/simulation/simulation.md`.
The full simulation architecture is intentionally broad. This document breaks it into phases that can be implemented and reviewed incrementally.
## Phase 1: Control Surface And Observability
Goal: start the normal app in simulation mode and inspect/drive the TUI through an external WebSocket driver.
This phase proves the core shape without swapping every foundational layer yet.
Implementation checklist:
- [x] Add `OPENCODE_SIMULATION=1` activation in V1/full-TUI startup.
- [x] Add simulation trace service with in-memory append-only records.
- [x] Add OpenTUI UI state extraction for screen, focus, elements, and generated actions.
- [x] Add OpenTUI UI action execution for typing, keys, enter, arrows, focus, and click.
- [x] Add reusable JSON-RPC WebSocket server on `127.0.0.1:40900+`.
- [x] Expose `ui.state`, `ui.action`, `ui.render`.
- [x] Expose `trace.list`, `trace.clear`, `trace.export`.
- [x] Wire visible V1/full-TUI renderer path through the same action protocol.
- [ ] Verify a local driver can inspect state and execute a real TUI input.
Scope:
- Add `OPENCODE_SIMULATION=1` activation.
- Start a TUI-owned JSON-RPC WebSocket server on `127.0.0.1:40900+`.
- Expose `ui.state`, `ui.action`, `ui.render`.
- Use the old simulation action model: type text, press keys, press enter, arrows, focus, click.
- Support fake OpenTUI renderer and visible renderer through the same action protocol.
- Add in-memory append-only trace with `trace.list`, `trace.clear`, `trace.export`.
- Record UI observations, generated actions, executed actions, errors, and render/stabilization events.
Done when:
- `OPENCODE_SIMULATION=1 bun run dev` starts the normal app.
- A local driver can connect to the WebSocket.
- The driver can inspect current screen/elements/actions.
- The driver can execute real TUI inputs.
- The trace shows observations and actions.
Out of scope:
- Backend layer replacement.
- Model-based runner.
- Generated plugin config.
- Deterministic replay tests.
## Phase 2: Foundational Simulation Layers
Goal: make the app safe and controlled by swapping the lowest layers, not app logic.
Scope:
- Wire simulation replacements through `AppNodeBuilder.build(...)` and `AppNodeBuilderV1.build(...)`.
- Create a real, empty anchor directory (`mkdtemp`) and `process.chdir` into it before any command resolves its working directory; skip creation when the runner already spawned the app inside an anchor.
- Root the in-memory filesystem at `process.cwd()` (the anchor). No cwd monkey-patching: cwd, `$PWD`, and `path.resolve()` stay truthful.
- Add snapshot loading from `OPENCODE_SIMULATION_STATE`: read the snapshot directory once at startup and seed the in-memory filesystem (snapshot `project/` paths joined onto the anchor root), config, env, and optional LLM/network state from it.
- Route config/data/state/cache/temp paths into the simulated space using existing env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`), set before `packages/core/src/global.ts` import-time path setup runs.
- Deny host filesystem escapes loudly (paths outside the anchor root fail with typed simulation errors).
- Assert the anchor directory on the host is still empty at the end of the run; anything written there means a code path bypassed the simulated filesystem.
- Add simulated network registry and deny unknown external network by default.
- Add scriptable LLM boundary.
- Add simulated process registry:
- shell through `just-bash` against the simulated filesystem.
- minimal fake `git` support for discovery/status paths.
- deny unsupported process spawns.
- Add simulation-gated backend control routes, proxied only through the frontend WebSocket.
- Expose backend methods through the frontend server: filesystem seed/write, network register, LLM enqueue, backend snapshot.
- Trace filesystem, network, LLM, process, and backend control activity.
Done when:
- Unknown network fails with a simulation error.
- Host filesystem escape fails with a simulation error.
- The anchor directory on the host is empty after a run.
- The app boots from a snapshot directory via `OPENCODE_SIMULATION_STATE` and observes the seeded project files, config, and env through normal app paths.
- A driver can seed a project filesystem.
- A driver can enqueue an LLM script and submit a prompt through the TUI.
- The real session/tool path consumes the scripted LLM behavior.
- Shell commands use `just-bash`; unsupported process spawns fail.
- Trace contains backend activity and snapshots.
Out of scope:
- Model-based generation.
- Generated plugin config state.
- Shrinking.
## Phase 3: Generated Config And Model-Based Runner
Goal: explore different app states using generated commands and plugin-provided config state.
Scope:
- Add generated simulation plugins as the primary config-state generation mechanism.
- Support generated plugin domains for:
- agents and defaults.
- provider/model availability.
- tool definitions and scripted tool behavior.
- MCP-like capabilities or endpoints.
- permission policies.
- instructions/system-context-like inputs where supported.
- workspace/project adapters where supported.
- Add runner commands to generate, enable, disable, and inspect generated plugin state.
- Build a custom external model-based runner, not `fast-check` yet.
- Runner command shape: precondition, execute, model update, postcondition.
- Runner model tracks only high-level observational state: screen category, prompt availability, sessions, files, queued LLM scripts, generated plugins, backend status, idle expectation.
- Generate valid command sequences from model state and current `ui.state.actions`.
- Record seed, command distribution, precondition rejections, generated plugin/config domain coverage, UI action coverage, and backend event coverage.
Done when:
- A seeded runner can generate a short valid exploration.
- The runner can generate plugin-provided config state without generating large arbitrary config files.
- The app loads and observes generated plugin state through normal plugin/config paths.
- The runner can type and submit prompts through the TUI using generated actions.
- Basic properties run after commands: no crash, no unknown network, no host FS escape, coherent stabilized state.
- Trace export includes enough state to replay the generated run later.
Out of scope:
- Shrinking.
- Coverage-guided mutation corpus.
- Differential testing.
- CI randomized runs.
## Phase 4: Replay, Promotion, And Campaigns
Goal: turn exploratory simulation into durable tests and prepare for larger campaigns.
Scope:
- Add replay from exported trace.
- Add deterministic replay test generation from successful or failing traces.
- Add stronger trace schema validation.
- Add property families beyond no-crash:
- durable prompt admission is not lost.
- no duplicated visible message IDs.
- no orphan tool results.
- queue/steer semantics hold at stabilization boundaries.
- interrupt/resume does not duplicate promoted inputs.
- Add corpus storage for interesting traces.
- Add simple coverage/novelty scoring over UI states, backend event types, tool outcomes, generated config domains, and errors.
- Add long-running campaign mode outside normal CI.
Done when:
- A trace from Phase 3 can be replayed deterministically.
- A trace can be promoted to a normal test fixture.
- Campaign runs can collect interesting traces without committing randomized tests to CI.
- Failures produce a compact reproduction command and trace export.
Out of scope:
- Full shrinking.
- Deterministic scheduler/clock control.
- Parallel campaigns.
- Differential testing across app versions.
## Later Work
- Shrinking failed traces.
- Coverage-guided mutation of structured traces.
- `fast-check` integration if the custom runner becomes too limited.
- Differential testing across versions, renderers, storage modes, or scheduler policies.
- Deterministic clock/random/scheduler control.
- Parallel isolated workers.
- Model-generated properties with validity/soundness/coverage scoring.
@@ -1,489 +0,0 @@
# Opencode Simulation Architecture
Status: first milestone architecture draft.
## Goal
Build a simulation environment for exploring opencode through the real app, primarily through the TUI, while replacing only the lowest foundational layers needed to make runs controlled, observable, and safe.
The first milestone is an interactive exploration and model-based testing environment. It should be enough to start opencode normally, put the app into generated states, drive real user-level TUI actions, observe what happened, and record an in-memory trace that can later be exported into deterministic replay tests.
This is not intended to be a custom simulated app or a separate `simulate` command. The normal app should run, with simulation enabled by one required flag:
```sh
OPENCODE_SIMULATION=1 bun run dev
```
## Non-Goals
- Do not reimplement the app.
- Do not replace mid-level services like session processing, tool registry, provider orchestration, route trees, or TUI components unless a foundational seam proves impossible.
- Do not build shrinking in the first milestone.
- Do not make generated randomized runs part of CI yet.
- Do not build differential testing in the first milestone.
- Do not expose simulation controls when `OPENCODE_SIMULATION` is not set.
## Design Principles
- Run the real app through normal commands.
- Drive the TUI using real user-level input: typing, keypresses, focus, click, and mouse actions.
- Keep simulation code isolated under a simulation/testing area.
- Touch production app code only at narrow activation points: builders, TUI startup, foundational layers, and simulation-gated backend routes.
- Swap foundational layers, not app logic.
- Make observations rich enough for humans and models.
- Treat traces as first-class artifacts.
- Use a lightweight model of expected high-level behavior, not a clone of opencode internals.
- Generate valid commands from current observed state rather than blindly fuzzing impossible actions.
## Activation
`OPENCODE_SIMULATION=1` is the only required flag.
Initial state is provided through an optional snapshot directory:
```sh
OPENCODE_SIMULATION=1 OPENCODE_SIMULATION_STATE=/path/to/snapshot bun run dev
```
Optional flags can be added later, but should stay minimal. Reasonable optional parameters later include renderer mode, trace output path, seed, or port override.
All simulation parameters are environment variables, not CLI flags. This is a hard requirement: `packages/core/src/global.ts` computes and creates XDG paths at module import time, so anything that redirects paths must be in place before the first import. Environment variables set by the parent process (or read at the very top of startup) satisfy this; CLI flags parsed after imports do not.
When enabled:
- The app creates and changes into a real, empty anchor directory (see Filesystem).
- The app reads the snapshot directory, if provided, and seeds all simulated state from it.
- The app builds with simulation layer replacements.
- The TUI process starts a loopback WebSocket control server.
- Simulation-gated backend control routes become available only to the frontend/control path.
- In-memory trace recording starts automatically.
Path seams reuse existing environment variables where they already exist: `OPENCODE_CONFIG_DIR` for global config, `OPENCODE_TEST_HOME` for home, and `OPENCODE_DB=:memory:` for the database. Simulation mode should set these before foundational modules load rather than inventing parallel mechanisms.
## Control Server
The external control surface lives in the TUI/frontend process, not the backend API server.
This is important because the frontend has direct access to the renderer, screen state, focus state, interactable elements, and user input APIs. The backend remains the normal backend, with only simulation-gated control routes used internally by the frontend when needed.
Protocol:
- JSON-RPC 2.0 over WebSocket.
- Loopback only.
- Start at `127.0.0.1:40900`.
- If occupied, scan upward and report the actual URL.
- External drivers connect only to this frontend WebSocket.
The app should not send JSON-RPC requests back to the driver in the first milestone. The driver sends requests; the app responds and emits notifications/events as useful.
Initial method groups:
- `ui.state`: return screen, elements, focus, and generated possible actions.
- `ui.action`: execute one real user-level action.
- `ui.render`: force or wait for a render and return state.
- `backend.filesystem.seed`: seed project files.
- `backend.filesystem.write`: write one file.
- `backend.network.register`: register a fake network response.
- `backend.llm.enqueue`: queue scripted LLM behavior.
- `backend.snapshot`: return backend simulation state.
- `trace.list`: return trace records.
- `trace.clear`: clear in-memory trace.
- `trace.export`: export trace JSON for replay/test generation.
- `run.stabilize`: wait for frontend/backend quiescence and return observations.
## TUI Actions
The old simulation branch had the right basic shape: observe OpenTUI renderables, derive executable actions, and execute those actions through OpenTUI input/mouse APIs.
The first action vocabulary should stay close to that work:
```ts
type UIAction =
| { type: "typeText"; text: string }
| { type: "pressKey"; key: string; modifiers?: KeyModifiers }
| { type: "pressEnter" }
| { type: "pressArrow"; direction: "up" | "down" | "left" | "right" }
| { type: "focus"; target: number }
| { type: "click"; target: number; x: number; y: number }
```
`ui.state` should return:
- Current screen text.
- Focused renderable/editor state.
- Interactable elements.
- Generated actions valid for the current UI state.
Elements should include stable-enough semantic data where available:
- Renderable ID and numeric target.
- Position and dimensions.
- Focusable/clickable/editor flags.
- Focused flag.
- Text or label when available.
- Role/capability when available.
Both fake OpenTUI renderer and visible terminal renderer should share this protocol. The architecture should support both; the default can be decided later.
## Backend Control
The backend server should be exactly the normal backend server.
Simulation-only backend routes may exist, but only when `OPENCODE_SIMULATION=1`. They are private implementation details for the frontend simulation server to proxy commands like filesystem seeding, LLM scripting, network registration, and snapshots.
External drivers should not use backend simulation routes directly.
## Foundational Layer Replacement
Current `origin/dev` has the right seam: `AppNodeBuilder.build(...)` and `AppNodeBuilderV1.build(...)` accept replacements over `LayerNode`s. Simulation should use those seams instead of adding large alternate app assemblies.
First milestone replacements:
- Filesystem.
- Network / HTTP client.
- LLM boundary.
- Process spawner.
First milestone generated state surfaces:
- Filesystem/project state.
- Network responses.
- LLM scripts.
- Process registry behavior.
- Plugin-generated config state.
Likely later replacements:
- Clock/random.
- Database path/isolation.
- Global paths/temp paths.
The goal is to swap things at the bottom of the app. Everything above these foundational services should behave as production code.
## Filesystem
The filesystem simulation is in-memory, anchored at a real empty directory.
On startup in simulation mode:
1. Create a real, empty anchor directory with `mkdtemp` (for example `$TMPDIR/opencode-sim-XXXXXX`).
2. `process.chdir(anchor)` before any command resolves its working directory.
3. Use `process.cwd()` — now the anchor — as the root of the in-memory filesystem.
4. Seed the in-memory filesystem from the snapshot directory, joining snapshot-relative paths onto the anchor root.
The anchor directory on the host stays empty for the entire run. All file content lives only in the in-memory filesystem.
Rationale for the real anchor:
- `process.cwd()`, `$PWD`, and `path.resolve()` are all genuinely correct with zero patching. The previous simulation branch used a virtual root (`/opencode`) that existed nowhere on the host, which forced monkey-patching `process.cwd` and `$PWD` and left raw `fs` relative-path resolution silently disagreeing with the faked cwd.
- The codebase reads `process.cwd()` at process edges (CLI entry points, TUI frontend, request-fallback in workspace routing) and converts it into an explicit `directory` value early; core never reads it directly. A truthful cwd at startup means every downstream consumer inherits the virtual root without touching those call sites.
- Leak detection is free: the anchor must be empty at the end of the run. Any file that appears there means some code path bypassed the simulated filesystem. This is an assertable invariant.
- Host filesystem bypasses read an empty directory instead of the developer's real project. Bypassed reads fail loudly instead of returning wrong-but-plausible data.
Rationale for in-memory content:
- The run is hermetic: no host writes, no cleanup dependencies, no cross-run contamination.
- Snapshots load and reset quickly, which matters for model-based runs that reset state often.
- The containment check (path must be inside the anchor root) doubles as the host-escape guard with a truthful boundary.
The in-memory filesystem is still controlled and isolated:
- Each run gets its own anchor root.
- Project files, config, data, state, cache, and temp paths should resolve inside that root (via `OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, and `OPENCODE_DB=:memory:`).
- Paths outside the anchor root fail loudly with a typed simulation error.
- Trace should record seeded files and file diffs/observations needed for replay.
The anchor may be created by the app itself at activation, or by an external runner that spawns the app with the anchor as its working directory. Both should work: the app creates and enters an anchor only when its current directory is not already a designated anchor.
## Initial State Snapshot
`OPENCODE_SIMULATION_STATE` points at a directory containing one complete initial state. On startup the app slurps this directory once and constructs all simulated state from it. The snapshot is never written back to; it is a pure input.
Proposed layout:
```text
snapshot/
project/... # workspace files, seeded into the in-memory FS under the anchor root
config/opencode.json # global config; the directory backs OPENCODE_CONFIG_DIR
env.json # extra environment values to apply
llm/... # scripted LLM behavior to pre-enqueue (optional)
network/... # network response registrations (optional)
```
Rules:
- Paths inside `project/` are snapshot-relative. The loader joins them onto the anchor root, so absolute virtual paths look like real host paths under the anchor.
- Anything the config references (skills, instructions, reference paths) must exist inside `project/`. A snapshot that references missing files is invalid.
- The snapshot directory format is the contract between external state generators and the app. Generators (such as the opencode-probe project) produce snapshot directories plus a derived expected model; the app consumes only the snapshot.
- Seeding through the control server (`backend.filesystem.seed` and friends) remains available for incremental changes during a run; the snapshot covers initial state.
## Configuration Via Generated Plugins
Generated configuration is a core first-milestone feature.
Much of opencode behavior is driven by config. The simulation runner needs to put the app into many different config-shaped states: different agents, tools, providers, MCP servers, permissions, modes, instructions, formatting settings, feature flags, and other config-dependent behavior.
The runner should not primarily generate arbitrary config files. Instead, the simulation should express config-shaped state as generated plugins.
Rationale:
- Plugins are already a normal extension surface for opencode behavior.
- Generated plugins can produce app states without making the simulation depend on config-file syntax and file layout details.
- Plugin-generated state keeps setup closer to runtime behavior: the app reads config, loads plugins, and observes plugin-provided behavior through normal app paths.
- Plugins are a better unit for model-based generation because they can be named, versioned, traced, reused, and minimized independently.
The first implementation should support generated simulation plugins that can contribute or affect config-equivalent domains such as:
- Agents and agent defaults.
- Provider/model availability.
- Tool definitions and tool behavior.
- MCP-like capabilities or endpoints.
- Permission defaults and policies.
- Instructions/system-context-like inputs where supported.
- Formatting/project behavior where supported.
- Workspace/project adapters where supported.
The simulation can still write the minimal bootstrap state needed for opencode to discover generated plugins, but the interesting generated state should live in plugin definitions rather than large generated `opencode.json` files.
Trace should record:
- Generated plugin IDs.
- Plugin-provided config/state fragments.
- Plugin hooks registered.
- Any plugin load/config errors.
- Which generated plugin state was active for each run.
The model-based runner should include commands for generating and enabling plugin state. These commands should have normal preconditions and postconditions just like UI actions or backend setup commands.
Example command families:
- Generate a provider/model plugin.
- Generate an agent configuration plugin.
- Generate a tool plugin with scripted behavior.
- Generate permission policy state.
- Generate MCP-like tool/resource state.
- Enable or disable a generated plugin for the next app run.
This is the main mechanism for exploring app states driven by configuration.
## Network
Unknown external network should fail loudly by default.
The simulation network should support explicit response registration:
- JSON response.
- Text response.
- Bytes response later if needed.
- Status-only response.
- Handler-style response later if needed.
Loopback traffic needed by the app/frontend/backend may be allowed explicitly.
All network calls should be traceable:
- Method.
- URL.
- Request headers/body where safe.
- Matched simulation route.
- Status.
- Response summary.
- Error if denied.
## LLM
The LLM boundary should be scriptable.
The driver can enqueue scripts that describe model behavior:
- Text chunks.
- Thinking/reasoning chunks if relevant.
- Tool calls.
- Errors.
- Finish reason.
The real session and tool pipeline should consume this behavior through the normal app path. The simulation should not bypass `SessionPrompt`, `SessionProcessor`, or tool execution.
Missing scripted LLM behavior should fail with a clear simulation error unless a default response is explicitly configured.
## Process Spawning
External process spawning should be denied by default.
The first milestone should provide a simulated process registry. This should be inspired by the old branch:
- Shell commands can run through `just-bash` against the simulated filesystem.
- A small fake `git` command set can support project discovery/status paths needed by the app.
- Unsupported process spawns fail loudly.
This preserves the rule that simulation does not spawn arbitrary external programs while still allowing useful shell/tool flows.
## Trace
Trace recording is always on in simulation mode, in memory for the first milestone.
Trace entries should be append-only JSON-compatible records. They do not need to be written to disk initially, but `trace.export` should return a structure suitable for later replay and test generation.
Trace should include:
- Run metadata: seed, app version, renderer mode, WebSocket URL.
- Initial world setup.
- UI observations.
- Generated UI actions.
- Executed UI actions.
- Backend control requests.
- Backend snapshots.
- Network requests and matches/denials.
- LLM scripts enqueued and consumed.
- Tool calls and results.
- Permission decisions.
- Filesystem seed/write/diff summaries.
- Generated plugin/config state and load results.
- Stabilization boundaries.
- Errors and crashes.
- Model command execution and postcondition results.
The trace is the bridge between exploratory simulation and deterministic tests.
## Model-Based Runner
The first runner is an external driver connecting to the frontend WebSocket.
Use a custom runner for now, not `fast-check`. It should still follow the core shape used by property/model-based testing libraries:
```ts
interface Command<Model> {
readonly name: string
check(model: Model): boolean
run(model: Model, app: SimulationClient): Promise<void>
}
```
Basic runner responsibilities:
- Keep a lightweight model of high-level expected state.
- Generate commands whose preconditions match the model and current app observations.
- Execute commands through the WebSocket.
- Update the model.
- Check postconditions/invariants.
- Record all steps in the trace.
- Support seed/replay.
- Track simple distribution stats.
The model should track high-level, observational state only, such as:
- Current screen/route category.
- Whether prompt editor is available.
- Known sessions.
- Known files and expected file contents/diffs.
- Queued LLM scripts.
- Recent backend/session status.
- Whether app is expected to be idle.
The model must not track implementation internals like fibers, exact runner loop state, cache internals, or database implementation details.
Initial command families:
- Seed filesystem.
- Generate and enable plugin config state.
- Register network response.
- Enqueue LLM script.
- Observe UI state.
- Execute one generated UI action.
- Type prompt text.
- Press enter.
- Stabilize.
- Assert no crash.
- Assert visible response or file effect.
- Export trace.
## Generators
The first milestone should include generation, but not shrinking.
Generation should be model-based and state-aware:
- Generate from currently valid `ui.state.actions`.
- Generate backend setup commands from scenario/model state.
- Generate plugin-provided config state.
- Generate LLM scripts that match likely user prompts and tool flows.
- Generate short command sequences using preconditions.
- Use a seed so runs can be replayed.
- Use simple weights to avoid degenerate action selection.
The generator should not attempt to produce arbitrary full app states upfront. It should build state by executing commands through the real app and observing the result.
Important stats to record:
- Seed.
- Command counts.
- Action type distribution.
- Generated plugin/config domain distribution.
- Rejected command/precondition counts.
- UI element/action coverage.
- Backend event type coverage where available.
- Errors and stabilization failures.
## Properties
First milestone properties should be simple and high-signal:
- App does not crash.
- Backend does not crash.
- Unknown network is denied.
- Host filesystem escape is denied.
- Prompt submission can reach a scripted LLM response.
- Stabilization eventually reaches a coherent idle state for the demo flow.
- File effects from scripted tool behavior are observable in the simulated filesystem.
- Trace contains enough information to replay the run.
More advanced model/refinement, metamorphic, and differential properties are future work.
## First Demo Flow
The first major demo should show this system as a real environment for exploring the app in controlled states:
1. Start opencode normally with `OPENCODE_SIMULATION=1`.
2. TUI starts and exposes the simulation WebSocket on `127.0.0.1:40900+`.
3. External runner connects.
4. Runner provides a snapshot directory (or seeds the in-memory project filesystem through the control server).
5. Runner generates and enables plugin-provided config state.
6. Runner queues a scripted LLM response.
7. Runner observes `ui.state` and generated actions.
8. Runner drives real TUI input to type and submit a prompt.
9. App processes the prompt through the real backend/session/tool path.
10. Scripted LLM response appears or executes a file-affecting tool flow.
11. Runner stabilizes the app.
12. Runner inspects trace, backend snapshot, UI state, generated plugin state, and filesystem state.
13. Runner exports a deterministic replay trace.
## Done-When Checklist
- `OPENCODE_SIMULATION=1` starts the normal app with simulation wiring.
- Simulation code is isolated under a dedicated simulation/testing area.
- App changes outside simulation are limited to activation hooks, builder replacements, TUI startup, and gated backend routes.
- TUI exposes JSON-RPC WebSocket on `127.0.0.1:40900+`.
- Driver can call `ui.state`.
- Driver can execute generated UI actions.
- Fake and visible renderer paths use the same action protocol.
- Driver can seed filesystem state.
- Driver can generate and enable plugin-provided config state.
- Driver can register network responses and observe denied unknown network.
- Driver can enqueue LLM scripts.
- External process spawning is denied by default, with shell via `just-bash` and minimal fake process registry support.
- Driver can run a basic model-based generated command sequence.
- In-memory trace records observations/actions/backend interactions.
- Driver can list, clear, and export trace.
- Demo flow succeeds end-to-end.
## Future Directions
- Shrinking failed traces.
- Promote minimized traces into normal committed tests.
- Coverage-guided corpus and structured trace mutation.
- Richer semantic UI grounding for model-driven exploration.
- LLM-generated property proposals with validity/soundness checks.
- Differential testing across app versions, renderers, or storage modes.
- Deterministic scheduler/clock/random control.
- Parallel campaigns with isolated workers.
- File-backed trace persistence and replay CLI.
+2 -2
View File
@@ -35,7 +35,7 @@ const QueryCommand = effectCmd({
}
return
}
const child = spawn("sqlite3", [Database.path()], {
const child = spawn("sqlite3", [yield* Database.configuredPath], {
stdio: "inherit",
})
yield* Effect.promise(() => new Promise((resolve) => child.on("close", resolve)))
@@ -47,7 +47,7 @@ const PathCommand = effectCmd({
describe: "print the database path",
instance: false,
handler: Effect.fn("Cli.db.path")(function* () {
console.log(Database.path())
console.log(yield* Database.configuredPath)
}),
})
+2 -1
View File
@@ -1,10 +1,11 @@
import { rm } from "fs/promises"
import { Database } from "@opencode-ai/core/database/database"
import { Effect } from "effect"
import { disposeAllInstances } from "./fixture"
export async function resetDatabase() {
await disposeAllInstances().catch(() => undefined)
const dbPath = Database.path()
const dbPath = await Effect.runPromise(Database.configuredPath)
await rm(dbPath, { force: true }).catch(() => undefined)
await rm(`${dbPath}-wal`, { force: true }).catch(() => undefined)
await rm(`${dbPath}-shm`, { force: true }).catch(() => undefined)
@@ -18,8 +18,10 @@ const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB
export const exerciseDatabasePath =
process.env.OPENCODE_HTTPAPI_EXERCISE_DB ??
path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`)
// Must run before the first Effect Config read anywhere in the process: the
// default ConfigProvider snapshots process.env on first use, so a Config read
// during an earlier module import would freeze the wrong database path.
process.env.OPENCODE_DB = exerciseDatabasePath
Flag.OPENCODE_DB = exerciseDatabasePath
export const original = {
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
+24 -57
View File
@@ -170,70 +170,40 @@ export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["param
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
export type SessionContextOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
export type Endpoint4_17Output = EffectValue<
ReturnType<RawClient["server.session"]["session.context.entry.list"]>
>["data"]
export type SessionListContextEntriesOperation<E = never> = (
input: Endpoint4_17Input,
) => Effect.Effect<Endpoint4_17Output, E>
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.history"]>[0]
export type Endpoint4_17Input = {
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
readonly limit?: Endpoint4_17Request["query"]["limit"]
readonly after?: Endpoint4_17Request["query"]["after"]
}
export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.history"]>>
export type SessionHistoryOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.events"]>[0]
export type Endpoint4_18Input = {
readonly sessionID: Endpoint4_18Request["params"]["sessionID"]
readonly key: Endpoint4_18Request["params"]["key"]
readonly value: Endpoint4_18Request["payload"]["value"]
readonly after?: Endpoint4_18Request["query"]["after"]
}
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.put"]>>
export type SessionPutContextEntryOperation<E = never> = (
input: Endpoint4_18Input,
) => Effect.Effect<Endpoint4_18Output, E>
export type Endpoint4_18Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.events"]>>>
export type SessionEventsOperation<E = never> = (input: Endpoint4_18Input) => Stream.Stream<Endpoint4_18Output, E>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
export type Endpoint4_19Input = {
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
readonly key: Endpoint4_19Request["params"]["key"]
}
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.remove"]>>
export type SessionRemoveContextEntryOperation<E = never> = (
input: Endpoint4_19Input,
) => Effect.Effect<Endpoint4_19Output, E>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.history"]>[0]
export type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly limit?: Endpoint4_20Request["query"]["limit"]
readonly after?: Endpoint4_20Request["query"]["after"]
}
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.history"]>>
export type SessionHistoryOperation<E = never> = (input: Endpoint4_20Input) => Effect.Effect<Endpoint4_20Output, E>
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_20Input) => Effect.Effect<Endpoint4_20Output, E>
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly after?: Endpoint4_21Request["query"]["after"]
readonly messageID: Endpoint4_21Request["params"]["messageID"]
}
export type Endpoint4_21Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.events"]>>>
export type SessionEventsOperation<E = never> = (input: Endpoint4_21Input) => Stream.Stream<Endpoint4_21Output, E>
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] }
export type Endpoint4_22Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint4_22Input) => Effect.Effect<Endpoint4_22Output, E>
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
export type Endpoint4_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_23Input) => Effect.Effect<Endpoint4_23Output, E>
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint4_24Input = {
readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
readonly messageID: Endpoint4_24Request["params"]["messageID"]
}
export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E>
export type Endpoint4_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint4_21Input) => Effect.Effect<Endpoint4_21Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -253,9 +223,6 @@ export interface SessionApi<E = never> {
readonly revertClear: SessionRevertClearOperation<E>
readonly revertCommit: SessionRevertCommitOperation<E>
readonly context: SessionContextOperation<E>
readonly listContextEntries: SessionListContextEntriesOperation<E>
readonly putContextEntry: SessionPutContextEntryOperation<E>
readonly removeContextEntry: SessionRemoveContextEntryOperation<E>
readonly history: SessionHistoryOperation<E>
readonly events: SessionEventsOperation<E>
readonly interrupt: SessionInterruptOperation<E>
-3
View File
@@ -59,9 +59,6 @@ export const endpointNames = {
"integration.attempt.status": "attemptStatus",
"integration.attempt.complete": "attemptComplete",
"integration.attempt.cancel": "attemptCancel",
"session.context.entry.list": "listContextEntries",
"session.context.entry.put": "putContextEntry",
"session.context.entry.remove": "removeContextEntry",
"session.revert.stage": "revertStage",
"session.revert.clear": "revertClear",
"session.revert.commit": "revertCommit",
-48
View File
@@ -2,7 +2,6 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionInput } from "@opencode-ai/schema/session-input"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Session } from "@opencode-ai/schema/session"
import { SessionContextEntry } from "@opencode-ai/schema/session-context-entry"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
import { Workspace } from "@opencode-ai/schema/workspace"
@@ -379,53 +378,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.get("session.context.entry.list", "/api/session/:sessionID/context-entry", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(SessionContextEntry.Info) }),
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context.entry.list",
summary: "List context entries",
description: "List API-managed context entries attached to the session's system context.",
}),
),
)
.add(
HttpApiEndpoint.put("session.context.entry.put", "/api/session/:sessionID/context-entry/:key", {
params: { sessionID: Session.ID, key: SessionContextEntry.Key },
payload: Schema.Struct({ value: Schema.Json }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context.entry.put",
summary: "Put context entry",
description:
"Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary.",
}),
),
)
.add(
HttpApiEndpoint.delete("session.context.entry.remove", "/api/session/:sessionID/context-entry/:key", {
params: { sessionID: Session.ID, key: SessionContextEntry.Key },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context.entry.remove",
summary: "Remove context entry",
description: "Remove one context entry; the removal is announced to the model at the next turn boundary.",
}),
),
)
.add(
HttpApiEndpoint.get("session.history", "/api/session/:sessionID/history", {
params: { sessionID: Session.ID },
@@ -1,20 +0,0 @@
export * as SessionContextEntry from "./session-context-entry.js"
import { Schema } from "effect"
/**
* Slash-free client-facing key for one API-managed context entry. The server
* derives the namespaced SystemContext key as `api/<key>`, keeping the
* `api/*` namespace enforced by construction.
*/
export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/)).annotate({
identifier: "SessionContextEntry.Key",
description: "Context entry key (lowercase alphanumerics plus . _ -)",
})
export type Key = typeof Key.Type
export const Info = Schema.Struct({
key: Key,
value: Schema.Json.annotate({ description: "JSON value attached to the session's system context" }),
}).annotate({ identifier: "SessionContextEntry.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
+32 -9
View File
@@ -4,17 +4,36 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import { Context, Effect, Layer, Scope } from "effect"
import { ConfigProvider, Context, Effect, Layer, Scope } from "effect"
import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"
export const create = Effect.fn("OpenCode.create")(function* () {
export interface Options {
/**
* Replaces the ConfigProvider read while this host's process-global layers
* are built, for example the OPENCODE_DB database placement. The default
* provider snapshots the process environment on first use, so per-host
* configuration must come through this seam rather than env mutation.
* Location-scoped layers build lazily outside host construction and still
* read the process default. Compose with
* ConfigProvider.orElse(ConfigProvider.fromEnv()) to keep environment
* fallback.
*/
readonly configProvider?: ConfigProvider.ConfigProvider
}
export const create = Effect.fn("OpenCode.create")(function* (options?: Options) {
const scope = yield* Scope.Scope
const memoMap = yield* Layer.makeMemoMap
const configLayer = options?.configProvider === undefined ? undefined : ConfigProvider.layer(options.configProvider)
const withConfig = <A, E, R>(layer: Layer.Layer<A, E, R>) =>
configLayer === undefined ? layer : layer.pipe(Layer.provide(configLayer))
const sdkPlugins = SdkPlugins.makeStore()
const context = yield* Layer.buildWithMemoMap(
AppNodeBuilder.build(LayerNode.group([PermissionSaved.node, SdkPlugins.node]), [
[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)],
]),
withConfig(
AppNodeBuilder.build(LayerNode.group([PermissionSaved.node, SdkPlugins.node]), [
[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)],
]),
),
memoMap,
scope,
)
@@ -23,9 +42,11 @@ export const create = Effect.fn("OpenCode.create")(function* () {
const web = yield* Effect.acquireRelease(
Effect.sync(() =>
HttpRouter.toWebHandler(
createEmbeddedRoutes(sdkPlugins).pipe(
HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)),
Layer.provide(HttpServer.layerServices),
withConfig(
createEmbeddedRoutes(sdkPlugins).pipe(
HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)),
Layer.provide(HttpServer.layerServices),
),
),
{ disableLogger: true, memoMap },
),
@@ -56,4 +77,6 @@ export type Interface = Effect.Success<ReturnType<typeof create>>
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk-next/OpenCode") {}
export const layer = Layer.effect(Service, create())
export const layerWith = (options: Options) => Layer.effect(Service, create(options))
export const layer = layerWith({})
+22 -15
View File
@@ -1,11 +1,14 @@
import { expect } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Deferred, Effect, Latch, Layer, Option, Schema, Stream } from "effect"
import { ConfigProvider, Deferred, Effect, Latch, Layer, Option, Schema, Stream } from "effect"
import { join } from "node:path"
import { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCodeEvent } from "../src"
Flag.OPENCODE_DB = ":memory:"
// Database placement resolves through Effect Config when a host's layers are
// built. The default ConfigProvider snapshots process.env on first use, so
// this must run before the first Config read in the process.
process.env.OPENCODE_DB = ":memory:"
const it = testEffect(Layer.empty)
type Sdk = typeof import("../src")
@@ -68,11 +71,6 @@ it.live(
resume: false,
})
const context = yield* opencode.sessions.context({ sessionID: id })
yield* opencode.sessions.putContextEntry({ sessionID: id, key: "deploy-target", value: "production" })
yield* opencode.sessions.putContextEntry({ sessionID: id, key: "flags", value: { beta: true } })
const contextEntries = yield* opencode.sessions.listContextEntries({ sessionID: id })
yield* opencode.sessions.removeContextEntry({ sessionID: id, key: "flags" })
const remainingContextEntries = yield* opencode.sessions.listContextEntries({ sessionID: id })
const wake = yield* opencode.sessions.prompt({
sessionID: id,
prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }),
@@ -99,7 +97,6 @@ it.live(
opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
opencode.sessions.listContextEntries({ sessionID: missingSessionID }).pipe(Effect.flip),
],
{ concurrency: "unbounded" },
)
@@ -118,11 +115,6 @@ it.live(
expect(admitted.sessionID).toBe(id)
expect(prompted.type).toBe("session.next.prompted")
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
expect(contextEntries).toEqual([
{ key: "deploy-target", value: "production" },
{ key: "flags", value: { beta: true } },
])
expect(remainingContextEntries).toEqual([{ key: "deploy-target", value: "production" }])
expect(context.some((message) => message.type === "model-switched")).toBe(true)
expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } })
expect(message).toEqual(modelMessage)
@@ -130,7 +122,6 @@ it.live(
"SessionNotFoundError",
"SessionNotFoundError",
"SessionNotFoundError",
"SessionNotFoundError",
])
expect(missingMessage._tag).toBe("MessageNotFoundError")
}),
@@ -216,3 +207,19 @@ it.live("embedded client is available as a Layer service", () =>
}).pipe(Effect.provide(fixture.sdk.OpenCode.layer))
}),
)
it.live("a host ConfigProvider chooses this host's database placement", () =>
withEmbedded("opencode-embedded-config-", (fixture) =>
Effect.gen(function* () {
// The process env pins OPENCODE_DB to ":memory:" above, so a database
// materializing at this path proves the host read the provider instead.
const file = join(fixture.directory, "seam.sqlite")
const opencode = yield* fixture.sdk.OpenCode.create({
configProvider: ConfigProvider.fromUnknown({ OPENCODE_DB: file }),
})
const created = yield* opencode.sessions.create({ location: location(fixture) })
expect(created.id).toBeTruthy()
expect(yield* Effect.promise(() => Bun.file(file).exists())).toBe(true)
}),
),
)
+31 -59
View File
@@ -1,5 +1,4 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionContextEntry } from "@opencode-ai/core/session/context-entry"
import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
@@ -219,49 +218,45 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.skill",
Effect.fn(function* (ctx) {
yield* session
.skill({
sessionID: ctx.params.sessionID,
id: ctx.payload.id,
skill: ctx.payload.skill,
resume: ctx.payload.resume,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
yield* session.skill({
sessionID: ctx.params.sessionID,
id: ctx.payload.id,
skill: ctx.payload.skill,
resume: ctx.payload.resume,
}).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag("Session.SkillNotFoundError", (error) =>
Effect.fail(new SkillNotFoundError({ skill: error.skill, message: `Skill not found: ${error.skill}` })),
),
)
),
Effect.catchTag("Session.SkillNotFoundError", (error) =>
Effect.fail(new SkillNotFoundError({ skill: error.skill, message: `Skill not found: ${error.skill}` })),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.synthetic",
Effect.fn(function* (ctx) {
yield* session
.synthetic({
sessionID: ctx.params.sessionID,
text: ctx.payload.text,
description: ctx.payload.description,
metadata: ctx.payload.metadata,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
yield* session.synthetic({
sessionID: ctx.params.sessionID,
text: ctx.payload.text,
description: ctx.payload.description,
metadata: ctx.payload.metadata,
}).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
)
),
)
return HttpApiSchema.NoContent.make()
}),
)
@@ -467,29 +462,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.context.entry.list",
Effect.fn(function* (ctx) {
const contextEntries = yield* SessionContextEntry.Service
return { data: yield* contextEntries.list(ctx.params.sessionID) }
}),
)
.handle(
"session.context.entry.put",
Effect.fn(function* (ctx) {
const contextEntries = yield* SessionContextEntry.Service
yield* contextEntries.put({ sessionID: ctx.params.sessionID, key: ctx.params.key, value: ctx.payload.value })
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.context.entry.remove",
Effect.fn(function* (ctx) {
const contextEntries = yield* SessionContextEntry.Service
yield* contextEntries.remove({ sessionID: ctx.params.sessionID, key: ctx.params.key })
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.history",
Effect.fn(function* (ctx) {
+10 -23
View File
@@ -16,7 +16,7 @@ import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Effect, Layer, Option } from "effect"
import { Layer, Option } from "effect"
import { Api } from "./api"
import { ServerAuth } from "./auth"
import { handlers } from "./handlers"
@@ -58,24 +58,15 @@ function makeRoutes<AuthError, AuthServices>(
sdkPlugins?: SdkPlugins.Store,
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[SessionExecution.node, SessionExecutionLocal.node],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
]
// Simulation replacements are loaded via dynamic import so the simulation
// module is never eagerly loaded. Layer.unwrap defers both the import and
// the app-node build to layer-build time; when simulation is off the branch
// is byte-for-byte identical to a plain AppNodeBuilder.build call.
const serviceLayer = simulationEnabled()
? Layer.unwrap(
Effect.gen(function* () {
const { simulationReplacements } = yield* Effect.promise(() => import("./simulation"))
return AppNodeBuilder.build(applicationServices, [...replacements, ...simulationReplacements])
}),
)
: AppNodeBuilder.build(applicationServices, replacements)
const serviceLayer = AppNodeBuilder.build(
applicationServices,
[
[SessionExecution.node, SessionExecutionLocal.node],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
],
)
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers),
@@ -88,10 +79,6 @@ function makeRoutes<AuthError, AuthServices>(
)
}
function simulationEnabled() {
return !!process.env.OPENCODE_SIMULATION
}
export const routes = createRoutes()
export const webHandler = () =>
-14
View File
@@ -1,14 +0,0 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
/**
* Layer replacements applied when the server is built in simulation mode.
*
* Empty for now; simulation-mode implementations will populate this with
* replacement nodes/layers that swap real services for simulated ones (e.g.
* a fake filesystem). The server merges these into the app node build when
* `OPENCODE_SIMULATION` is enabled, via a dynamic import so this module is
* never loaded eagerly.
*/
export const simulationReplacements: LayerNode.Replacements = []
export * as Simulation from "./index"
+4 -12
View File
@@ -8,7 +8,7 @@ import { ClipboardProvider, useClipboard } from "./context/clipboard"
import { ExitProvider, useExit } from "./context/exit"
import { EpilogueProvider } from "./context/epilogue"
import * as Selection from "./util/selection"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { createCliRenderer, MouseButton } from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
import {
Switch,
@@ -184,8 +184,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
Effect.gen(function* () {
const renderer = yield* Effect.acquireRelease(
Effect.tryPromise({
try: async () => {
const options = {
try: () =>
createCliRenderer({
externalOutputMode: "passthrough",
targetFps: 60,
gatherStats: false,
@@ -197,15 +197,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
} satisfies CliRendererConfig
if (process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true") {
const { Simulation } = await import("./simulation/simulation")
return Simulation.createSimulation(options)
}
return createCliRenderer(options)
},
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
(renderer) =>
-177
View File
@@ -1,177 +0,0 @@
import type { CliRenderer, Renderable } from "@opentui/core"
import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing"
import { SimulationRenderer } from "./renderer"
import { SimulationTrace } from "./trace"
export interface KeyModifiers {
readonly ctrl?: boolean
readonly shift?: boolean
readonly meta?: boolean
readonly super?: boolean
readonly hyper?: boolean
}
export type Action =
| { readonly type: "typeText"; readonly text: string }
| { readonly type: "pressKey"; readonly key: string; readonly modifiers?: KeyModifiers }
| { readonly type: "pressEnter" }
| { readonly type: "pressArrow"; readonly direction: "up" | "down" | "left" | "right" }
| { readonly type: "focus"; readonly target: number }
| { readonly type: "click"; readonly target: number; readonly x: number; readonly y: number }
export interface Element {
readonly id: string
readonly num: number
readonly x: number
readonly y: number
readonly width: number
readonly height: number
readonly focusable: boolean
readonly focused: boolean
readonly clickable: boolean
readonly editor: boolean
}
export interface Harness {
readonly renderer: CliRenderer
readonly mockInput: MockInput
readonly mockMouse: MockMouse
readonly renderOnce: () => Promise<void>
readonly screen: () => string
}
type RenderBuffer = {
readonly width: number
readonly height: number
getRealCharBytes(includeAnsi?: boolean): Uint8Array
}
const decoder = new TextDecoder()
function children(renderable: Renderable) {
return renderable.getChildren().filter((child): child is Renderable => "num" in child)
}
function all(renderable: Renderable): Renderable[] {
return [renderable, ...children(renderable).flatMap(all)]
}
function mouseListeners(renderable: Renderable) {
const general = Reflect.get(renderable, "_mouseListener")
const specific = Reflect.get(renderable, "_mouseListeners")
return Boolean(general) || (specific && typeof specific === "object" && Object.keys(specific).length > 0)
}
function hit(renderer: CliRenderer, renderable: Renderable) {
if (renderable.width <= 0 || renderable.height <= 0) return false
const x = Math.floor(renderable.screenX + renderable.width / 2)
const y = Math.floor(renderable.screenY + renderable.height / 2)
return renderer.hitTest(x, y) === renderable.num
}
/**
* Builds the harness the simulation server drives.
*
* When the renderer is the fake simulation renderer, its TestRendererSetup
* provides the supported testing APIs. For the visible terminal renderer the
* harness falls back to `requestRender` + `idle` and reading the private
* `currentRenderBuffer`.
*/
export function createHarness(renderer: CliRenderer): Harness {
const setup = SimulationRenderer.setupFor(renderer)
return {
renderer,
mockInput: setup?.mockInput ?? createMockKeys(renderer),
mockMouse: setup?.mockMouse ?? createMockMouse(renderer),
renderOnce:
setup?.renderOnce ??
(async () => {
renderer.requestRender()
await renderer.idle()
}),
screen:
setup?.captureCharFrame ??
(() => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(true))),
}
}
export function elements(renderer: CliRenderer): Element[] {
return all(renderer.root)
.filter((renderable) => renderable.visible && !renderable.isDestroyed)
.map((renderable) => {
const clickable = mouseListeners(renderable) && hit(renderer, renderable)
return {
id: renderable.id,
num: renderable.num,
x: renderable.screenX,
y: renderable.screenY,
width: renderable.width,
height: renderable.height,
focusable: renderable.focusable,
focused: renderable.focused,
clickable,
editor: renderer.currentFocusedEditor === renderable,
} satisfies Element
})
.filter((element) => element.focusable || element.clickable || element.editor)
}
export function actions(renderer: CliRenderer, options: { text?: string } = {}): Action[] {
const items = elements(renderer)
return [
...(renderer.currentFocusedEditor
? ([{ type: "typeText", text: options.text ?? "hello" }, { type: "pressEnter" }] satisfies Action[])
: []),
...items.filter((item) => item.focusable && !item.focused).map((item) => ({ type: "focus" as const, target: item.num })),
...items
.filter((item) => item.clickable)
.map((item) => ({
type: "click" as const,
target: item.num,
x: Math.floor(item.x + item.width / 2),
y: Math.floor(item.y + item.height / 2),
})),
{ type: "pressArrow", direction: "down" },
{ type: "pressArrow", direction: "up" },
]
}
export function state(harness: Harness) {
return {
screen: harness.screen(),
focused: {
renderable: harness.renderer.currentFocusedRenderable?.num,
editor: Boolean(harness.renderer.currentFocusedEditor),
},
elements: elements(harness.renderer),
actions: actions(harness.renderer),
}
}
export async function execute(harness: Harness, action: Action) {
SimulationTrace.add("ui.action", { action })
switch (action.type) {
case "typeText":
await harness.mockInput.typeText(action.text)
break
case "pressKey":
harness.mockInput.pressKey(action.key, action.modifiers)
break
case "pressEnter":
harness.mockInput.pressEnter()
break
case "pressArrow":
harness.mockInput.pressArrow(action.direction)
break
case "focus":
all(harness.renderer.root).find((item) => item.num === action.target)?.focus()
break
case "click":
await harness.mockMouse.click(action.x, action.y)
break
}
await harness.renderOnce()
return state(harness)
}
export * as SimulationActions from "./actions"
-26
View File
@@ -1,26 +0,0 @@
import type { CliRenderer, CliRendererConfig } from "@opentui/core"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
const setups = new WeakMap<CliRenderer, TestRendererSetup>()
/**
* Creates the fake simulation renderer: a real CliRenderer backed by an
* in-memory screen buffer instead of a terminal. The TestRendererSetup is
* kept module-side (keyed by renderer) so the harness can use the supported
* testing APIs without app code carrying it around.
*/
export async function create(options: CliRendererConfig): Promise<CliRenderer> {
const setup = await createTestRenderer({
...options,
width: Number(process.env.OPENCODE_SIMULATION_TUI_WIDTH) || 100,
height: Number(process.env.OPENCODE_SIMULATION_TUI_HEIGHT) || 40,
})
setups.set(setup.renderer, setup)
return setup.renderer
}
export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined {
return setups.get(renderer)
}
export * as SimulationRenderer from "./renderer"
-174
View File
@@ -1,174 +0,0 @@
import { SimulationActions, type Action, type Harness } from "./actions"
import { SimulationTrace } from "./trace"
const DefaultPort = 40900
const MaxPortAttempts = 100
type JsonRpcRequest = {
readonly jsonrpc: "2.0"
readonly id?: string | number | null
readonly method: string
readonly params?: unknown
}
type JsonRpcResponse = {
readonly jsonrpc: "2.0"
readonly id: string | number | null
readonly result?: unknown
readonly error?: {
readonly code: number
readonly message: string
readonly data?: unknown
}
}
export interface Server {
readonly url: string
readonly stop: () => void
}
function isEnabled() {
return process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true"
}
function isPortUnavailable(error: unknown) {
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use")
}
function parseRequest(input: string | Buffer): JsonRpcRequest {
const value = JSON.parse(typeof input === "string" ? input : input.toString()) as unknown
if (typeof value !== "object" || value === null) throw new Error("Invalid JSON-RPC request")
if (!("jsonrpc" in value) || value.jsonrpc !== "2.0") throw new Error("Invalid JSON-RPC version")
if (!("method" in value) || typeof value.method !== "string") throw new Error("Invalid JSON-RPC method")
return value as JsonRpcRequest
}
function isAction(input: unknown): input is Action {
if (typeof input !== "object" || input === null || !("type" in input)) return false
switch (input.type) {
case "typeText":
return "text" in input && typeof input.text === "string"
case "pressKey":
return "key" in input && typeof input.key === "string"
case "pressEnter":
return true
case "pressArrow":
return "direction" in input && ["up", "down", "left", "right"].includes(String(input.direction))
case "focus":
return "target" in input && typeof input.target === "number"
case "click":
return (
"target" in input &&
typeof input.target === "number" &&
"x" in input &&
typeof input.x === "number" &&
"y" in input &&
typeof input.y === "number"
)
}
return false
}
function actionParam(params: unknown) {
if (typeof params !== "object" || params === null || !("action" in params)) throw new Error("Missing action")
if (!isAction(params.action)) throw new Error("Invalid action")
return params.action
}
function response(id: JsonRpcRequest["id"], result: unknown): JsonRpcResponse | undefined {
if (id === undefined) return undefined
return { jsonrpc: "2.0", id, result }
}
function errorResponse(id: JsonRpcRequest["id"], error: unknown): JsonRpcResponse {
return {
jsonrpc: "2.0",
id: id ?? null,
error: {
code: -32000,
message: error instanceof Error ? error.message : String(error),
},
}
}
async function handle(harness: Harness, request: JsonRpcRequest) {
switch (request.method) {
case "ui.state": {
const result = SimulationActions.state(harness)
SimulationTrace.add("ui.state", { elements: result.elements.length, actions: result.actions.length })
return result
}
case "ui.action":
return SimulationActions.execute(harness, actionParam(request.params))
case "ui.render": {
await harness.renderOnce()
const result = SimulationActions.state(harness)
SimulationTrace.add("ui.render", { elements: result.elements.length, actions: result.actions.length })
return result
}
case "trace.list":
return { records: SimulationTrace.list() }
case "trace.clear":
SimulationTrace.clear()
return { cleared: true }
case "trace.export":
return SimulationTrace.exportTrace()
}
throw new Error(`Unknown simulation method: ${request.method}`)
}
function serve(
harness: Harness,
port = DefaultPort,
attempts = MaxPortAttempts,
): Bun.Server<{ readonly simulation: true }> {
try {
return Bun.serve<{ readonly simulation: true }>({
hostname: "127.0.0.1",
port,
fetch(request, server) {
if (server.upgrade(request, { data: { simulation: true } })) return undefined
return new Response("opencode simulation websocket", { status: 426 })
},
websocket: {
open() {
SimulationTrace.add("control.connect")
},
close() {
SimulationTrace.add("control.disconnect")
},
async message(socket, message) {
let request: JsonRpcRequest | undefined
try {
request = parseRequest(message)
const result = await handle(harness, request)
const next = response(request.id, result)
if (next) socket.send(JSON.stringify(next))
} catch (error) {
socket.send(JSON.stringify(errorResponse(request?.id, error)))
}
},
},
})
} catch (error) {
if (!isPortUnavailable(error) || attempts <= 1 || port >= 65535) throw error
return serve(harness, port + 1, attempts - 1)
}
}
export function start(harness: Harness): Server | undefined {
if (!isEnabled()) return
const server = serve(harness)
const url = `ws://${server.hostname}:${server.port}`
SimulationTrace.add("control.start", { url })
return {
url,
stop: () => {
SimulationTrace.add("control.stop", { url })
server.stop(true)
},
}
}
export * as SimulationServer from "./server"
-27
View File
@@ -1,27 +0,0 @@
import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@opentui/core"
import { SimulationActions } from "./actions"
import { SimulationRenderer } from "./renderer"
import { SimulationServer } from "./server"
/**
* Simulation-mode renderer entry point.
*
* Creates the renderer (fake when OPENCODE_SIMULATION_RENDERER=fake, the
* normal visible renderer otherwise) and starts the simulation control
* server against it. The server stops when the renderer is destroyed, so the
* caller only manages the renderer lifecycle.
*/
export async function createSimulation(options: CliRendererConfig): Promise<CliRenderer> {
const renderer =
process.env.OPENCODE_SIMULATION_RENDERER === "fake"
? await SimulationRenderer.create(options)
: await createCliRenderer(options)
const server = SimulationServer.start(SimulationActions.createHarness(renderer))
if (server) {
process.stderr.write(`opencode simulation websocket: ${server.url}\n`)
renderer.once("destroy", () => server.stop())
}
return renderer
}
export * as Simulation from "./simulation"
-37
View File
@@ -1,37 +0,0 @@
export type TraceRecord = {
readonly id: number
readonly time: string
readonly type: string
readonly data?: unknown
}
const records: TraceRecord[] = []
let nextID = 0
export function add(type: string, data?: unknown) {
const record = {
id: ++nextID,
time: new Date().toISOString(),
type,
...(data === undefined ? {} : { data }),
} satisfies TraceRecord
records.push(record)
return record
}
export function list() {
return [...records]
}
export function clear() {
records.length = 0
nextID = 0
}
export function exportTrace() {
return {
records: list(),
}
}
export * as SimulationTrace from "./trace"