Compare commits

...

24 Commits

Author SHA1 Message Date
Kit Langton 1e22f324dc improve bash timeout retry guidance 2026-04-13 23:01:56 -04:00
Kit Langton f954854232 refactor(instance): remove state helper (#22381) 2026-04-13 22:40:12 -04:00
Kit Langton 6a99079012 kit/env instance state (#22383) 2026-04-13 22:28:16 -04:00
Kit Langton 0a8b6298cd refactor(tui): move config cache to InstanceState (#22378) 2026-04-13 22:24:40 -04:00
Kit Langton f40209bdfb refactor(snapshot): remove async facade exports (#22370) 2026-04-13 21:24:54 -04:00
Kit Langton a2cb4909da refactor(plugin): remove async facade exports (#22367) 2026-04-13 21:24:20 -04:00
Kit Langton 7a05ba47d1 refactor(session): remove compaction async facade exports (#22366) 2026-04-13 21:23:34 -04:00
Kit Langton 36745caa2a refactor(worktree): remove async facade exports (#22369) 2026-04-13 21:23:15 -04:00
Nazar H. c2403d0f15 fix(provider): guard reasoningSummary injection for @ai-sdk/openai-compatible providers (#22352)
Co-authored-by: Nazar Hnatyshen <nazar.hnatyshen@atolls.com>
2026-04-13 20:20:06 -05:00
Aiden Cline 34e2429c49 feat: add experimental.compaction.autocontinue hook to disable auto continuing after compaction (#22361) 2026-04-13 20:14:53 -05:00
opencode-agent[bot] 10ba68c772 chore: update nix node_modules hashes 2026-04-14 00:23:25 +00:00
Kit Langton e8471256f2 refactor(session): move llm stream into layer (#22358) 2026-04-13 19:53:30 -04:00
Kit Langton 43b37346b6 feat: add interactive burst to the TUI logo (#22098) 2026-04-13 19:36:28 -04:00
Kit Langton d199648aeb refactor(permission): remove async facade exports (#22342) 2026-04-13 19:33:58 -04:00
Kit Langton a06f40297b fix grep exact file path searches (#22356) 2026-04-13 19:26:50 -04:00
Dax Raad 59c0fc28ee ignore: v2 thoughts 2026-04-13 17:33:34 -04:00
James Long b22add292c refactor(core): publish sync events to global event stream (#22347) 2026-04-13 16:51:59 -04:00
Kit Langton 67aaecacac refactor(session): remove revert async facade exports (#22339) 2026-04-13 16:16:13 -04:00
Kit Langton 29c202e6ab refactor(mcp): remove mcp auth async facade exports (#22338) 2026-04-13 15:36:12 -04:00
Kit Langton dcbf11f41a refactor(session): remove summary async facades (#22337) 2026-04-13 15:35:38 -04:00
Kit Langton 14ccff4037 refactor(agent): remove async facade exports (#22341) 2026-04-13 14:54:01 -04:00
Kit Langton 5b8b874732 update effect docs (#22340) 2026-04-13 14:07:59 -04:00
opencode-agent[bot] 1d81c0266c chore: generate 2026-04-13 18:02:12 +00:00
Dax Raad 913120759a session entry 2026-04-13 14:00:49 -04:00
91 changed files with 7066 additions and 4251 deletions
+7
View File
@@ -371,6 +371,7 @@
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"cli-sound": "1.1.3",
"clipboardy": "4.0.0",
"cross-spawn": "catalog:",
"decimal.js": "10.5.0",
@@ -2668,6 +2669,8 @@
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
"cli-sound": ["cli-sound@1.1.3", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="],
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
@@ -3092,6 +3095,8 @@
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
"find-exec": ["find-exec@1.0.3", "", { "dependencies": { "shell-quote": "^1.8.1" } }, "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug=="],
"find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
@@ -4412,6 +4417,8 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=",
"aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=",
"aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=",
"x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA="
"x86_64-linux": "sha256-fiMi8VxyMhNTaZf0ButrMEwT/ZmfeEg1T3c6HwUz8p4=",
"aarch64-linux": "sha256-1Mzjijq/INZGGEm4EerYN3hu1VxiQ8wuGg6t+XPDf6w=",
"aarch64-darwin": "sha256-3SH8Q2kK/F2kM29FmFUMR1aA23rSei+mPJliRIGfvCM=",
"x86_64-darwin": "sha256-RPsyoNXn84K93gunRFLsBvkZIQilfmUXdwkeieQjbd8="
}
}
+6 -1
View File
@@ -155,7 +155,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
const payload = event.payload
if (event.payload.type === "sync") {
continue
}
const payload = event.payload as Event
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
@@ -0,0 +1,13 @@
CREATE TABLE `session_entry` (
`id` text PRIMARY KEY,
`session_id` text NOT NULL,
`type` text NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
`data` text NOT NULL,
CONSTRAINT `fk_session_entry_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `session_entry_session_idx` ON `session_entry` (`session_id`);--> statement-breakpoint
CREATE INDEX `session_entry_session_type_idx` ON `session_entry` (`session_id`,`type`);--> statement-breakpoint
CREATE INDEX `session_entry_time_created_idx` ON `session_entry` (`time_created`);
@@ -0,0 +1,1399 @@
{
"version": "7",
"dialect": "sqlite",
"id": "30b928c5-deef-472c-856d-b5b5064bf6d4",
"prevIds": ["b61476b8-3b92-49ae-9fa5-6eef586ed64b"],
"ddl": [
{
"name": "account_state",
"entityType": "tables"
},
{
"name": "account",
"entityType": "tables"
},
{
"name": "control_account",
"entityType": "tables"
},
{
"name": "workspace",
"entityType": "tables"
},
{
"name": "project",
"entityType": "tables"
},
{
"name": "message",
"entityType": "tables"
},
{
"name": "part",
"entityType": "tables"
},
{
"name": "permission",
"entityType": "tables"
},
{
"name": "session_entry",
"entityType": "tables"
},
{
"name": "session",
"entityType": "tables"
},
{
"name": "todo",
"entityType": "tables"
},
{
"name": "session_share",
"entityType": "tables"
},
{
"name": "event_sequence",
"entityType": "tables"
},
{
"name": "event",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "account_state"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "active_account_id",
"entityType": "columns",
"table": "account_state"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "active_org_id",
"entityType": "columns",
"table": "account_state"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "token_expiry",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "control_account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "control_account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token",
"entityType": "columns",
"table": "control_account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token",
"entityType": "columns",
"table": "control_account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "token_expiry",
"entityType": "columns",
"table": "control_account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "active",
"entityType": "columns",
"table": "control_account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "control_account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "control_account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "''",
"generated": null,
"name": "name",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "branch",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "extra",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "worktree",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "vcs",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_url",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_color",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_initialized",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sandboxes",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "commands",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "message_id",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "part"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "part"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "permission"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "permission"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "permission"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "permission"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "session_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "session_entry"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "workspace_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "parent_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "slug",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "version",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "share_url",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_additions",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_deletions",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_files",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_diffs",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "revert",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "permission",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_compacting",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_archived",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "content",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "status",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "priority",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "position",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "secret",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "aggregate_id",
"entityType": "columns",
"table": "event_sequence"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "seq",
"entityType": "columns",
"table": "event_sequence"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "event"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "aggregate_id",
"entityType": "columns",
"table": "event"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "seq",
"entityType": "columns",
"table": "event"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "event"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "event"
},
{
"columns": ["active_account_id"],
"tableTo": "account",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
"name": "fk_account_state_active_account_id_account_id_fk",
"entityType": "fks",
"table": "account_state"
},
{
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_workspace_project_id_project_id_fk",
"entityType": "fks",
"table": "workspace"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_message_session_id_session_id_fk",
"entityType": "fks",
"table": "message"
},
{
"columns": ["message_id"],
"tableTo": "message",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_part_message_id_message_id_fk",
"entityType": "fks",
"table": "part"
},
{
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_permission_project_id_project_id_fk",
"entityType": "fks",
"table": "permission"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_entry_session_id_session_id_fk",
"entityType": "fks",
"table": "session_entry"
},
{
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_project_id_project_id_fk",
"entityType": "fks",
"table": "session"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_todo_session_id_session_id_fk",
"entityType": "fks",
"table": "todo"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_share_session_id_session_id_fk",
"entityType": "fks",
"table": "session_share"
},
{
"columns": ["aggregate_id"],
"tableTo": "event_sequence",
"columnsTo": ["aggregate_id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk",
"entityType": "fks",
"table": "event"
},
{
"columns": ["email", "url"],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": ["session_id", "position"],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": ["project_id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_entry_pk",
"table": "session_entry",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": ["session_id"],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
"entityType": "pks"
},
{
"columns": ["aggregate_id"],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "time_created",
"isExpression": false
},
{
"value": "id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "message_session_time_created_id_idx",
"entityType": "indexes",
"table": "message"
},
{
"columns": [
{
"value": "message_id",
"isExpression": false
},
{
"value": "id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "part_message_id_id_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "part_session_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_entry_session_idx",
"entityType": "indexes",
"table": "session_entry"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "type",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_entry_session_type_idx",
"entityType": "indexes",
"table": "session_entry"
},
{
"columns": [
{
"value": "time_created",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_entry_time_created_idx",
"entityType": "indexes",
"table": "session_entry"
},
{
"columns": [
{
"value": "project_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_project_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "workspace_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_workspace_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "parent_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_parent_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "todo_session_idx",
"entityType": "indexes",
"table": "todo"
}
],
"renames": []
}
+1
View File
@@ -128,6 +128,7 @@
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"cli-sound": "1.1.3",
"clipboardy": "4.0.0",
"cross-spawn": "catalog:",
"decimal.js": "10.5.0",
+238
View File
@@ -0,0 +1,238 @@
# Facade removal checklist
Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`.
As of 2026-04-13, latest `origin/dev`:
- `src/` still has 15 `makeRuntime(...)` call sites.
- 13 of those are still in scope for facade removal.
- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`.
Recent progress:
- Wave 1 is merged: `Pty`, `Skill`, `Vcs`, `ToolRegistry`, `Auth`.
- Wave 2 is merged: `Config`, `Provider`, `File`, `LSP`, `MCP`.
## Priority hotspots
- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades.
- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion.
## Completed Batches
Low-risk batch, all merged:
1. `src/pty/index.ts`
2. `src/skill/index.ts`
3. `src/project/vcs.ts`
4. `src/tool/registry.ts`
5. `src/auth/index.ts`
Caller-heavy batch, all merged:
1. `src/config/config.ts`
2. `src/provider/provider.ts`
3. `src/file/index.ts`
4. `src/lsp/index.ts`
5. `src/mcp/index.ts`
Shared pattern:
- one service file still exports `makeRuntime(...)` + async facades
- one or two route or CLI entrypoints call those facades directly
- tests call the facade directly and need to switch to `yield* svc.method(...)`
- once callers are gone, delete `makeRuntime(...)`, remove async facade exports, and drop the `makeRuntime` import
## Done means
For each service in the low-risk batch, the work is complete only when all of these are true:
1. all production callers stop using `Namespace.method(...)` facade calls
2. all direct test callers stop using the facade and instead yield the service from context
3. the service file no longer has `makeRuntime(...)`
4. the service file no longer exports runtime-backed facade helpers
5. `grep` for the migrated facade methods only finds the service implementation itself or unrelated names
## Caller templates
### Route handlers
Use one `AppRuntime.runPromise(Effect.gen(...))` body and yield the service inside it.
```ts
const value = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}),
)
```
If two service calls are independent, keep them in the same effect body and use `Effect.all(...)`.
### Plain async CLI or script entrypoints
If the caller is not itself an Effect service yet, still prefer one contiguous `AppRuntime.runPromise(Effect.gen(...))` block for the whole unit of work.
```ts
const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
const skill = yield* Skill.Service
yield* auth.set(key, info)
return yield* skill.all()
}),
)
```
Only fall back to `AppRuntime.runPromise(Service.use(...))` for truly isolated one-off calls or awkward callback boundaries. Do not stack multiple tiny `runPromise(...)` calls in the same contiguous workflow.
This is the right intermediate state. Do not block facade removal on effectifying the whole CLI file.
### Bootstrap or fire-and-forget startup code
If the old facade call existed only to kick off initialization, call the service through the existing runtime for that file.
```ts
void BootstrapRuntime.runPromise(Vcs.Service.use((svc) => svc.init()))
```
Do not reintroduce a dedicated runtime in the service just for bootstrap.
### Tests
Convert facade tests to full effect style.
```ts
it.effect("does the thing", () =>
Effect.gen(function* () {
const svc = yield* Pty.Service
const info = yield* svc.create({ command: "cat", title: "a" })
yield* svc.remove(info.id)
}).pipe(Effect.provide(Pty.defaultLayer)),
)
```
If the repo test already uses `testEffect(...)`, prefer `testEffect(Service.defaultLayer)` and `yield* Service.Service` inside the test body.
Do not route tests through `AppRuntime` unless the test is explicitly exercising the app runtime. For facade removal, tests should usually provide the specific service layer they need.
If the test uses `provideTmpdirInstance(...)`, remember that fixture needs a live `ChildProcessSpawner` layer. For services whose `defaultLayer` does not already provide that infra, prefer the repo-standard cross-spawn layer:
```ts
const infra = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(MyService.defaultLayer, infra))
```
Without that extra layer, tests fail at runtime with `Service not found: effect/process/ChildProcessSpawner`.
## Questions already answered
### Do we need to effectify the whole caller first?
No.
- route files: compose the handler with `AppRuntime.runPromise(Effect.gen(...))`
- CLI and scripts: use `AppRuntime.runPromise(Service.use(...))`
- bootstrap: use the existing bootstrap runtime
Facade removal does not require a bigger refactor than that.
### Should tests keep calling the namespace from async test bodies?
No. Convert them now.
The end state is `yield* svc.method(...)`, not `await Namespace.method(...)` inside `async` tests.
### Should we keep `runPromise` exported for convenience?
No. For this batch the goal is to delete the service-local runtime entirely.
### What if a route has websocket callbacks or nested async handlers?
Keep the route shape, but replace each facade call with `AppRuntime.runPromise(Service.use(...))` or wrap the surrounding async section in one `Effect.gen(...)` when practical. Do not keep the service facade just because the route has callback-shaped code.
### Should we use one `runPromise` per service call?
No.
Default to one contiguous `AppRuntime.runPromise(Effect.gen(...))` block per handler, command, or workflow. Yield every service you need inside that block.
Multiple tiny `runPromise(...)` calls are only acceptable when the caller structure forces it, such as websocket lifecycle callbacks, external callback APIs, or genuinely unrelated one-off operations.
### Should we wrap a single service expression in `Effect.gen(...)`?
Usually no.
Prefer the direct form when there is only one expression:
```ts
await AppRuntime.runPromise(File.Service.use((svc) => svc.read(path)))
```
Use `Effect.gen(...)` when the workflow actually needs multiple yielded values or branching.
## Learnings
These were the recurring mistakes and useful corrections from the first two batches:
1. Tests should usually provide the specific service layer, not `AppRuntime`.
2. If a test uses `provideTmpdirInstance(...)` and needs child processes, prefer `CrossSpawnSpawner.defaultLayer`.
3. Instance-scoped services may need both the service layer and the right instance fixture. `File` tests, for example, needed `provideInstance(...)` plus `File.defaultLayer`.
4. Do not wrap a single `Service.use(...)` call in `Effect.gen(...)` just to return it. Use the direct form.
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
## Next batch
Recommended next five, in order:
1. `src/permission/index.ts`
2. `src/agent/agent.ts`
3. `src/session/summary.ts`
4. `src/session/revert.ts`
5. `src/mcp/auth.ts`
Why this batch:
- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`.
- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`.
- `McpAuth` is small and closely related to the just-landed `MCP` cleanup.
After that batch, the expected follow-up is the main session cluster:
1. `src/session/index.ts`
2. `src/session/prompt.ts`
3. `src/session/compaction.ts`
## Checklist
- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts`
- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts`
- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts`
- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts`
- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts`
- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`
- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts`
- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts`
- [x] `src/file/index.ts` (`File`) - facades removed and merged.
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged.
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged.
- [x] `src/config/config.ts` (`Config`) - facades removed and merged.
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged.
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged.
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged.
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged.
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged.
- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts`
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged.
- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts`
- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts`
- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts`
- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts`
## Excluded `makeRuntime(...)` sites
- `src/bus/index.ts` - core bus plumbing, not a normal facade-removal target.
- `src/effect/cross-spawn-spawner.ts` - runtime helper for `ChildProcessSpawner`, not a service namespace facade.
@@ -0,0 +1,36 @@
# Effect loose ends
Small follow-ups that do not fit neatly into the main facade, route, tool, or schema migration checklists.
## Config / TUI
- [ ] `config/tui.ts` - finish the internal Effect migration after the `Instance.state(...)` removal.
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
- [ ] `config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
- [ ] `env/index.ts` - move the last production `Instance.state(...)` usage onto `InstanceState` (or its replacement) so `Instance.state` can be deleted.
## ConfigPaths
- [ ] `config/paths.ts` - split pure helpers from effectful helpers.
Keep `fileInDirectory(...)` as a plain function.
- [ ] `config/paths.ts` - add a `ConfigPaths.Service` for the effectful operations so callers do not inherit `AppFileSystem.Service` directly.
Initial service surface should cover:
- `projectFiles(...)`
- `directories(...)`
- `readFile(...)`
- `parseText(...)`
- [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
- [ ] `config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
## Instance cleanup
- [ ] `project/instance.ts` - remove `Instance.state(...)` once `env/index.ts` is migrated.
- [ ] `project/state.ts` - delete the bespoke per-instance state helper after the last production caller is gone.
- [ ] `test/project/state.test.ts` - replace or delete the old `Instance.state(...)` tests after the removal.
## Notes
- Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally.
- When changing config loading internals, rerun the config and TUI suites first before broad package sweeps.
+2 -2
View File
@@ -180,7 +180,7 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
Service-shape migrated (single namespace, traced methods, `InstanceState` where needed).
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in [Destroying the facades](#destroying-the-facades).
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in `facades.md`.
- [x] `Account``account/index.ts`
- [x] `Agent``agent/agent.ts`
@@ -263,7 +263,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
## Destroying the facades
This phase is still broadly open. As of 2026-04-11 there are still 31 `makeRuntime(...)` call sites under `src/`, and many service namespaces still export async facade helpers like `export async function read(...) { return runPromise(...) }`.
This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
+4 -2
View File
@@ -40,6 +40,7 @@ import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { Agent as AgentModule } from "../agent/agent"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config/config"
@@ -1166,7 +1167,7 @@ export namespace ACP {
this.sessionManager.get(sessionId).modeId ||
(await (async () => {
if (!availableModes.length) return undefined
const defaultAgentName = await AgentModule.defaultAgent()
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
const resolvedModeId =
availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId)
@@ -1367,7 +1368,8 @@ export namespace ACP {
if (!current) {
this.sessionManager.setModel(session.id, model)
}
const agent = session.modeId ?? (await AgentModule.defaultAgent())
const agent =
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())))
const parts: Array<
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
+3 -22
View File
@@ -21,7 +21,6 @@ import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, Context, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace Agent {
export const Info = z
@@ -74,6 +73,7 @@ export namespace Agent {
Effect.gen(function* () {
const config = yield* Config.Service
const auth = yield* Auth.Service
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service
const provider = yield* Provider.Service
@@ -336,9 +336,7 @@ export namespace Agent {
const language = yield* provider.getLanguage(resolved)
const system = [PROMPT_GENERATE]
yield* Effect.promise(() =>
Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }),
)
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
// TODO: clean this up so provider specific logic doesnt bleed over
@@ -399,27 +397,10 @@ export namespace Agent {
)
export const defaultLayer = layer.pipe(
Layer.provide(Plugin.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(agent: string) {
return runPromise((svc) => svc.get(agent))
}
export async function list() {
return runPromise((svc) => svc.list())
}
export async function defaultAgent() {
return runPromise((svc) => svc.defaultAgent())
}
export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
return runPromise((svc) => svc.generate(input))
}
}
+4
View File
@@ -0,0 +1,4 @@
declare module "*.wav" {
const file: string
export default file
}
+11 -18
View File
@@ -16,25 +16,18 @@ export namespace BusEvent {
}
export function payloads() {
return z
.discriminatedUnion(
"type",
registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
})
return registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
})
.toArray() as any,
)
.meta({
ref: "Event",
})
.toArray()
}
}
+5 -2
View File
@@ -1,5 +1,6 @@
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { UI } from "../ui"
import { Global } from "../../global"
import { Agent } from "../../agent/agent"
@@ -110,7 +111,9 @@ const AgentCreateCommand = cmd({
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await Agent.generate({ description, model }).catch((error) => {
const generated = await AppRuntime.runPromise(
Agent.Service.use((svc) => svc.generate({ description, model })),
).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
@@ -220,7 +223,7 @@ const AgentListCommand = cmd({
await Instance.provide({
directory: process.cwd(),
async fn() {
const agents = await Agent.list()
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
const sortedAgents = agents.sort((a, b) => {
if (a.native !== b.native) {
return a.native ? -1 : 1
+1 -1
View File
@@ -35,7 +35,7 @@ export const AgentCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
const agentName = args.name as string
const agent = await Agent.get(agentName)
const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName)))
if (!agent) {
process.stderr.write(
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
@@ -1,3 +1,4 @@
import { AppRuntime } from "@/effect/app-runtime"
import { Snapshot } from "../../../snapshot"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -14,7 +15,7 @@ const TrackCommand = cmd({
describe: "track current snapshot state",
async handler() {
await bootstrap(process.cwd(), async () => {
console.log(await Snapshot.track())
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.track())))
})
},
})
@@ -30,7 +31,7 @@ const PatchCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
console.log(await Snapshot.patch(args.hash))
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.patch(args.hash))))
})
},
})
@@ -46,7 +47,7 @@ const DiffCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
console.log(await Snapshot.diff(args.hash))
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.diff(args.hash))))
})
},
})
+6 -1
View File
@@ -361,7 +361,6 @@ export const McpLogoutCommand = cmd({
UI.empty()
prompts.intro("MCP OAuth Logout")
const authPath = path.join(Global.Path.data, "mcp-auth.json")
const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all()))
const serverNames = Object.keys(credentials)
@@ -717,6 +716,11 @@ export const McpDebugCommand = cmd({
// Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
const auth = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* McpAuth.Service
}),
)
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
@@ -729,6 +733,7 @@ export const McpDebugCommand = cmd({
{
onRedirect: async () => {},
},
auth,
)
prompts.log.info("Testing OAuth flow (without completing authorization)...")
+9 -3
View File
@@ -340,6 +340,12 @@ export const ProvidersLoginCommand = cmd({
}
return filtered
})
const hooks = await AppRuntime.runPromise(
Effect.gen(function* () {
const plugin = yield* Plugin.Service
return yield* plugin.list()
}),
)
const priority: Record<string, number> = {
opencode: 0,
@@ -351,7 +357,7 @@ export const ProvidersLoginCommand = cmd({
vercel: 6,
}
const pluginProviders = resolvePluginProviders({
hooks: await Plugin.list(),
hooks,
existingProviders: providers,
disabled,
enabled,
@@ -408,7 +414,7 @@ export const ProvidersLoginCommand = cmd({
provider = selected as string
}
const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
if (handled) return
@@ -422,7 +428,7 @@ export const ProvidersLoginCommand = cmd({
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
if (handled) return
+10 -8
View File
@@ -27,6 +27,7 @@ import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util/locale"
import { AppRuntime } from "@/effect/app-runtime"
type ToolProps<T> = {
input: Tool.InferParameters<T>
@@ -573,6 +574,7 @@ export const RunCommand = cmd({
// Validate agent if specified
const agent = await (async () => {
if (!args.agent) return undefined
const name = args.agent
// When attaching, validate against the running server instead of local Instance state.
if (args.attach) {
@@ -590,12 +592,12 @@ export const RunCommand = cmd({
return undefined
}
const agent = modes.find((a) => a.name === args.agent)
const agent = modes.find((a) => a.name === name)
if (!agent) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" not found. Falling back to default agent`,
`agent "${name}" not found. Falling back to default agent`,
)
return undefined
}
@@ -604,20 +606,20 @@ export const RunCommand = cmd({
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
return args.agent
return name
}
const entry = await Agent.get(args.agent)
const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
if (!entry) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" not found. Falling back to default agent`,
`agent "${name}" not found. Falling back to default agent`,
)
return undefined
}
@@ -625,11 +627,11 @@ export const RunCommand = cmd({
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
return args.agent
return name
})()
const sessionID = await session(sdk)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,82 +1,630 @@
import { TextAttributes, RGBA } from "@opentui/core"
import { For, type JSX } from "solid-js"
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme"
import { logo, marks } from "@/cli/logo"
import { Sound } from "@tui/util/sound"
import { logo } from "@/cli/logo"
// Shadow markers (rendered chars in parens):
// _ = full shadow cell (space with bg=shadow)
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
// ~ = shadow top only (▀ with fg=shadow)
const SHADOW_MARKER = new RegExp(`[${marks}]`)
const GAP = 1
const WIDTH = 0.76
const GAIN = 2.3
const FLASH = 2.15
const TRAIL = 0.28
const SWELL = 0.24
const WIDE = 1.85
const DRIFT = 1.45
const EXPAND = 1.62
const LIFE = 1020
const CHARGE = 3000
const HOLD = 90
const SINK = 40
const ARC = 2.2
const FORK = 1.2
const DIM = 1.04
const KICK = 0.86
const LAG = 60
const SUCK = 0.34
const SHIMMER_IN = 60
const SHIMMER_OUT = 2.8
const TRACE = 0.033
const TAIL = 1.8
const TRACE_IN = 200
const GLOW_OUT = 1600
const PEAK = RGBA.fromInts(255, 255, 255)
type Ring = {
x: number
y: number
at: number
force: number
kick: number
}
type Hold = {
x: number
y: number
at: number
glyph: number | undefined
}
type Release = {
x: number
y: number
at: number
glyph: number | undefined
level: number
rise: number
}
type Glow = {
glyph: number
at: number
force: number
}
type Frame = {
t: number
list: Ring[]
hold: Hold | undefined
release: Release | undefined
glow: Glow | undefined
spark: number
}
const LEFT = logo.left[0]?.length ?? 0
const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
const NEAR = [
[1, 0],
[1, 1],
[0, 1],
[-1, 1],
[-1, 0],
[-1, -1],
[0, -1],
[1, -1],
] as const
type Trace = {
glyph: number
i: number
l: number
}
function clamp(n: number) {
return Math.max(0, Math.min(1, n))
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * clamp(t)
}
function ease(t: number) {
const p = clamp(t)
return p * p * (3 - 2 * p)
}
function push(t: number) {
const p = clamp(t)
return ease(p * p)
}
function ramp(t: number, start: number, end: number) {
if (end <= start) return ease(t >= end ? 1 : 0)
return ease((t - start) / (end - start))
}
function glow(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
const mid = tint(base, theme.primary, 0.84)
const top = tint(theme.primary, PEAK, 0.96)
if (n <= 1) return tint(base, mid, Math.min(1, Math.sqrt(Math.max(0, n)) * 1.14))
return tint(mid, top, Math.min(1, 1 - Math.exp(-2.4 * (n - 1))))
}
function shade(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
if (n >= 0) return glow(base, theme, n)
return tint(base, theme.background, Math.min(0.82, -n * 0.64))
}
function ghost(n: number, scale: number) {
if (n < 0) return n
return n * scale
}
function noise(x: number, y: number, t: number) {
const n = Math.sin(x * 12.9898 + y * 78.233 + t * 0.043) * 43758.5453
return n - Math.floor(n)
}
function lit(char: string) {
return char !== " " && char !== "_" && char !== "~"
}
function key(x: number, y: number) {
return `${x},${y}`
}
function route(list: Array<{ x: number; y: number }>) {
const left = new Map(list.map((item) => [key(item.x, item.y), item]))
const path: Array<{ x: number; y: number }> = []
let cur = [...left.values()].sort((a, b) => a.y - b.y || a.x - b.x)[0]
let dir = { x: 1, y: 0 }
while (cur) {
path.push(cur)
left.delete(key(cur.x, cur.y))
if (!left.size) return path
const next = NEAR.map(([dx, dy]) => left.get(key(cur.x + dx, cur.y + dy)))
.filter((item): item is { x: number; y: number } => !!item)
.sort((a, b) => {
const ax = a.x - cur.x
const ay = a.y - cur.y
const bx = b.x - cur.x
const by = b.y - cur.y
const adot = ax * dir.x + ay * dir.y
const bdot = bx * dir.x + by * dir.y
if (adot !== bdot) return bdot - adot
return Math.abs(ax) + Math.abs(ay) - (Math.abs(bx) + Math.abs(by))
})[0]
if (!next) {
cur = [...left.values()].sort((a, b) => {
const da = (a.x - cur.x) ** 2 + (a.y - cur.y) ** 2
const db = (b.x - cur.x) ** 2 + (b.y - cur.y) ** 2
return da - db
})[0]
dir = { x: 1, y: 0 }
continue
}
dir = { x: next.x - cur.x, y: next.y - cur.y }
cur = next
}
return path
}
function mapGlyphs() {
const cells = [] as Array<{ x: number; y: number }>
for (let y = 0; y < FULL.length; y++) {
for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
}
}
const all = new Map(cells.map((item) => [key(item.x, item.y), item]))
const seen = new Set<string>()
const glyph = new Map<string, number>()
const trace = new Map<string, Trace>()
const center = new Map<number, { x: number; y: number }>()
let id = 0
for (const item of cells) {
const start = key(item.x, item.y)
if (seen.has(start)) continue
const stack = [item]
const part = [] as Array<{ x: number; y: number }>
seen.add(start)
while (stack.length) {
const cur = stack.pop()!
part.push(cur)
glyph.set(key(cur.x, cur.y), id)
for (const [dx, dy] of NEAR) {
const next = all.get(key(cur.x + dx, cur.y + dy))
if (!next) continue
const mark = key(next.x, next.y)
if (seen.has(mark)) continue
seen.add(mark)
stack.push(next)
}
}
const path = route(part)
path.forEach((cell, i) => trace.set(key(cell.x, cell.y), { glyph: id, i, l: path.length }))
center.set(id, {
x: part.reduce((sum, item) => sum + item.x, 0) / part.length + 0.5,
y: (part.reduce((sum, item) => sum + item.y, 0) / part.length) * 2 + 1,
})
id++
}
return { glyph, trace, center }
}
const MAP = mapGlyphs()
function shimmer(x: number, y: number, frame: Frame) {
return frame.list.reduce((best, item) => {
const age = frame.t - item.at
if (age < SHIMMER_IN || age > LIFE) return best
const dx = x + 0.5 - item.x
const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy)
const p = age / LIFE
const r = SPAN * (1 - (1 - p) ** EXPAND)
const lag = r - dist
if (lag < 0.18 || lag > SHIMMER_OUT) return best
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
const wobble = 0.5 + 0.5 * Math.sin(frame.t * 0.035 + x * 0.9 + y * 1.7)
const n = band * wobble * (1 - p) ** 1.45
if (n > best) return n
return best
}, 0)
}
function remain(x: number, y: number, item: Release, t: number) {
const age = t - item.at
if (age < 0 || age > LIFE) return 0
const p = age / LIFE
const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy)
const r = SPAN * (1 - (1 - p) ** EXPAND)
if (dist > r) return 1
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
}
function wave(x: number, y: number, frame: Frame, live: boolean) {
return frame.list.reduce((sum, item) => {
const age = frame.t - item.at
if (age < 0 || age > LIFE) return sum
const p = age / LIFE
const dx = x + 0.5 - item.x
const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy)
const r = SPAN * (1 - (1 - p) ** EXPAND)
const fade = (1 - p) ** 1.32
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
const swell = Math.exp(-(((dist - Math.max(0, r - DRIFT)) / WIDE) ** 2)) * SWELL * fade * item.force
const trail = dist < r ? Math.exp(-(r - dist) / 2.4) * TRAIL * fade * item.force * lerp(0.92, 1.22, j) : 0
const flash = Math.exp(-(dist * dist) / 3.2) * FLASH * item.force * Math.max(0, 1 - age / 140) * lerp(0.95, 1.18, j)
const kick = Math.exp(-(dist * dist) / 2) * item.kick * Math.max(0, 1 - age / 100)
const suck = Math.exp(-(((dist - 1.25) / 0.75) ** 2)) * item.kick * SUCK * Math.max(0, 1 - age / 110)
const wake = live && dist < r ? Math.exp(-(r - dist) / 1.25) * 0.32 * fade : 0
return sum + edge + swell + trail + flash + wake - kick - suck
}, 0)
}
function field(x: number, y: number, frame: Frame) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
if (!item) return 0
const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
const level = held ? push(rise) : rest!.level
const body = rise
const storm = level * level
const sink = held ? ramp(frame.t - held.at, SINK, CHARGE) : rest!.rise
const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy)
const angle = Math.atan2(dy, dx)
const spin = frame.t * lerp(0.008, 0.018, storm)
const dim = lerp(0, DIM, sink) * lerp(0.99, 1.01, 0.5 + 0.5 * Math.sin(frame.t * 0.014))
const core = Math.exp(-(dist * dist) / Math.max(0.22, lerp(0.22, 3.2, body))) * lerp(0.42, 2.45, body)
const shell =
Math.exp(-(((dist - lerp(0.16, 2.05, body)) / Math.max(0.18, lerp(0.18, 0.82, body))) ** 2)) * lerp(0.1, 0.95, body)
const ember =
Math.exp(-(((dist - lerp(0.45, 2.65, body)) / Math.max(0.14, lerp(0.14, 0.62, body))) ** 2)) *
lerp(0.02, 0.78, body)
const arc = Math.max(0, Math.cos(angle * 3 - spin + frame.spark * 2.2)) ** 8
const seam = Math.max(0, Math.cos(angle * 5 + spin * 1.55)) ** 12
const ring = Math.exp(-(((dist - lerp(1.05, 3, level)) / 0.48) ** 2)) * arc * lerp(0.03, 0.5 + ARC, storm)
const fork = Math.exp(-(((dist - (1.55 + storm * 2.1)) / 0.36) ** 2)) * seam * storm * FORK
const spark = Math.max(0, noise(x, y, frame.t) - lerp(0.94, 0.66, storm)) * lerp(0, 5.4, storm)
const glitch = spark * Math.exp(-dist / Math.max(1.2, 3.1 - storm))
const crack = Math.max(0, Math.cos((dx - dy) * 1.6 + spin * 2.1)) ** 18
const lash = crack * Math.exp(-(((dist - (1.95 + storm * 2)) / 0.28) ** 2)) * storm * 1.1
const flicker =
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
Math.exp(-(dist * dist) / 0.15) *
lerp(0.08, 0.42, body)
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
}
function pick(x: number, y: number, frame: Frame) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
if (!item) return 0
const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy)
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
}
function select(x: number, y: number) {
const direct = MAP.glyph.get(key(x, y))
if (direct !== undefined) return direct
const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find(
(item): item is number => item !== undefined,
)
return near
}
function trace(x: number, y: number, frame: Frame) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
if (!item || item.glyph === undefined) return 0
const step = MAP.trace.get(key(x, y))
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
const age = frame.t - item.at
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
const appear = held ? ramp(age, 0, TRACE_IN) : 1
const speed = lerp(TRACE * 0.48, TRACE * 0.88, rise)
const head = (age * speed) % step.l
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
const tail = (head - TAIL + step.l) % step.l
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
return (core + glow + trail) * appear * fade
}
function bloom(x: number, y: number, frame: Frame) {
const item = frame.glow
if (!item) return 0
const glyph = MAP.glyph.get(key(x, y))
if (glyph !== item.glyph) return 0
const age = frame.t - item.at
if (age < 0 || age > GLOW_OUT) return 0
const p = age / GLOW_OUT
const flash = (1 - p) ** 2
const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
}
export function Logo() {
const { theme } = useTheme()
const [rings, setRings] = createSignal<Ring[]>([])
const [hold, setHold] = createSignal<Hold>()
const [release, setRelease] = createSignal<Release>()
const [glow, setGlow] = createSignal<Glow>()
const [now, setNow] = createSignal(0)
let box: BoxRenderable | undefined
let timer: ReturnType<typeof setInterval> | undefined
let hum = false
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(theme.background, fg, 0.25)
const stop = () => {
if (!timer) return
clearInterval(timer)
timer = undefined
}
const tick = () => {
const t = performance.now()
setNow(t)
const item = hold()
if (item && !hum && t - item.at >= HOLD) {
hum = true
Sound.start()
}
if (item && t - item.at >= CHARGE) {
burst(item.x, item.y)
}
let live = false
setRings((list) => {
const next = list.filter((item) => t - item.at < LIFE)
live = next.length > 0
return next
})
const flash = glow()
if (flash && t - flash.at >= GLOW_OUT) {
setGlow(undefined)
}
if (!live) setRelease(undefined)
if (live || hold() || release() || glow()) return
stop()
}
const start = () => {
if (timer) return
timer = setInterval(tick, 16)
}
const hit = (x: number, y: number) => {
const char = FULL[y]?.[x]
return char !== undefined && char !== " "
}
const press = (x: number, y: number, t: number) => {
const last = hold()
if (last) burst(last.x, last.y)
setNow(t)
if (!last) setRelease(undefined)
setHold({ x, y, at: t, glyph: select(x, y) })
hum = false
start()
}
const burst = (x: number, y: number) => {
const item = hold()
if (!item) return
hum = false
const t = performance.now()
const age = t - item.at
const rise = ramp(age, HOLD, CHARGE)
const level = push(rise)
setHold(undefined)
setRelease({ x, y, at: t, glyph: item.glyph, level, rise })
if (item.glyph !== undefined) {
setGlow({ glyph: item.glyph, at: t, force: lerp(0.18, 1.5, rise * level) })
}
setRings((list) => [
...list,
{
x: x + 0.5,
y: y * 2 + 1,
at: t,
force: lerp(0.82, 2.55, level),
kick: lerp(0.32, 0.32 + KICK, level),
},
])
setNow(t)
start()
Sound.pulse(lerp(0.8, 1, level))
}
const frame = createMemo(() => {
const t = now()
const item = hold()
return {
t,
list: rings(),
hold: item,
release: release(),
glow: glow(),
spark: item ? noise(item.x, item.y, t) : 0,
}
})
const dusk = createMemo(() => {
const base = frame()
const t = base.t - LAG
const item = base.hold
return {
t,
list: base.list,
hold: item,
release: base.release,
glow: base.glow,
spark: item ? noise(item.x, item.y, t) : 0,
}
})
const renderLine = (
line: string,
y: number,
ink: RGBA,
bold: boolean,
off: number,
frame: Frame,
dusk: Frame,
): JSX.Element[] => {
const shadow = tint(theme.background, ink, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
const elements: JSX.Element[] = []
let i = 0
while (i < line.length) {
const rest = line.slice(i)
const markerIndex = rest.search(SHADOW_MARKER)
return [...line].map((char, i) => {
const h = field(off + i, y, frame)
const n = wave(off + i, y, frame, lit(char)) + h
const s = wave(off + i, y, dusk, false) + h
const p = lit(char) ? pick(off + i, y, frame) : 0
const e = lit(char) ? trace(off + i, y, frame) : 0
const b = lit(char) ? bloom(off + i, y, frame) : 0
const q = shimmer(off + i, y, frame)
if (markerIndex === -1) {
elements.push(
<text fg={fg} attributes={attrs} selectable={false}>
{rest}
</text>,
)
break
}
if (markerIndex > 0) {
elements.push(
<text fg={fg} attributes={attrs} selectable={false}>
{rest.slice(0, markerIndex)}
</text>,
if (char === "_") {
return (
<text
fg={shade(ink, theme, s * 0.08)}
bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
attributes={attrs}
selectable={false}
>
{" "}
</text>
)
}
const marker = rest[markerIndex]
switch (marker) {
case "_":
elements.push(
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
{" "}
</text>,
)
break
case "^":
elements.push(
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
</text>,
)
break
case "~":
elements.push(
<text fg={shadow} attributes={attrs} selectable={false}>
</text>,
)
break
if (char === "^") {
return (
<text
fg={shade(ink, theme, n + p + e + b)}
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
attributes={attrs}
selectable={false}
>
</text>
)
}
i += markerIndex + 1
if (char === "~") {
return (
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
</text>
)
}
if (char === " ") {
return (
<text fg={ink} attributes={attrs} selectable={false}>
{char}
</text>
)
}
return (
<text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
{char}
</text>
)
})
}
onCleanup(() => {
stop()
hum = false
Sound.dispose()
})
const mouse = (evt: MouseEvent) => {
if (!box) return
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
const x = evt.x - box.x
const y = evt.y - box.y
if (!hit(x, y)) return
if (evt.type === "drag" && hold()) return
evt.preventDefault()
evt.stopPropagation()
const t = performance.now()
press(x, y, t)
return
}
return elements
if (!hold()) return
if (evt.type === "up") {
const item = hold()
if (!item) return
burst(item.x, item.y)
}
}
return (
<box>
<box ref={(item: BoxRenderable) => (box = item)}>
<box
position="absolute"
top={0}
left={0}
width={FULL[0]?.length ?? 0}
height={FULL.length}
zIndex={1}
onMouse={mouse}
/>
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
<box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
<box flexDirection="row">
{renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
</box>
</box>
)}
</For>
@@ -8,6 +8,10 @@ export function useEvent() {
function subscribe(handler: (event: Event) => void) {
return sdk.event.on("event", (event) => {
if (event.payload.type === "sync") {
return
}
// Special hack for truly global events
if (event.directory === "global") {
handler(event.payload)
@@ -0,0 +1,156 @@
import { Player } from "cli-sound"
import { mkdirSync } from "node:fs"
import { tmpdir } from "node:os"
import { basename, join } from "node:path"
import { Process } from "@/util/process"
import { which } from "@/util/which"
import pulseA from "../asset/pulse-a.wav" with { type: "file" }
import pulseB from "../asset/pulse-b.wav" with { type: "file" }
import pulseC from "../asset/pulse-c.wav" with { type: "file" }
import charge from "../asset/charge.wav" with { type: "file" }
const FILE = [pulseA, pulseB, pulseC]
const HUM = charge
const DIR = join(tmpdir(), "opencode-sfx")
const LIST = [
"ffplay",
"mpv",
"mpg123",
"mpg321",
"mplayer",
"afplay",
"play",
"omxplayer",
"aplay",
"cmdmp3",
"cvlc",
"powershell.exe",
] as const
type Kind = (typeof LIST)[number]
function args(kind: Kind, file: string, volume: number) {
if (kind === "ffplay") return [kind, "-autoexit", "-nodisp", "-af", `volume=${volume}`, file]
if (kind === "mpv")
return [kind, "--no-video", "--audio-display=no", "--volume", String(Math.round(volume * 100)), file]
if (kind === "mpg123" || kind === "mpg321") return [kind, "-g", String(Math.round(volume * 100)), file]
if (kind === "mplayer") return [kind, "-vo", "null", "-volume", String(Math.round(volume * 100)), file]
if (kind === "afplay" || kind === "omxplayer" || kind === "aplay" || kind === "cmdmp3") return [kind, file]
if (kind === "play") return [kind, "-v", String(volume), file]
if (kind === "cvlc") return [kind, `--gain=${volume}`, "--play-and-exit", file]
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
}
export namespace Sound {
let item: Player | null | undefined
let kind: Kind | null | undefined
let proc: Process.Child | undefined
let tail: ReturnType<typeof setTimeout> | undefined
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
let seq = 0
let shot = 0
function load() {
if (item !== undefined) return item
try {
item = new Player({ volume: 0.35 })
} catch {
item = null
}
return item
}
async function file(path: string) {
mkdirSync(DIR, { recursive: true })
const next = join(DIR, basename(path))
const out = Bun.file(next)
if (await out.exists()) return next
await Bun.write(out, Bun.file(path))
return next
}
function asset() {
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
return cache
}
function pick() {
if (kind !== undefined) return kind
kind = LIST.find((item) => which(item)) ?? null
return kind
}
function run(file: string, volume: number) {
const kind = pick()
if (!kind) return
return Process.spawn(args(kind, file, volume), {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
})
}
function clear() {
if (!tail) return
clearTimeout(tail)
tail = undefined
}
function play(file: string, volume: number) {
const item = load()
if (!item) return run(file, volume)?.exited
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
}
export function start() {
stop()
const id = ++seq
void asset().then(({ hum }) => {
if (id !== seq) return
const next = run(hum, 0.24)
if (!next) return
proc = next
void next.exited.then(
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
)
})
}
export function stop(delay = 0) {
seq++
clear()
if (!proc) return
const next = proc
if (delay <= 0) {
proc = undefined
void Process.stop(next).catch(() => undefined)
return
}
tail = setTimeout(() => {
tail = undefined
if (proc === next) proc = undefined
void Process.stop(next).catch(() => undefined)
}, delay)
}
export function pulse(scale = 1) {
stop(140)
const index = shot++ % FILE.length
void asset()
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
.catch(() => undefined)
}
export function dispose() {
stop()
}
}
+456 -458
View File
@@ -1161,502 +1161,500 @@ export namespace Config {
}),
)
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const authSvc = yield* Auth.Service
const accountSvc = yield* Account.Service
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Auth.Service | Account.Service | Env.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const authSvc = yield* Auth.Service
const accountSvc = yield* Account.Service
const env = yield* Env.Service
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(
Effect.catchIf(
(e) => e.reason._tag === "NotFound",
() => Effect.succeed(undefined),
),
Effect.orDie,
)
})
const loadConfig = Effect.fnUntraced(function* (
text: string,
options: { path: string } | { dir: string; source: string },
) {
const original = text
const source = "path" in options ? options.path : options.source
const isFile = "path" in options
const data = yield* Effect.promise(() =>
ConfigPaths.parseText(
text,
"path" in options ? options.path : { source: options.source, dir: options.dir },
),
)
const normalized = (() => {
if (!data || typeof data !== "object" || Array.isArray(data)) return data
const copy = { ...(data as Record<string, unknown>) }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy
})()
const parsed = Info.safeParse(normalized)
if (parsed.success) {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
}
const data = parsed.data
if (data.plugin && isFile) {
const list = data.plugin
for (let i = 0; i < list.length; i++) {
list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
}
}
return data
}
throw new InvalidError({
path: source,
issues: parsed.error.issues,
})
})
const loadFile = Effect.fnUntraced(function* (filepath: string) {
log.info("loading", { path: filepath })
const text = yield* readConfigFile(filepath)
if (!text) return {} as Info
return yield* loadConfig(text, { path: filepath })
})
const loadGlobal = Effect.fnUntraced(function* () {
let result: Info = pipe(
{},
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
)
const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) {
yield* Effect.promise(() =>
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
.then(async (mod) => {
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fsNode.unlink(legacy)
})
.catch(() => {}),
)
}
return result
})
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
loadGlobal().pipe(
Effect.tapError((error) =>
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
),
Effect.orElseSucceed((): Info => ({})),
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(
Effect.catchIf(
(e) => e.reason._tag === "NotFound",
() => Effect.succeed(undefined),
),
Duration.infinity,
Effect.orDie,
)
})
const loadConfig = Effect.fnUntraced(function* (
text: string,
options: { path: string } | { dir: string; source: string },
) {
const original = text
const source = "path" in options ? options.path : options.source
const isFile = "path" in options
const data = yield* Effect.promise(() =>
ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
)
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
return yield* cachedGlobal
const normalized = (() => {
if (!data || typeof data !== "object" || Array.isArray(data)) return data
const copy = { ...(data as Record<string, unknown>) }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy
})()
const parsed = Info.safeParse(normalized)
if (parsed.success) {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
}
const data = parsed.data
if (data.plugin && isFile) {
const list = data.plugin
for (let i = 0; i < list.length; i++) {
list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
}
}
return data
}
throw new InvalidError({
path: source,
issues: parsed.error.issues,
})
})
const loadFile = Effect.fnUntraced(function* (filepath: string) {
log.info("loading", { path: filepath })
const text = yield* readConfigFile(filepath)
if (!text) return {} as Info
return yield* loadConfig(text, { path: filepath })
})
const loadGlobal = Effect.fnUntraced(function* () {
let result: Info = pipe(
{},
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
)
const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) {
yield* Effect.promise(() =>
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
.then(async (mod) => {
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fsNode.unlink(legacy)
})
.catch(() => {}),
)
}
return result
})
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
loadGlobal().pipe(
Effect.tapError((error) =>
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
),
Effect.orElseSucceed((): Info => ({})),
),
Duration.infinity,
)
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
return yield* cachedGlobal
})
const install = Effect.fnUntraced(function* (dir: string) {
const pkg = path.join(dir, "package.json")
const gitignore = path.join(dir, ".gitignore")
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = yield* fs.readJson(pkg).pipe(
Effect.catch(() => Effect.succeed({} satisfies Package)),
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
)
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
const hasIgnore = yield* fs.existsSafe(gitignore)
const hasPkg = yield* fs.existsSafe(plugin)
if (!hasDep) {
yield* fs.writeJson(pkg, {
...json,
dependencies: {
...json.dependencies,
"@opencode-ai/plugin": target,
},
})
}
if (!hasIgnore) {
yield* fs.writeFileString(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
if (hasDep && hasIgnore && hasPkg) return
yield* Effect.promise(() => Npm.install(dir))
})
const installDependencies = Effect.fn("Config.installDependencies")(function* (
dir: string,
input?: InstallInput,
) {
if (
!(yield* fs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
))
)
return
const key =
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
yield* Effect.acquireUseRelease(
Effect.promise((signal) =>
Flock.acquire(key, {
signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
}),
),
() => install(dir),
(lease) => Effect.promise(() => lease.release()),
)
})
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const scope = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global"
})
const install = Effect.fnUntraced(function* (dir: string) {
const pkg = path.join(dir, "package.json")
const gitignore = path.join(dir, ".gitignore")
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = yield* fs.readJson(pkg).pipe(
Effect.catch(() => Effect.succeed({} satisfies Package)),
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
)
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
const hasIgnore = yield* fs.existsSafe(gitignore)
const hasPkg = yield* fs.existsSafe(plugin)
const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) {
if (!list?.length) return
const hit = kind ?? (yield* scope(source))
const plugins = deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
})
if (!hasDep) {
yield* fs.writeJson(pkg, {
...json,
dependencies: {
...json.dependencies,
"@opencode-ai/plugin": target,
},
const merge = (source: string, next: Info, kind?: PluginScope) => {
result = mergeConfigConcatArrays(result, next)
return track(source, next.plugin, kind)
}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (yield* Effect.promise(() => response.json())) as any
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
const source = `${url}/.well-known/opencode`
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
})
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}
const global = yield* getGlobal()
yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) {
yield* merge(file, yield* loadFile(file), "local")
}
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Fiber.Fiber<void, never>[] = []
for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
}
if (!hasIgnore) {
yield* fs.writeFileString(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
if (hasDep && hasIgnore && hasPkg) return
yield* Effect.promise(() => Npm.install(dir))
})
const installDependencies = Effect.fn("Config.installDependencies")(function* (
dir: string,
input?: InstallInput,
) {
if (
!(yield* fs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
))
)
return
const key =
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
yield* Effect.acquireUseRelease(
Effect.promise((signal) =>
Flock.acquire(key, {
signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
}),
const dep = yield* installDependencies(dir).pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
() => install(dir),
(lease) => Effect.promise(() => lease.release()),
Effect.asVoid,
Effect.forkScoped,
)
})
deps.push(dep)
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
const list = yield* Effect.promise(() => loadPlugin(dir))
yield* track(dir, list)
}
let result: Info = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const scope = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global"
if (process.env.OPENCODE_CONFIG_CONTENT) {
const source = "OPENCODE_CONFIG_CONTENT"
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source,
})
yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const track = Effect.fnUntraced(function* (
source: string,
list: PluginSpec[] | undefined,
kind?: PluginScope,
) {
if (!list?.length) return
const hit = kind ?? (yield* scope(source))
const plugins = deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
})
const activeOrg = Option.getOrUndefined(
yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
if (activeOrg) {
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
}
const merge = (source: string, next: Info, kind?: PluginScope) => {
result = mergeConfigConcatArrays(result, next)
return track(source, next.plugin, kind)
}
activeOrgName = activeOrg.org.name
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (yield* Effect.promise(() => response.json())) as any
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
const source = `${url}/.well-known/opencode`
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
if (Option.isSome(configOpt)) {
const source = `${activeOrg.account.url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}
const global = yield* getGlobal()
yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) {
yield* merge(file, yield* loadFile(file), "local")
}
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Fiber.Fiber<void, never>[] = []
for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
}
const dep = yield* installDependencies(dir).pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
Effect.asVoid,
Effect.forkScoped,
)
deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
const list = yield* Effect.promise(() => loadPlugin(dir))
yield* track(dir, list)
}
if (process.env.OPENCODE_CONFIG_CONTENT) {
const source = "OPENCODE_CONFIG_CONTENT"
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source,
})
yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const activeOrg = Option.getOrUndefined(
yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
}).pipe(
Effect.catch((err) => {
log.debug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
})
return Effect.void
}),
)
if (activeOrg) {
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
Env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
}
}
activeOrgName = activeOrg.org.name
if (Option.isSome(configOpt)) {
const source = `${activeOrg.account.url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
yield* merge(source, next, "global")
}
}).pipe(
Effect.catch((err) => {
log.debug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
})
return Effect.void
}),
)
if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
}
}
if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
}
}
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
if (result.tools) {
const perms: Record<string, Config.PermissionAction> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: Config.PermissionAction = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.OPENCODE_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
return {
config: result,
directories,
deps,
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
if (result.tools) {
const perms: Record<string, Config.PermissionAction> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: Config.PermissionAction = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
perms[tool] = action
}
})
result.permission = mergeDeep(perms, result.permission ?? {})
}
const state = yield* InstanceState.make<State>(
Effect.fn("Config.state")(function* (ctx) {
return yield* loadInstanceState(ctx)
}),
)
if (!result.username) result.username = os.userInfo().username
const get = Effect.fn("Config.get")(function* () {
return yield* InstanceState.use(state, (s) => s.config)
})
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
const directories = Effect.fn("Config.directories")(function* () {
return yield* InstanceState.use(state, (s) => s.directories)
})
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.OPENCODE_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
return yield* InstanceState.use(state, (s) => s.consoleState)
})
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
)
})
const update = Effect.fn("Config.update")(function* (config: Info) {
const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json")
const existing = yield* loadFile(file)
yield* fs
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
.pipe(Effect.orDie)
yield* Effect.promise(() => Instance.dispose())
})
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
yield* invalidateGlobal
const task = Instance.disposeAll()
.catch(() => undefined)
.finally(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
}),
)
if (wait) yield* Effect.promise(() => task)
else void task
})
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}"
const input = writable(config)
let next: Info
if (!file.endsWith(".jsonc")) {
const existing = parseConfig(before, file)
const merged = mergeDeep(writable(existing), input)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, input)
next = parseConfig(updated, file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
yield* invalidate()
return next
})
return Service.of({
get,
getGlobal,
getConsoleState,
installDependencies,
update,
updateGlobal,
invalidate,
return {
config: result,
directories,
waitForDependencies,
})
}),
)
deps,
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
}
})
const state = yield* InstanceState.make<State>(
Effect.fn("Config.state")(function* (ctx) {
return yield* loadInstanceState(ctx)
}),
)
const get = Effect.fn("Config.get")(function* () {
return yield* InstanceState.use(state, (s) => s.config)
})
const directories = Effect.fn("Config.directories")(function* () {
return yield* InstanceState.use(state, (s) => s.directories)
})
const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
return yield* InstanceState.use(state, (s) => s.consoleState)
})
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
)
})
const update = Effect.fn("Config.update")(function* (config: Info) {
const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json")
const existing = yield* loadFile(file)
yield* fs
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
.pipe(Effect.orDie)
yield* Effect.promise(() => Instance.dispose())
})
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
yield* invalidateGlobal
const task = Instance.disposeAll()
.catch(() => undefined)
.finally(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
}),
)
if (wait) yield* Effect.promise(() => task)
else void task
})
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}"
const input = writable(config)
let next: Info
if (!file.endsWith(".jsonc")) {
const existing = parseConfig(before, file)
const merged = mergeDeep(writable(existing), input)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, input)
next = parseConfig(updated, file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
yield* invalidate()
return next
})
return Service.of({
get,
getGlobal,
getConsoleState,
installDependencies,
update,
updateGlobal,
invalidate,
directories,
waitForDependencies,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
)
+68 -33
View File
@@ -1,16 +1,19 @@
import { existsSync } from "fs"
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
import { Config } from "./config"
import { ConfigPaths } from "./paths"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { AppRuntime } from "@/effect/app-runtime"
import { Filesystem } from "@/util/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
@@ -21,13 +24,26 @@ export namespace TuiConfig {
result: Info
}
type State = {
config: Info
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
}
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
plugin_origins?: Config.PluginOrigin[]
}
function pluginScope(file: string): Config.PluginScope {
if (Instance.containsPath(file)) return "local"
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
if (Filesystem.contains(ctx.directory, file)) return "local"
if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
return "global"
}
@@ -51,16 +67,12 @@ export namespace TuiConfig {
}
}
function installDeps(dir: string): Promise<void> {
return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir)))
}
async function mergeFile(acc: Acc, file: string) {
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file)
const scope = pluginScope(file, ctx)
const plugins = Config.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
@@ -69,46 +81,48 @@ export namespace TuiConfig {
acc.result.plugin_origins = plugins
}
const state = Instance.state(async () => {
async function loadState(ctx: { directory: string; worktree: string }) {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
const custom = customPath()
const managed = Config.managedConfigDir()
await migrateTuiConfig({ directories, custom, managed })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
const acc: Acc = {
result: {},
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file)
await mergeFile(acc, file, ctx)
}
if (custom) {
await mergeFile(acc, custom)
await mergeFile(acc, custom, ctx)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
await mergeFile(acc, file)
await mergeFile(acc, file, ctx)
}
for (const dir of unique(directories)) {
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file)
await mergeFile(acc, file, ctx)
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
await mergeFile(acc, file)
await mergeFile(acc, file, ctx)
}
}
@@ -122,27 +136,48 @@ export namespace TuiConfig {
}
acc.result.keybinds = Config.Keybinds.parse(keybinds)
const deps: Promise<void>[] = []
if (acc.result.plugin?.length) {
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
deps.push(installDeps(dir))
}
}
return {
config: acc.result,
deps,
dirs: acc.result.plugin?.length ? dirs : [],
}
})
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const state = yield* InstanceState.make<State>(
Effect.fn("TuiConfig.state")(function* (ctx) {
const data = yield* Effect.promise(() => loadState(ctx))
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
concurrency: "unbounded",
})
return { config: data.config, deps }
}),
)
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
),
)
return Service.of({ get, waitForDependencies })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get() {
return state().then((x) => x.config)
return runPromise((svc) => svc.get())
}
export async function waitForDependencies() {
const deps = await state().then((x) => x.deps)
await Promise.all(deps)
await runPromise((svc) => svc.waitForDependencies())
}
async function loadFile(filepath: string): Promise<Info> {
@@ -1,4 +1,5 @@
import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Worktree } from "@/worktree"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
@@ -12,7 +13,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
const worktree = await Worktree.makeWorktreeInfo(undefined)
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
return {
...info,
name: worktree.name,
@@ -22,15 +23,19 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
},
async create(info) {
const config = WorktreeConfig.parse(info)
await Worktree.createFromInfo({
name: config.name,
directory: config.directory,
branch: config.branch,
})
await AppRuntime.runPromise(
Worktree.Service.use((svc) =>
svc.createFromInfo({
name: config.name,
directory: config.directory,
branch: config.branch,
}),
),
)
},
async remove(info) {
const config = WorktreeConfig.parse(info)
await Worktree.remove({ directory: config.directory })
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
},
target(info) {
const config = WorktreeConfig.parse(info)
+41 -13
View File
@@ -1,28 +1,56 @@
import { Instance } from "../project/instance"
import { Context, Effect, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace Env {
const state = Instance.state(() => {
// Create a shallow copy to isolate environment per instance
// Prevents parallel tests from interfering with each other's env vars
return { ...process.env } as Record<string, string | undefined>
})
type State = Record<string, string | undefined>
export interface Interface {
readonly get: (key: string) => Effect.Effect<string | undefined>
readonly all: () => Effect.Effect<State>
readonly set: (key: string, value: string) => Effect.Effect<void>
readonly remove: (key: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Env") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(Effect.fn("Env.state")(() => Effect.succeed({ ...process.env })))
const get = Effect.fn("Env.get")((key: string) => InstanceState.use(state, (env) => env[key]))
const all = Effect.fn("Env.all")(() => InstanceState.get(state))
const set = Effect.fn("Env.set")(function* (key: string, value: string) {
const env = yield* InstanceState.get(state)
env[key] = value
})
const remove = Effect.fn("Env.remove")(function* (key: string) {
const env = yield* InstanceState.get(state)
delete env[key]
})
return Service.of({ get, all, set, remove })
}),
)
export const defaultLayer = layer
const rt = makeRuntime(Service, defaultLayer)
export function get(key: string) {
const env = state()
return env[key]
return rt.runSync((svc) => svc.get(key))
}
export function all() {
return state()
return rt.runSync((svc) => svc.all())
}
export function set(key: string, value: string) {
const env = state()
env[key] = value
return rt.runSync((svc) => svc.set(key, value))
}
export function remove(key: string) {
const env = state()
delete env[key]
return rt.runSync((svc) => svc.remove(key))
}
}
+5 -1
View File
@@ -330,6 +330,7 @@ export namespace Ripgrep {
glob?: string[]
limit?: number
follow?: boolean
file?: string[]
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
}
@@ -351,6 +352,7 @@ export namespace Ripgrep {
maxDepth?: number
limit?: number
pattern?: string
file?: string[]
}) {
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
if (input.follow) out.push("--follow")
@@ -363,7 +365,7 @@ export namespace Ripgrep {
}
if (input.limit) out.push(`--max-count=${input.limit}`)
if (input.mode === "search") out.push("--no-messages")
if (input.pattern) out.push("--", input.pattern)
if (input.pattern) out.push("--", input.pattern, ...(input.file ?? []))
return out
})
@@ -405,6 +407,7 @@ export namespace Ripgrep {
glob?: string[]
limit?: number
follow?: boolean
file?: string[]
}) {
return yield* Effect.scoped(
Effect.gen(function* () {
@@ -414,6 +417,7 @@ export namespace Ripgrep {
follow: input.follow,
limit: input.limit,
pattern: input.pattern,
file: input.file,
})
const handle = yield* spawner.spawn(
-29
View File
@@ -3,7 +3,6 @@ import z from "zod"
import { Global } from "../global"
import { Effect, Layer, Context } from "effect"
import { AppFileSystem } from "@/filesystem"
import { makeRuntime } from "@/effect/run-service"
export namespace McpAuth {
export const Tokens = z.object({
@@ -142,32 +141,4 @@ export namespace McpAuth {
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
// Async facades for backward compat (used by McpOAuthProvider, CLI)
export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
export const getForUrl = async (mcpName: string, serverUrl: string) =>
runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
export const all = async () => runPromise((svc) => svc.all())
export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
runPromise((svc) => svc.set(mcpName, entry, serverUrl))
export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
export const updateOAuthState = async (mcpName: string, oauthState: string) =>
runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
}
+2
View File
@@ -293,6 +293,7 @@ export namespace MCP {
log.info("oauth redirect requested", { key, url: url.toString() })
},
},
auth,
)
}
@@ -744,6 +745,7 @@ export namespace MCP {
capturedUrl = url
},
},
auth,
)
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
+35 -29
View File
@@ -5,6 +5,7 @@ import type {
OAuthClientInformation,
OAuthClientInformationFull,
} from "@modelcontextprotocol/sdk/shared/auth.js"
import { Effect } from "effect"
import { McpAuth } from "./auth"
import { Log } from "../util/log"
@@ -30,6 +31,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
private serverUrl: string,
private config: McpOAuthConfig,
private callbacks: McpOAuthCallbacks,
private auth: McpAuth.Interface,
) {}
get redirectUrl(): string {
@@ -61,7 +63,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
// Check stored client info (from dynamic registration)
// Use getForUrl to validate credentials are for the current server URL
const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl)
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
if (entry?.clientInfo) {
// Check if client secret has expired
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
@@ -79,15 +81,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
await McpAuth.updateClientInfo(
this.mcpName,
{
clientId: info.client_id,
clientSecret: info.client_secret,
clientIdIssuedAt: info.client_id_issued_at,
clientSecretExpiresAt: info.client_secret_expires_at,
},
this.serverUrl,
await Effect.runPromise(
this.auth.updateClientInfo(
this.mcpName,
{
clientId: info.client_id,
clientSecret: info.client_secret,
clientIdIssuedAt: info.client_id_issued_at,
clientSecretExpiresAt: info.client_secret_expires_at,
},
this.serverUrl,
),
)
log.info("saved dynamically registered client", {
mcpName: this.mcpName,
@@ -97,7 +101,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
async tokens(): Promise<OAuthTokens | undefined> {
// Use getForUrl to validate tokens are for the current server URL
const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl)
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
if (!entry?.tokens) return undefined
return {
@@ -112,15 +116,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
await McpAuth.updateTokens(
this.mcpName,
{
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
scope: tokens.scope,
},
this.serverUrl,
await Effect.runPromise(
this.auth.updateTokens(
this.mcpName,
{
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
scope: tokens.scope,
},
this.serverUrl,
),
)
log.info("saved oauth tokens", { mcpName: this.mcpName })
}
@@ -131,11 +137,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
async saveCodeVerifier(codeVerifier: string): Promise<void> {
await McpAuth.updateCodeVerifier(this.mcpName, codeVerifier)
await Effect.runPromise(this.auth.updateCodeVerifier(this.mcpName, codeVerifier))
}
async codeVerifier(): Promise<string> {
const entry = await McpAuth.get(this.mcpName)
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
if (!entry?.codeVerifier) {
throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`)
}
@@ -143,11 +149,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
async saveState(state: string): Promise<void> {
await McpAuth.updateOAuthState(this.mcpName, state)
await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, state))
}
async state(): Promise<string> {
const entry = await McpAuth.get(this.mcpName)
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
if (entry?.oauthState) {
return entry.oauthState
}
@@ -159,28 +165,28 @@ export class McpOAuthProvider implements OAuthClientProvider {
const newState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
await McpAuth.updateOAuthState(this.mcpName, newState)
await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, newState))
return newState
}
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
log.info("invalidating credentials", { mcpName: this.mcpName, type })
const entry = await McpAuth.get(this.mcpName)
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
if (!entry) {
return
}
switch (type) {
case "all":
await McpAuth.remove(this.mcpName)
await Effect.runPromise(this.auth.remove(this.mcpName))
break
case "client":
delete entry.clientInfo
await McpAuth.set(this.mcpName, entry)
await Effect.runPromise(this.auth.set(this.mcpName, entry))
break
case "tokens":
delete entry.tokens
await McpAuth.set(this.mcpName, entry)
await Effect.runPromise(this.auth.set(this.mcpName, entry))
break
}
}
-15
View File
@@ -2,7 +2,6 @@ import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { ProjectID } from "@/project/schema"
import { Instance } from "@/project/instance"
import { MessageID, SessionID } from "@/session/schema"
@@ -308,18 +307,4 @@ export namespace Permission {
}
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export const { runPromise } = makeRuntime(Service, defaultLayer)
export async function ask(input: z.infer<typeof AskInput>) {
return runPromise((s) => s.ask(input))
}
export async function reply(input: z.infer<typeof ReplyInput>) {
return runPromise((s) => s.reply(input))
}
export async function list() {
return runPromise((s) => s.list())
}
}
-18
View File
@@ -20,7 +20,6 @@ import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cl
import { Effect, Layer, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
@@ -290,21 +289,4 @@ export namespace Plugin {
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function trigger<
Name extends TriggerName,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
return runPromise((svc) => svc.trigger(name, input, output))
}
export async function list(): Promise<Hooks[]> {
return runPromise((svc) => svc.list())
}
export async function init() {
return runPromise((svc) => svc.init())
}
}
+2 -8
View File
@@ -6,7 +6,6 @@ import { Log } from "@/util/log"
import { LocalContext } from "../util/local-context"
import { Project } from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { State } from "./state"
export interface InstanceContext {
directory: string
@@ -21,8 +20,6 @@ const disposal = {
all: undefined as Promise<void> | undefined,
}
function emitDisposed(directory: string) {}
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
return iife(async () => {
const ctx =
@@ -115,13 +112,10 @@ export const Instance = {
restore<R>(ctx: InstanceContext, fn: () => R): R {
return context.provide(ctx, fn)
},
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose)
},
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = Filesystem.resolve(input.directory)
Log.Default.info("reloading instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
await disposeInstance(directory)
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
@@ -143,7 +137,7 @@ export const Instance = {
const directory = Instance.directory
const project = Instance.project
Log.Default.info("disposing instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
await disposeInstance(directory)
cache.delete(directory)
GlobalBus.emit("event", {
-70
View File
@@ -1,70 +0,0 @@
import { Log } from "@/util/log"
export namespace State {
interface Entry {
state: any
dispose?: (state: any) => Promise<void>
}
const log = Log.create({ service: "state" })
const recordsByKey = new Map<string, Map<any, Entry>>()
export function create<S>(root: () => string, init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) {
return () => {
const key = root()
let entries = recordsByKey.get(key)
if (!entries) {
entries = new Map<string, Entry>()
recordsByKey.set(key, entries)
}
const exists = entries.get(init)
if (exists) return exists.state as S
const state = init()
entries.set(init, {
state,
dispose,
})
return state
}
}
export async function dispose(key: string) {
const entries = recordsByKey.get(key)
if (!entries) return
log.info("waiting for state disposal to complete", { key })
let disposalFinished = false
setTimeout(() => {
if (!disposalFinished) {
log.warn(
"state disposal is taking an unusually long time - if it does not complete in a reasonable time, please report this as a bug",
{ key },
)
}
}, 10000).unref()
const tasks: Promise<void>[] = []
for (const [init, entry] of entries) {
if (!entry.dispose) continue
const label = typeof init === "function" ? init.name : String(init)
const task = Promise.resolve(entry.state)
.then((state) => entry.dispose!(state))
.catch((error) => {
log.error("Error while disposing state:", { error, key, init: label })
})
tasks.push(task)
}
await Promise.all(tasks)
entries.clear()
recordsByKey.delete(key)
disposalFinished = true
log.info("state disposal completed", { key })
}
}
+640 -644
View File
@@ -116,12 +116,6 @@ export namespace Provider {
})
}
function e2eURL() {
const url = Env.get("OPENCODE_E2E_LLM_URL")
if (typeof url !== "string" || url === "") return
return url
}
type BundledSDK = {
languageModel(modelId: string): LanguageModelV3
}
@@ -166,6 +160,8 @@ export namespace Provider {
type CustomDep = {
auth: (id: string) => Effect.Effect<Auth.Info | undefined>
config: () => Effect.Effect<Config.Info>
env: () => Effect.Effect<Record<string, string | undefined>>
get: (key: string) => Effect.Effect<string | undefined>
}
function useLanguageModel(sdk: any) {
@@ -184,7 +180,7 @@ export namespace Provider {
},
}),
opencode: Effect.fnUntraced(function* (input: Info) {
const env = Env.all()
const env = yield* dep.env()
const hasKey = iife(() => {
if (input.env.some((item) => env[item])) return true
return false
@@ -231,14 +227,15 @@ export namespace Provider {
},
options: {},
}),
azure: (provider) => {
azure: Effect.fnUntraced(function* (provider: Info) {
const env = yield* dep.env()
const resource = iife(() => {
const name = provider.options?.resourceName
if (typeof name === "string" && name.trim() !== "") return name
return Env.get("AZURE_RESOURCE_NAME")
return env["AZURE_RESOURCE_NAME"]
})
return Effect.succeed({
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
@@ -254,11 +251,11 @@ export namespace Provider {
...(resource && { AZURE_RESOURCE_NAME: resource }),
}
},
})
},
"azure-cognitive-services": () => {
const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
return Effect.succeed({
}
}),
"azure-cognitive-services": Effect.fnUntraced(function* () {
const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
@@ -271,23 +268,24 @@ export namespace Provider {
options: {
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
},
})
},
}
}),
"amazon-bedrock": Effect.fnUntraced(function* () {
const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"]
const auth = yield* dep.auth("amazon-bedrock")
const env = yield* dep.env()
// Region precedence: 1) config file, 2) env var, 3) default
const configRegion = providerConfig?.options?.region
const envRegion = Env.get("AWS_REGION")
const envRegion = env["AWS_REGION"]
const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
// Profile: config file takes precedence over env var
const configProfile = providerConfig?.options?.profile
const envProfile = Env.get("AWS_PROFILE")
const envProfile = env["AWS_PROFILE"]
const profile = configProfile ?? envProfile
const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
const awsAccessKeyId = env["AWS_ACCESS_KEY_ID"]
// TODO: Using process.env directly because Env.set only updates a process.env shallow copy,
// until the scope of the Env API is clarified (test only or runtime?)
@@ -301,7 +299,7 @@ export namespace Provider {
return undefined
})
const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
const awsWebIdentityTokenFile = env["AWS_WEB_IDENTITY_TOKEN_FILE"]
const containerCreds = Boolean(
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
@@ -439,24 +437,22 @@ export namespace Provider {
},
},
}),
"google-vertex": (provider) => {
"google-vertex": Effect.fnUntraced(function* (provider: Info) {
const env = yield* dep.env()
const project =
provider.options?.project ??
Env.get("GOOGLE_CLOUD_PROJECT") ??
Env.get("GCP_PROJECT") ??
Env.get("GCLOUD_PROJECT")
provider.options?.project ?? env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
const location = String(
provider.options?.location ??
Env.get("GOOGLE_VERTEX_LOCATION") ??
Env.get("GOOGLE_CLOUD_LOCATION") ??
Env.get("VERTEX_LOCATION") ??
env["GOOGLE_VERTEX_LOCATION"] ??
env["GOOGLE_CLOUD_LOCATION"] ??
env["VERTEX_LOCATION"] ??
"us-central1",
)
const autoload = Boolean(project)
if (!autoload) return Effect.succeed({ autoload: false })
return Effect.succeed({
if (!autoload) return { autoload: false }
return {
autoload: true,
vars(_options: Record<string, any>) {
const endpoint =
@@ -485,14 +481,15 @@ export namespace Provider {
const id = String(modelID).trim()
return sdk.languageModel(id)
},
})
},
"google-vertex-anthropic": () => {
const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "global"
}
}),
"google-vertex-anthropic": Effect.fnUntraced(function* () {
const env = yield* dep.env()
const project = env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "global"
const autoload = Boolean(project)
if (!autoload) return Effect.succeed({ autoload: false })
return Effect.succeed({
if (!autoload) return { autoload: false }
return {
autoload: true,
options: {
project,
@@ -502,8 +499,8 @@ export namespace Provider {
const id = String(modelID).trim()
return sdk.languageModel(id)
},
})
},
}
}),
"sap-ai-core": Effect.fnUntraced(function* () {
const auth = yield* dep.auth("sap-ai-core")
// TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env),
@@ -539,14 +536,15 @@ export namespace Provider {
},
}),
gitlab: Effect.fnUntraced(function* (input: Info) {
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com"
const auth = yield* dep.auth(input.id)
const apiKey = yield* Effect.sync(() => {
if (auth?.type === "oauth") return auth.access
if (auth?.type === "api") return auth.key
return Env.get("GITLAB_TOKEN")
return undefined
})
const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN"))
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
@@ -563,10 +561,10 @@ export namespace Provider {
}
return {
autoload: !!apiKey,
autoload: !!token,
options: {
instanceUrl,
apiKey,
apiKey: token,
aiGatewayHeaders,
featureFlags,
},
@@ -681,8 +679,8 @@ export namespace Provider {
if (input.options?.baseURL) return { autoload: false }
const auth = yield* dep.auth(input.id)
const accountId =
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
const env = yield* dep.env()
const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
if (!accountId)
return {
autoload: false,
@@ -694,7 +692,7 @@ export namespace Provider {
}
const apiKey = yield* Effect.gen(function* () {
const envToken = Env.get("CLOUDFLARE_API_KEY")
const envToken = env["CLOUDFLARE_API_KEY"]
if (envToken) return envToken
if (auth?.type === "api") return auth.key
return undefined
@@ -723,10 +721,9 @@ export namespace Provider {
if (input.options?.baseURL) return { autoload: false }
const auth = yield* dep.auth(input.id)
const accountId =
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
const gateway =
Env.get("CLOUDFLARE_GATEWAY_ID") || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
const env = yield* dep.env()
const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
const gateway = env["CLOUDFLARE_GATEWAY_ID"] || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
if (!accountId || !gateway) {
const missing = [
@@ -745,7 +742,7 @@ export namespace Provider {
// Get API token from env or auth - required for authenticated gateways
const apiToken = yield* Effect.gen(function* () {
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
const envToken = env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"]
if (envToken) return envToken
if (auth?.type === "api") return auth.key
return undefined
@@ -1030,662 +1027,661 @@ export namespace Provider {
}
}
const layer: Layer.Layer<Service, never, Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const config = yield* Config.Service
const auth = yield* Auth.Service
const plugin = yield* Plugin.Service
const layer: Layer.Layer<
Service,
never,
Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const config = yield* Config.Service
const auth = yield* Auth.Service
const env = yield* Env.Service
const plugin = yield* Plugin.Service
const state = yield* InstanceState.make<State>(() =>
Effect.gen(function* () {
using _ = log.time("state")
const cfg = yield* config.get()
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
const database = mapValues(modelsDev, fromModelsDevProvider)
const state = yield* InstanceState.make<State>(() =>
Effect.gen(function* () {
using _ = log.time("state")
const cfg = yield* config.get()
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
const database = mapValues(modelsDev, fromModelsDevProvider)
const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
const languages = new Map<string, LanguageModelV3>()
const modelLoaders: {
[providerID: string]: CustomModelLoader
} = {}
const varsLoaders: {
[providerID: string]: CustomVarsLoader
} = {}
const sdk = new Map<string, BundledSDK>()
const discoveryLoaders: {
[providerID: string]: CustomDiscoverModels
} = {}
const dep = {
auth: (id: string) => auth.get(id).pipe(Effect.orDie),
config: () => config.get(),
}
const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
const languages = new Map<string, LanguageModelV3>()
const modelLoaders: {
[providerID: string]: CustomModelLoader
} = {}
const varsLoaders: {
[providerID: string]: CustomVarsLoader
} = {}
const sdk = new Map<string, BundledSDK>()
const discoveryLoaders: {
[providerID: string]: CustomDiscoverModels
} = {}
const dep = {
auth: (id: string) => auth.get(id).pipe(Effect.orDie),
config: () => config.get(),
env: () => env.all(),
get: (key: string) => env.get(key),
}
log.info("init")
log.info("init")
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
const existing = providers[providerID]
if (existing) {
// @ts-expect-error
providers[providerID] = mergeDeep(existing, provider)
return
}
const match = database[providerID]
if (!match) return
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
const existing = providers[providerID]
if (existing) {
// @ts-expect-error
providers[providerID] = mergeDeep(match, provider)
providers[providerID] = mergeDeep(existing, provider)
return
}
const match = database[providerID]
if (!match) return
// @ts-expect-error
providers[providerID] = mergeDeep(match, provider)
}
// load plugins first so config() hook runs before reading cfg.provider
const plugins = yield* plugin.list()
// now read config providers - includes any modifications from plugin config() hook
const configProviders = Object.entries(cfg.provider ?? {})
const disabled = new Set(cfg.disabled_providers ?? [])
const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null
function isProviderAllowed(providerID: ProviderID): boolean {
if (enabled && !enabled.has(providerID)) return false
if (disabled.has(providerID)) return false
return true
}
// extend database from config
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
const parsed: Info = {
id: ProviderID.make(providerID),
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
source: "config",
models: existing?.models ?? {},
}
// load plugins first so config() hook runs before reading cfg.provider
const plugins = yield* plugin.list()
// now read config providers - includes any modifications from plugin config() hook
const configProviders = Object.entries(cfg.provider ?? {})
const disabled = new Set(cfg.disabled_providers ?? [])
const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null
function isProviderAllowed(providerID: ProviderID): boolean {
if (enabled && !enabled.has(providerID)) return false
if (disabled.has(providerID)) return false
return true
}
// extend database from config
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
const parsed: Info = {
id: ProviderID.make(providerID),
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
source: "config",
models: existing?.models ?? {},
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
const existingModel = parsed.models[model.id ?? modelID]
const name = iife(() => {
if (model.name) return model.name
if (model.id && model.id !== modelID) return modelID
return existingModel?.name ?? modelID
})
const parsedModel: Model = {
id: ModelID.make(modelID),
api: {
id: model.id ?? existingModel?.api.id ?? modelID,
npm:
model.provider?.npm ??
provider.npm ??
existingModel?.api.npm ??
modelsDev[providerID]?.npm ??
"@ai-sdk/openai-compatible",
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
},
status: model.status ?? existingModel?.status ?? "active",
name,
providerID: ProviderID.make(providerID),
capabilities: {
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
input: {
text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
audio:
model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
image:
model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
video:
model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
},
output: {
text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
audio:
model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
image:
model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
video:
model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
},
interleaved: model.interleaved ?? false,
},
cost: {
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
cache: {
read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0,
write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
},
},
options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
limit: {
context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
input: model.limit?.input ?? existingModel?.limit?.input,
output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
},
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
family: model.family ?? existingModel?.family ?? "",
release_date: model.release_date ?? existingModel?.release_date ?? "",
variants: {},
}
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
const existingModel = parsed.models[model.id ?? modelID]
const name = iife(() => {
if (model.name) return model.name
if (model.id && model.id !== modelID) return modelID
return existingModel?.name ?? modelID
})
const parsedModel: Model = {
id: ModelID.make(modelID),
api: {
id: model.id ?? existingModel?.api.id ?? modelID,
npm:
model.provider?.npm ??
provider.npm ??
existingModel?.api.npm ??
modelsDev[providerID]?.npm ??
"@ai-sdk/openai-compatible",
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
},
status: model.status ?? existingModel?.status ?? "active",
name,
providerID: ProviderID.make(providerID),
capabilities: {
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
input: {
text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
audio:
model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
image:
model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
video:
model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
},
output: {
text:
model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
audio:
model.modalities?.output?.includes("audio") ??
existingModel?.capabilities.output.audio ??
false,
image:
model.modalities?.output?.includes("image") ??
existingModel?.capabilities.output.image ??
false,
video:
model.modalities?.output?.includes("video") ??
existingModel?.capabilities.output.video ??
false,
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
},
interleaved: model.interleaved ?? false,
},
cost: {
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
cache: {
read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0,
write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
},
},
options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
limit: {
context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
input: model.limit?.input ?? existingModel?.limit?.input,
output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
},
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
family: model.family ?? existingModel?.family ?? "",
release_date: model.release_date ?? existingModel?.release_date ?? "",
variants: {},
}
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
parsedModel.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
)
parsed.models[modelID] = parsedModel
}
database[providerID] = parsed
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
parsedModel.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
)
parsed.models[modelID] = parsedModel
}
database[providerID] = parsed
}
// load env
const env = Env.all()
for (const [id, provider] of Object.entries(database)) {
const providerID = ProviderID.make(id)
if (disabled.has(providerID)) continue
const apiKey = provider.env.map((item) => env[item]).find(Boolean)
if (!apiKey) continue
// load env
const envs = yield* env.all()
for (const [id, provider] of Object.entries(database)) {
const providerID = ProviderID.make(id)
if (disabled.has(providerID)) continue
const apiKey = provider.env.map((item) => envs[item]).find(Boolean)
if (!apiKey) continue
mergeProvider(providerID, {
source: "env",
key: provider.env.length === 1 ? apiKey : undefined,
})
}
// load apikeys
const auths = yield* auth.all().pipe(Effect.orDie)
for (const [id, provider] of Object.entries(auths)) {
const providerID = ProviderID.make(id)
if (disabled.has(providerID)) continue
if (provider.type === "api") {
mergeProvider(providerID, {
source: "env",
key: provider.env.length === 1 ? apiKey : undefined,
source: "api",
key: provider.key,
})
}
}
// load apikeys
const auths = yield* auth.all().pipe(Effect.orDie)
for (const [id, provider] of Object.entries(auths)) {
const providerID = ProviderID.make(id)
if (disabled.has(providerID)) continue
if (provider.type === "api") {
mergeProvider(providerID, {
source: "api",
key: provider.key,
})
}
// plugin auth loader - database now has entries for config providers
for (const plugin of plugins) {
if (!plugin.auth) continue
const providerID = ProviderID.make(plugin.auth.provider)
if (disabled.has(providerID)) continue
const stored = yield* auth.get(providerID).pipe(Effect.orDie)
if (!stored) continue
if (!plugin.auth.loader) continue
const options = yield* Effect.promise(() =>
plugin.auth!.loader!(
() =>
Effect.runPromise(auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer))) as any,
database[plugin.auth!.provider],
),
)
const opts = options ?? {}
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
mergeProvider(providerID, patch)
}
for (const [id, fn] of Object.entries(custom(dep))) {
const providerID = ProviderID.make(id)
if (disabled.has(providerID)) continue
const data = database[providerID]
if (!data) {
log.error("Provider does not exist in model list " + providerID)
continue
}
// plugin auth loader - database now has entries for config providers
for (const plugin of plugins) {
if (!plugin.auth) continue
const providerID = ProviderID.make(plugin.auth.provider)
if (disabled.has(providerID)) continue
const stored = yield* auth.get(providerID).pipe(Effect.orDie)
if (!stored) continue
if (!plugin.auth.loader) continue
const options = yield* Effect.promise(() =>
plugin.auth!.loader!(
() =>
Effect.runPromise(
auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer)),
) as any,
database[plugin.auth!.provider],
),
)
const opts = options ?? {}
const result = yield* fn(data)
if (result && (result.autoload || providers[providerID])) {
if (result.getModel) modelLoaders[providerID] = result.getModel
if (result.vars) varsLoaders[providerID] = result.vars
if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
const opts = result.options ?? {}
const patch: Partial<Info> = providers[providerID]
? { options: opts }
: { source: "custom", options: opts }
mergeProvider(providerID, patch)
}
}
for (const [id, fn] of Object.entries(custom(dep))) {
const providerID = ProviderID.make(id)
if (disabled.has(providerID)) continue
const data = database[providerID]
if (!data) {
log.error("Provider does not exist in model list " + providerID)
continue
}
const result = yield* fn(data)
if (result && (result.autoload || providers[providerID])) {
if (result.getModel) modelLoaders[providerID] = result.getModel
if (result.vars) varsLoaders[providerID] = result.vars
if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
const opts = result.options ?? {}
const patch: Partial<Info> = providers[providerID]
? { options: opts }
: { source: "custom", options: opts }
mergeProvider(providerID, patch)
}
}
// load config - re-apply with updated data
for (const [id, provider] of configProviders) {
const providerID = ProviderID.make(id)
const partial: Partial<Info> = { source: "config" }
if (provider.env) partial.env = provider.env
if (provider.name) partial.name = provider.name
if (provider.options) partial.options = provider.options
mergeProvider(providerID, partial)
}
// load config - re-apply with updated data
for (const [id, provider] of configProviders) {
const providerID = ProviderID.make(id)
const partial: Partial<Info> = { source: "config" }
if (provider.env) partial.env = provider.env
if (provider.name) partial.name = provider.name
if (provider.options) partial.options = provider.options
mergeProvider(providerID, partial)
}
const gitlab = ProviderID.make("gitlab")
if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) {
yield* Effect.promise(async () => {
try {
const discovered = await discoveryLoaders[gitlab]()
for (const [modelID, model] of Object.entries(discovered)) {
if (!providers[gitlab].models[modelID]) {
providers[gitlab].models[modelID] = model
}
const gitlab = ProviderID.make("gitlab")
if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) {
yield* Effect.promise(async () => {
try {
const discovered = await discoveryLoaders[gitlab]()
for (const [modelID, model] of Object.entries(discovered)) {
if (!providers[gitlab].models[modelID]) {
providers[gitlab].models[modelID] = model
}
} catch (e) {
log.warn("state discovery error", { id: "gitlab", error: e })
}
})
}
for (const hook of plugins) {
const p = hook.provider
const models = p?.models
if (!p || !models) continue
const providerID = ProviderID.make(p.id)
if (disabled.has(providerID)) continue
const provider = providers[providerID]
if (!provider) continue
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
provider.models = yield* Effect.promise(async () => {
const next = await models(provider, { auth: pluginAuth })
return Object.fromEntries(
Object.entries(next).map(([id, model]) => [
id,
{
...model,
id: ModelID.make(id),
providerID,
},
]),
)
})
}
for (const [id, provider] of Object.entries(providers)) {
const providerID = ProviderID.make(id)
if (!isProviderAllowed(providerID)) {
delete providers[providerID]
continue
} catch (e) {
log.warn("state discovery error", { id: "gitlab", error: e })
}
})
}
const configProvider = cfg.provider?.[providerID]
for (const hook of plugins) {
const p = hook.provider
const models = p?.models
if (!p || !models) continue
for (const [modelID, model] of Object.entries(provider.models)) {
model.api.id = model.api.id ?? model.id ?? modelID
if (
modelID === "gpt-5-chat-latest" ||
(providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
)
delete provider.models[modelID]
if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS)
delete provider.models[modelID]
if (model.status === "deprecated") delete provider.models[modelID]
if (
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
(configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
)
delete provider.models[modelID]
const providerID = ProviderID.make(p.id)
if (disabled.has(providerID)) continue
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
const provider = providers[providerID]
if (!provider) continue
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
const configVariants = configProvider?.models?.[modelID]?.variants
if (configVariants && model.variants) {
const merged = mergeDeep(model.variants, configVariants)
model.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
)
}
}
provider.models = yield* Effect.promise(async () => {
const next = await models(provider, { auth: pluginAuth })
return Object.fromEntries(
Object.entries(next).map(([id, model]) => [
id,
{
...model,
id: ModelID.make(id),
providerID,
},
]),
)
})
}
if (Object.keys(provider.models).length === 0) {
delete providers[providerID]
continue
}
log.info("found", { providerID })
for (const [id, provider] of Object.entries(providers)) {
const providerID = ProviderID.make(id)
if (!isProviderAllowed(providerID)) {
delete providers[providerID]
continue
}
return {
models: languages,
providers,
sdk,
modelLoaders,
varsLoaders,
const configProvider = cfg.provider?.[providerID]
for (const [modelID, model] of Object.entries(provider.models)) {
model.api.id = model.api.id ?? model.id ?? modelID
if (
modelID === "gpt-5-chat-latest" ||
(providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
)
delete provider.models[modelID]
if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
if (model.status === "deprecated") delete provider.models[modelID]
if (
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
(configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
)
delete provider.models[modelID]
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
const configVariants = configProvider?.models?.[modelID]?.variants
if (configVariants && model.variants) {
const merged = mergeDeep(model.variants, configVariants)
model.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
)
}
}
}),
)
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
if (Object.keys(provider.models).length === 0) {
delete providers[providerID]
continue
}
async function resolveSDK(model: Model, s: State) {
try {
using _ = log.time("getSDK", {
log.info("found", { providerID })
}
return {
models: languages,
providers,
sdk,
modelLoaders,
varsLoaders,
}
}),
)
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
try {
using _ = log.time("getSDK", {
providerID: model.providerID,
})
const provider = s.providers[model.providerID]
const options = { ...provider.options }
if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
delete options.fetch
}
if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
options["includeUsage"] = true
}
const baseURL = iife(() => {
let url =
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
if (!url) return
const loader = s.varsLoaders[model.providerID]
if (loader) {
const vars = loader(options)
for (const [key, value] of Object.entries(vars)) {
const field = "${" + key + "}"
url = url.replaceAll(field, value)
}
}
url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
const val = envs[String(key)]
return val ?? item
})
return url
})
if (baseURL !== undefined) options["baseURL"] = baseURL
if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
if (model.headers)
options["headers"] = {
...options["headers"],
...model.headers,
}
const key = Hash.fast(
JSON.stringify({
providerID: model.providerID,
})
const provider = s.providers[model.providerID]
const options = { ...provider.options }
npm: model.api.npm,
options,
}),
)
const existing = s.sdk.get(key)
if (existing) return existing
if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
delete options.fetch
}
const customFetch = options["fetch"]
const chunkTimeout = options["chunkTimeout"]
delete options["chunkTimeout"]
if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
options["includeUsage"] = true
}
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
const fetchFn = customFetch ?? fetch
const opts = init ?? {}
const chunkAbortCtl =
typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
const signals: AbortSignal[] = []
const baseURL = iife(() => {
let url =
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
if (!url) return
if (opts.signal) signals.push(opts.signal)
if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
signals.push(AbortSignal.timeout(options["timeout"]))
const loader = s.varsLoaders[model.providerID]
if (loader) {
const vars = loader(options)
for (const [key, value] of Object.entries(vars)) {
const field = "${" + key + "}"
url = url.replaceAll(field, value)
}
}
const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
if (combined) opts.signal = combined
url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
const val = Env.get(String(key))
return val ?? item
})
return url
})
if (baseURL !== undefined) options["baseURL"] = baseURL
if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
if (model.headers)
options["headers"] = {
...options["headers"],
...model.headers,
}
const key = Hash.fast(
JSON.stringify({
providerID: model.providerID,
npm: model.api.npm,
options,
}),
)
const existing = s.sdk.get(key)
if (existing) return existing
const customFetch = options["fetch"]
const chunkTimeout = options["chunkTimeout"]
delete options["chunkTimeout"]
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
const fetchFn = customFetch ?? fetch
const opts = init ?? {}
const chunkAbortCtl =
typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
const signals: AbortSignal[] = []
if (opts.signal) signals.push(opts.signal)
if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
signals.push(AbortSignal.timeout(options["timeout"]))
const combined =
signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
if (combined) opts.signal = combined
// Strip openai itemId metadata following what codex does
if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
const body = JSON.parse(opts.body as string)
const isAzure = model.providerID.includes("azure")
const keepIds = isAzure && body.store === true
if (!keepIds && Array.isArray(body.input)) {
for (const item of body.input) {
if ("id" in item) {
delete item.id
}
// Strip openai itemId metadata following what codex does
if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
const body = JSON.parse(opts.body as string)
const isAzure = model.providerID.includes("azure")
const keepIds = isAzure && body.store === true
if (!keepIds && Array.isArray(body.input)) {
for (const item of body.input) {
if ("id" in item) {
delete item.id
}
opts.body = JSON.stringify(body)
}
opts.body = JSON.stringify(body)
}
const res = await fetchFn(input, {
...opts,
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
timeout: false,
})
if (!chunkAbortCtl) return res
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
}
const bundledFn = BUNDLED_PROVIDERS[model.api.npm]
if (bundledFn) {
log.info("using bundled provider", {
providerID: model.providerID,
pkg: model.api.npm,
})
const loaded = bundledFn({
name: model.providerID,
...options,
})
s.sdk.set(key, loaded)
return loaded as SDK
}
const res = await fetchFn(input, {
...opts,
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
timeout: false,
})
let installedPath: string
if (!model.api.npm.startsWith("file://")) {
const item = await Npm.add(model.api.npm)
if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`)
installedPath = item.entrypoint
} else {
log.info("loading local provider", { pkg: model.api.npm })
installedPath = model.api.npm
}
if (!chunkAbortCtl) return res
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
}
const mod = await import(installedPath)
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn({
const bundledFn = BUNDLED_PROVIDERS[model.api.npm]
if (bundledFn) {
log.info("using bundled provider", {
providerID: model.providerID,
pkg: model.api.npm,
})
const loaded = bundledFn({
name: model.providerID,
...options,
})
s.sdk.set(key, loaded)
return loaded as SDK
}
let installedPath: string
if (!model.api.npm.startsWith("file://")) {
const item = await Npm.add(model.api.npm)
if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`)
installedPath = item.entrypoint
} else {
log.info("loading local provider", { pkg: model.api.npm })
installedPath = model.api.npm
}
const mod = await import(installedPath)
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn({
name: model.providerID,
...options,
})
s.sdk.set(key, loaded)
return loaded as SDK
} catch (e) {
throw new InitError({ providerID: model.providerID }, { cause: e })
}
}
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
InstanceState.use(state, (s) => s.providers[providerID]),
)
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
const s = yield* InstanceState.get(state)
const provider = s.providers[providerID]
if (!provider) {
const available = Object.keys(s.providers)
const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 })
throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
}
const info = provider.models[modelID]
if (!info) {
const available = Object.keys(provider.models)
const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 })
throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
}
return info
})
const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
const s = yield* InstanceState.get(state)
const envs = yield* env.all()
const key = `${model.providerID}/${model.id}`
if (s.models.has(key)) return s.models.get(key)!
return yield* Effect.promise(async () => {
const url = (() => {
const item = envs["OPENCODE_E2E_LLM_URL"]
if (typeof item !== "string" || item === "") return
return item
})()
if (url) {
const language = createOpenAICompatible({
name: model.providerID,
apiKey: "test-key",
baseURL: url,
}).chatModel(model.api.id)
s.models.set(key, language)
return language
}
const provider = s.providers[model.providerID]
const sdk = await resolveSDK(model, s, envs)
try {
const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, {
...provider.options,
...model.options,
})
: sdk.languageModel(model.api.id)
s.models.set(key, language)
return language
} catch (e) {
throw new InitError({ providerID: model.providerID }, { cause: e })
if (e instanceof NoSuchModelError)
throw new ModelNotFoundError(
{
modelID: model.id,
providerID: model.providerID,
},
{ cause: e },
)
throw e
}
})
})
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
const s = yield* InstanceState.get(state)
const provider = s.providers[providerID]
if (!provider) return undefined
for (const item of query) {
for (const modelID of Object.keys(provider.models)) {
if (modelID.includes(item)) return { providerID, modelID }
}
}
return undefined
})
const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) {
const cfg = yield* config.get()
if (cfg.small_model) {
const parsed = parseModel(cfg.small_model)
return yield* getModel(parsed.providerID, parsed.modelID)
}
const s = yield* InstanceState.get(state)
const provider = s.providers[providerID]
if (!provider) return undefined
let priority = [
"claude-haiku-4-5",
"claude-haiku-4.5",
"3-5-haiku",
"3.5-haiku",
"gemini-3-flash",
"gemini-2.5-flash",
"gpt-5-nano",
]
if (providerID.startsWith("opencode")) {
priority = ["gpt-5-nano"]
}
if (providerID.startsWith("github-copilot")) {
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
}
for (const item of priority) {
if (providerID === ProviderID.amazonBedrock) {
const crossRegionPrefixes = ["global.", "us.", "eu."]
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
const globalMatch = candidates.find((m) => m.startsWith("global."))
if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch))
const region = provider.options?.region
if (region) {
const regionPrefix = region.split("-")[0]
if (regionPrefix === "us" || regionPrefix === "eu") {
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch))
}
}
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed))
} else {
for (const model of Object.keys(provider.models)) {
if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model))
}
}
}
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
InstanceState.use(state, (s) => s.providers[providerID]),
return undefined
})
const defaultModel = Effect.fn("Provider.defaultModel")(function* () {
const cfg = yield* config.get()
if (cfg.model) return parseModel(cfg.model)
const s = yield* InstanceState.get(state)
const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => {
if (!isRecord(x) || !Array.isArray(x.recent)) return []
return x.recent.flatMap((item) => {
if (!isRecord(item)) return []
if (typeof item.providerID !== "string") return []
if (typeof item.modelID !== "string") return []
return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }]
})
}),
Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])),
)
for (const entry of recent) {
const provider = s.providers[entry.providerID]
if (!provider) continue
if (!provider.models[entry.modelID]) continue
return { providerID: entry.providerID, modelID: entry.modelID }
}
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
const s = yield* InstanceState.get(state)
const provider = s.providers[providerID]
if (!provider) {
const available = Object.keys(s.providers)
const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 })
throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
}
const provider = Object.values(s.providers).find(
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id),
)
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.models))
if (!model) throw new Error("no models found")
return {
providerID: provider.id,
modelID: model.id,
}
})
const info = provider.models[modelID]
if (!info) {
const available = Object.keys(provider.models)
const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 })
throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
}
return info
})
const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
const s = yield* InstanceState.get(state)
const key = `${model.providerID}/${model.id}`
if (s.models.has(key)) return s.models.get(key)!
return yield* Effect.promise(async () => {
const url = e2eURL()
if (url) {
const language = createOpenAICompatible({
name: model.providerID,
apiKey: "test-key",
baseURL: url,
}).chatModel(model.api.id)
s.models.set(key, language)
return language
}
const provider = s.providers[model.providerID]
const sdk = await resolveSDK(model, s)
try {
const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, {
...provider.options,
...model.options,
})
: sdk.languageModel(model.api.id)
s.models.set(key, language)
return language
} catch (e) {
if (e instanceof NoSuchModelError)
throw new ModelNotFoundError(
{
modelID: model.id,
providerID: model.providerID,
},
{ cause: e },
)
throw e
}
})
})
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
const s = yield* InstanceState.get(state)
const provider = s.providers[providerID]
if (!provider) return undefined
for (const item of query) {
for (const modelID of Object.keys(provider.models)) {
if (modelID.includes(item)) return { providerID, modelID }
}
}
return undefined
})
const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) {
const cfg = yield* config.get()
if (cfg.small_model) {
const parsed = parseModel(cfg.small_model)
return yield* getModel(parsed.providerID, parsed.modelID)
}
const s = yield* InstanceState.get(state)
const provider = s.providers[providerID]
if (!provider) return undefined
let priority = [
"claude-haiku-4-5",
"claude-haiku-4.5",
"3-5-haiku",
"3.5-haiku",
"gemini-3-flash",
"gemini-2.5-flash",
"gpt-5-nano",
]
if (providerID.startsWith("opencode")) {
priority = ["gpt-5-nano"]
}
if (providerID.startsWith("github-copilot")) {
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
}
for (const item of priority) {
if (providerID === ProviderID.amazonBedrock) {
const crossRegionPrefixes = ["global.", "us.", "eu."]
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
const globalMatch = candidates.find((m) => m.startsWith("global."))
if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch))
const region = provider.options?.region
if (region) {
const regionPrefix = region.split("-")[0]
if (regionPrefix === "us" || regionPrefix === "eu") {
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch))
}
}
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed))
} else {
for (const model of Object.keys(provider.models)) {
if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model))
}
}
}
return undefined
})
const defaultModel = Effect.fn("Provider.defaultModel")(function* () {
const cfg = yield* config.get()
if (cfg.model) return parseModel(cfg.model)
const s = yield* InstanceState.get(state)
const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => {
if (!isRecord(x) || !Array.isArray(x.recent)) return []
return x.recent.flatMap((item) => {
if (!isRecord(item)) return []
if (typeof item.providerID !== "string") return []
if (typeof item.modelID !== "string") return []
return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }]
})
}),
Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])),
)
for (const entry of recent) {
const provider = s.providers[entry.providerID]
if (!provider) continue
if (!provider.models[entry.modelID]) continue
return { providerID: entry.providerID, modelID: entry.modelID }
}
const provider = Object.values(s.providers).find(
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id),
)
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.models))
if (!model) throw new Error("no models found")
return {
providerID: provider.id,
modelID: model.id,
}
})
return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel })
}),
)
return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel })
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Plugin.defaultLayer),
+10 -1
View File
@@ -832,7 +832,16 @@ export namespace ProviderTransform {
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
result["reasoningSummary"] = "auto"
// Only inject reasoningSummary for providers that support it natively.
// @ai-sdk/openai-compatible proxies (e.g. LiteLLM) do not understand this
// parameter and return "Unknown parameter: 'reasoningSummary'".
if (
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/azure" ||
input.model.api.npm === "@ai-sdk/github-copilot"
) {
result["reasoningSummary"] = "auto"
}
}
// Only set textVerbosity for non-chat gpt-5.x models
@@ -1,8 +1,10 @@
import z from "zod"
import { Hono } from "hono"
import { describeRoute, resolver } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import { Log } from "@/util/log"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { Bus } from "@/bus"
import { AsyncQueue } from "../../util/queue"
@@ -20,7 +22,11 @@ export const EventRoutes = () =>
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(BusEvent.payloads()),
schema: resolver(
z.union(BusEvent.payloads()).meta({
ref: "Event",
}),
),
},
},
},
@@ -254,7 +254,7 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", Worktree.CreateInput.optional()),
async (c) => {
const body = c.req.valid("json")
const worktree = await Worktree.create(body)
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body)))
return c.json(worktree)
},
)
@@ -301,7 +301,7 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", Worktree.RemoveInput),
async (c) => {
const body = c.req.valid("json")
await Worktree.remove(body)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
await Project.removeSandbox(Instance.project.id, body.directory)
return c.json(true)
},
@@ -327,7 +327,7 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", Worktree.ResetInput),
async (c) => {
const body = c.req.valid("json")
await Worktree.reset(body)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
return c.json(true)
},
)
@@ -109,7 +109,7 @@ export const GlobalRoutes = lazy(() =>
directory: z.string(),
project: z.string().optional(),
workspace: z.string().optional(),
payload: BusEvent.payloads(),
payload: z.union([...BusEvent.payloads(), ...SyncEvent.payloads()]),
})
.meta({
ref: "GlobalEvent",
@@ -135,52 +135,6 @@ export const GlobalRoutes = lazy(() =>
})
},
)
.get(
"/sync-event",
describeRoute({
summary: "Subscribe to global sync events",
description: "Get global sync events",
operationId: "global.sync-event.subscribe",
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(
z
.object({
payload: SyncEvent.payloads(),
})
.meta({
ref: "SyncEvent",
}),
),
},
},
},
},
}),
async (c) => {
log.info("global sync event connected")
c.header("Cache-Control", "no-cache, no-transform")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamEvents(c, (q) => {
return SyncEvent.subscribeAll(({ def, event }) => {
// TODO: don't pass def, just pass the type (and it should
// be versioned)
q.push(
JSON.stringify({
payload: {
...event,
type: SyncEvent.versionedType(def.type, def.version),
},
}),
)
})
})
},
)
.get(
"/config",
describeRoute({
@@ -207,7 +207,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
},
}),
async (c) => {
const modes = await Agent.list()
const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
return c.json(modes)
},
)
@@ -1,6 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { errors } from "../error"
@@ -36,11 +37,15 @@ export const PermissionRoutes = lazy(() =>
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await Permission.reply({
requestID: params.requestID,
reply: json.reply,
message: json.message,
})
await AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({
requestID: params.requestID,
reply: json.reply,
message: json.message,
}),
),
)
return c.json(true)
},
)
@@ -62,7 +67,7 @@ export const PermissionRoutes = lazy(() =>
},
}),
async (c) => {
const permissions = await Permission.list()
const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
return c.json(permissions)
},
),
@@ -474,10 +474,14 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const query = c.req.valid("query")
const params = c.req.valid("param")
const result = await SessionSummary.diff({
sessionID: params.sessionID,
messageID: query.messageID,
})
const result = await AppRuntime.runPromise(
SessionSummary.Service.use((summary) =>
summary.diff({
sessionID: params.sessionID,
messageID: query.messageID,
}),
),
)
return c.json(result)
},
)
@@ -547,27 +551,38 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const session = await Session.get(sessionID)
await SessionRevert.cleanup(session)
const msgs = await Session.messages({ sessionID })
let currentAgent = await Agent.defaultAgent()
for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info
if (info.role === "user") {
currentAgent = info.agent || (await Agent.defaultAgent())
break
}
}
await SessionCompaction.create({
sessionID,
agent: currentAgent,
model: {
providerID: body.providerID,
modelID: body.modelID,
},
auto: body.auto,
})
await SessionPrompt.loop({ sessionID })
await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const compact = yield* SessionCompaction.Service
const prompt = yield* SessionPrompt.Service
const agent = yield* Agent.Service
yield* revert.cleanup(yield* session.get(sessionID))
const msgs = yield* session.messages({ sessionID })
const defaultAgent = yield* agent.defaultAgent()
let currentAgent = defaultAgent
for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info
if (info.role === "user") {
currentAgent = info.agent || defaultAgent
break
}
}
yield* compact.create({
sessionID,
agent: currentAgent,
model: {
providerID: body.providerID,
modelID: body.modelID,
},
auto: body.auto,
})
yield* prompt.loop({ sessionID })
}),
)
return c.json(true)
},
)
@@ -985,10 +1000,14 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
log.info("revert", c.req.valid("json"))
const session = await SessionRevert.revert({
sessionID,
...c.req.valid("json"),
})
const session = await AppRuntime.runPromise(
SessionRevert.Service.use((svc) =>
svc.revert({
sessionID,
...c.req.valid("json"),
}),
),
)
return c.json(session)
},
)
@@ -1018,7 +1037,7 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await SessionRevert.unrevert({ sessionID })
const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID })))
return c.json(session)
},
)
@@ -1051,10 +1070,14 @@ export const SessionRoutes = lazy(() =>
validator("json", z.object({ response: Permission.Reply })),
async (c) => {
const params = c.req.valid("param")
Permission.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
})
await AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
}),
),
)
return c.json(true)
},
),
+45 -48
View File
@@ -9,14 +9,12 @@ import z from "zod"
import { Token } from "../util/token"
import { Log } from "../util/log"
import { SessionProcessor } from "./processor"
import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/db"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow } from "./overflow"
@@ -310,31 +308,51 @@ When constructing the summary, try to stick to this template:
}
if (!replay) {
const continueMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
})
const info = yield* provider.getProvider(userMessage.model.providerID)
if (
(yield* plugin.trigger(
"experimental.compaction.autocontinue",
{
sessionID: input.sessionID,
agent: userMessage.agent,
model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
provider: {
source: info.source,
info,
options: info.options,
},
message: userMessage,
overflow: input.overflow === true,
},
{ enabled: true },
)).enabled
) {
const continueMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
})
}
}
}
@@ -388,25 +406,4 @@ When constructing the summary, try to stick to this template:
Layer.provide(Config.defaultLayer),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
return runPromise((svc) => svc.isOverflow(input))
}
export async function prune(input: { sessionID: SessionID }) {
return runPromise((svc) => svc.prune(input))
}
export const create = fn(
z.object({
sessionID: SessionID.zod,
agent: z.string(),
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
auto: z.boolean(),
overflow: z.boolean().optional(),
}),
(input) => runPromise((svc) => svc.create(input)),
)
}
+351 -337
View File
@@ -1,7 +1,6 @@
import { Provider } from "@/provider/provider"
import { Log } from "@/util/log"
import { Cause, Effect, Layer, Record, Context } from "effect"
import * as Queue from "effect/Queue"
import { Context, Effect, Layer, Record } from "effect"
import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
import { mergeDeep, pipe } from "remeda"
@@ -21,10 +20,13 @@ import { Wildcard } from "@/util/wildcard"
import { SessionID } from "@/session/schema"
import { Auth } from "@/auth"
import { Installation } from "@/installation"
import { makeRuntime } from "@/effect/run-service"
export namespace LLM {
const log = Log.create({ service: "llm" })
const perms = makeRuntime(Permission.Service, Permission.defaultLayer)
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
type Result = Awaited<ReturnType<typeof streamText>>
export type StreamInput = {
user: MessageV2.User
@@ -45,7 +47,7 @@ export namespace LLM {
abort: AbortSignal
}
export type Event = Awaited<ReturnType<typeof stream>>["fullStream"] extends AsyncIterable<infer T> ? T : never
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
export interface Interface {
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
@@ -53,12 +55,340 @@ export namespace LLM {
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
return Service.of({
stream(input) {
return Stream.scoped(
export const layer: Layer.Layer<Service, never, Auth.Service | Config.Service | Provider.Service | Plugin.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.Service
const config = yield* Config.Service
const provider = yield* Provider.Service
const plugin = yield* Plugin.Service
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
const l = log
.clone()
.tag("providerID", input.model.providerID)
.tag("modelID", input.model.id)
.tag("sessionID", input.sessionID)
.tag("small", (input.small ?? false).toString())
.tag("agent", input.agent.name)
.tag("mode", input.agent.mode)
l.info("stream", {
modelID: input.model.id,
providerID: input.model.providerID,
})
const [language, cfg, item, info] = yield* Effect.all(
[
provider.getLanguage(input.model),
config.get(),
provider.getProvider(input.model.providerID),
auth.get(input.model.providerID),
],
{ concurrency: "unbounded" },
)
// TODO: move this to a proper hook
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
const system: string[] = []
system.push(
[
// use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
)
const header = system[0]
yield* plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
// rejoin to maintain 2-part structure for caching if header unchanged
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
}
const variant =
!input.small && input.model.variants && input.user.model.variant
? input.model.variants[input.user.model.variant]
: {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options({
model: input.model,
sessionID: input.sessionID,
providerOptions: item.options,
})
const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isOpenaiOauth) {
options.instructions = system.join("\n")
}
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
const messages = isOpenaiOauth
? input.messages
: isWorkflow
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const params = yield* plugin.trigger(
"chat.params",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
temperature: input.model.capabilities.temperature
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
options,
},
)
const { headers } = yield* plugin.trigger(
"chat.headers",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
headers: {},
},
)
const tools = resolveTools(input)
// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.
// Add a dummy tool that is never called to satisfy this validation.
// This is enabled for:
// 1. Providers with "litellm" in their ID or API ID (auto-detected)
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
const isLiteLLMProxy =
item.options?.["litellmProxy"] === true ||
input.model.providerID.toLowerCase().includes("litellm") ||
input.model.api.id.toLowerCase().includes("litellm")
// LiteLLM/Bedrock rejects requests where the message history contains tool
// calls but no tools param is present. When there are no active tools (e.g.
// during compaction), inject a stub tool to satisfy the validation requirement.
// The stub description explicitly tells the model not to call it.
if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
tools["_noop"] = tool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({
type: "object",
properties: {
reason: { type: "string", description: "Unused" },
},
}),
execute: async () => ({ output: "", title: "", metadata: {} }),
})
}
// Wire up toolExecutor for DWS workflow models so that tool calls
// from the workflow service are executed via opencode's tool system
// and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language as GitLabWorkflowLanguageModel & {
sessionID?: string
sessionPreapprovedTools?: string[]
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
}
workflowModel.sessionID = input.sessionID
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName]
if (!t || !t.execute) {
return { result: "", error: `Unknown tool: ${toolName}` }
}
try {
const result = await t.execute!(JSON.parse(argsJson), {
toolCallId: _requestID,
messages: input.messages,
abortSignal: input.abort,
})
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
return {
result: output,
metadata: typeof result === "object" ? result?.metadata : undefined,
title: typeof result === "object" ? result?.title : undefined,
}
} catch (e: any) {
return { result: "", error: e.message ?? String(e) }
}
}
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
return !match || match.action !== "ask"
})
const approvedToolsForSession = new Set<string>()
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
// Auto-approve tools that were already approved in this session
// (prevents infinite approval loops for server-side MCP tools)
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
return { approved: true }
}
const id = PermissionID.ascending()
let reply: Permission.Reply | undefined
let unsub: (() => void) | undefined
try {
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
if (evt.properties.requestID === id) reply = evt.properties.reply
})
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
try {
const parsed = JSON.parse(t.args) as Record<string, unknown>
const title = (parsed?.title ?? parsed?.name ?? "") as string
return title ? `${t.name}: ${title}` : t.name
} catch {
return t.name
}
})
const uniquePatterns = [...new Set(toolPatterns)] as string[]
await perms.runPromise((svc) =>
svc.ask({
id,
sessionID: SessionID.make(input.sessionID),
permission: "workflow_tool_approval",
patterns: uniquePatterns,
metadata: { tools: approvalTools },
always: uniquePatterns,
ruleset: [],
}),
)
for (const name of uniqueNames) approvedToolsForSession.add(name)
workflowModel.sessionPreapprovedTools = [
...(workflowModel.sessionPreapprovedTools ?? []),
...uniqueNames,
]
return { approved: true }
} catch {
return { approved: false }
} finally {
unsub?.()
}
})
}
return streamText({
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && tools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
},
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
tools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${Installation.VERSION}`,
}),
...input.model.headers,
...headers,
},
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
},
},
})
})
const stream: Interface["stream"] = (input) =>
Stream.scoped(
Stream.unwrap(
Effect.gen(function* () {
const ctrl = yield* Effect.acquireRelease(
@@ -66,7 +396,7 @@ export namespace LLM {
(ctrl) => Effect.sync(() => ctrl.abort()),
)
const result = yield* Effect.promise(() => LLM.stream({ ...input, abort: ctrl.signal }))
const result = yield* run({ ...input, abort: ctrl.signal })
return Stream.fromAsyncIterable(result.fullStream, (e) =>
e instanceof Error ? e : new Error(String(e)),
@@ -74,335 +404,19 @@ export namespace LLM {
}),
),
)
},
})
}),
)
export const defaultLayer = layer
export async function stream(input: StreamRequest) {
const l = log
.clone()
.tag("providerID", input.model.providerID)
.tag("modelID", input.model.id)
.tag("sessionID", input.sessionID)
.tag("small", (input.small ?? false).toString())
.tag("agent", input.agent.name)
.tag("mode", input.agent.mode)
l.info("stream", {
modelID: input.model.id,
providerID: input.model.providerID,
})
const [language, cfg, provider, info] = await Effect.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
const cfg = yield* Config.Service
const provider = yield* Provider.Service
return yield* Effect.all(
[
provider.getLanguage(input.model),
cfg.get(),
provider.getProvider(input.model.providerID),
auth.get(input.model.providerID),
],
{ concurrency: "unbounded" },
)
}).pipe(Effect.provide(Layer.mergeAll(Auth.defaultLayer, Config.defaultLayer, Provider.defaultLayer))),
)
// TODO: move this to a proper hook
const isOpenaiOauth = provider.id === "openai" && info?.type === "oauth"
const system: string[] = []
system.push(
[
// use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
)
const header = system[0]
await Plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
// rejoin to maintain 2-part structure for caching if header unchanged
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
}
const variant =
!input.small && input.model.variants && input.user.model.variant
? input.model.variants[input.user.model.variant]
: {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options({
model: input.model,
sessionID: input.sessionID,
providerOptions: provider.options,
})
const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isOpenaiOauth) {
options.instructions = system.join("\n")
}
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
const messages = isOpenaiOauth
? input.messages
: isWorkflow
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const params = await Plugin.trigger(
"chat.params",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider,
message: input.user,
},
{
temperature: input.model.capabilities.temperature
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
options,
},
)
const { headers } = await Plugin.trigger(
"chat.headers",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider,
message: input.user,
},
{
headers: {},
},
)
const tools = resolveTools(input)
// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.
// Add a dummy tool that is never called to satisfy this validation.
// This is enabled for:
// 1. Providers with "litellm" in their ID or API ID (auto-detected)
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
const isLiteLLMProxy =
provider.options?.["litellmProxy"] === true ||
input.model.providerID.toLowerCase().includes("litellm") ||
input.model.api.id.toLowerCase().includes("litellm")
// LiteLLM/Bedrock rejects requests where the message history contains tool
// calls but no tools param is present. When there are no active tools (e.g.
// during compaction), inject a stub tool to satisfy the validation requirement.
// The stub description explicitly tells the model not to call it.
if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
tools["_noop"] = tool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({
type: "object",
properties: {
reason: { type: "string", description: "Unused" },
},
}),
execute: async () => ({ output: "", title: "", metadata: {} }),
})
}
// Wire up toolExecutor for DWS workflow models so that tool calls
// from the workflow service are executed via opencode's tool system
// and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language as GitLabWorkflowLanguageModel & {
sessionID?: string
sessionPreapprovedTools?: string[]
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
}
workflowModel.sessionID = input.sessionID
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName]
if (!t || !t.execute) {
return { result: "", error: `Unknown tool: ${toolName}` }
}
try {
const result = await t.execute!(JSON.parse(argsJson), {
toolCallId: _requestID,
messages: input.messages,
abortSignal: input.abort,
})
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
return {
result: output,
metadata: typeof result === "object" ? result?.metadata : undefined,
title: typeof result === "object" ? result?.title : undefined,
}
} catch (e: any) {
return { result: "", error: e.message ?? String(e) }
}
}
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
return !match || match.action !== "ask"
})
const approvedToolsForSession = new Set<string>()
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
// Auto-approve tools that were already approved in this session
// (prevents infinite approval loops for server-side MCP tools)
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
return { approved: true }
}
const id = PermissionID.ascending()
let reply: Permission.Reply | undefined
let unsub: (() => void) | undefined
try {
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
if (evt.properties.requestID === id) reply = evt.properties.reply
})
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
try {
const parsed = JSON.parse(t.args) as Record<string, unknown>
const title = (parsed?.title ?? parsed?.name ?? "") as string
return title ? `${t.name}: ${title}` : t.name
} catch {
return t.name
}
})
const uniquePatterns = [...new Set(toolPatterns)] as string[]
await Permission.ask({
id,
sessionID: SessionID.make(input.sessionID),
permission: "workflow_tool_approval",
patterns: uniquePatterns,
metadata: { tools: approvalTools },
always: uniquePatterns,
ruleset: [],
})
for (const name of uniqueNames) approvedToolsForSession.add(name)
workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
return { approved: true }
} catch {
return { approved: false }
} finally {
unsub?.()
}
})
}
return streamText({
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && tools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
},
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
tools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${Installation.VERSION}`,
}),
...input.model.headers,
...headers,
},
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
return Service.of({ stream })
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
},
},
})
}
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer),
),
)
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
+11 -5
View File
@@ -1,4 +1,4 @@
import { Cause, Deferred, Effect, Layer, Context } from "effect"
import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect"
import * as Stream from "effect/Stream"
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
@@ -89,6 +89,7 @@ export namespace SessionProcessor {
| LLM.Service
| Permission.Service
| Plugin.Service
| SessionSummary.Service
| SessionStatus.Service
> = Layer.effect(
Service,
@@ -101,6 +102,8 @@ export namespace SessionProcessor {
const llm = yield* LLM.Service
const permission = yield* Permission.Service
const plugin = yield* Plugin.Service
const summary = yield* SessionSummary.Service
const scope = yield* Scope.Scope
const status = yield* SessionStatus.Service
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
@@ -385,10 +388,12 @@ export namespace SessionProcessor {
}
ctx.snapshot = undefined
}
SessionSummary.summarize({
sessionID: ctx.sessionID,
messageID: ctx.assistantMessage.parentID,
})
yield* summary
.summarize({
sessionID: ctx.sessionID,
messageID: ctx.assistantMessage.parentID,
})
.pipe(Effect.ignore, Effect.forkIn(scope))
if (
!ctx.assistantMessage.summary &&
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
@@ -603,6 +608,7 @@ export namespace SessionProcessor {
Layer.provide(LLM.defaultLayer),
Layer.provide(Permission.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
+2 -31
View File
@@ -2,10 +2,10 @@ import { NotFoundError, eq, and, sql } from "../storage/db"
import { SyncEvent } from "@/sync"
import { Session } from "./index"
import { MessageV2 } from "./message-v2"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { SessionTable, MessageTable, PartTable, SessionEntryTable } from "./session.sql"
import { Log } from "../util/log"
import { DateTime } from "effect"
import { SessionEntry } from "@/v2/session-entry"
const log = Log.create({ service: "session.projector" })
@@ -133,33 +133,4 @@ export default [
log.warn("ignored late part update", { partID: id, messageID, sessionID })
}
}),
// Experimental
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
/*
const id = SessionEntry.ID.make(data.part.id.replace("prt", "ent"))
switch (data.part.type) {
case "text":
db.insert(SessionEntryTable)
.values({
id,
session_id: data.sessionID,
type: "text",
data: new SessionEntry.Text({
id,
text: data.part.text,
type: "text",
time: {
created: DateTime.makeUnsafe(data.part.time?.start ?? Date.now()),
completed: data.part.time?.end ? DateTime.makeUnsafe(data.part.time.end) : undefined,
},
}),
time_created: Date.now(),
time_updated: Date.now(),
})
.onConflictDoUpdate({ target: SessionEntryTable.id, set: { data: sql`excluded.data` } })
.run()
}
*/
}),
]
+6 -1
View File
@@ -102,6 +102,7 @@ export namespace SessionPrompt {
const instruction = yield* Instruction.Service
const state = yield* SessionRunState.Service
const revert = yield* SessionRevert.Service
const summary = yield* SessionSummary.Service
const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service
@@ -1444,7 +1445,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
}
if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser.id })
if (step === 1)
yield* summary
.summarize({ sessionID, messageID: lastUser.id })
.pipe(Effect.ignore, Effect.forkIn(scope))
if (step > 1 && lastFinished) {
for (const m of msgs) {
@@ -1692,6 +1696,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(
Layer.mergeAll(
Agent.defaultLayer,
-15
View File
@@ -1,6 +1,5 @@
import z from "zod"
import { Effect, Layer, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { Bus } from "../bus"
import { Snapshot } from "../snapshot"
import { Storage } from "@/storage/storage"
@@ -160,18 +159,4 @@ export namespace SessionRevert {
Layer.provide(SessionSummary.defaultLayer),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function revert(input: RevertInput) {
return runPromise((svc) => svc.revert(input))
}
export async function unrevert(input: { sessionID: SessionID }) {
return runPromise((svc) => svc.unrevert(input))
}
export async function cleanup(session: Session.Info) {
return runPromise((svc) => svc.cleanup(session))
}
}
+2 -5
View File
@@ -11,7 +11,6 @@ import { Timestamps } from "../storage/schema.sql"
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
type EntryData = Omit<SessionEntry.Entry, "id" | "type">
export const SessionTable = sqliteTable(
"session",
@@ -96,7 +95,6 @@ export const TodoTable = sqliteTable(
],
)
/*
export const SessionEntryTable = sqliteTable(
"session_entry",
{
@@ -105,9 +103,9 @@ export const SessionEntryTable = sqliteTable(
.$type<SessionID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
type: text().notNull(),
type: text().$type<SessionEntry.Type>().notNull(),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<SessionEntry.Entry>(),
data: text({ mode: "json" }).notNull().$type<Omit<SessionEntry.Entry, "type" | "id">>(),
},
(table) => [
index("session_entry_session_idx").on(table.session_id),
@@ -115,7 +113,6 @@ export const SessionEntryTable = sqliteTable(
index("session_entry_time_created_idx").on(table.time_created),
],
)
*/
export const PermissionTable = sqliteTable("permission", {
project_id: text()
-10
View File
@@ -1,6 +1,5 @@
import z from "zod"
import { Effect, Layer, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { Bus } from "@/bus"
import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage/storage"
@@ -159,17 +158,8 @@ export namespace SessionSummary {
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export const summarize = (input: { sessionID: SessionID; messageID: MessageID }) =>
void runPromise((svc) => svc.summarize(input)).catch(() => {})
export const DiffInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
})
export async function diff(input: z.infer<typeof DiffInput>) {
return runPromise((svc) => svc.diff(input))
}
}
-31
View File
@@ -5,7 +5,6 @@ import path from "path"
import z from "zod"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Hash } from "@/util/hash"
import { Config } from "../config/config"
@@ -770,34 +769,4 @@ export namespace Snapshot {
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Config.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function init() {
return runPromise((svc) => svc.init())
}
export async function track() {
return runPromise((svc) => svc.track())
}
export async function patch(hash: string) {
return runPromise((svc) => svc.patch(hash))
}
export async function restore(snapshot: string) {
return runPromise((svc) => svc.restore(snapshot))
}
export async function revert(patches: Patch[]) {
return runPromise((svc) => svc.revert(patches))
}
export async function diff(hash: string) {
return runPromise((svc) => svc.diff(hash))
}
export async function diffFull(from: string, to: string) {
return runPromise((svc) => svc.diffFull(from, to))
}
}
+29 -30
View File
@@ -2,9 +2,12 @@ import z from "zod"
import type { ZodObject } from "zod"
import { EventEmitter } from "events"
import { Database, eq } from "@/storage/db"
import { GlobalBus } from "@/bus/global"
import { Bus as ProjectBus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Instance } from "@/project/instance"
import { EventSequenceTable, EventTable } from "./event.sql"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { EventID } from "./schema"
import { Flag } from "@/flag/flag"
@@ -37,8 +40,6 @@ export namespace SyncEvent {
let frozen = false
let convertEvent: (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown>
const Bus = new EventEmitter<{ event: [{ def: Definition; event: Event }] }>()
export function reset() {
frozen = false
projectors = undefined
@@ -140,11 +141,6 @@ export namespace SyncEvent {
}
Database.effect(() => {
Bus.emit("event", {
def,
event,
})
if (options?.publish) {
const result = convertEvent(def.type, event.data)
if (result instanceof Promise) {
@@ -154,6 +150,17 @@ export namespace SyncEvent {
} else {
ProjectBus.publish({ type: def.type, properties: def.schema }, result)
}
GlobalBus.emit("event", {
directory: Instance.directory,
project: Instance.project.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "sync",
name: versionedType(def.type, def.version),
...event,
},
})
}
})
})
@@ -235,31 +242,23 @@ export namespace SyncEvent {
})
}
export function subscribeAll(handler: (event: { def: Definition; event: Event }) => void) {
Bus.on("event", handler)
return () => Bus.off("event", handler)
}
export function payloads() {
return z
.union(
registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
aggregate: z.literal(def.aggregate),
data: def.schema,
})
.meta({
ref: "SyncEvent" + "." + def.type,
})
return registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal("sync"),
name: z.literal(type),
id: z.string(),
seq: z.number(),
aggregateID: z.literal(def.aggregate),
data: def.schema,
})
.meta({
ref: "SyncEvent" + "." + def.type,
})
.toArray() as any,
)
.meta({
ref: "SyncEvent",
})
.toArray()
}
}
+5 -1
View File
@@ -437,7 +437,11 @@ export const BashTool = Tool.define(
).pipe(Effect.orDie)
const meta: string[] = []
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
if (expired) {
meta.push(
`bash tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`,
)
}
if (aborted) meta.push("User aborted the command")
if (meta.length > 0) {
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
+4
View File
@@ -40,6 +40,10 @@ export const GlobTool = Tool.define(
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (info?.type === "File") {
throw new Error(`glob path must be a directory: ${search}`)
}
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
const limit = 100
+9 -3
View File
@@ -51,19 +51,25 @@ export const GrepTool = Tool.define(
? (params.path ?? Instance.directory)
: path.join(Instance.directory, params.path ?? "."),
)
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
const info = yield* fs.stat(searchPath).pipe(Effect.catch(() => Effect.succeed(undefined)))
const cwd = info?.type === "Directory" ? searchPath : path.dirname(searchPath)
const file = info?.type === "Directory" ? undefined : [searchPath]
yield* assertExternalDirectoryEffect(ctx, searchPath, {
kind: info?.type === "Directory" ? "directory" : "file",
})
const result = yield* rg.search({
cwd: searchPath,
cwd,
pattern: params.pattern,
glob: params.include ? [params.include] : undefined,
file,
})
if (result.items.length === 0) return empty
const rows = result.items.map((item) => ({
path: AppFileSystem.resolve(
path.isAbsolute(item.path.text) ? item.path.text : path.join(searchPath, item.path.text),
path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text),
),
line: item.line_number,
text: item.lines.text,
+8 -3
View File
@@ -78,6 +78,7 @@ export namespace ToolRegistry {
Service,
never,
| Config.Service
| Env.Service
| Plugin.Service
| Question.Service
| Todo.Service
@@ -99,6 +100,7 @@ export namespace ToolRegistry {
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const env = yield* Env.Service
const plugin = yield* Plugin.Service
const agents = yield* Agent.Service
const skill = yield* Skill.Service
@@ -121,6 +123,7 @@ export namespace ToolRegistry {
const greptool = yield* GrepTool
const patchtool = yield* ApplyPatchTool
const skilltool = yield* SkillTool
const agent = yield* Agent.Service
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -140,8 +143,8 @@ export namespace ToolRegistry {
worktree: ctx.worktree,
}
const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
const agent = yield* Effect.promise(() => Agent.get(toolCtx.agent))
const out = yield* truncate.output(result, {}, agent)
const info = yield* agent.get(toolCtx.agent)
const out = yield* truncate.output(result, {}, info)
return {
title: "",
output: out.truncated ? out.content : result,
@@ -271,13 +274,14 @@ export namespace ToolRegistry {
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const e2e = !!(yield* env.get("OPENCODE_E2E_LLM_URL"))
const filtered = (yield* all()).filter((tool) => {
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") ||
e2e ||
(input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4"))
if (tool.id === ApplyPatchTool.id) return usePatch
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
@@ -324,6 +328,7 @@ export namespace ToolRegistry {
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer),
+43 -2
View File
@@ -1,6 +1,10 @@
import { Identifier } from "@/id/id"
import { Database } from "@/node"
import type { SessionID } from "@/session/schema"
import { SessionEntryTable } from "@/session/session.sql"
import { withStatics } from "@/util/schema"
import { DateTime, Effect, Schema } from "effect"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { eq } from "../storage/db"
export namespace SessionEntry {
export const ID = Schema.String.pipe(Schema.brand("Session.Entry.ID")).pipe(
@@ -181,6 +185,43 @@ export namespace SessionEntry {
overflow: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([User, Synthetic, Request, Tool, Text, Reasoning, Complete, Retry, Compaction])
export const Entry = Schema.Union([User, Synthetic, Request, Tool, Text, Reasoning, Complete, Retry, Compaction], {
mode: "oneOf",
})
export type Entry = Schema.Schema.Type<typeof Entry>
export type Type = Entry["type"]
export interface Interface {
readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry
readonly fromSession: (sessionID: SessionID) => Effect.Effect<Entry[], never>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEntry") {}
export const layer: Layer.Layer<Service, never, never> = Layer.effect(
Service,
Effect.gen(function* () {
const decodeEntry = Schema.decodeUnknownSync(Entry)
const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type })
const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) {
return Database.use((db) =>
db
.select()
.from(SessionEntryTable)
.where(eq(SessionEntryTable.session_id, sessionID))
.orderBy(SessionEntryTable.id)
.all()
.map((row) => decode(row)),
)
})
return Service.of({
decode,
fromSession,
})
}),
)
}
-22
View File
@@ -18,7 +18,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@/filesystem"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { makeRuntime } from "@/effect/run-service"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
@@ -598,25 +597,4 @@ export namespace Worktree {
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function makeWorktreeInfo(name?: string) {
return runPromise((svc) => svc.makeWorktreeInfo(name))
}
export async function createFromInfo(info: Info, startCommand?: string) {
return runPromise((svc) => svc.createFromInfo(info, startCommand))
}
export async function create(input?: CreateInput) {
return runPromise((svc) => svc.create(input))
}
export async function remove(input: RemoveInput) {
return runPromise((svc) => svc.remove(input))
}
export async function reset(input: ResetInput) {
return runPromise((svc) => svc.reset(input))
}
}
+48 -41
View File
@@ -1,6 +1,7 @@
import { afterEach, test, expect } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission"
@@ -11,6 +12,10 @@ function evalPerm(agent: Agent.Info | undefined, permission: string): Permission
return Permission.evaluate(permission, "*", agent.permission).action
}
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}
afterEach(async () => {
await Instance.disposeAll()
})
@@ -20,7 +25,7 @@ test("returns default native agents when no config", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agents = await Agent.list()
const agents = await load(tmp.path, (svc) => svc.list())
const names = agents.map((a) => a.name)
expect(names).toContain("build")
expect(names).toContain("plan")
@@ -38,7 +43,7 @@ test("build agent has correct default properties", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(build).toBeDefined()
expect(build?.mode).toBe("primary")
expect(build?.native).toBe(true)
@@ -53,7 +58,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const plan = await Agent.get("plan")
const plan = await load(tmp.path, (svc) => svc.get("plan"))
expect(plan).toBeDefined()
// Wildcard is denied
expect(evalPerm(plan, "edit")).toBe("deny")
@@ -68,7 +73,7 @@ test("explore agent denies edit and write", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const explore = await Agent.get("explore")
const explore = await load(tmp.path, (svc) => svc.get("explore"))
expect(explore).toBeDefined()
expect(explore?.mode).toBe("subagent")
expect(evalPerm(explore, "edit")).toBe("deny")
@@ -84,7 +89,7 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
await Instance.provide({
directory: tmp.path,
fn: async () => {
const explore = await Agent.get("explore")
const explore = await load(tmp.path, (svc) => svc.get("explore"))
expect(explore).toBeDefined()
expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
@@ -97,7 +102,7 @@ test("general agent denies todo tools", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const general = await Agent.get("general")
const general = await load(tmp.path, (svc) => svc.get("general"))
expect(general).toBeDefined()
expect(general?.mode).toBe("subagent")
expect(general?.hidden).toBeUndefined()
@@ -111,7 +116,7 @@ test("compaction agent denies all permissions", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const compaction = await Agent.get("compaction")
const compaction = await load(tmp.path, (svc) => svc.get("compaction"))
expect(compaction).toBeDefined()
expect(compaction?.hidden).toBe(true)
expect(evalPerm(compaction, "bash")).toBe("deny")
@@ -137,7 +142,7 @@ test("custom agent from config creates new agent", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const custom = await Agent.get("my_custom_agent")
const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent"))
expect(custom).toBeDefined()
expect(String(custom?.model?.providerID)).toBe("openai")
expect(String(custom?.model?.modelID)).toBe("gpt-4")
@@ -166,7 +171,7 @@ test("custom agent config overrides native agent properties", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(build).toBeDefined()
expect(String(build?.model?.providerID)).toBe("anthropic")
expect(String(build?.model?.modelID)).toBe("claude-3")
@@ -189,9 +194,9 @@ test("agent disable removes agent from list", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const explore = await Agent.get("explore")
const explore = await load(tmp.path, (svc) => svc.get("explore"))
expect(explore).toBeUndefined()
const agents = await Agent.list()
const agents = await load(tmp.path, (svc) => svc.list())
const names = agents.map((a) => a.name)
expect(names).not.toContain("explore")
},
@@ -215,7 +220,7 @@ test("agent permission config merges with defaults", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(build).toBeDefined()
// Specific pattern is denied
expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
@@ -236,7 +241,7 @@ test("global permission config applies to all agents", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(build).toBeDefined()
expect(evalPerm(build, "bash")).toBe("deny")
},
@@ -255,8 +260,8 @@ test("agent steps/maxSteps config sets steps property", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const plan = await Agent.get("plan")
const build = await load(tmp.path, (svc) => svc.get("build"))
const plan = await load(tmp.path, (svc) => svc.get("plan"))
expect(build?.steps).toBe(50)
expect(plan?.steps).toBe(100)
},
@@ -274,7 +279,7 @@ test("agent mode can be overridden", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const explore = await Agent.get("explore")
const explore = await load(tmp.path, (svc) => svc.get("explore"))
expect(explore?.mode).toBe("primary")
},
})
@@ -291,7 +296,7 @@ test("agent name can be overridden", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(build?.name).toBe("Builder")
},
})
@@ -308,7 +313,7 @@ test("agent prompt can be set from config", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(build?.prompt).toBe("Custom system prompt")
},
})
@@ -328,7 +333,7 @@ test("unknown agent properties are placed into options", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(build?.options.random_property).toBe("hello")
expect(build?.options.another_random).toBe(123)
},
@@ -351,7 +356,7 @@ test("agent options merge correctly", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(build?.options.custom_option).toBe(true)
expect(build?.options.another_option).toBe("value")
},
@@ -376,8 +381,8 @@ test("multiple custom agents can be defined", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agentA = await Agent.get("agent_a")
const agentB = await Agent.get("agent_b")
const agentA = await load(tmp.path, (svc) => svc.get("agent_a"))
const agentB = await load(tmp.path, (svc) => svc.get("agent_b"))
expect(agentA?.description).toBe("Agent A")
expect(agentA?.mode).toBe("subagent")
expect(agentB?.description).toBe("Agent B")
@@ -405,7 +410,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn
await Instance.provide({
directory: tmp.path,
fn: async () => {
const names = (await Agent.list()).map((a) => a.name)
const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name)
expect(names[0]).toBe("plan")
expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b)))
},
@@ -417,7 +422,7 @@ test("Agent.get returns undefined for non-existent agent", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nonExistent = await Agent.get("does_not_exist")
const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist"))
expect(nonExistent).toBeUndefined()
},
})
@@ -428,7 +433,7 @@ test("default permission includes doom_loop and external_directory as ask", asyn
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(evalPerm(build, "doom_loop")).toBe("ask")
expect(evalPerm(build, "external_directory")).toBe("ask")
},
@@ -440,7 +445,7 @@ test("webfetch is allowed by default", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(evalPerm(build, "webfetch")).toBe("allow")
},
})
@@ -462,7 +467,7 @@ test("legacy tools config converts to permissions", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(evalPerm(build, "bash")).toBe("deny")
expect(evalPerm(build, "read")).toBe("deny")
},
@@ -484,7 +489,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(evalPerm(build, "edit")).toBe("deny")
},
})
@@ -502,7 +507,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
@@ -526,7 +531,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
@@ -549,7 +554,7 @@ test("explicit Truncate.GLOB deny is respected", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
},
@@ -581,7 +586,7 @@ description: Permission skill.
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
const target = path.join(skillDir, "reference", "notes.md")
expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
@@ -597,7 +602,7 @@ test("defaultAgent returns build when no default_agent config", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agent = await Agent.defaultAgent()
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
expect(agent).toBe("build")
},
})
@@ -612,7 +617,7 @@ test("defaultAgent respects default_agent config set to plan", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agent = await Agent.defaultAgent()
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
expect(agent).toBe("plan")
},
})
@@ -632,7 +637,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agent = await Agent.defaultAgent()
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
expect(agent).toBe("my_custom")
},
})
@@ -647,7 +652,7 @@ test("defaultAgent throws when default_agent points to subagent", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(Agent.defaultAgent()).rejects.toThrow('default agent "explore" is a subagent')
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent')
},
})
})
@@ -661,7 +666,7 @@ test("defaultAgent throws when default_agent points to hidden agent", async () =
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(Agent.defaultAgent()).rejects.toThrow('default agent "compaction" is hidden')
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden')
},
})
})
@@ -675,7 +680,9 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(Agent.defaultAgent()).rejects.toThrow('default agent "does_not_exist" not found')
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow(
'default agent "does_not_exist" not found',
)
},
})
})
@@ -691,7 +698,7 @@ test("defaultAgent returns plan when build is disabled and default_agent not set
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agent = await Agent.defaultAgent()
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
// build is disabled, so it should return plan (next primary agent)
expect(agent).toBe("plan")
},
@@ -711,7 +718,7 @@ test("defaultAgent throws when all primary agents are disabled", async () => {
directory: tmp.path,
fn: async () => {
// build and plan are disabled, no primary-capable agents remain
await expect(Agent.defaultAgent()).rejects.toThrow("no primary visible agent found")
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow("no primary visible agent found")
},
})
})
@@ -1,6 +1,7 @@
import { test, expect } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Config } from "../../src/config/config"
import { Agent as AgentSvc } from "../../src/agent/agent"
@@ -8,6 +9,8 @@ import { Color } from "../../src/util/color"
import { AppRuntime } from "../../src/effect/app-runtime"
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
const agent = <A>(dir: string, fn: (svc: AgentSvc.Interface) => Effect.Effect<A>) =>
Effect.runPromise(provideInstance(dir)(AgentSvc.Service.use(fn)).pipe(Effect.provide(AgentSvc.defaultLayer)))
test("agent color parsed from project config", async () => {
await using tmp = await tmpdir({
@@ -52,9 +55,9 @@ test("Agent.get includes color from config", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const plan = await AgentSvc.get("plan")
const plan = await agent(tmp.path, (svc) => svc.get("plan"))
expect(plan?.color).toBe("#A855F7")
const build = await AgentSvc.get("build")
const build = await agent(tmp.path, (svc) => svc.get("build"))
expect(build?.color).toBe("accent")
},
})
@@ -6,6 +6,7 @@ import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { AppFileSystem } from "../../src/filesystem"
import { Env } from "../../src/env"
import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir, tmpdirScoped } from "../fixture/fixture"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -35,6 +36,7 @@ const emptyAuth = Layer.mock(Auth.Service)({
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
@@ -332,6 +334,7 @@ test("resolves env templates in account config with account token", async () =>
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(fakeAccount),
Layer.provideMerge(infra),
@@ -1824,6 +1827,7 @@ test("project config overrides remote well-known config", async () => {
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
@@ -1879,6 +1883,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
@@ -76,6 +76,25 @@ describe("Ripgrep.Service", () => {
expect(result.items[0]?.lines.text).toContain("needle")
})
test("search supports explicit file targets", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
await Bun.write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
},
})
const file = path.join(tmp.path, "match.ts")
const result = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.search({ cwd: tmp.path, pattern: "needle", file: [file] })
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toBe(file)
})
test("files returns stream of filenames", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -154,15 +154,22 @@ test("state() generates a new state when none is saved", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const auth = await Effect.runPromise(
Effect.gen(function* () {
return yield* McpAuth.Service
}).pipe(Effect.provide(McpAuth.defaultLayer)),
)
const provider = new McpOAuthProvider(
"test-state-gen",
"https://example.com/mcp",
{},
{ onRedirect: async () => {} },
auth,
)
// Ensure no state exists
const entryBefore = await McpAuth.get("test-state-gen")
const entryBefore = await Effect.runPromise(
McpAuth.Service.use((auth) => auth.get("test-state-gen")).pipe(Effect.provide(McpAuth.defaultLayer)),
)
expect(entryBefore?.oauthState).toBeUndefined()
// state() should generate and return a new state, not throw
@@ -171,7 +178,9 @@ test("state() generates a new state when none is saved", async () => {
expect(state.length).toBe(64) // 32 bytes as hex
// The generated state should be persisted
const entryAfter = await McpAuth.get("test-state-gen")
const entryAfter = await Effect.runPromise(
McpAuth.Service.use((auth) => auth.get("test-state-gen")).pipe(Effect.provide(McpAuth.defaultLayer)),
)
expect(entryAfter?.oauthState).toBe(state)
},
})
@@ -186,16 +195,26 @@ test("state() returns existing state when one is saved", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const auth = await Effect.runPromise(
Effect.gen(function* () {
return yield* McpAuth.Service
}).pipe(Effect.provide(McpAuth.defaultLayer)),
)
const provider = new McpOAuthProvider(
"test-state-existing",
"https://example.com/mcp",
{},
{ onRedirect: async () => {} },
auth,
)
// Pre-save a state
const existingState = "pre-saved-state-value"
await McpAuth.updateOAuthState("test-state-existing", existingState)
await Effect.runPromise(
McpAuth.Service.use((auth) => auth.updateOAuthState("test-state-existing", existingState)).pipe(
Effect.provide(McpAuth.defaultLayer),
),
)
// state() should return the existing state
const state = await provider.state()
+429 -497
View File
@@ -1,33 +1,77 @@
import { afterEach, test, expect } from "bun:test"
import os from "os"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { Bus } from "../../src/bus"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Permission } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, provideTmpdirInstance, tmpdir, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { MessageID, SessionID } from "../../src/session/schema"
const bus = Bus.layer
const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer)
const it = testEffect(env)
afterEach(async () => {
await Instance.disposeAll()
})
async function rejectAll(message?: string) {
for (const req of await Permission.list()) {
await Permission.reply({
requestID: req.id,
reply: "reject",
message,
})
}
const rejectAll = (message?: string) =>
Effect.gen(function* () {
const permission = yield* Permission.Service
for (const req of yield* permission.list()) {
yield* permission.reply({
requestID: req.id,
reply: "reject",
message,
})
}
})
const waitForPending = (count: number) =>
Effect.gen(function* () {
const permission = yield* Permission.Service
for (let i = 0; i < 100; i++) {
const list = yield* permission.list()
if (list.length === count) return list
yield* Effect.sleep("10 millis")
}
return yield* Effect.fail(new Error(`timed out waiting for ${count} pending permission request(s)`))
})
const fail = <A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const exit = yield* self.pipe(Effect.exit)
if (Exit.isFailure(exit)) return Cause.squash(exit.cause)
throw new Error("expected permission effect to fail")
})
const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
Effect.gen(function* () {
const permission = yield* Permission.Service
return yield* permission.ask(input)
})
const reply = (input: Parameters<Permission.Interface["reply"]>[0]) =>
Effect.gen(function* () {
const permission = yield* Permission.Service
return yield* permission.reply(input)
})
const list = () =>
Effect.gen(function* () {
const permission = yield* Permission.Service
return yield* permission.list()
})
function withDir(options: { git?: boolean } | undefined, self: (dir: string) => Effect.Effect<any, any, any>) {
return provideTmpdirInstance(self, options)
}
async function waitForPending(count: number) {
for (let i = 0; i < 20; i++) {
const list = await Permission.list()
if (list.length === count) return list
await Bun.sleep(0)
}
return Permission.list()
function withProvided(dir: string) {
return <A, E, R>(self: Effect.Effect<A, E, R>) => self.pipe(provideInstance(dir))
}
// fromConfig tests
@@ -170,24 +214,19 @@ test("merge - preserves rule order", () => {
})
test("merge - config permission overrides default ask", () => {
// Simulates: defaults have "*": "ask", config sets bash: "allow"
const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const merged = Permission.merge(defaults, config)
// Config's bash allow should override default ask
expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
// Other permissions should still be ask (from defaults)
expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask")
})
test("merge - config ask overrides default allow", () => {
// Simulates: defaults have bash: "allow", config sets bash: "ask"
const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const merged = Permission.merge(defaults, config)
// Config's ask should override default allow
expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
})
@@ -233,7 +272,6 @@ test("evaluate - last matching glob wins", () => {
})
test("evaluate - order matters for specificity", () => {
// If more specific rule comes first, later wildcard overrides it
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/components/*", action: "allow" },
{ permission: "edit", pattern: "src/*", action: "deny" },
@@ -350,19 +388,16 @@ test("evaluate - wildcard permission fallback for unknown tool", () => {
})
test("evaluate - permission patterns sorted by length regardless of object order", () => {
// specific permission listed before wildcard, but specific should still win
const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "*", pattern: "*", action: "deny" },
])
// With flat list, last matching rule wins - so "*" matches bash and wins
expect(result.action).toBe("deny")
})
test("evaluate - merges multiple rulesets", () => {
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
// approved comes after config, so rm should be denied
const result = Permission.evaluate("bash", "rm", config, approved)
expect(result.action).toBe("deny")
})
@@ -419,8 +454,6 @@ test("disabled - does not disable when action is ask", () => {
})
test("disabled - does not disable when specific allow after wildcard deny", () => {
// Tool is NOT disabled because a specific allow after wildcard deny means
// there's at least some usage allowed
const result = Permission.disabled(
["bash"],
[
@@ -478,12 +511,10 @@ test("disabled - specific allow overrides wildcard deny", () => {
// ask tests
test("ask - resolves immediately when action is allow", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await Permission.ask({
it.live("ask - resolves immediately when action is allow", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const result = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -492,17 +523,15 @@ test("ask - resolves immediately when action is allow", async () => {
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
})
expect(result).toBeUndefined()
},
})
})
}),
),
)
test("ask - throws RejectedError when action is deny", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(
Permission.ask({
it.live("ask - throws DeniedError when action is deny", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const err = yield* fail(
ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["rm -rf /"],
@@ -510,39 +539,35 @@ test("ask - throws RejectedError when action is deny", async () => {
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}),
).rejects.toBeInstanceOf(Permission.DeniedError)
},
})
})
)
expect(err).toBeInstanceOf(Permission.DeniedError)
}),
),
)
test("ask - returns pending promise when action is ask", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = Permission.ask({
it.live("ask - stays pending when action is ask", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiber = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
})
// Promise should be pending, not resolved
expect(promise).toBeInstanceOf(Promise)
// Don't await - just verify it returns a promise
await rejectAll()
await promise.catch(() => {})
},
})
})
}).pipe(Effect.forkScoped)
test("ask - adds request to pending list", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = Permission.ask({
expect(yield* waitForPending(1)).toHaveLength(1)
yield* rejectAll()
yield* Fiber.await(fiber)
}),
),
)
it.live("ask - adds request to pending list", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiber = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -553,11 +578,11 @@ test("ask - adds request to pending list", async () => {
callID: "call_test",
},
ruleset: [],
})
}).pipe(Effect.forkScoped)
const list = await Permission.list()
expect(list).toHaveLength(1)
expect(list[0]).toMatchObject({
const items = yield* waitForPending(1)
expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -569,58 +594,58 @@ test("ask - adds request to pending list", async () => {
},
})
await rejectAll()
await ask.catch(() => {})
},
})
})
yield* rejectAll()
yield* Fiber.await(fiber)
}),
),
)
test("ask - publishes asked event", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live("ask - publishes asked event", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
let seen: Permission.Request | undefined
const unsub = Bus.subscribe(Permission.Event.Asked, (event) => {
const unsub = yield* bus.subscribeCallback(Permission.Event.Asked, (event) => {
seen = event.properties
})
const ask = Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
tool: {
messageID: MessageID.make("msg_test"),
callID: "call_test",
},
ruleset: [],
})
try {
const fiber = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
tool: {
messageID: MessageID.make("msg_test"),
callID: "call_test",
},
ruleset: [],
}).pipe(Effect.forkScoped)
expect(await Permission.list()).toHaveLength(1)
expect(seen).toBeDefined()
expect(seen).toMatchObject({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
})
expect(yield* waitForPending(1)).toHaveLength(1)
expect(seen).toBeDefined()
expect(seen).toMatchObject({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
})
unsub()
await rejectAll()
await ask.catch(() => {})
},
})
})
yield* rejectAll()
yield* Fiber.await(fiber)
} finally {
unsub()
}
}),
),
)
// reply tests
test("reply - once resolves the pending ask", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Permission.ask({
it.live("reply - once resolves the pending ask", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test1"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -628,26 +653,19 @@ test("reply - once resolves the pending ask", async () => {
metadata: {},
always: [],
ruleset: [],
})
}).pipe(Effect.forkScoped)
await waitForPending(1)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test1"), reply: "once" })
yield* Fiber.join(fiber)
}),
),
)
await Permission.reply({
requestID: PermissionID.make("per_test1"),
reply: "once",
})
await expect(askPromise).resolves.toBeUndefined()
},
})
})
test("reply - reject throws RejectedError", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Permission.ask({
it.live("reply - reject throws RejectedError", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test2"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -655,26 +673,22 @@ test("reply - reject throws RejectedError", async () => {
metadata: {},
always: [],
ruleset: [],
})
}).pipe(Effect.forkScoped)
await waitForPending(1)
yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test2"), reply: "reject" })
await Permission.reply({
requestID: PermissionID.make("per_test2"),
reply: "reject",
})
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
}),
),
)
await expect(askPromise).rejects.toBeInstanceOf(Permission.RejectedError)
},
})
})
test("reply - reject with message throws CorrectedError", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = Permission.ask({
it.live("reply - reject with message throws CorrectedError", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test2b"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -682,72 +696,60 @@ test("reply - reject with message throws CorrectedError", async () => {
metadata: {},
always: [],
ruleset: [],
})
}).pipe(Effect.forkScoped)
await waitForPending(1)
await Permission.reply({
yield* waitForPending(1)
yield* reply({
requestID: PermissionID.make("per_test2b"),
reply: "reject",
message: "Use a safer command",
})
const err = await ask.catch((err) => err)
expect(err).toBeInstanceOf(Permission.CorrectedError)
expect(err.message).toContain("Use a safer command")
},
})
})
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
expect(err).toBeInstanceOf(Permission.CorrectedError)
expect(String(err)).toContain("Use a safer command")
}
}),
),
)
test("reply - always persists approval and resolves", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Permission.ask({
id: PermissionID.make("per_test3"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: ["ls"],
ruleset: [],
})
it.live("reply - always persists approval and resolves", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const run = withProvided(dir)
const fiber = yield* ask({
id: PermissionID.make("per_test3"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: ["ls"],
ruleset: [],
}).pipe(run, Effect.forkScoped)
await waitForPending(1)
yield* waitForPending(1).pipe(run)
yield* reply({ requestID: PermissionID.make("per_test3"), reply: "always" }).pipe(run)
yield* Fiber.join(fiber)
await Permission.reply({
requestID: PermissionID.make("per_test3"),
reply: "always",
})
const result = yield* ask({
sessionID: SessionID.make("session_test2"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(run)
expect(result).toBeUndefined()
}),
)
await expect(askPromise).resolves.toBeUndefined()
},
})
// Re-provide to reload state with stored permissions
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Stored approval should allow without asking
const result = await Permission.ask({
sessionID: SessionID.make("session_test2"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
},
})
})
test("reply - reject cancels all pending for same session", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise1 = Permission.ask({
it.live("reply - reject cancels all pending for same session", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test4a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
@@ -755,9 +757,9 @@ test("reply - reject cancels all pending for same session", async () => {
metadata: {},
always: [],
ruleset: [],
})
}).pipe(Effect.forkScoped)
const askPromise2 = Permission.ask({
const b = yield* ask({
id: PermissionID.make("per_test4b"),
sessionID: SessionID.make("session_same"),
permission: "edit",
@@ -765,33 +767,24 @@ test("reply - reject cancels all pending for same session", async () => {
metadata: {},
always: [],
ruleset: [],
})
}).pipe(Effect.forkScoped)
await waitForPending(2)
yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test4a"), reply: "reject" })
// Catch rejections before they become unhandled
const result1 = askPromise1.catch((e) => e)
const result2 = askPromise2.catch((e) => e)
const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
expect(Exit.isFailure(ea)).toBe(true)
expect(Exit.isFailure(eb)).toBe(true)
if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(Permission.RejectedError)
if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(Permission.RejectedError)
}),
),
)
// Reject the first one
await Permission.reply({
requestID: PermissionID.make("per_test4a"),
reply: "reject",
})
// Both should be rejected
expect(await result1).toBeInstanceOf(Permission.RejectedError)
expect(await result2).toBeInstanceOf(Permission.RejectedError)
},
})
})
test("reply - always resolves matching pending requests in same session", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const a = Permission.ask({
it.live("reply - always resolves matching pending requests in same session", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test5a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
@@ -799,9 +792,9 @@ test("reply - always resolves matching pending requests in same session", async
metadata: {},
always: ["ls"],
ruleset: [],
})
}).pipe(Effect.forkScoped)
const b = Permission.ask({
const b = yield* ask({
id: PermissionID.make("per_test5b"),
sessionID: SessionID.make("session_same"),
permission: "bash",
@@ -809,28 +802,22 @@ test("reply - always resolves matching pending requests in same session", async
metadata: {},
always: [],
ruleset: [],
})
}).pipe(Effect.forkScoped)
await waitForPending(2)
yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test5a"), reply: "always" })
await Permission.reply({
requestID: PermissionID.make("per_test5a"),
reply: "always",
})
yield* Fiber.join(a)
yield* Fiber.join(b)
expect(yield* list()).toHaveLength(0)
}),
),
)
await expect(a).resolves.toBeUndefined()
await expect(b).resolves.toBeUndefined()
expect(await Permission.list()).toHaveLength(0)
},
})
})
test("reply - always keeps other session pending", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const a = Permission.ask({
it.live("reply - always keeps other session pending", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test6a"),
sessionID: SessionID.make("session_a"),
permission: "bash",
@@ -838,9 +825,9 @@ test("reply - always keeps other session pending", async () => {
metadata: {},
always: ["ls"],
ruleset: [],
})
}).pipe(Effect.forkScoped)
const b = Permission.ask({
const b = yield* ask({
id: PermissionID.make("per_test6b"),
sessionID: SessionID.make("session_b"),
permission: "bash",
@@ -848,30 +835,37 @@ test("reply - always keeps other session pending", async () => {
metadata: {},
always: [],
ruleset: [],
})
}).pipe(Effect.forkScoped)
await waitForPending(2)
yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test6a"), reply: "always" })
await Permission.reply({
requestID: PermissionID.make("per_test6a"),
reply: "always",
})
yield* Fiber.join(a)
expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test6b")])
await expect(a).resolves.toBeUndefined()
expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
yield* rejectAll()
yield* Fiber.await(b)
}),
),
)
await rejectAll()
await b.catch(() => {})
},
})
})
it.live("reply - publishes replied event", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
let resolve!: (value: { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }) => void
const seen = Effect.promise<{
sessionID: SessionID
requestID: PermissionID
reply: Permission.Reply
}>(
() =>
new Promise((res) => {
resolve = res
}),
)
test("reply - publishes replied event", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = Permission.ask({
const fiber = yield* ask({
id: PermissionID.make("per_test7"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -879,183 +873,132 @@ test("reply - publishes replied event", async () => {
metadata: {},
always: [],
ruleset: [],
}).pipe(Effect.forkScoped)
yield* waitForPending(1)
const unsub = yield* bus.subscribeCallback(Permission.Event.Replied, (event) => {
resolve(event.properties)
})
await waitForPending(1)
try {
yield* reply({ requestID: PermissionID.make("per_test7"), reply: "once" })
yield* Fiber.join(fiber)
expect(yield* seen).toEqual({
sessionID: SessionID.make("session_test"),
requestID: PermissionID.make("per_test7"),
reply: "once",
})
} finally {
unsub()
}
}),
),
)
let seen:
| {
sessionID: SessionID
requestID: PermissionID
reply: Permission.Reply
}
| undefined
const unsub = Bus.subscribe(Permission.Event.Replied, (event) => {
seen = event.properties
})
it.live("permission requests stay isolated by directory", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped({ git: true })
const two = yield* tmpdirScoped({ git: true })
const runOne = withProvided(one)
const runTwo = withProvided(two)
await Permission.reply({
requestID: PermissionID.make("per_test7"),
reply: "once",
})
const a = yield* ask({
id: PermissionID.make("per_dir_a"),
sessionID: SessionID.make("session_dir_a"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(runOne, Effect.forkScoped)
await expect(ask).resolves.toBeUndefined()
expect(seen).toEqual({
sessionID: SessionID.make("session_test"),
requestID: PermissionID.make("per_test7"),
reply: "once",
})
unsub()
},
})
})
const b = yield* ask({
id: PermissionID.make("per_dir_b"),
sessionID: SessionID.make("session_dir_b"),
permission: "bash",
patterns: ["pwd"],
metadata: {},
always: [],
ruleset: [],
}).pipe(runTwo, Effect.forkScoped)
test("permission requests stay isolated by directory", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const onePending = yield* waitForPending(1).pipe(runOne)
const twoPending = yield* waitForPending(1).pipe(runTwo)
const a = Instance.provide({
directory: one.path,
fn: () =>
Permission.ask({
id: PermissionID.make("per_dir_a"),
sessionID: SessionID.make("session_dir_a"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}),
})
expect(onePending).toHaveLength(1)
expect(twoPending).toHaveLength(1)
expect(onePending[0].id).toBe(PermissionID.make("per_dir_a"))
expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b"))
const b = Instance.provide({
directory: two.path,
fn: () =>
Permission.ask({
id: PermissionID.make("per_dir_b"),
sessionID: SessionID.make("session_dir_b"),
permission: "bash",
patterns: ["pwd"],
metadata: {},
always: [],
ruleset: [],
}),
})
yield* reply({ requestID: onePending[0].id, reply: "reject" }).pipe(runOne)
yield* reply({ requestID: twoPending[0].id, reply: "reject" }).pipe(runTwo)
const onePending = await Instance.provide({
directory: one.path,
fn: () => waitForPending(1),
})
const twoPending = await Instance.provide({
directory: two.path,
fn: () => waitForPending(1),
})
yield* Fiber.await(a)
yield* Fiber.await(b)
}),
)
expect(onePending).toHaveLength(1)
expect(twoPending).toHaveLength(1)
expect(onePending[0].id).toBe(PermissionID.make("per_dir_a"))
expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b"))
it.live("pending permission rejects on instance dispose", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const run = withProvided(dir)
const fiber = yield* ask({
id: PermissionID.make("per_dispose"),
sessionID: SessionID.make("session_dispose"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(run, Effect.forkScoped)
await Instance.provide({
directory: one.path,
fn: () => Permission.reply({ requestID: onePending[0].id, reply: "reject" }),
})
await Instance.provide({
directory: two.path,
fn: () => Permission.reply({ requestID: twoPending[0].id, reply: "reject" }),
})
expect(yield* waitForPending(1).pipe(run)).toHaveLength(1)
yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() }))
await a.catch(() => {})
await b.catch(() => {})
})
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
}),
)
test("pending permission rejects on instance dispose", async () => {
await using tmp = await tmpdir({ git: true })
it.live("pending permission rejects on instance reload", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const run = withProvided(dir)
const fiber = yield* ask({
id: PermissionID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(run, Effect.forkScoped)
const ask = Instance.provide({
directory: tmp.path,
fn: () =>
Permission.ask({
id: PermissionID.make("per_dispose"),
sessionID: SessionID.make("session_dispose"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}),
})
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
expect(yield* waitForPending(1).pipe(run)).toHaveLength(1)
yield* Effect.promise(() => Instance.reload({ directory: dir }))
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await waitForPending(1)
expect(pending).toHaveLength(1)
await Instance.dispose()
},
})
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
}),
)
expect(await result).toBeInstanceOf(Permission.RejectedError)
})
it.live("reply - does nothing for unknown requestID", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" })
expect(yield* list()).toHaveLength(0)
}),
),
)
test("pending permission rejects on instance reload", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
directory: tmp.path,
fn: () =>
Permission.ask({
id: PermissionID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}),
})
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await waitForPending(1)
expect(pending).toHaveLength(1)
await Instance.reload({ directory: tmp.path })
},
})
expect(await result).toBeInstanceOf(Permission.RejectedError)
})
test("reply - does nothing for unknown requestID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Permission.reply({
requestID: PermissionID.make("per_unknown"),
reply: "once",
})
expect(await Permission.list()).toHaveLength(0)
},
})
})
test("ask - checks all patterns and stops on first deny", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(
Permission.ask({
it.live("ask - checks all patterns and stops on first deny", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const err = yield* fail(
ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
@@ -1066,17 +1009,16 @@ test("ask - checks all patterns and stops on first deny", async () => {
{ permission: "bash", pattern: "rm *", action: "deny" },
],
}),
).rejects.toBeInstanceOf(Permission.DeniedError)
},
})
})
)
expect(err).toBeInstanceOf(Permission.DeniedError)
}),
),
)
test("ask - allows all patterns when all match allow rules", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await Permission.ask({
it.live("ask - allows all patterns when all match allow rules", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const result = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "ls -la", "pwd"],
@@ -1085,64 +1027,54 @@ test("ask - allows all patterns when all match allow rules", async () => {
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
})
expect(result).toBeUndefined()
},
})
})
}),
),
)
test("ask - should deny even when an earlier pattern is ask", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const err = await Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
metadata: {},
always: [],
ruleset: [
{ permission: "bash", pattern: "echo *", action: "ask" },
{ permission: "bash", pattern: "rm *", action: "deny" },
],
}).then(
() => undefined,
(err) => err,
it.live("ask - should deny even when an earlier pattern is ask", () =>
withDir({ git: true }, () =>
Effect.gen(function* () {
const err = yield* fail(
ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
metadata: {},
always: [],
ruleset: [
{ permission: "bash", pattern: "echo *", action: "ask" },
{ permission: "bash", pattern: "rm *", action: "deny" },
],
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(await Permission.list()).toHaveLength(0)
},
})
})
expect(yield* list()).toHaveLength(0)
}),
),
)
test("ask - abort should clear pending request", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ctl = new AbortController()
const ask = Permission.runPromise(
(svc) =>
svc.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
}),
{ signal: ctl.signal },
)
it.live("ask - abort should clear pending request", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const run = withProvided(dir)
await waitForPending(1)
ctl.abort()
await ask.catch(() => {})
const fiber = yield* ask({
id: PermissionID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
}).pipe(run, Effect.forkScoped)
try {
expect(await Permission.list()).toHaveLength(0)
} finally {
await rejectAll()
}
},
})
})
const pending = yield* waitForPending(1).pipe(run)
expect(pending).toHaveLength(1)
yield* Effect.promise(() => Instance.reload({ directory: dir }))
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
}),
)
@@ -1,4 +1,5 @@
import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
@@ -29,9 +30,11 @@ afterEach(async () => {
async function load(dir: string) {
return Instance.provide({
directory: dir,
fn: async () => {
await Plugin.list()
},
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
yield* plugin.list()
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
}
+33 -28
View File
@@ -1,4 +1,5 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
@@ -56,20 +57,22 @@ describe("plugin.trigger", () => {
const out = await Instance.provide({
directory: tmp.path,
fn: async () => {
const out = { system: [] as string[] }
await Plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
} as any,
},
out,
)
return out
},
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const out = { system: [] as string[] }
yield* plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
} as any,
},
out,
)
return out
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
expect(out.system).toEqual(["sync"])
@@ -90,20 +93,22 @@ describe("plugin.trigger", () => {
const out = await Instance.provide({
directory: tmp.path,
fn: async () => {
const out = { system: [] as string[] }
await Plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
} as any,
},
out,
)
return out
},
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const out = { system: [] as string[] }
yield* plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
} as any,
},
out,
)
return out
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
expect(out.system).toEqual(["async"])
@@ -1,4 +1,5 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
@@ -72,15 +73,17 @@ describe("plugin.workspace", () => {
const info = await Instance.provide({
directory: tmp.path,
fn: async () => {
await Plugin.init()
return Workspace.create({
type: tmp.extra.type,
branch: null,
extra: { key: "value" },
projectID: Instance.project.id,
})
},
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
yield* plugin.init()
return Workspace.create({
type: tmp.extra.type,
branch: null,
extra: { key: "value" },
projectID: Instance.project.id,
})
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
expect(info.type).toBe(tmp.extra.type)
@@ -1,115 +0,0 @@
import { afterEach, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
})
test("Instance.state caches values for the same instance", async () => {
await using tmp = await tmpdir()
let n = 0
const state = Instance.state(() => ({ n: ++n }))
await Instance.provide({
directory: tmp.path,
fn: async () => {
const a = state()
const b = state()
expect(a).toBe(b)
expect(n).toBe(1)
},
})
})
test("Instance.state isolates values by directory", async () => {
await using a = await tmpdir()
await using b = await tmpdir()
let n = 0
const state = Instance.state(() => ({ n: ++n }))
const x = await Instance.provide({
directory: a.path,
fn: async () => state(),
})
const y = await Instance.provide({
directory: b.path,
fn: async () => state(),
})
const z = await Instance.provide({
directory: a.path,
fn: async () => state(),
})
expect(x).toBe(z)
expect(x).not.toBe(y)
expect(n).toBe(2)
})
test("Instance.state is disposed on instance reload", async () => {
await using tmp = await tmpdir()
const seen: string[] = []
let n = 0
const state = Instance.state(
() => ({ n: ++n }),
async (value) => {
seen.push(String(value.n))
},
)
const a = await Instance.provide({
directory: tmp.path,
fn: async () => state(),
})
await Instance.reload({ directory: tmp.path })
const b = await Instance.provide({
directory: tmp.path,
fn: async () => state(),
})
expect(a).not.toBe(b)
expect(seen).toEqual(["1"])
})
test("Instance.state is disposed on disposeAll", async () => {
await using a = await tmpdir()
await using b = await tmpdir()
const seen: string[] = []
const state = Instance.state(
() => ({ dir: Instance.directory }),
async (value) => {
seen.push(value.dir)
},
)
await Instance.provide({
directory: a.path,
fn: async () => state(),
})
await Instance.provide({
directory: b.path,
fn: async () => state(),
})
await Instance.disposeAll()
expect(seen.sort()).toEqual([a.path, b.path].sort())
})
test("Instance.state dedupes concurrent promise initialization", async () => {
await using tmp = await tmpdir()
let n = 0
const state = Instance.state(async () => {
n += 1
await Bun.sleep(10)
return { n }
})
const [a, b] = await Instance.provide({
directory: tmp.path,
fn: async () => Promise.all([state(), state()]),
})
expect(a).toBe(b)
expect(n).toBe(1)
})
@@ -1,96 +1,126 @@
import { describe, expect, test } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import * as fs from "fs/promises"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Effect, Layer } from "effect"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Worktree } from "../../src/worktree"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const wintest = process.platform === "win32" ? test : test.skip
const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
const wintest = process.platform === "win32" ? it.live : it.live.skip
describe("Worktree.remove", () => {
test("continues when git remove exits non-zero after detaching", async () => {
await using tmp = await tmpdir({ git: true })
const root = tmp.path
const name = `remove-regression-${Date.now().toString(36)}`
const branch = `opencode/${name}`
const dir = path.join(root, "..", name)
it.live("continues when git remove exits non-zero after detaching", () =>
provideTmpdirInstance(
(root) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const name = `remove-regression-${Date.now().toString(36)}`
const branch = `opencode/${name}`
const dir = path.join(root, "..", name)
await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
await $`git reset --hard`.cwd(dir).quiet()
yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet())
yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet())
const real = (await $`which git`.quiet().text()).trim()
expect(real).toBeTruthy()
const real = (yield* Effect.promise(() => $`which git`.quiet().text())).trim()
expect(real).toBeTruthy()
const bin = path.join(root, "bin")
const shim = path.join(bin, "git")
await fs.mkdir(bin, { recursive: true })
await Bun.write(
shim,
[
"#!/bin/bash",
`REAL_GIT=${JSON.stringify(real)}`,
'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
' "$REAL_GIT" "$@" >/dev/null 2>&1',
' echo "fatal: failed to remove worktree: Directory not empty" >&2',
" exit 1",
"fi",
'exec "$REAL_GIT" "$@"',
].join("\n"),
)
await fs.chmod(shim, 0o755)
const bin = path.join(root, "bin")
const shim = path.join(bin, "git")
yield* Effect.promise(() => fs.mkdir(bin, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
shim,
[
"#!/bin/bash",
`REAL_GIT=${JSON.stringify(real)}`,
'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
' "$REAL_GIT" "$@" >/dev/null 2>&1',
' echo "fatal: failed to remove worktree: Directory not empty" >&2',
" exit 1",
"fi",
'exec "$REAL_GIT" "$@"',
].join("\n"),
),
)
yield* Effect.promise(() => fs.chmod(shim, 0o755))
const prev = process.env.PATH ?? ""
process.env.PATH = `${bin}${path.delimiter}${prev}`
const prev = yield* Effect.acquireRelease(
Effect.sync(() => {
const prev = process.env.PATH ?? ""
process.env.PATH = `${bin}${path.delimiter}${prev}`
return prev
}),
(prev) =>
Effect.sync(() => {
process.env.PATH = prev
}),
)
void prev
const ok = await (async () => {
try {
return await Instance.provide({
directory: root,
fn: () => Worktree.remove({ directory: dir }),
})
} finally {
process.env.PATH = prev
}
})()
const ok = yield* svc.remove({ directory: dir })
expect(ok).toBe(true)
expect(await Filesystem.exists(dir)).toBe(false)
expect(ok).toBe(true)
expect(
yield* Effect.promise(() =>
fs
.stat(dir)
.then(() => true)
.catch(() => false),
),
).toBe(false)
const list = await $`git worktree list --porcelain`.cwd(root).quiet().text()
expect(list).not.toContain(`worktree ${dir}`)
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(root).quiet().text())
expect(list).not.toContain(`worktree ${dir}`)
const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
expect(ref.exitCode).not.toBe(0)
})
const ref = yield* Effect.promise(() =>
$`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
)
expect(ref.exitCode).not.toBe(0)
}),
{ git: true },
),
)
wintest("stops fsmonitor before removing a worktree", async () => {
await using tmp = await tmpdir({ git: true })
const root = tmp.path
const name = `remove-fsmonitor-${Date.now().toString(36)}`
const branch = `opencode/${name}`
const dir = path.join(root, "..", name)
wintest("stops fsmonitor before removing a worktree", () =>
provideTmpdirInstance(
(root) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const name = `remove-fsmonitor-${Date.now().toString(36)}`
const branch = `opencode/${name}`
const dir = path.join(root, "..", name)
await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
await $`git reset --hard`.cwd(dir).quiet()
await $`git config core.fsmonitor true`.cwd(dir).quiet()
await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()
await Bun.write(path.join(dir, "tracked.txt"), "next\n")
await $`git diff`.cwd(dir).quiet()
yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet())
yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet())
yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(dir).quiet())
yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow())
yield* Effect.promise(() => Bun.write(path.join(dir, "tracked.txt"), "next\n"))
yield* Effect.promise(() => $`git diff`.cwd(dir).quiet())
const before = await $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow()
expect(before.exitCode).toBe(0)
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow())
expect(before.exitCode).toBe(0)
const ok = await Instance.provide({
directory: root,
fn: () => Worktree.remove({ directory: dir }),
})
const ok = yield* svc.remove({ directory: dir })
expect(ok).toBe(true)
expect(await Filesystem.exists(dir)).toBe(false)
expect(ok).toBe(true)
expect(
yield* Effect.promise(() =>
fs
.stat(dir)
.then(() => true)
.catch(() => false),
),
).toBe(false)
const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
expect(ref.exitCode).not.toBe(0)
})
const ref = yield* Effect.promise(() =>
$`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
)
expect(ref.exitCode).not.toBe(0)
}),
{ git: true },
),
)
})
+147 -106
View File
@@ -1,16 +1,16 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
const wintest = process.platform !== "win32" ? test : test.skip
import fs from "fs/promises"
import { afterEach, describe, expect } from "bun:test"
import * as fs from "fs/promises"
import path from "path"
import { Cause, Effect, Exit, Layer } from "effect"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Instance } from "../../src/project/instance"
import { Worktree } from "../../src/worktree"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
function withInstance(directory: string, fn: () => Promise<any>) {
return Instance.provide({ directory, fn })
}
const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
const wintest = process.platform !== "win32" ? it.live : it.live.skip
function normalize(input: string) {
return input.replace(/\\/g, "/").toLowerCase()
@@ -40,134 +40,175 @@ describe("Worktree", () => {
afterEach(() => Instance.disposeAll())
describe("makeWorktreeInfo", () => {
test("returns info with name, branch, and directory", async () => {
await using tmp = await tmpdir({ git: true })
it.live("returns info with name, branch, and directory", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo()
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo())
expect(info.name).toBeDefined()
expect(typeof info.name).toBe("string")
expect(info.branch).toBe(`opencode/${info.name}`)
expect(info.directory).toContain(info.name)
}),
{ git: true },
),
)
expect(info.name).toBeDefined()
expect(typeof info.name).toBe("string")
expect(info.branch).toBe(`opencode/${info.name}`)
expect(info.directory).toContain(info.name)
})
it.live("uses provided name as base", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo("my-feature")
test("uses provided name as base", async () => {
await using tmp = await tmpdir({ git: true })
expect(info.name).toBe("my-feature")
expect(info.branch).toBe("opencode/my-feature")
}),
{ git: true },
),
)
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("my-feature"))
it.live("slugifies the provided name", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo("My Feature Branch!")
expect(info.name).toBe("my-feature")
expect(info.branch).toBe("opencode/my-feature")
})
expect(info.name).toBe("my-feature-branch")
}),
{ git: true },
),
)
test("slugifies the provided name", async () => {
await using tmp = await tmpdir({ git: true })
it.live("throws NotGitError for non-git directories", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.makeWorktreeInfo())
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("My Feature Branch!"))
expect(info.name).toBe("my-feature-branch")
})
test("throws NotGitError for non-git directories", async () => {
await using tmp = await tmpdir()
await expect(withInstance(tmp.path, () => Worktree.makeWorktreeInfo())).rejects.toThrow("WorktreeNotGitError")
})
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
}),
),
)
})
describe("create + remove lifecycle", () => {
test("create returns worktree info and remove cleans up", async () => {
await using tmp = await tmpdir({ git: true })
it.live("create returns worktree info and remove cleans up", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.create()
const info = await withInstance(tmp.path, () => Worktree.create())
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
expect(info.directory).toBeDefined()
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
expect(info.directory).toBeDefined()
yield* Effect.promise(() => Bun.sleep(1000))
// Wait for bootstrap to complete
await Bun.sleep(1000)
const ok = yield* svc.remove({ directory: info.directory })
expect(ok).toBe(true)
}),
{ git: true },
),
)
const ok = await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
expect(ok).toBe(true)
})
it.live("create returns after setup and fires Event.Ready after bootstrap", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ready = waitReady()
const info = yield* svc.create()
test("create returns after setup and fires Event.Ready after bootstrap", async () => {
await using tmp = await tmpdir({ git: true })
const ready = waitReady()
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
const info = await withInstance(tmp.path, () => Worktree.create())
const text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory))
expect(normalize(text)).toContain(normalize(next))
// create returns before bootstrap completes, but the worktree already exists
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
const props = yield* Effect.promise(() => ready)
expect(props.name).toBe(info.name)
expect(props.branch).toBe(info.branch)
const text = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
const dir = await fs.realpath(info.directory).catch(() => info.directory)
expect(normalize(text)).toContain(normalize(dir))
yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory))
yield* Effect.promise(() => Bun.sleep(100))
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
),
)
// Event.Ready fires after bootstrap finishes in the background
const props = await ready
expect(props.name).toBe(info.name)
expect(props.branch).toBe(info.branch)
it.live("create with custom name", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ready = waitReady()
const info = yield* svc.create({ name: "test-workspace" })
// Cleanup
await withInstance(info.directory, () => Instance.dispose())
await Bun.sleep(100)
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
expect(info.name).toBe("test-workspace")
expect(info.branch).toBe("opencode/test-workspace")
test("create with custom name", async () => {
await using tmp = await tmpdir({ git: true })
const ready = waitReady()
const info = await withInstance(tmp.path, () => Worktree.create({ name: "test-workspace" }))
expect(info.name).toBe("test-workspace")
expect(info.branch).toBe("opencode/test-workspace")
// Cleanup
await ready
await withInstance(info.directory, () => Instance.dispose())
await Bun.sleep(100)
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
yield* Effect.promise(() => ready)
yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory))
yield* Effect.promise(() => Bun.sleep(100))
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
),
)
})
describe("createFromInfo", () => {
wintest("creates and bootstraps git worktree", async () => {
await using tmp = await tmpdir({ git: true })
wintest("creates and bootstraps git worktree", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo("from-info-test")
yield* svc.createFromInfo(info)
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("from-info-test"))
await withInstance(tmp.path, () => Worktree.createFromInfo(info))
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
const normalizedList = list.replace(/\\/g, "/")
const normalizedDir = info.directory.replace(/\\/g, "/")
expect(normalizedList).toContain(normalizedDir)
// Worktree should exist in git (normalize slashes for Windows)
const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
const normalizedList = list.replace(/\\/g, "/")
const normalizedDir = info.directory.replace(/\\/g, "/")
expect(normalizedList).toContain(normalizedDir)
// Cleanup
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
),
)
})
describe("remove edge cases", () => {
test("remove non-existent directory succeeds silently", async () => {
await using tmp = await tmpdir({ git: true })
it.live("remove non-existent directory succeeds silently", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ok = yield* svc.remove({ directory: path.join(dir, "does-not-exist") })
expect(ok).toBe(true)
}),
{ git: true },
),
)
const ok = await withInstance(tmp.path, () =>
Worktree.remove({ directory: path.join(tmp.path, "does-not-exist") }),
)
expect(ok).toBe(true)
})
it.live("throws NotGitError for non-git directories", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.remove({ directory: "/tmp/fake" }))
test("throws NotGitError for non-git directories", async () => {
await using tmp = await tmpdir()
await expect(withInstance(tmp.path, () => Worktree.remove({ directory: "/tmp/fake" }))).rejects.toThrow(
"WorktreeNotGitError",
)
})
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
}),
),
)
})
})
@@ -2436,10 +2436,15 @@ test("plugin config providers persist after instance dispose", async () => {
const first = await Instance.provide({
directory: tmp.path,
fn: async () => {
await Plugin.init()
return list()
},
fn: async () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const provider = yield* Provider.Service
yield* plugin.init()
return yield* provider.list()
}),
),
})
expect(first[ProviderID.make("demo")]).toBeDefined()
expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { GlobalBus } from "../../src/bus/global"
import { Snapshot } from "../../src/snapshot"
@@ -8,7 +9,7 @@ import { Server } from "../../src/server/server"
import { Filesystem } from "../../src/util/filesystem"
import { Log } from "../../src/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir } from "../fixture/fixture"
Log.init({ print: false })
@@ -60,12 +61,14 @@ describe("project.initGit endpoint", () => {
worktree: tmp.path,
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await Snapshot.track()).toBeTruthy()
},
})
expect(
await Effect.runPromise(
Snapshot.Service.use((svc) => svc.track()).pipe(
provideInstance(tmp.path),
Effect.provide(Snapshot.defaultLayer),
),
),
).toBeTruthy()
} finally {
await Instance.disposeAll()
reloadSpy.mockRestore()
+371 -156
View File
@@ -13,19 +13,31 @@ import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { tmpdir } from "../fixture/fixture"
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { ModelID, ProviderID } from "../../src/provider/schema"
import type { Provider } from "../../src/provider/provider"
import * as SessionProcessorModule from "../../src/session/processor"
import { Snapshot } from "../../src/snapshot"
import { ProviderTest } from "../fake/provider"
import { testEffect } from "../lib/effect"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
Log.init({ print: false })
const summary = Layer.succeed(
SessionSummary.Service,
SessionSummary.Service.of({
summarize: () => Effect.void,
diff: () => Effect.succeed([]),
computeDiff: () => Effect.succeed([]),
}),
)
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
@@ -169,6 +181,23 @@ function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, p
)
}
const deps = Layer.mergeAll(
ProviderTest.fake().layer,
layer("continue"),
Agent.defaultLayer,
Plugin.defaultLayer,
Bus.layer,
Config.defaultLayer,
)
const env = Layer.mergeAll(
Session.defaultLayer,
CrossSpawnSpawner.defaultLayer,
SessionCompaction.layer.pipe(Layer.provide(Session.defaultLayer), Layer.provideMerge(deps)),
)
const it = testEffect(env)
function llm() {
const queue: Array<
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
@@ -194,7 +223,7 @@ function llm() {
function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fake()) {
const bus = Bus.layer
const status = SessionStatus.layer.pipe(Layer.provide(bus))
const processor = SessionProcessorModule.SessionProcessor.layer
const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary))
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
Layer.provide(provider.layer),
@@ -234,78 +263,92 @@ function plugin(ready: ReturnType<typeof defer>) {
})
}
function autocontinue(enabled: boolean) {
return Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
if (name !== "experimental.compaction.autocontinue") return Effect.succeed(output)
return Effect.sync(() => {
;(output as { enabled: boolean }).enabled = enabled
return output
})
},
list: () => Effect.succeed([]),
init: () => Effect.void,
})
}
describe("session.compaction.isOverflow", () => {
test("returns true when token count exceeds usable context", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"returns true when token count exceeds usable context",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 100_000, output: 32_000 })
const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
}),
),
)
test("returns false when token count within usable context", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"returns false when token count within usable context",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 200_000, output: 32_000 })
const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
}),
),
)
test("includes cache.read in token count", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"includes cache.read in token count",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 100_000, output: 32_000 })
const tokens = { input: 60_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
}),
),
)
test("respects input limit for input caps", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"respects input limit for input caps",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
const tokens = { input: 271_000, output: 1_000, reasoning: 0, cache: { read: 2_000, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
}),
),
)
test("returns false when input/output are within input caps", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"returns false when input/output are within input caps",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
const tokens = { input: 200_000, output: 20_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
}),
),
)
test("returns false when output within limit with input caps", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"returns false when output within limit with input caps",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 200_000, input: 120_000, output: 10_000 })
const tokens = { input: 50_000, output: 9_999, reasoning: 0, cache: { read: 0, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
}),
),
)
// ─── Bug reproduction tests ───────────────────────────────────────────
// These tests demonstrate that when limit.input is set, isOverflow()
@@ -319,11 +362,11 @@ describe("session.compaction.isOverflow", () => {
// Related issues: #10634, #8089, #11086, #12621
// Open PRs: #6875, #12924
test("BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
// Simulate Claude with prompt caching: input limit = 200K, output limit = 32K
const model = createModel({ context: 200_000, input: 200_000, output: 32_000 })
@@ -340,16 +383,16 @@ describe("session.compaction.isOverflow", () => {
// With 198K used and only 2K headroom, the next turn will overflow.
// Compaction MUST trigger here.
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
}),
),
)
test("BUG: without limit.input, same token count correctly triggers compaction", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"BUG: without limit.input, same token count correctly triggers compaction",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
// Same model but without limit.input — uses context - output instead
const model = createModel({ context: 200_000, output: 32_000 })
@@ -359,17 +402,17 @@ describe("session.compaction.isOverflow", () => {
// usable = context - output = 200K - 32K = 168K
// 198K > 168K = true → compaction correctly triggered
const result = await SessionCompaction.isOverflow({ tokens, model })
const result = yield* compact.isOverflow({ tokens, model })
expect(result).toBe(true) // ← Correct: headroom is reserved
},
})
})
}),
),
)
test("BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
// Two models with identical context/output limits, differing only in limit.input
const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 })
const withoutInputLimit = createModel({ context: 200_000, output: 32_000 })
@@ -377,67 +420,66 @@ describe("session.compaction.isOverflow", () => {
// 170K total tokens — well above context-output (168K) but below input limit (200K)
const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } }
const withLimit = await SessionCompaction.isOverflow({ tokens, model: withInputLimit })
const withoutLimit = await SessionCompaction.isOverflow({ tokens, model: withoutInputLimit })
const withLimit = yield* compact.isOverflow({ tokens, model: withInputLimit })
const withoutLimit = yield* compact.isOverflow({ tokens, model: withoutInputLimit })
// Both models have identical real capacity — they should agree:
expect(withLimit).toBe(true) // should compact (170K leaves no room for 32K output)
expect(withoutLimit).toBe(true) // correctly compacts (170K > 168K)
},
})
})
}),
),
)
test("returns false when model context limit is 0", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"returns false when model context limit is 0",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 0, output: 32_000 })
const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
}),
),
)
test("returns false when compaction.auto is disabled", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
compaction: { auto: false },
}),
)
it.live(
"returns false when compaction.auto is disabled",
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 100_000, output: 32_000 })
const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
}),
{
config: {
compaction: { auto: false },
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = createModel({ context: 100_000, output: 32_000 })
const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
},
})
})
),
)
})
describe("session.compaction.create", () => {
test("creates a compaction user message and part", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
it.live(
"creates a compaction user message and part",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const session = yield* Session.Service
await SessionCompaction.create({
sessionID: session.id,
const info = yield* session.create({})
yield* compact.create({
sessionID: info.id,
agent: "build",
model: ref,
auto: true,
overflow: true,
})
const msgs = await Session.messages({ sessionID: session.id })
const msgs = yield* session.messages({ sessionID: info.id })
expect(msgs).toHaveLength(1)
expect(msgs[0].info.role).toBe("user")
expect(msgs[0].parts).toHaveLength(1)
@@ -446,60 +488,190 @@ describe("session.compaction.create", () => {
auto: true,
overflow: true,
})
},
})
})
}),
),
)
})
describe("session.compaction.prune", () => {
test("compacts old completed tool output", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const a = await user(session.id, "first")
const b = await assistant(session.id, a.id, tmp.path)
await tool(session.id, b.id, "bash", "x".repeat(200_000))
await user(session.id, "second")
await user(session.id, "third")
it.live(
"compacts old completed tool output",
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const session = yield* Session.Service
const info = yield* session.create({})
const a = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: ref,
time: { created: Date.now() },
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: info.id,
type: "text",
text: "first",
})
const b: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID: info.id,
mode: "build",
agent: "build",
path: { cwd: dir, root: dir },
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ref.modelID,
providerID: ref.providerID,
parentID: a.id,
time: { created: Date.now() },
finish: "end_turn",
}
yield* session.updateMessage(b)
yield* session.updatePart({
id: PartID.ascending(),
messageID: b.id,
sessionID: info.id,
type: "tool",
callID: crypto.randomUUID(),
tool: "bash",
state: {
status: "completed",
input: {},
output: "x".repeat(200_000),
title: "done",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
})
for (const text of ["second", "third"]) {
const msg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: ref,
time: { created: Date.now() },
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: info.id,
type: "text",
text,
})
}
await SessionCompaction.prune({ sessionID: session.id })
yield* compact.prune({ sessionID: info.id })
const msgs = await Session.messages({ sessionID: session.id })
const msgs = yield* session.messages({ sessionID: info.id })
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool")
expect(part?.state.status).toBe("completed")
if (part?.type === "tool" && part.state.status === "completed") {
expect(part.state.time.compacted).toBeNumber()
}
},
})
})
}),
),
)
test("skips protected skill tool output", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const a = await user(session.id, "first")
const b = await assistant(session.id, a.id, tmp.path)
await tool(session.id, b.id, "skill", "x".repeat(200_000))
await user(session.id, "second")
await user(session.id, "third")
it.live(
"skips protected skill tool output",
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const session = yield* Session.Service
const info = yield* session.create({})
const a = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: ref,
time: { created: Date.now() },
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: info.id,
type: "text",
text: "first",
})
const b: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID: info.id,
mode: "build",
agent: "build",
path: { cwd: dir, root: dir },
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ref.modelID,
providerID: ref.providerID,
parentID: a.id,
time: { created: Date.now() },
finish: "end_turn",
}
yield* session.updateMessage(b)
yield* session.updatePart({
id: PartID.ascending(),
messageID: b.id,
sessionID: info.id,
type: "tool",
callID: crypto.randomUUID(),
tool: "skill",
state: {
status: "completed",
input: {},
output: "x".repeat(200_000),
title: "done",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
})
for (const text of ["second", "third"]) {
const msg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: ref,
time: { created: Date.now() },
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: info.id,
type: "text",
text,
})
}
await SessionCompaction.prune({ sessionID: session.id })
yield* compact.prune({ sessionID: info.id })
const msgs = await Session.messages({ sessionID: session.id })
const msgs = yield* session.messages({ sessionID: info.id })
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool")
if (part?.type === "tool" && part.state.status === "completed") {
expect(part.state.time.compacted).toBeUndefined()
}
},
})
})
}),
),
)
})
describe("session.compaction.process", () => {
@@ -661,6 +833,49 @@ describe("session.compaction.process", () => {
})
})
test("allows plugins to disable synthetic continue prompt", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const msg = await user(session.id, "hello")
const rt = runtime("continue", autocontinue(false), wide())
try {
const msgs = await Session.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: msg.id,
messages: msgs,
sessionID: session.id,
auto: true,
}),
),
)
const all = await Session.messages({ sessionID: session.id })
const last = all.at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("assistant")
expect(
all.some(
(msg) =>
msg.info.role === "user" &&
msg.parts.some(
(part) =>
part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"),
),
),
).toBe(false)
} finally {
await rt.dispose()
}
},
})
})
test("replays the prior user turn on overflow when earlier context exists", async () => {
await using tmp = await tmpdir()
await Instance.provide({
+13 -106
View File
@@ -26,6 +26,12 @@ async function getModel(providerID: ProviderID, modelID: ModelID) {
)
}
const llm = makeRuntime(LLM.Service, LLM.defaultLayer)
async function drain(input: LLM.StreamInput) {
return llm.runPromise((svc) => svc.stream(input).pipe(Stream.runDrain))
}
describe("session.llm.hasToolCalls", () => {
test("returns false for empty messages array", () => {
expect(LLM.hasToolCalls([])).toBe(false)
@@ -355,20 +361,16 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User
const stream = await LLM.stream({
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
const body = capture.body
const headers = capture.headers
@@ -393,80 +395,6 @@ describe("session.llm.stream", () => {
})
})
test("raw stream abort signal cancels provider response body promptly", async () => {
const server = state.server
if (!server) throw new Error("Server not initialized")
const providerID = "alibaba"
const modelID = "qwen-plus"
const fixture = await loadFixture(providerID, modelID)
const model = fixture.model
const pending = waitStreamingRequest("/chat/completions")
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: [providerID],
provider: {
[providerID]: {
options: {
apiKey: "test-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-raw-abort")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-raw-abort"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
} satisfies MessageV2.User
const ctrl = new AbortController()
const result = await LLM.stream({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: ctrl.signal,
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
const iter = result.fullStream[Symbol.asyncIterator]()
await pending.request
await iter.next()
ctrl.abort()
await Promise.race([pending.responseCanceled, timeout(500)])
await Promise.race([pending.requestAborted, timeout(500)]).catch(() => undefined)
await iter.return?.()
},
})
})
test("service stream cancellation cancels provider response body promptly", async () => {
const server = state.server
if (!server) throw new Error("Server not initialized")
@@ -518,8 +446,7 @@ describe("session.llm.stream", () => {
} satisfies MessageV2.User
const ctrl = new AbortController()
const { runPromiseExit } = makeRuntime(LLM.Service, LLM.defaultLayer)
const run = runPromiseExit(
const run = llm.runPromiseExit(
(svc) =>
svc
.stream({
@@ -610,14 +537,13 @@ describe("session.llm.stream", () => {
tools: { question: true },
} satisfies MessageV2.User
const stream = await LLM.stream({
await drain({
user,
sessionID,
model: resolved,
agent,
permission: [{ permission: "question", pattern: "*", action: "allow" }],
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [{ role: "user", content: "Hello" }],
tools: {
question: tool({
@@ -628,9 +554,6 @@ describe("session.llm.stream", () => {
},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
expect(tools?.some((item) => item.function?.name === "question")).toBe(true)
@@ -728,20 +651,16 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User
const stream = await LLM.stream({
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
const body = capture.body
@@ -847,13 +766,12 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
} satisfies MessageV2.User
const stream = await LLM.stream({
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [
{
role: "user",
@@ -871,9 +789,6 @@ describe("session.llm.stream", () => {
tools: {},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
},
@@ -972,20 +887,16 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") },
} satisfies MessageV2.User
const stream = await LLM.stream({
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
const body = capture.body
@@ -1073,20 +984,16 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
} satisfies MessageV2.User
const stream = await LLM.stream({
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
const body = capture.body
const config = body.generationConfig as
@@ -16,6 +16,7 @@ import { MessageV2 } from "../../src/session/message-v2"
import { SessionProcessor } from "../../src/session/processor"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { Snapshot } from "../../src/snapshot"
import { Log } from "../../src/util/log"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -25,6 +26,15 @@ import { raw, reply, TestLLMServer } from "../lib/llm-server"
Log.init({ print: false })
const summary = Layer.succeed(
SessionSummary.Service,
SessionSummary.Service.of({
summarize: () => Effect.void,
diff: () => Effect.succeed([]),
computeDiff: () => Effect.succeed([]),
}),
)
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
@@ -156,7 +166,10 @@ const deps = Layer.mergeAll(
Provider.defaultLayer,
status,
).pipe(Layer.provideMerge(infra))
const env = Layer.mergeAll(TestLLMServer.layer, SessionProcessor.layer.pipe(Layer.provideMerge(deps)))
const env = Layer.mergeAll(
TestLLMServer.layer,
SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)),
)
const it = testEffect(env)
@@ -14,6 +14,7 @@ import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Env } from "../../src/env"
import type { Provider } from "../../src/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question"
@@ -23,6 +24,7 @@ import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import { AppFileSystem } from "../../src/filesystem"
import { SessionCompaction } from "../../src/session/compaction"
import { SessionSummary } from "../../src/session/summary"
import { Instruction } from "../../src/session/instruction"
import { SessionProcessor } from "../../src/session/processor"
import { SessionPrompt } from "../../src/session/prompt"
@@ -46,6 +48,15 @@ import { reply, TestLLMServer } from "../lib/llm-server"
Log.init({ print: false })
const summary = Layer.succeed(
SessionSummary.Service,
SessionSummary.Service.of({
summarize: () => Effect.void,
diff: () => Effect.succeed([]),
computeDiff: () => Effect.succeed([]),
}),
)
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
@@ -157,6 +168,7 @@ function makeHttp() {
Session.defaultLayer,
Snapshot.defaultLayer,
LLM.defaultLayer,
Env.defaultLayer,
AgentSvc.defaultLayer,
Command.defaultLayer,
Permission.defaultLayer,
@@ -182,12 +194,13 @@ function makeHttp() {
Layer.provideMerge(deps),
)
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps))
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
return Layer.mergeAll(
TestLLMServer.layer,
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(summary),
Layer.provideMerge(run),
Layer.provideMerge(compact),
Layer.provideMerge(proc),
@@ -1,35 +1,47 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { Session } from "../../src/session"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionRevert } from "../../src/session/revert"
import { SessionCompaction } from "../../src/session/compaction"
import { MessageV2 } from "../../src/session/message-v2"
import { Snapshot } from "../../src/snapshot"
import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance"
import { MessageID, PartID } from "../../src/session/schema"
import { tmpdir } from "../fixture/fixture"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
Log.init({ print: false })
function user(sessionID: string, agent = "default") {
return Session.updateMessage({
const env = Layer.mergeAll(
Session.defaultLayer,
SessionRevert.defaultLayer,
Snapshot.defaultLayer,
CrossSpawnSpawner.defaultLayer,
)
const it = testEffect(env)
const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "default") {
const session = yield* Session.Service
return yield* session.updateMessage({
id: MessageID.ascending(),
role: "user" as const,
sessionID: sessionID as any,
sessionID,
agent,
model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4") },
time: { created: Date.now() },
})
}
})
function assistant(sessionID: string, parentID: string, dir: string) {
return Session.updateMessage({
const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, parentID: MessageID, dir: string) {
const session = yield* Session.Service
return yield* session.updateMessage({
id: MessageID.ascending(),
role: "assistant" as const,
sessionID: sessionID as any,
sessionID,
mode: "default",
agent: "default",
path: { cwd: dir, root: dir },
@@ -37,27 +49,29 @@ function assistant(sessionID: string, parentID: string, dir: string) {
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ModelID.make("gpt-4"),
providerID: ProviderID.make("openai"),
parentID: parentID as any,
parentID,
time: { created: Date.now() },
finish: "end_turn",
})
}
})
function text(sessionID: string, messageID: string, content: string) {
return Session.updatePart({
const text = Effect.fn("test.text")(function* (sessionID: SessionID, messageID: MessageID, content: string) {
const session = yield* Session.Service
return yield* session.updatePart({
id: PartID.ascending(),
messageID: messageID as any,
sessionID: sessionID as any,
messageID,
sessionID,
type: "text" as const,
text: content,
})
}
})
function tool(sessionID: string, messageID: string) {
return Session.updatePart({
const tool = Effect.fn("test.tool")(function* (sessionID: SessionID, messageID: MessageID) {
const session = yield* Session.Service
return yield* session.updatePart({
id: PartID.ascending(),
messageID: messageID as any,
sessionID: sessionID as any,
messageID,
sessionID,
type: "tool" as const,
tool: "bash",
callID: "call-1",
@@ -70,7 +84,10 @@ function tool(sessionID: string, messageID: string) {
time: { start: 0, end: 1 },
},
})
}
})
const read = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8"))
const write = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text))
const tokens = {
input: 0,
@@ -80,542 +97,543 @@ const tokens = {
}
describe("revert + compact workflow", () => {
test("should properly handle compact command after revert", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Create a session
const session = await Session.create({})
const sessionID = session.id
it.live(
"should properly handle compact command after revert",
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
// Create a user message
const userMsg1 = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "default",
model: {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
},
time: {
created: Date.now(),
},
})
const info = yield* session.create({})
const sessionID = info.id
// Add a text part to the user message
await Session.updatePart({
id: PartID.ascending(),
messageID: userMsg1.id,
sessionID,
type: "text",
text: "Hello, please help me",
})
// Create an assistant response message
const assistantMsg1: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "default",
agent: "default",
path: {
cwd: tmp.path,
root: tmp.path,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ModelID.make("gpt-4"),
providerID: ProviderID.make("openai"),
parentID: userMsg1.id,
time: {
created: Date.now(),
},
finish: "end_turn",
}
await Session.updateMessage(assistantMsg1)
// Add a text part to the assistant message
await Session.updatePart({
id: PartID.ascending(),
messageID: assistantMsg1.id,
sessionID,
type: "text",
text: "Sure, I'll help you!",
})
// Create another user message
const userMsg2 = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "default",
model: {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
},
time: {
created: Date.now(),
},
})
await Session.updatePart({
id: PartID.ascending(),
messageID: userMsg2.id,
sessionID,
type: "text",
text: "What's the capital of France?",
})
// Create another assistant response
const assistantMsg2: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "default",
agent: "default",
path: {
cwd: tmp.path,
root: tmp.path,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ModelID.make("gpt-4"),
providerID: ProviderID.make("openai"),
parentID: userMsg2.id,
time: {
created: Date.now(),
},
finish: "end_turn",
}
await Session.updateMessage(assistantMsg2)
await Session.updatePart({
id: PartID.ascending(),
messageID: assistantMsg2.id,
sessionID,
type: "text",
text: "The capital of France is Paris.",
})
// Verify messages before revert
let messages = await Session.messages({ sessionID })
expect(messages.length).toBe(4) // 2 user + 2 assistant messages
const messageIds = messages.map((m) => m.info.id)
expect(messageIds).toContain(userMsg1.id)
expect(messageIds).toContain(userMsg2.id)
expect(messageIds).toContain(assistantMsg1.id)
expect(messageIds).toContain(assistantMsg2.id)
// Revert the last user message (userMsg2)
await SessionRevert.revert({
sessionID,
messageID: userMsg2.id,
})
// Check that revert state is set
let sessionInfo = await Session.get(sessionID)
expect(sessionInfo.revert).toBeDefined()
const revertMessageID = sessionInfo.revert?.messageID
expect(revertMessageID).toBeDefined()
// Messages should still be in the list (not removed yet, just marked for revert)
messages = await Session.messages({ sessionID })
expect(messages.length).toBe(4)
// Now clean up the revert state (this is what the compact endpoint should do)
await SessionRevert.cleanup(sessionInfo)
// After cleanup, the reverted messages (those after the revert point) should be removed
messages = await Session.messages({ sessionID })
const remainingIds = messages.map((m) => m.info.id)
// The revert point is somewhere in the message chain, so we should have fewer messages
expect(messages.length).toBeLessThan(4)
// userMsg2 and assistantMsg2 should be removed (they come after the revert point)
expect(remainingIds).not.toContain(userMsg2.id)
expect(remainingIds).not.toContain(assistantMsg2.id)
// Revert state should be cleared
sessionInfo = await Session.get(sessionID)
expect(sessionInfo.revert).toBeUndefined()
// Clean up
await Session.remove(sessionID)
},
})
})
test("should properly clean up revert state before creating compaction message", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Create a session
const session = await Session.create({})
const sessionID = session.id
// Create initial messages
const userMsg = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "default",
model: {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
},
time: {
created: Date.now(),
},
})
await Session.updatePart({
id: PartID.ascending(),
messageID: userMsg.id,
sessionID,
type: "text",
text: "Hello",
})
const assistantMsg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "default",
agent: "default",
path: {
cwd: tmp.path,
root: tmp.path,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ModelID.make("gpt-4"),
providerID: ProviderID.make("openai"),
parentID: userMsg.id,
time: {
created: Date.now(),
},
finish: "end_turn",
}
await Session.updateMessage(assistantMsg)
await Session.updatePart({
id: PartID.ascending(),
messageID: assistantMsg.id,
sessionID,
type: "text",
text: "Hi there!",
})
// Revert the user message
await SessionRevert.revert({
sessionID,
messageID: userMsg.id,
})
// Check that revert state is set
let sessionInfo = await Session.get(sessionID)
expect(sessionInfo.revert).toBeDefined()
// Simulate what the compact endpoint does: cleanup revert before creating compaction
await SessionRevert.cleanup(sessionInfo)
// Verify revert state is cleared
sessionInfo = await Session.get(sessionID)
expect(sessionInfo.revert).toBeUndefined()
// Verify messages are properly cleaned up
const messages = await Session.messages({ sessionID })
expect(messages.length).toBe(0) // All messages should be reverted
// Clean up
await Session.remove(sessionID)
},
})
})
test("cleanup with partID removes parts from the revert point onward", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const sid = session.id
const u1 = await user(sid)
const p1 = await text(sid, u1.id, "first part")
const p2 = await tool(sid, u1.id)
const p3 = await text(sid, u1.id, "third part")
// Set revert state pointing at a specific part
await Session.setRevert({
sessionID: sid,
revert: { messageID: u1.id, partID: p2.id },
summary: { additions: 0, deletions: 0, files: 0 },
})
const info = await Session.get(sid)
await SessionRevert.cleanup(info)
const msgs = await Session.messages({ sessionID: sid })
expect(msgs.length).toBe(1)
// Only the first part should remain (before the revert partID)
expect(msgs[0].parts.length).toBe(1)
expect(msgs[0].parts[0].id).toBe(p1.id)
const cleared = await Session.get(sid)
expect(cleared.revert).toBeUndefined()
},
})
})
test("cleanup removes messages after revert point but keeps earlier ones", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const sid = session.id
const u1 = await user(sid)
await text(sid, u1.id, "hello")
const a1 = await assistant(sid, u1.id, tmp.path)
await text(sid, a1.id, "hi back")
const u2 = await user(sid)
await text(sid, u2.id, "second question")
const a2 = await assistant(sid, u2.id, tmp.path)
await text(sid, a2.id, "second answer")
// Revert from u2 onward
await Session.setRevert({
sessionID: sid,
revert: { messageID: u2.id },
summary: { additions: 0, deletions: 0, files: 0 },
})
const info = await Session.get(sid)
await SessionRevert.cleanup(info)
const msgs = await Session.messages({ sessionID: sid })
const ids = msgs.map((m) => m.info.id)
expect(ids).toContain(u1.id)
expect(ids).toContain(a1.id)
expect(ids).not.toContain(u2.id)
expect(ids).not.toContain(a2.id)
},
})
})
test("cleanup is a no-op when session has no revert state", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const sid = session.id
const u1 = await user(sid)
await text(sid, u1.id, "hello")
const info = await Session.get(sid)
expect(info.revert).toBeUndefined()
await SessionRevert.cleanup(info)
const msgs = await Session.messages({ sessionID: sid })
expect(msgs.length).toBe(1)
},
})
})
test("restore messages in sequential order", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await fs.writeFile(path.join(tmp.path, "a.txt"), "a0")
await fs.writeFile(path.join(tmp.path, "b.txt"), "b0")
await fs.writeFile(path.join(tmp.path, "c.txt"), "c0")
const session = await Session.create({})
const sid = session.id
const turn = async (file: string, next: string) => {
const u = await user(sid)
await text(sid, u.id, `${file}:${next}`)
const a = await assistant(sid, u.id, tmp.path)
const before = await Snapshot.track()
if (!before) throw new Error("expected snapshot")
await fs.writeFile(path.join(tmp.path, file), next)
const after = await Snapshot.track()
if (!after) throw new Error("expected snapshot")
const patch = await Snapshot.patch(before)
await Session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "step-start",
snapshot: before,
const userMsg1 = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "default",
model: {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
},
time: {
created: Date.now(),
},
})
await Session.updatePart({
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "step-finish",
reason: "stop",
snapshot: after,
messageID: userMsg1.id,
sessionID,
type: "text",
text: "Hello, please help me",
})
const assistantMsg1: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "default",
agent: "default",
path: {
cwd: dir,
root: dir,
},
cost: 0,
tokens,
})
await Session.updatePart({
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ModelID.make("gpt-4"),
providerID: ProviderID.make("openai"),
parentID: userMsg1.id,
time: {
created: Date.now(),
},
finish: "end_turn",
}
yield* session.updateMessage(assistantMsg1)
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "patch",
hash: patch.hash,
files: patch.files,
messageID: assistantMsg1.id,
sessionID,
type: "text",
text: "Sure, I'll help you!",
})
return u.id
}
const first = await turn("a.txt", "a1")
const second = await turn("b.txt", "b2")
const third = await turn("c.txt", "c3")
await SessionRevert.revert({
sessionID: sid,
messageID: first,
})
expect((await Session.get(sid)).revert?.messageID).toBe(first)
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a0")
expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b0")
expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0")
await SessionRevert.revert({
sessionID: sid,
messageID: second,
})
expect((await Session.get(sid)).revert?.messageID).toBe(second)
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1")
expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b0")
expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0")
await SessionRevert.revert({
sessionID: sid,
messageID: third,
})
expect((await Session.get(sid)).revert?.messageID).toBe(third)
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1")
expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b2")
expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0")
await SessionRevert.unrevert({
sessionID: sid,
})
expect((await Session.get(sid)).revert).toBeUndefined()
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1")
expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b2")
expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c3")
},
})
})
test("restore same file in sequential order", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await fs.writeFile(path.join(tmp.path, "a.txt"), "a0")
const session = await Session.create({})
const sid = session.id
const turn = async (next: string) => {
const u = await user(sid)
await text(sid, u.id, `a.txt:${next}`)
const a = await assistant(sid, u.id, tmp.path)
const before = await Snapshot.track()
if (!before) throw new Error("expected snapshot")
await fs.writeFile(path.join(tmp.path, "a.txt"), next)
const after = await Snapshot.track()
if (!after) throw new Error("expected snapshot")
const patch = await Snapshot.patch(before)
await Session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "step-start",
snapshot: before,
const userMsg2 = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "default",
model: {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
},
time: {
created: Date.now(),
},
})
await Session.updatePart({
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "step-finish",
reason: "stop",
snapshot: after,
messageID: userMsg2.id,
sessionID,
type: "text",
text: "What's the capital of France?",
})
const assistantMsg2: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "default",
agent: "default",
path: {
cwd: dir,
root: dir,
},
cost: 0,
tokens,
})
await Session.updatePart({
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ModelID.make("gpt-4"),
providerID: ProviderID.make("openai"),
parentID: userMsg2.id,
time: {
created: Date.now(),
},
finish: "end_turn",
}
yield* session.updateMessage(assistantMsg2)
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "patch",
hash: patch.hash,
files: patch.files,
messageID: assistantMsg2.id,
sessionID,
type: "text",
text: "The capital of France is Paris.",
})
return u.id
}
const first = await turn("a1")
const second = await turn("a2")
const third = await turn("a3")
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a3")
let messages = yield* session.messages({ sessionID })
expect(messages.length).toBe(4)
const messageIds = messages.map((m) => m.info.id)
expect(messageIds).toContain(userMsg1.id)
expect(messageIds).toContain(userMsg2.id)
expect(messageIds).toContain(assistantMsg1.id)
expect(messageIds).toContain(assistantMsg2.id)
await SessionRevert.revert({
sessionID: sid,
messageID: first,
})
expect((await Session.get(sid)).revert?.messageID).toBe(first)
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a0")
yield* revert.revert({
sessionID,
messageID: userMsg2.id,
})
await SessionRevert.revert({
sessionID: sid,
messageID: second,
})
expect((await Session.get(sid)).revert?.messageID).toBe(second)
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1")
let sessionInfo = yield* session.get(sessionID)
expect(sessionInfo.revert).toBeDefined()
expect(sessionInfo.revert?.messageID).toBeDefined()
await SessionRevert.revert({
sessionID: sid,
messageID: third,
})
expect((await Session.get(sid)).revert?.messageID).toBe(third)
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a2")
messages = yield* session.messages({ sessionID })
expect(messages.length).toBe(4)
await SessionRevert.unrevert({
sessionID: sid,
})
expect((await Session.get(sid)).revert).toBeUndefined()
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a3")
},
})
})
yield* revert.cleanup(sessionInfo)
messages = yield* session.messages({ sessionID })
const remainingIds = messages.map((m) => m.info.id)
expect(messages.length).toBeLessThan(4)
expect(remainingIds).not.toContain(userMsg2.id)
expect(remainingIds).not.toContain(assistantMsg2.id)
sessionInfo = yield* session.get(sessionID)
expect(sessionInfo.revert).toBeUndefined()
yield* session.remove(sessionID)
}),
{ git: true },
),
)
it.live(
"should properly clean up revert state before creating compaction message",
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const info = yield* session.create({})
const sessionID = info.id
const userMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "default",
model: {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
},
time: {
created: Date.now(),
},
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: userMsg.id,
sessionID,
type: "text",
text: "Hello",
})
const assistantMsg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "default",
agent: "default",
path: {
cwd: dir,
root: dir,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ModelID.make("gpt-4"),
providerID: ProviderID.make("openai"),
parentID: userMsg.id,
time: {
created: Date.now(),
},
finish: "end_turn",
}
yield* session.updateMessage(assistantMsg)
yield* session.updatePart({
id: PartID.ascending(),
messageID: assistantMsg.id,
sessionID,
type: "text",
text: "Hi there!",
})
yield* revert.revert({
sessionID,
messageID: userMsg.id,
})
let sessionInfo = yield* session.get(sessionID)
expect(sessionInfo.revert).toBeDefined()
yield* revert.cleanup(sessionInfo)
sessionInfo = yield* session.get(sessionID)
expect(sessionInfo.revert).toBeUndefined()
const messages = yield* session.messages({ sessionID })
expect(messages.length).toBe(0)
yield* session.remove(sessionID)
}),
{ git: true },
),
)
it.live(
"cleanup with partID removes parts from the revert point onward",
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const info = yield* session.create({})
const sid = info.id
const u1 = yield* user(sid)
const p1 = yield* text(sid, u1.id, "first part")
const p2 = yield* tool(sid, u1.id)
yield* text(sid, u1.id, "third part")
yield* session.setRevert({
sessionID: sid,
revert: { messageID: u1.id, partID: p2.id },
summary: { additions: 0, deletions: 0, files: 0 },
})
const state = yield* session.get(sid)
yield* revert.cleanup(state)
const msgs = yield* session.messages({ sessionID: sid })
expect(msgs.length).toBe(1)
expect(msgs[0].parts.length).toBe(1)
expect(msgs[0].parts[0].id).toBe(p1.id)
const cleared = yield* session.get(sid)
expect(cleared.revert).toBeUndefined()
}),
{ git: true },
),
)
it.live(
"cleanup removes messages after revert point but keeps earlier ones",
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const info = yield* session.create({})
const sid = info.id
const u1 = yield* user(sid)
yield* text(sid, u1.id, "hello")
const a1 = yield* assistant(sid, u1.id, dir)
yield* text(sid, a1.id, "hi back")
const u2 = yield* user(sid)
yield* text(sid, u2.id, "second question")
const a2 = yield* assistant(sid, u2.id, dir)
yield* text(sid, a2.id, "second answer")
yield* session.setRevert({
sessionID: sid,
revert: { messageID: u2.id },
summary: { additions: 0, deletions: 0, files: 0 },
})
const state = yield* session.get(sid)
yield* revert.cleanup(state)
const msgs = yield* session.messages({ sessionID: sid })
const ids = msgs.map((m) => m.info.id)
expect(ids).toContain(u1.id)
expect(ids).toContain(a1.id)
expect(ids).not.toContain(u2.id)
expect(ids).not.toContain(a2.id)
}),
{ git: true },
),
)
it.live(
"cleanup is a no-op when session has no revert state",
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const info = yield* session.create({})
const sid = info.id
const u1 = yield* user(sid)
yield* text(sid, u1.id, "hello")
const state = yield* session.get(sid)
expect(state.revert).toBeUndefined()
yield* revert.cleanup(state)
const msgs = yield* session.messages({ sessionID: sid })
expect(msgs.length).toBe(1)
}),
{ git: true },
),
)
it.live(
"restore messages in sequential order",
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const snapshot = yield* Snapshot.Service
yield* write(path.join(dir, "a.txt"), "a0")
yield* write(path.join(dir, "b.txt"), "b0")
yield* write(path.join(dir, "c.txt"), "c0")
const info = yield* session.create({})
const sid = info.id
const turn = Effect.fn("test.turn")(function* (file: string, next: string) {
const u = yield* user(sid)
yield* text(sid, u.id, `${file}:${next}`)
const a = yield* assistant(sid, u.id, dir)
const before = yield* snapshot.track()
if (!before) throw new Error("expected snapshot")
yield* write(path.join(dir, file), next)
const after = yield* snapshot.track()
if (!after) throw new Error("expected snapshot")
const patch = yield* snapshot.patch(before)
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "step-start",
snapshot: before,
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "step-finish",
reason: "stop",
snapshot: after,
cost: 0,
tokens,
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "patch",
hash: patch.hash,
files: patch.files,
})
return u.id
})
const first = yield* turn("a.txt", "a1")
const second = yield* turn("b.txt", "b2")
const third = yield* turn("c.txt", "c3")
yield* revert.revert({
sessionID: sid,
messageID: first,
})
expect((yield* session.get(sid)).revert?.messageID).toBe(first)
expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
yield* revert.revert({
sessionID: sid,
messageID: second,
})
expect((yield* session.get(sid)).revert?.messageID).toBe(second)
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
yield* revert.revert({
sessionID: sid,
messageID: third,
})
expect((yield* session.get(sid)).revert?.messageID).toBe(third)
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
yield* revert.unrevert({
sessionID: sid,
})
expect((yield* session.get(sid)).revert).toBeUndefined()
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
expect(yield* read(path.join(dir, "c.txt"))).toBe("c3")
}),
{ git: true },
),
)
it.live(
"restore same file in sequential order",
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const snapshot = yield* Snapshot.Service
yield* write(path.join(dir, "a.txt"), "a0")
const info = yield* session.create({})
const sid = info.id
const turn = Effect.fn("test.turnSame")(function* (next: string) {
const u = yield* user(sid)
yield* text(sid, u.id, `a.txt:${next}`)
const a = yield* assistant(sid, u.id, dir)
const before = yield* snapshot.track()
if (!before) throw new Error("expected snapshot")
yield* write(path.join(dir, "a.txt"), next)
const after = yield* snapshot.track()
if (!after) throw new Error("expected snapshot")
const patch = yield* snapshot.patch(before)
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "step-start",
snapshot: before,
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "step-finish",
reason: "stop",
snapshot: after,
cost: 0,
tokens,
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "patch",
hash: patch.hash,
files: patch.files,
})
return u.id
})
const first = yield* turn("a1")
const second = yield* turn("a2")
const third = yield* turn("a3")
expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
yield* revert.revert({
sessionID: sid,
messageID: first,
})
expect((yield* session.get(sid)).revert?.messageID).toBe(first)
expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
yield* revert.revert({
sessionID: sid,
messageID: second,
})
expect((yield* session.get(sid)).revert?.messageID).toBe(second)
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
yield* revert.revert({
sessionID: sid,
messageID: third,
})
expect((yield* session.get(sid)).revert?.messageID).toBe(third)
expect(yield* read(path.join(dir, "a.txt"))).toBe("a2")
yield* revert.unrevert({
sessionID: sid,
})
expect((yield* session.get(sid)).revert).toBeUndefined()
expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
}),
{ git: true },
),
)
})
@@ -39,6 +39,7 @@ import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Env } from "../../src/env"
import { Question } from "../../src/question"
import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
@@ -121,6 +122,7 @@ function makeHttp() {
Session.defaultLayer,
Snapshot.defaultLayer,
LLM.defaultLayer,
Env.defaultLayer,
AgentSvc.defaultLayer,
Command.defaultLayer,
Permission.defaultLayer,
@@ -146,12 +148,14 @@ function makeHttp() {
Layer.provideMerge(deps),
)
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(deps))
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
return Layer.mergeAll(
TestLLMServer.layer,
SessionSummary.defaultLayer,
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provideMerge(run),
Layer.provideMerge(compact),
Layer.provideMerge(proc),
@@ -200,6 +204,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
Effect.fnUntraced(function* ({ dir, llm }) {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const summary = yield* SessionSummary.Service
const session = yield* sessions.create({
title: "snapshot race test",
@@ -244,9 +249,9 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
expect(tool?.state.status).toBe("completed")
// Poll for diff — summarize() is fire-and-forget
let diff: Awaited<ReturnType<typeof SessionSummary.diff>> = []
let diff: Array<{ file: string }> = []
for (let i = 0; i < 50; i++) {
diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id }))
diff = yield* summary.diff({ sessionID: session.id })
if (diff.length > 0) break
yield* Effect.sleep("100 millis")
}
@@ -4,7 +4,11 @@ import { Effect } from "effect"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { SystemPrompt } from "../../src/session/system"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir } from "../fixture/fixture"
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}
describe("session.system", () => {
test("skills output is sorted by name and stable across calls", async () => {
@@ -38,7 +42,7 @@ description: ${description}
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const build = await load(tmp.path, (svc) => svc.get("build"))
const runSkills = Effect.gen(function* () {
const svc = yield* SystemPrompt.Service
return yield* svc.skills(build!)
+176 -154
View File
@@ -2,10 +2,11 @@ import { afterEach, test, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Snapshot } from "../../src/snapshot"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir } from "../fixture/fixture"
// Git always outputs /-separated paths internally. Snapshot.patch() joins them
// with path.join (which produces \ on Windows) then normalizes back to /.
@@ -35,17 +36,26 @@ async function bootstrap() {
})
}
function run<A>(dir: string, body: (snapshot: Snapshot.Interface) => Effect.Effect<A>) {
return Effect.runPromise(
Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
return yield* body(snapshot)
}).pipe(provideInstance(dir), Effect.provide(Snapshot.defaultLayer)),
)
}
test("tracks deleted files correctly", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await $`rm ${tmp.path}/a.txt`.quiet()
expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "a.txt"))
expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files).toContain(fwd(tmp.path, "a.txt"))
},
})
})
@@ -55,12 +65,14 @@ test("revert should remove new files", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/new.txt`, "NEW")
await Snapshot.revert([await Snapshot.patch(before!)])
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
await run(tmp.path, (snapshot) => snapshot.revert([patch]))
expect(
await fs
@@ -77,13 +89,15 @@ test("revert in subdirectory", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await $`mkdir -p ${tmp.path}/sub`.quiet()
await Filesystem.write(`${tmp.path}/sub/file.txt`, "SUB")
await Snapshot.revert([await Snapshot.patch(before!)])
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
await run(tmp.path, (snapshot) => snapshot.revert([patch]))
expect(
await fs
@@ -102,7 +116,7 @@ test("multiple file operations", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await $`rm ${tmp.path}/a.txt`.quiet()
@@ -111,7 +125,9 @@ test("multiple file operations", async () => {
await Filesystem.write(`${tmp.path}/dir/d.txt`, "D")
await Filesystem.write(`${tmp.path}/b.txt`, "MODIFIED")
await Snapshot.revert([await Snapshot.patch(before!)])
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
await run(tmp.path, (snapshot) => snapshot.revert([patch]))
expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent)
expect(
@@ -132,12 +148,12 @@ test("empty directory handling", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await $`mkdir ${tmp.path}/empty`.quiet()
expect((await Snapshot.patch(before!)).files.length).toBe(0)
expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files.length).toBe(0)
},
})
})
@@ -147,15 +163,15 @@ test("binary file handling", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47]))
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
expect(patch.files).toContain(fwd(tmp.path, "image.png"))
await Snapshot.revert([patch])
await run(tmp.path, (snapshot) => snapshot.revert([patch]))
expect(
await fs
.access(`${tmp.path}/image.png`)
@@ -171,12 +187,12 @@ test("symlink handling", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await fs.symlink(`${tmp.path}/a.txt`, `${tmp.path}/link.txt`, "file")
expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "link.txt"))
expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files).toContain(fwd(tmp.path, "link.txt"))
},
})
})
@@ -186,12 +202,12 @@ test("file under size limit handling", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024))
expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "large.txt"))
expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files).toContain(fwd(tmp.path, "large.txt"))
},
})
})
@@ -201,14 +217,14 @@ test("large added files are skipped", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/huge.txt`, new Uint8Array(2 * 1024 * 1024 + 1))
expect((await Snapshot.patch(before!)).files).toEqual([])
expect(await Snapshot.diff(before!)).toBe("")
expect(await Snapshot.track()).toBe(before)
expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files).toEqual([])
expect(await run(tmp.path, (snapshot) => snapshot.diff(before!))).toBe("")
expect(await run(tmp.path, (snapshot) => snapshot.track())).toBe(before)
},
})
})
@@ -218,13 +234,15 @@ test("nested directory revert", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await $`mkdir -p ${tmp.path}/level1/level2/level3`.quiet()
await Filesystem.write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP")
await Snapshot.revert([await Snapshot.patch(before!)])
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
await run(tmp.path, (snapshot) => snapshot.revert([patch]))
expect(
await fs
@@ -241,14 +259,14 @@ test("special characters in filenames", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/file with spaces.txt`, "SPACES")
await Filesystem.write(`${tmp.path}/file-with-dashes.txt`, "DASHES")
await Filesystem.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES")
const files = (await Snapshot.patch(before!)).files
const files = (await run(tmp.path, (snapshot) => snapshot.patch(before!))).files
expect(files).toContain(fwd(tmp.path, "file with spaces.txt"))
expect(files).toContain(fwd(tmp.path, "file-with-dashes.txt"))
expect(files).toContain(fwd(tmp.path, "file_with_underscores.txt"))
@@ -262,10 +280,10 @@ test("revert with empty patches", async () => {
directory: tmp.path,
fn: async () => {
// Should not crash with empty patches
expect(Snapshot.revert([])).resolves.toBeUndefined()
expect(run(tmp.path, (snapshot) => snapshot.revert([]))).resolves.toBeUndefined()
// Should not crash with patches that have empty file lists
expect(Snapshot.revert([{ hash: "dummy", files: [] }])).resolves.toBeUndefined()
expect(run(tmp.path, (snapshot) => snapshot.revert([{ hash: "dummy", files: [] }]))).resolves.toBeUndefined()
},
})
})
@@ -275,14 +293,14 @@ test("patch with invalid hash", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
// Create a change
await Filesystem.write(`${tmp.path}/test.txt`, "TEST")
// Try to patch with invalid hash - should handle gracefully
const patch = await Snapshot.patch("invalid-hash-12345")
const patch = await run(tmp.path, (snapshot) => snapshot.patch("invalid-hash-12345"))
expect(patch.files).toEqual([])
expect(patch.hash).toBe("invalid-hash-12345")
},
@@ -294,18 +312,20 @@ test("revert non-existent file", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
// Try to revert a file that doesn't exist in the snapshot
// This should not crash
expect(
Snapshot.revert([
{
hash: before!,
files: [`${tmp.path}/nonexistent.txt`],
},
]),
run(tmp.path, (snapshot) =>
snapshot.revert([
{
hash: before!,
files: [`${tmp.path}/nonexistent.txt`],
},
]),
),
).resolves.toBeUndefined()
},
})
@@ -316,7 +336,7 @@ test("unicode filenames", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
const unicodeFiles = [
@@ -330,14 +350,14 @@ test("unicode filenames", async () => {
await Filesystem.write(file.path, file.content)
}
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
expect(patch.files.length).toBe(4)
for (const file of unicodeFiles) {
expect(patch.files).toContain(file.path)
}
await Snapshot.revert([patch])
await run(tmp.path, (snapshot) => snapshot.revert([patch]))
for (const file of unicodeFiles) {
expect(
@@ -362,17 +382,17 @@ test.skip("unicode filenames modification and restore", async () => {
await Filesystem.write(chineseFile, "original chinese")
await Filesystem.write(cyrillicFile, "original cyrillic")
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(chineseFile, "modified chinese")
await Filesystem.write(cyrillicFile, "modified cyrillic")
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
expect(patch.files).toContain(chineseFile)
expect(patch.files).toContain(cyrillicFile)
await Snapshot.revert([patch])
await run(tmp.path, (snapshot) => snapshot.revert([patch]))
expect(await fs.readFile(chineseFile, "utf-8")).toBe("original chinese")
expect(await fs.readFile(cyrillicFile, "utf-8")).toBe("original cyrillic")
@@ -385,17 +405,17 @@ test("unicode filenames in subdirectories", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet()
const deepFile = fwd(tmp.path, "目录", "подкаталог", "文件.txt")
await Filesystem.write(deepFile, "deep unicode content")
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
expect(patch.files).toContain(deepFile)
await Snapshot.revert([patch])
await run(tmp.path, (snapshot) => snapshot.revert([patch]))
expect(
await fs
.access(deepFile)
@@ -411,7 +431,7 @@ test("very long filenames", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
const longName = "a".repeat(200) + ".txt"
@@ -419,10 +439,10 @@ test("very long filenames", async () => {
await Filesystem.write(longFile, "long filename content")
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
expect(patch.files).toContain(longFile)
await Snapshot.revert([patch])
await run(tmp.path, (snapshot) => snapshot.revert([patch]))
expect(
await fs
.access(longFile)
@@ -438,14 +458,14 @@ test("hidden files", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/.hidden`, "hidden content")
await Filesystem.write(`${tmp.path}/.gitignore`, "*.log")
await Filesystem.write(`${tmp.path}/.config`, "config content")
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
expect(patch.files).toContain(fwd(tmp.path, ".hidden"))
expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
expect(patch.files).toContain(fwd(tmp.path, ".config"))
@@ -458,7 +478,7 @@ test("nested symlinks", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await $`mkdir -p ${tmp.path}/sub/dir`.quiet()
@@ -466,7 +486,7 @@ test("nested symlinks", async () => {
await fs.symlink(`${tmp.path}/sub/dir/target.txt`, `${tmp.path}/sub/dir/link.txt`, "file")
await fs.symlink(`${tmp.path}/sub`, `${tmp.path}/sub-link`, "dir")
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
expect(patch.files).toContain(fwd(tmp.path, "sub", "dir", "link.txt"))
expect(patch.files).toContain(fwd(tmp.path, "sub-link"))
},
@@ -478,7 +498,7 @@ test("file permissions and ownership changes", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
// Change permissions multiple times
@@ -486,7 +506,7 @@ test("file permissions and ownership changes", async () => {
await $`chmod 755 ${tmp.path}/a.txt`.quiet()
await $`chmod 644 ${tmp.path}/a.txt`.quiet()
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
// Note: git doesn't track permission changes on existing files by default
// Only tracks executable bit when files are first added
expect(patch.files.length).toBe(0)
@@ -499,13 +519,13 @@ test("circular symlinks", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
// Create circular symlink
await fs.symlink(`${tmp.path}/circular`, `${tmp.path}/circular`, "dir").catch(() => {})
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash
},
})
@@ -530,7 +550,7 @@ test("source project gitignore is respected - ignored files are not snapshotted"
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
// Modify tracked files and create new ones - some ignored, some not
@@ -539,7 +559,7 @@ test("source project gitignore is respected - ignored files are not snapshotted"
await Filesystem.write(`${tmp.path}/new-tracked.txt`, "new tracked")
await Filesystem.write(`${tmp.path}/build/new-build.js`, "new build file")
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
// Modified and new tracked files should be in snapshot
expect(patch.files).toContain(fwd(tmp.path, "new-tracked.txt"))
@@ -559,14 +579,14 @@ test("gitignore changes", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/.gitignore`, "*.ignored")
await Filesystem.write(`${tmp.path}/test.ignored`, "ignored content")
await Filesystem.write(`${tmp.path}/normal.txt`, "normal content")
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
// Should track gitignore itself
expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
@@ -585,7 +605,7 @@ test("files tracked in snapshot but now gitignored are filtered out", async () =
fn: async () => {
// First, create a file and snapshot it
await Filesystem.write(`${tmp.path}/later-ignored.txt`, "initial content")
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
// Modify the file (so it appears in diff-files)
@@ -597,7 +617,7 @@ test("files tracked in snapshot but now gitignored are filtered out", async () =
// Also create another tracked file
await Filesystem.write(`${tmp.path}/still-tracked.txt`, "new tracked file")
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
// The file that is now gitignored should NOT appear, even though it was
// previously tracked and modified
@@ -618,7 +638,7 @@ test("gitignore updated between track calls filters from diff", async () => {
directory: tmp.path,
fn: async () => {
// a.txt is already committed from bootstrap - track it in snapshot
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
// Modify a.txt (so it appears in diff-files)
@@ -631,11 +651,11 @@ test("gitignore updated between track calls filters from diff", async () => {
await Filesystem.write(`${tmp.path}/b.txt`, "also modified")
// Second track - should not include a.txt even though it changed
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
// Verify a.txt is NOT in the diff between snapshots
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.some((x) => x.file === "a.txt")).toBe(false)
// But .gitignore should be in the diff
@@ -652,7 +672,7 @@ test("git info exclude changes", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
const file = `${tmp.path}/.git/info/exclude`
@@ -661,12 +681,12 @@ test("git info exclude changes", async () => {
await Bun.write(`${tmp.path}/ignored.txt`, "ignored content")
await Bun.write(`${tmp.path}/normal.txt`, "normal content")
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
expect(patch.files).not.toContain(fwd(tmp.path, "ignored.txt"))
const after = await Snapshot.track()
const diffs = await Snapshot.diffFull(before!, after!)
const after = await run(tmp.path, (snapshot) => snapshot.track())
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.some((x) => x.file === "normal.txt")).toBe(true)
expect(diffs.some((x) => x.file === "ignored.txt")).toBe(false)
},
@@ -686,7 +706,7 @@ test("git info exclude keeps global excludes", async () => {
const prev = process.env.GIT_CONFIG_GLOBAL
process.env.GIT_CONFIG_GLOBAL = config
try {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
const file = `${tmp.path}/.git/info/exclude`
@@ -697,7 +717,7 @@ test("git info exclude keeps global excludes", async () => {
await Bun.write(`${tmp.path}/info.tmp`, "info content")
await Bun.write(`${tmp.path}/normal.txt`, "normal content")
const patch = await Snapshot.patch(before!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!))
expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
expect(patch.files).not.toContain(fwd(tmp.path, "global.tmp"))
expect(patch.files).not.toContain(fwd(tmp.path, "info.tmp"))
@@ -714,7 +734,7 @@ test("concurrent file operations during patch", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
// Start creating files
@@ -727,7 +747,7 @@ test("concurrent file operations during patch", async () => {
})()
// Get patch while files are being created
const patchPromise = Snapshot.patch(before!)
const patchPromise = run(tmp.path, (snapshot) => snapshot.patch(before!))
await createPromise
const patch = await patchPromise
@@ -746,9 +766,9 @@ test("snapshot state isolation between projects", async () => {
await Instance.provide({
directory: tmp1.path,
fn: async () => {
const before1 = await Snapshot.track()
const before1 = await run(tmp1.path, (snapshot) => snapshot.track())
await Filesystem.write(`${tmp1.path}/project1.txt`, "project1 content")
const patch1 = await Snapshot.patch(before1!)
const patch1 = await run(tmp1.path, (snapshot) => snapshot.patch(before1!))
expect(patch1.files).toContain(fwd(tmp1.path, "project1.txt"))
},
})
@@ -756,9 +776,9 @@ test("snapshot state isolation between projects", async () => {
await Instance.provide({
directory: tmp2.path,
fn: async () => {
const before2 = await Snapshot.track()
const before2 = await run(tmp2.path, (snapshot) => snapshot.track())
await Filesystem.write(`${tmp2.path}/project2.txt`, "project2 content")
const patch2 = await Snapshot.patch(before2!)
const patch2 = await run(tmp2.path, (snapshot) => snapshot.patch(before2!))
expect(patch2.files).toContain(fwd(tmp2.path, "project2.txt"))
// Ensure project1 files don't appear in project2
@@ -776,20 +796,20 @@ test("patch detects changes in secondary worktree", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await Snapshot.track()).toBeTruthy()
expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy()
},
})
await Instance.provide({
directory: worktreePath,
fn: async () => {
const before = await Snapshot.track()
const before = await run(worktreePath, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
const worktreeFile = fwd(worktreePath, "worktree.txt")
await Filesystem.write(worktreeFile, "worktree content")
const patch = await Snapshot.patch(before!)
const patch = await run(worktreePath, (snapshot) => snapshot.patch(before!))
expect(patch.files).toContain(worktreeFile)
},
})
@@ -808,7 +828,7 @@ test("revert only removes files in invoking worktree", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await Snapshot.track()).toBeTruthy()
expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy()
},
})
const primaryFile = `${tmp.path}/worktree.txt`
@@ -817,14 +837,14 @@ test("revert only removes files in invoking worktree", async () => {
await Instance.provide({
directory: worktreePath,
fn: async () => {
const before = await Snapshot.track()
const before = await run(worktreePath, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
const worktreeFile = fwd(worktreePath, "worktree.txt")
await Filesystem.write(worktreeFile, "worktree content")
const patch = await Snapshot.patch(before!)
await Snapshot.revert([patch])
const patch = await run(worktreePath, (snapshot) => snapshot.patch(before!))
await run(worktreePath, (snapshot) => snapshot.revert([patch]))
expect(
await fs
@@ -852,14 +872,14 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async (
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await Snapshot.track()).toBeTruthy()
expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy()
},
})
await Instance.provide({
directory: worktreePath,
fn: async () => {
const before = await Snapshot.track()
const before = await run(worktreePath, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${worktreePath}/worktree-only.txt`, "worktree diff content")
@@ -867,7 +887,7 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async (
await Filesystem.write(`${tmp.path}/shared.txt`, "primary edit")
await Filesystem.write(`${tmp.path}/primary-only.txt`, "primary change")
const diff = await Snapshot.diff(before!)
const diff = await run(worktreePath, (snapshot) => snapshot.diff(before!))
expect(diff).toContain("worktree-only.txt")
expect(diff).toContain("shared.txt")
expect(diff).not.toContain("primary-only.txt")
@@ -886,15 +906,15 @@ test("track with no changes returns same hash", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const hash1 = await Snapshot.track()
const hash1 = await run(tmp.path, (snapshot) => snapshot.track())
expect(hash1).toBeTruthy()
// Track again with no changes
const hash2 = await Snapshot.track()
const hash2 = await run(tmp.path, (snapshot) => snapshot.track())
expect(hash2).toBe(hash1!)
// Track again
const hash3 = await Snapshot.track()
const hash3 = await run(tmp.path, (snapshot) => snapshot.track())
expect(hash3).toBe(hash1!)
},
})
@@ -905,7 +925,7 @@ test("diff function with various changes", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
// Make various changes
@@ -913,7 +933,7 @@ test("diff function with various changes", async () => {
await Filesystem.write(`${tmp.path}/new.txt`, "new content")
await Filesystem.write(`${tmp.path}/b.txt`, "modified content")
const diff = await Snapshot.diff(before!)
const diff = await run(tmp.path, (snapshot) => snapshot.diff(before!))
expect(diff).toContain("a.txt")
expect(diff).toContain("b.txt")
expect(diff).toContain("new.txt")
@@ -926,7 +946,7 @@ test("restore function", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
// Make changes
@@ -935,7 +955,7 @@ test("restore function", async () => {
await Filesystem.write(`${tmp.path}/b.txt`, "modified")
// Restore to original state
await Snapshot.restore(before!)
await run(tmp.path, (snapshot) => snapshot.restore(before!))
expect(
await fs
@@ -960,20 +980,20 @@ test("revert should not delete files that existed but were deleted in snapshot",
await Instance.provide({
directory: tmp.path,
fn: async () => {
const snapshot1 = await Snapshot.track()
const snapshot1 = await run(tmp.path, (snapshot) => snapshot.track())
expect(snapshot1).toBeTruthy()
await $`rm ${tmp.path}/a.txt`.quiet()
const snapshot2 = await Snapshot.track()
const snapshot2 = await run(tmp.path, (snapshot) => snapshot.track())
expect(snapshot2).toBeTruthy()
await Filesystem.write(`${tmp.path}/a.txt`, "recreated content")
const patch = await Snapshot.patch(snapshot2!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(snapshot2!))
expect(patch.files).toContain(fwd(tmp.path, "a.txt"))
await Snapshot.revert([patch])
await run(tmp.path, (snapshot) => snapshot.revert([patch]))
expect(
await fs
@@ -992,18 +1012,18 @@ test("revert preserves file that existed in snapshot when deleted then recreated
fn: async () => {
await Filesystem.write(`${tmp.path}/existing.txt`, "original content")
const snapshot = await Snapshot.track()
expect(snapshot).toBeTruthy()
const hash = await run(tmp.path, (snapshot) => snapshot.track())
expect(hash).toBeTruthy()
await $`rm ${tmp.path}/existing.txt`.quiet()
await Filesystem.write(`${tmp.path}/existing.txt`, "recreated")
await Filesystem.write(`${tmp.path}/newfile.txt`, "new")
const patch = await Snapshot.patch(snapshot!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(hash!))
expect(patch.files).toContain(fwd(tmp.path, "existing.txt"))
expect(patch.files).toContain(fwd(tmp.path, "newfile.txt"))
await Snapshot.revert([patch])
await run(tmp.path, (snapshot) => snapshot.revert([patch]))
expect(
await fs
@@ -1031,7 +1051,7 @@ test("diffFull sets status based on git change type", async () => {
await Filesystem.write(`${tmp.path}/trim.txt`, "line1\nline2\n")
await Filesystem.write(`${tmp.path}/delete.txt`, "gone")
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/grow.txt`, "one\ntwo\n")
@@ -1039,10 +1059,10 @@ test("diffFull sets status based on git change type", async () => {
await $`rm ${tmp.path}/delete.txt`.quiet()
await Filesystem.write(`${tmp.path}/added.txt`, "new")
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.length).toBe(4)
const added = diffs.find((d) => d.file === "added.txt")
@@ -1073,15 +1093,15 @@ test("diffFull with new file additions", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/new.txt`, "new content")
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.length).toBe(1)
const newFileDiff = diffs[0]
@@ -1111,7 +1131,7 @@ test("diffFull with a large interleaved mixed diff", async () => {
...bin.map((file, i) => Filesystem.write(file, new Uint8Array([0, i, 255, i % 251]))),
])
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Promise.all([
@@ -1121,10 +1141,10 @@ test("diffFull with a large interleaved mixed diff", async () => {
...del.map((file) => fs.rm(file)),
])
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs).toHaveLength(ids.length * 4)
const map = new Map(diffs.map((item) => [item.file, item]))
@@ -1166,17 +1186,17 @@ test("diffFull preserves git diff order across batch boundaries", async () => {
await $`mkdir -p ${tmp.path}/order`.quiet()
await Promise.all(ids.map((id) => Filesystem.write(`${tmp.path}/order/${id}.txt`, `before-${id}`)))
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Promise.all(ids.map((id) => Filesystem.write(`${tmp.path}/order/${id}.txt`, `after-${id}`)))
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
const expected = ids.map((id) => `order/${id}.txt`)
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.map((item) => item.file)).toEqual(expected)
},
})
@@ -1187,15 +1207,15 @@ test("diffFull with file modifications", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/b.txt`, "modified content")
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.length).toBe(1)
const modifiedFileDiff = diffs[0]
@@ -1213,15 +1233,15 @@ test("diffFull with file deletions", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await $`rm ${tmp.path}/a.txt`.quiet()
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.length).toBe(1)
const removedFileDiff = diffs[0]
@@ -1238,15 +1258,15 @@ test("diffFull with multiple line additions", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/multi.txt`, "line1\nline2\nline3")
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.length).toBe(1)
const multiDiff = diffs[0]
@@ -1264,16 +1284,16 @@ test("diffFull with addition and deletion", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/added.txt`, "added content")
await $`rm ${tmp.path}/a.txt`.quiet()
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.length).toBe(2)
const addedFileDiff = diffs.find((d) => d.file === "added.txt")
@@ -1296,7 +1316,7 @@ test("diffFull with multiple additions and deletions", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3")
@@ -1304,10 +1324,10 @@ test("diffFull with multiple additions and deletions", async () => {
await $`rm ${tmp.path}/a.txt`.quiet()
await $`rm ${tmp.path}/b.txt`.quiet()
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.length).toBe(4)
const multi1Diff = diffs.find((d) => d.file === "multi1.txt")
@@ -1338,13 +1358,13 @@ test("diffFull with no changes", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.length).toBe(0)
},
})
@@ -1355,15 +1375,15 @@ test("diffFull with binary file changes", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03]))
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.length).toBe(1)
const binaryDiff = diffs[0]
@@ -1379,15 +1399,15 @@ test("diffFull with whitespace changes", async () => {
directory: tmp.path,
fn: async () => {
await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\nline2")
const before = await Snapshot.track()
const before = await run(tmp.path, (snapshot) => snapshot.track())
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n")
const after = await Snapshot.track()
const after = await run(tmp.path, (snapshot) => snapshot.track())
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!))
expect(diffs.length).toBe(1)
const whitespaceDiff = diffs[0]
@@ -1404,26 +1424,26 @@ test("revert with overlapping files across patches uses first patch hash", async
fn: async () => {
// Write initial content and snapshot
await Filesystem.write(`${tmp.path}/shared.txt`, "v1")
const snap1 = await Snapshot.track()
const snap1 = await run(tmp.path, (snapshot) => snapshot.track())
expect(snap1).toBeTruthy()
// Modify and snapshot again
await Filesystem.write(`${tmp.path}/shared.txt`, "v2")
const snap2 = await Snapshot.track()
const snap2 = await run(tmp.path, (snapshot) => snapshot.track())
expect(snap2).toBeTruthy()
// Modify once more so both patches include shared.txt
await Filesystem.write(`${tmp.path}/shared.txt`, "v3")
const patch1 = await Snapshot.patch(snap1!)
const patch2 = await Snapshot.patch(snap2!)
const patch1 = await run(tmp.path, (snapshot) => snapshot.patch(snap1!))
const patch2 = await run(tmp.path, (snapshot) => snapshot.patch(snap2!))
// Both patches should include shared.txt
expect(patch1.files).toContain(fwd(tmp.path, "shared.txt"))
expect(patch2.files).toContain(fwd(tmp.path, "shared.txt"))
// Revert with patch1 first — should use snap1's hash (restoring "v1")
await Snapshot.revert([patch1, patch2])
await run(tmp.path, (snapshot) => snapshot.revert([patch1, patch2]))
const content = await fs.readFile(`${tmp.path}/shared.txt`, "utf-8")
expect(content).toBe("v1")
@@ -1440,24 +1460,26 @@ test("revert preserves patch order when the same hash appears again", async () =
await Filesystem.write(`${tmp.path}/foo/bar`, "v1")
await Filesystem.write(`${tmp.path}/a.txt`, "v1")
const snap1 = await Snapshot.track()
const snap1 = await run(tmp.path, (snapshot) => snapshot.track())
expect(snap1).toBeTruthy()
await $`rm -rf ${tmp.path}/foo`.quiet()
await Filesystem.write(`${tmp.path}/foo`, "v2")
await Filesystem.write(`${tmp.path}/a.txt`, "v2")
const snap2 = await Snapshot.track()
const snap2 = await run(tmp.path, (snapshot) => snapshot.track())
expect(snap2).toBeTruthy()
await $`rm -rf ${tmp.path}/foo`.quiet()
await Filesystem.write(`${tmp.path}/a.txt`, "v3")
await Snapshot.revert([
{ hash: snap1!, files: [fwd(tmp.path, "a.txt")] },
{ hash: snap2!, files: [fwd(tmp.path, "foo")] },
{ hash: snap1!, files: [fwd(tmp.path, "foo", "bar")] },
])
await run(tmp.path, (snapshot) =>
snapshot.revert([
{ hash: snap1!, files: [fwd(tmp.path, "a.txt")] },
{ hash: snap2!, files: [fwd(tmp.path, "foo")] },
{ hash: snap1!, files: [fwd(tmp.path, "foo", "bar")] },
]),
)
expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe("v1")
expect((await fs.stat(`${tmp.path}/foo`)).isDirectory()).toBe(true)
@@ -1477,16 +1499,16 @@ test("revert handles large mixed batches across chunk boundaries", async () => {
await $`mkdir -p ${tmp.path}/batch ${tmp.path}/fresh`.quiet()
await Promise.all(base.map((file, i) => Filesystem.write(file, `base-${i}`)))
const snap = await Snapshot.track()
const snap = await run(tmp.path, (snapshot) => snapshot.track())
expect(snap).toBeTruthy()
await Promise.all(base.map((file, i) => Filesystem.write(file, `next-${i}`)))
await Promise.all(fresh.map((file, i) => Filesystem.write(file, `fresh-${i}`)))
const patch = await Snapshot.patch(snap!)
const patch = await run(tmp.path, (snapshot) => snapshot.patch(snap!))
expect(patch.files.length).toBe(base.length + fresh.length)
await Snapshot.revert([patch])
await run(tmp.path, (snapshot) => snapshot.revert([patch]))
await Promise.all(
base.map(async (file, i) => {
+1
View File
@@ -1024,6 +1024,7 @@ describe("tool.bash abort", () => {
)
expect(result.output).toContain("started")
expect(result.output).toContain("bash tool terminated command after exceeding timeout")
expect(result.output).toContain("retry with a larger timeout value in milliseconds")
},
})
}, 15_000)
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Cause, Effect, Exit, Layer } from "effect"
import { GlobTool } from "../../src/tool/glob"
import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "../../src/filesystem"
import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
)
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
describe("tool.glob", () => {
it.live("matches files from a directory path", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n"))
yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n"))
const info = yield* GlobTool
const glob = yield* info.init()
const result = yield* glob.execute(
{
pattern: "*.ts",
path: dir,
},
ctx,
)
expect(result.metadata.count).toBe(1)
expect(result.output).toContain(path.join(dir, "a.ts"))
expect(result.output).not.toContain(path.join(dir, "b.txt"))
}),
),
)
it.live("rejects exact file paths", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "a.ts")
yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
const info = yield* GlobTool
const glob = yield* info.init()
const exit = yield* glob
.execute(
{
pattern: "*.ts",
path: file,
},
ctx,
)
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
}
}),
),
)
})
+21
View File
@@ -90,4 +90,25 @@ describe("tool.grep", () => {
}),
),
)
it.live("supports exact file paths", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "test.txt")
yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "line2",
path: file,
},
ctx,
)
expect(result.metadata.matches).toBe(1)
expect(result.output).toContain(file)
expect(result.output).toContain("Line 2: line2")
}),
),
)
})
+18
View File
@@ -304,6 +304,24 @@ export interface Hooks {
input: { sessionID: string },
output: { context: string[]; prompt?: string },
) => Promise<void>
/**
* Called after compaction succeeds and before a synthetic user
* auto-continue message is added.
*
* - `enabled`: Defaults to `true`. Set to `false` to skip the synthetic
* user "continue" turn.
*/
"experimental.compaction.autocontinue"?: (
input: {
sessionID: string
agent: string
model: Model
provider: ProviderContext
message: UserMessage
overflow: boolean
},
output: { enabled: boolean },
) => Promise<void>
"experimental.text.complete"?: (
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
-20
View File
@@ -51,7 +51,6 @@ import type {
GlobalDisposeResponses,
GlobalEventResponses,
GlobalHealthResponses,
GlobalSyncEventSubscribeResponses,
GlobalUpgradeErrors,
GlobalUpgradeResponses,
InstanceDisposeResponses,
@@ -237,20 +236,6 @@ class HeyApiRegistry<T> {
}
}
export class SyncEvent extends HeyApiClient {
/**
* Subscribe to global sync events
*
* Get global sync events
*/
public subscribe<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).sse.get<GlobalSyncEventSubscribeResponses, unknown, ThrowOnError>({
url: "/global/sync-event",
...options,
})
}
}
export class Config extends HeyApiClient {
/**
* Get global configuration
@@ -350,11 +335,6 @@ export class Global extends HeyApiClient {
})
}
private _syncEvent?: SyncEvent
get syncEvent(): SyncEvent {
return (this._syncEvent ??= new SyncEvent({ client: this.client }))
}
private _config?: Config
get config(): Config {
return (this._config ??= new Config({ client: this.client }))
+141 -88
View File
@@ -971,64 +971,12 @@ export type EventSessionDeleted = {
}
}
export type Event =
| EventProjectUpdated
| EventServerInstanceDisposed
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventServerConnected
| EventGlobalDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventLspClientDiagnostics
| EventLspUpdated
| EventMessagePartDelta
| EventPermissionAsked
| EventPermissionReplied
| EventSessionDiff
| EventSessionError
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventVcsBranchUpdated
| EventWorktreeReady
| EventWorktreeFailed
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceStatus
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventSessionCreated
| EventSessionUpdated
| EventSessionDeleted
export type GlobalEvent = {
directory: string
project?: string
workspace?: string
payload: Event
}
export type SyncEventMessageUpdated = {
type: "message.updated.1"
aggregate: "sessionID"
type: "sync"
name: "message.updated.1"
id: string
seq: number
aggregateID: "sessionID"
data: {
sessionID: string
info: Message
@@ -1036,8 +984,11 @@ export type SyncEventMessageUpdated = {
}
export type SyncEventMessageRemoved = {
type: "message.removed.1"
aggregate: "sessionID"
type: "sync"
name: "message.removed.1"
id: string
seq: number
aggregateID: "sessionID"
data: {
sessionID: string
messageID: string
@@ -1045,8 +996,11 @@ export type SyncEventMessageRemoved = {
}
export type SyncEventMessagePartUpdated = {
type: "message.part.updated.1"
aggregate: "sessionID"
type: "sync"
name: "message.part.updated.1"
id: string
seq: number
aggregateID: "sessionID"
data: {
sessionID: string
part: Part
@@ -1055,8 +1009,11 @@ export type SyncEventMessagePartUpdated = {
}
export type SyncEventMessagePartRemoved = {
type: "message.part.removed.1"
aggregate: "sessionID"
type: "sync"
name: "message.part.removed.1"
id: string
seq: number
aggregateID: "sessionID"
data: {
sessionID: string
messageID: string
@@ -1065,8 +1022,11 @@ export type SyncEventMessagePartRemoved = {
}
export type SyncEventSessionCreated = {
type: "session.created.1"
aggregate: "sessionID"
type: "sync"
name: "session.created.1"
id: string
seq: number
aggregateID: "sessionID"
data: {
sessionID: string
info: Session
@@ -1074,8 +1034,11 @@ export type SyncEventSessionCreated = {
}
export type SyncEventSessionUpdated = {
type: "session.updated.1"
aggregate: "sessionID"
type: "sync"
name: "session.updated.1"
id: string
seq: number
aggregateID: "sessionID"
data: {
sessionID: string
info: {
@@ -1114,16 +1077,75 @@ export type SyncEventSessionUpdated = {
}
export type SyncEventSessionDeleted = {
type: "session.deleted.1"
aggregate: "sessionID"
type: "sync"
name: "session.deleted.1"
id: string
seq: number
aggregateID: "sessionID"
data: {
sessionID: string
info: Session
}
}
export type SyncEvent = {
payload: SyncEvent
export type GlobalEvent = {
directory: string
project?: string
workspace?: string
payload:
| EventProjectUpdated
| EventServerInstanceDisposed
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventServerConnected
| EventGlobalDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventLspClientDiagnostics
| EventLspUpdated
| EventMessagePartDelta
| EventPermissionAsked
| EventPermissionReplied
| EventSessionDiff
| EventSessionError
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventVcsBranchUpdated
| EventWorktreeReady
| EventWorktreeFailed
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceStatus
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventSessionCreated
| EventSessionUpdated
| EventSessionDeleted
| SyncEventMessageUpdated
| SyncEventMessageRemoved
| SyncEventMessagePartUpdated
| SyncEventMessagePartRemoved
| SyncEventSessionCreated
| SyncEventSessionUpdated
| SyncEventSessionDeleted
}
/**
@@ -1982,6 +2004,54 @@ export type File = {
status: "added" | "deleted" | "modified"
}
export type Event =
| EventProjectUpdated
| EventServerInstanceDisposed
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventServerConnected
| EventGlobalDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventLspClientDiagnostics
| EventLspUpdated
| EventMessagePartDelta
| EventPermissionAsked
| EventPermissionReplied
| EventSessionDiff
| EventSessionError
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventVcsBranchUpdated
| EventWorktreeReady
| EventWorktreeFailed
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceStatus
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventSessionCreated
| EventSessionUpdated
| EventSessionDeleted
export type McpStatusConnected = {
status: "connected"
}
@@ -2113,23 +2183,6 @@ export type GlobalEventResponses = {
export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses]
export type GlobalSyncEventSubscribeData = {
body?: never
path?: never
query?: never
url: "/global/sync-event"
}
export type GlobalSyncEventSubscribeResponses = {
/**
* Event stream
*/
200: SyncEvent
}
export type GlobalSyncEventSubscribeResponse =
GlobalSyncEventSubscribeResponses[keyof GlobalSyncEventSubscribeResponses]
export type GlobalConfigGetData = {
body?: never
path?: never
+398 -202
View File
@@ -66,31 +66,6 @@
]
}
},
"/global/sync-event": {
"get": {
"operationId": "global.sync-event.subscribe",
"summary": "Subscribe to global sync events",
"description": "Get global sync events",
"responses": {
"200": {
"description": "Event stream",
"content": {
"text/event-stream": {
"schema": {
"$ref": "#/components/schemas/SyncEvent"
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.sync-event.subscribe({\n ...\n})"
}
]
}
},
"/global/config": {
"get": {
"operationId": "global.config.get",
@@ -9925,174 +9900,24 @@
},
"required": ["type", "properties"]
},
"Event": {
"anyOf": [
{
"$ref": "#/components/schemas/Event.project.updated"
},
{
"$ref": "#/components/schemas/Event.server.instance.disposed"
},
{
"$ref": "#/components/schemas/Event.installation.updated"
},
{
"$ref": "#/components/schemas/Event.installation.update-available"
},
{
"$ref": "#/components/schemas/Event.server.connected"
},
{
"$ref": "#/components/schemas/Event.global.disposed"
},
{
"$ref": "#/components/schemas/Event.file.edited"
},
{
"$ref": "#/components/schemas/Event.file.watcher.updated"
},
{
"$ref": "#/components/schemas/Event.lsp.client.diagnostics"
},
{
"$ref": "#/components/schemas/Event.lsp.updated"
},
{
"$ref": "#/components/schemas/Event.message.part.delta"
},
{
"$ref": "#/components/schemas/Event.permission.asked"
},
{
"$ref": "#/components/schemas/Event.permission.replied"
},
{
"$ref": "#/components/schemas/Event.session.diff"
},
{
"$ref": "#/components/schemas/Event.session.error"
},
{
"$ref": "#/components/schemas/Event.question.asked"
},
{
"$ref": "#/components/schemas/Event.question.replied"
},
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.todo.updated"
},
{
"$ref": "#/components/schemas/Event.session.status"
},
{
"$ref": "#/components/schemas/Event.session.idle"
},
{
"$ref": "#/components/schemas/Event.session.compacted"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/Event.tui.toast.show"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/Event.mcp.tools.changed"
},
{
"$ref": "#/components/schemas/Event.mcp.browser.open.failed"
},
{
"$ref": "#/components/schemas/Event.command.executed"
},
{
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
"$ref": "#/components/schemas/Event.worktree.ready"
},
{
"$ref": "#/components/schemas/Event.worktree.failed"
},
{
"$ref": "#/components/schemas/Event.pty.created"
},
{
"$ref": "#/components/schemas/Event.pty.updated"
},
{
"$ref": "#/components/schemas/Event.pty.exited"
},
{
"$ref": "#/components/schemas/Event.pty.deleted"
},
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{
"$ref": "#/components/schemas/Event.message.updated"
},
{
"$ref": "#/components/schemas/Event.message.removed"
},
{
"$ref": "#/components/schemas/Event.message.part.updated"
},
{
"$ref": "#/components/schemas/Event.message.part.removed"
},
{
"$ref": "#/components/schemas/Event.session.created"
},
{
"$ref": "#/components/schemas/Event.session.updated"
},
{
"$ref": "#/components/schemas/Event.session.deleted"
}
]
},
"GlobalEvent": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"project": {
"type": "string"
},
"workspace": {
"type": "string"
},
"payload": {
"$ref": "#/components/schemas/Event"
}
},
"required": ["directory", "payload"]
},
"SyncEvent.message.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "sync"
},
"name": {
"type": "string",
"const": "message.updated.1"
},
"aggregate": {
"id": {
"type": "string"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string",
"const": "sessionID"
},
@@ -10110,16 +9935,26 @@
"required": ["sessionID", "info"]
}
},
"required": ["type", "aggregate", "data"]
"required": ["type", "name", "id", "seq", "aggregateID", "data"]
},
"SyncEvent.message.removed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "sync"
},
"name": {
"type": "string",
"const": "message.removed.1"
},
"aggregate": {
"id": {
"type": "string"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string",
"const": "sessionID"
},
@@ -10138,16 +9973,26 @@
"required": ["sessionID", "messageID"]
}
},
"required": ["type", "aggregate", "data"]
"required": ["type", "name", "id", "seq", "aggregateID", "data"]
},
"SyncEvent.message.part.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "sync"
},
"name": {
"type": "string",
"const": "message.part.updated.1"
},
"aggregate": {
"id": {
"type": "string"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string",
"const": "sessionID"
},
@@ -10168,16 +10013,26 @@
"required": ["sessionID", "part", "time"]
}
},
"required": ["type", "aggregate", "data"]
"required": ["type", "name", "id", "seq", "aggregateID", "data"]
},
"SyncEvent.message.part.removed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "sync"
},
"name": {
"type": "string",
"const": "message.part.removed.1"
},
"aggregate": {
"id": {
"type": "string"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string",
"const": "sessionID"
},
@@ -10200,16 +10055,26 @@
"required": ["sessionID", "messageID", "partID"]
}
},
"required": ["type", "aggregate", "data"]
"required": ["type", "name", "id", "seq", "aggregateID", "data"]
},
"SyncEvent.session.created": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "sync"
},
"name": {
"type": "string",
"const": "session.created.1"
},
"aggregate": {
"id": {
"type": "string"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string",
"const": "sessionID"
},
@@ -10227,16 +10092,26 @@
"required": ["sessionID", "info"]
}
},
"required": ["type", "aggregate", "data"]
"required": ["type", "name", "id", "seq", "aggregateID", "data"]
},
"SyncEvent.session.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "sync"
},
"name": {
"type": "string",
"const": "session.updated.1"
},
"aggregate": {
"id": {
"type": "string"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string",
"const": "sessionID"
},
@@ -10479,16 +10354,26 @@
"required": ["sessionID", "info"]
}
},
"required": ["type", "aggregate", "data"]
"required": ["type", "name", "id", "seq", "aggregateID", "data"]
},
"SyncEvent.session.deleted": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "sync"
},
"name": {
"type": "string",
"const": "session.deleted.1"
},
"aggregate": {
"id": {
"type": "string"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string",
"const": "sessionID"
},
@@ -10506,16 +10391,185 @@
"required": ["sessionID", "info"]
}
},
"required": ["type", "aggregate", "data"]
"required": ["type", "name", "id", "seq", "aggregateID", "data"]
},
"SyncEvent": {
"GlobalEvent": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"project": {
"type": "string"
},
"workspace": {
"type": "string"
},
"payload": {
"$ref": "#/components/schemas/SyncEvent"
"anyOf": [
{
"$ref": "#/components/schemas/Event.project.updated"
},
{
"$ref": "#/components/schemas/Event.server.instance.disposed"
},
{
"$ref": "#/components/schemas/Event.installation.updated"
},
{
"$ref": "#/components/schemas/Event.installation.update-available"
},
{
"$ref": "#/components/schemas/Event.server.connected"
},
{
"$ref": "#/components/schemas/Event.global.disposed"
},
{
"$ref": "#/components/schemas/Event.file.edited"
},
{
"$ref": "#/components/schemas/Event.file.watcher.updated"
},
{
"$ref": "#/components/schemas/Event.lsp.client.diagnostics"
},
{
"$ref": "#/components/schemas/Event.lsp.updated"
},
{
"$ref": "#/components/schemas/Event.message.part.delta"
},
{
"$ref": "#/components/schemas/Event.permission.asked"
},
{
"$ref": "#/components/schemas/Event.permission.replied"
},
{
"$ref": "#/components/schemas/Event.session.diff"
},
{
"$ref": "#/components/schemas/Event.session.error"
},
{
"$ref": "#/components/schemas/Event.question.asked"
},
{
"$ref": "#/components/schemas/Event.question.replied"
},
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.todo.updated"
},
{
"$ref": "#/components/schemas/Event.session.status"
},
{
"$ref": "#/components/schemas/Event.session.idle"
},
{
"$ref": "#/components/schemas/Event.session.compacted"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/Event.tui.toast.show"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/Event.mcp.tools.changed"
},
{
"$ref": "#/components/schemas/Event.mcp.browser.open.failed"
},
{
"$ref": "#/components/schemas/Event.command.executed"
},
{
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
"$ref": "#/components/schemas/Event.worktree.ready"
},
{
"$ref": "#/components/schemas/Event.worktree.failed"
},
{
"$ref": "#/components/schemas/Event.pty.created"
},
{
"$ref": "#/components/schemas/Event.pty.updated"
},
{
"$ref": "#/components/schemas/Event.pty.exited"
},
{
"$ref": "#/components/schemas/Event.pty.deleted"
},
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{
"$ref": "#/components/schemas/Event.message.updated"
},
{
"$ref": "#/components/schemas/Event.message.removed"
},
{
"$ref": "#/components/schemas/Event.message.part.updated"
},
{
"$ref": "#/components/schemas/Event.message.part.removed"
},
{
"$ref": "#/components/schemas/Event.session.created"
},
{
"$ref": "#/components/schemas/Event.session.updated"
},
{
"$ref": "#/components/schemas/Event.session.deleted"
},
{
"$ref": "#/components/schemas/SyncEvent.message.updated"
},
{
"$ref": "#/components/schemas/SyncEvent.message.removed"
},
{
"$ref": "#/components/schemas/SyncEvent.message.part.updated"
},
{
"$ref": "#/components/schemas/SyncEvent.message.part.removed"
},
{
"$ref": "#/components/schemas/SyncEvent.session.created"
},
{
"$ref": "#/components/schemas/SyncEvent.session.updated"
},
{
"$ref": "#/components/schemas/SyncEvent.session.deleted"
}
]
}
},
"required": ["payload"]
"required": ["directory", "payload"]
},
"LogLevel": {
"description": "Log level",
@@ -12608,6 +12662,148 @@
},
"required": ["path", "added", "removed", "status"]
},
"Event": {
"anyOf": [
{
"$ref": "#/components/schemas/Event.project.updated"
},
{
"$ref": "#/components/schemas/Event.server.instance.disposed"
},
{
"$ref": "#/components/schemas/Event.installation.updated"
},
{
"$ref": "#/components/schemas/Event.installation.update-available"
},
{
"$ref": "#/components/schemas/Event.server.connected"
},
{
"$ref": "#/components/schemas/Event.global.disposed"
},
{
"$ref": "#/components/schemas/Event.file.edited"
},
{
"$ref": "#/components/schemas/Event.file.watcher.updated"
},
{
"$ref": "#/components/schemas/Event.lsp.client.diagnostics"
},
{
"$ref": "#/components/schemas/Event.lsp.updated"
},
{
"$ref": "#/components/schemas/Event.message.part.delta"
},
{
"$ref": "#/components/schemas/Event.permission.asked"
},
{
"$ref": "#/components/schemas/Event.permission.replied"
},
{
"$ref": "#/components/schemas/Event.session.diff"
},
{
"$ref": "#/components/schemas/Event.session.error"
},
{
"$ref": "#/components/schemas/Event.question.asked"
},
{
"$ref": "#/components/schemas/Event.question.replied"
},
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.todo.updated"
},
{
"$ref": "#/components/schemas/Event.session.status"
},
{
"$ref": "#/components/schemas/Event.session.idle"
},
{
"$ref": "#/components/schemas/Event.session.compacted"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/Event.tui.toast.show"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/Event.mcp.tools.changed"
},
{
"$ref": "#/components/schemas/Event.mcp.browser.open.failed"
},
{
"$ref": "#/components/schemas/Event.command.executed"
},
{
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
"$ref": "#/components/schemas/Event.worktree.ready"
},
{
"$ref": "#/components/schemas/Event.worktree.failed"
},
{
"$ref": "#/components/schemas/Event.pty.created"
},
{
"$ref": "#/components/schemas/Event.pty.updated"
},
{
"$ref": "#/components/schemas/Event.pty.exited"
},
{
"$ref": "#/components/schemas/Event.pty.deleted"
},
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{
"$ref": "#/components/schemas/Event.message.updated"
},
{
"$ref": "#/components/schemas/Event.message.removed"
},
{
"$ref": "#/components/schemas/Event.message.part.updated"
},
{
"$ref": "#/components/schemas/Event.message.part.removed"
},
{
"$ref": "#/components/schemas/Event.session.created"
},
{
"$ref": "#/components/schemas/Event.session.updated"
},
{
"$ref": "#/components/schemas/Event.session.deleted"
}
]
},
"MCPStatusConnected": {
"type": "object",
"properties": {