Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e22f324dc | |||
| f954854232 | |||
| 6a99079012 | |||
| 0a8b6298cd | |||
| f40209bdfb | |||
| a2cb4909da | |||
| 7a05ba47d1 | |||
| 36745caa2a | |||
| c2403d0f15 | |||
| 34e2429c49 | |||
| 10ba68c772 | |||
| e8471256f2 | |||
| 43b37346b6 | |||
| d199648aeb | |||
| a06f40297b | |||
| 59c0fc28ee | |||
| b22add292c | |||
| 67aaecacac | |||
| 29c202e6ab | |||
| dcbf11f41a | |||
| 14ccff4037 | |||
| 5b8b874732 | |||
| 1d81c0266c | |||
| 913120759a |
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "*.wav" {
|
||||
const file: string
|
||||
export default 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)...")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+41
-13
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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()
|
||||
}
|
||||
*/
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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!)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user