diff --git a/bun.lock b/bun.lock index 25068f3d9a..07415dd79f 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.33", + "version": "1.14.35", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -354,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.33", + "version": "1.14.35", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -531,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "cross-spawn": "catalog:", }, @@ -546,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -581,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -630,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 5f4d79e44f..cde4986d18 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.33", + "version": "1.14.35", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index d8ed63b8d2..6dae9de955 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -482,7 +482,7 @@ export const Terminal = (props: TerminalProps) => { const connectToken = async () => { const result = await client.pty .connectToken( - { ptyID: id }, + { ptyID: id, directory }, { throwOnError: false, headers: { "x-opencode-ticket": "1" }, diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 623303fbf4..5bca1b4b7e 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -62,7 +62,10 @@ describe("getTerminalServerScope", () => { ), ).toBe("wsl:Debian" as ServerKey) expect( - getTerminalServerScope({ type: "http", http: { url: "https://example.com" } }, "https://example.com" as ServerKey), + getTerminalServerScope( + { type: "http", http: { url: "https://example.com" } }, + "https://example.com" as ServerKey, + ), ).toBe("https://example.com" as ServerKey) }) }) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 0dcebd567d..f6751c3f0e 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -94,7 +94,12 @@ export function getTerminalServerScope(conn: ServerConnection.Any | undefined, k if (conn.type === "http") { try { const url = new URL(conn.http.url) - if (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]") + if ( + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "::1" || + url.hostname === "[::1]" + ) return } catch { return key @@ -127,12 +132,7 @@ const trimTerminal = (pty: LocalPTY) => { } } -export function clearWorkspaceTerminals( - dir: string, - sessionIDs?: string[], - platform?: Platform, - scope?: string, -) { +export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) { const key = getWorkspaceTerminalCacheKey(dir, scope) for (const cache of caches) { const entry = cache.get(key) diff --git a/packages/console/app/package.json b/packages/console/app/package.json index cb5b4bf9a4..fb2e71d22d 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.33", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/routes/download/[channel]/[platform].ts b/packages/console/app/src/routes/download/[channel]/[platform].ts index b486acb99d..7a4b5ef65e 100644 --- a/packages/console/app/src/routes/download/[channel]/[platform].ts +++ b/packages/console/app/src/routes/download/[channel]/[platform].ts @@ -2,11 +2,11 @@ import type { APIEvent } from "@solidjs/start" import type { DownloadPlatform } from "../types" const prodAssetNames: Record = { - "darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg", - "darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg", - "windows-x64-nsis": "opencode-desktop-windows-x64.exe", + "darwin-aarch64-dmg": "opencode-desktop-mac-arm64.dmg", + "darwin-x64-dmg": "opencode-desktop-mac-x64.dmg", + "windows-x64-nsis": "opencode-desktop-win-x64.exe", "linux-x64-deb": "opencode-desktop-linux-amd64.deb", - "linux-x64-appimage": "opencode-desktop-linux-amd64.AppImage", + "linux-x64-appimage": "opencode-desktop-linux-x86_64.AppImage", "linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm", } satisfies Record @@ -32,13 +32,6 @@ export async function GET({ params: { platform, channel } }: APIEvent) { const resp = await fetch( `https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`, - { - cf: { - // in case gh releases has rate limits - cacheTtl: 60 * 5, - cacheEverything: true, - }, - } as any, ) const downloadName = downloadNames[platform] diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 2f75668e67..7f36246ee5 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -158,11 +158,13 @@ export async function handler( Object.entries(obj).flatMap(([k, v]) => { if (Array.isArray(v)) return [[k, v]] if (typeof v === "object") return [[k, replacer(v)]] - if (v === "$ip") return [[k, ip]] - if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] - if (v.startsWith("$header.")) { - const headerValue = input.request.headers.get(v.slice(8)) - return headerValue ? [[k, headerValue]] : [] + if (typeof v === "string") { + if (v === "$ip") return [[k, ip]] + if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] + if (v.startsWith("$header.")) { + const headerValue = input.request.headers.get(v.slice(8)) + return headerValue ? [[k, headerValue]] : [] + } } return [[k, v]] }), @@ -917,6 +919,13 @@ export async function handler( "tokens.cache_read": cacheReadTokens, "tokens.cache_write_5m": cacheWrite5mTokens, "tokens.cache_write_1h": cacheWrite1hTokens, + "cost.input.microcents": centsToMicroCents(inputCost), + "cost.output.microcents": centsToMicroCents(outputCost), + "cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined, + "cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined, + "cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined, + "cost.total.microcents": centsToMicroCents(totalCostInCent), + // deprecated - remove after May 20, 2026 "cost.input": Math.round(inputCost), "cost.output": Math.round(outputCost), "cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined, diff --git a/packages/console/core/package.json b/packages/console/core/package.json index bfb7f7db8f..7301b23e5c 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.33", + "version": "1.14.35", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index f6072bd379..06fb0affd0 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.33", + "version": "1.14.35", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index d73a23e081..674fc55fd5 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.33", + "version": "1.14.35", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 4ba8d1401b..e90ab7628a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.33", + "version": "1.14.35", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 1acc3f47f1..6560d308c1 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -71,6 +71,8 @@ export const layer = Layer.effect( Effect.sync(() => Service.of(make())), ) +export const defaultLayer = layer + export const layerWith = (input: Partial) => Layer.effect( Service, diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 7a26516a99..ba981e637a 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.14.33", + "version": "1.14.35", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1327423e51..e60320300a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.33", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 16e142b9cf..dce25e204d 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.33", + "version": "1.14.35", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index d9e71219f5..775f826d4c 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.33" +version = "1.14.35" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 1eb790cced..1039677b52 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.33", + "version": "1.14.35", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql b/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql new file mode 100644 index 0000000000..3bdf2b85e9 --- /dev/null +++ b/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `event_sequence` ADD `owner_id` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json new file mode 100644 index 0000000000..4f6ebe00c0 --- /dev/null +++ b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json @@ -0,0 +1,1429 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "27114226-085b-421a-9a40-29b88747e29a", + "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"], + "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_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "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": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "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": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "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": [] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index adb4a7db1b..bafa532de7 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.33", + "version": "1.14.35", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 9755cf4017..771e1e417e 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -776,9 +776,9 @@ const scenarios: Scenario[] = [ })) .status(200), http - .post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore") + .post("/experimental/workspace/warp", "experimental.workspace.warp") .at((ctx) => ({ - path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), + path: "/experimental/workspace/warp", headers: ctx.headers(), body: {}, })) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 04c6b9945c..09d952ef81 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createResource, createSignal, onMount } from "solid-js" +import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js" import { Locale } from "@/util/locale" import { useProject } from "@tui/context/project" import { useKeybind } from "../context/keybind" @@ -10,15 +10,13 @@ import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" -import { Keybind } from "@/util/keybind" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create" +import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" - -type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" +import { WorkspaceLabel } from "./workspace-label" export function DialogSessionList() { const dialog = useDialog() @@ -44,26 +42,39 @@ export function DialogSessionList() { const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const sessions = createMemo(() => searchResults() ?? sync.data.session) - function createWorkspace() { - dialog.replace(() => ( - - openWorkspaceSession({ - dialog, - route, - sdk, - sync, - toast, - workspaceID, - }) - } - /> - )) - } - function recover(session: NonNullable[number]>) { const workspace = project.workspace.get(session.workspaceID!) const list = () => dialog.replace(() => ) + const warp = async (selection: WorkspaceSelection) => { + const workspaceID = await (async () => { + if (selection.type === "none") return null + if (selection.type === "existing") return selection.workspaceID + const result = await sdk.client.experimental.workspace + .create({ type: selection.workspaceType, branch: null }) + .catch(() => undefined) + const workspace = result?.data + if (!workspace) { + toast.show({ + message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, + variant: "error", + }) + return + } + await project.workspace.sync() + return workspace.id + })() + if (workspaceID === undefined) return + await warpWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + workspaceID, + sessionID: session.id, + done: list, + }) + } dialog.replace(() => ( { - dialog.replace(() => ( - - restoreWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - workspaceID, - sessionID: session.id, - done: list, - }) - } - /> - )) + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warp(selection) + }, + }) return false }} /> @@ -124,30 +128,17 @@ export function DialogSessionList() { .map((x) => { const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined - let workspaceStatus: WorkspaceStatus | null = null - if (x.workspaceID) { - workspaceStatus = project.workspace.status(x.workspaceID) || "error" - } - - let footer = "" + let footer: JSX.Element | string = "" if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { if (x.workspaceID) { - let desc = "unknown" - if (workspace) { - desc = `${workspace.type}: ${workspace.name}` - } - - footer = ( - <> - {desc}{" "} - - ● - - + footer = workspace ? ( + + ) : ( + ) } } else { @@ -250,15 +241,6 @@ export function DialogSessionList() { dialog.replace(() => ) }, }, - { - keybind: Keybind.parse("ctrl+w")[0], - title: "new workspace", - side: "right", - disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - onTrigger: () => { - createWorkspace() - }, - }, ]} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 0aa61c313a..ad40637575 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -1,11 +1,9 @@ -import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import type { Workspace } from "@opencode-ai/sdk/v2" import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useRoute } from "@tui/context/route" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { useSync } from "@tui/context/sync" import { useProject } from "@tui/context/project" import { createMemo, createSignal, onMount } from "solid-js" -import { setTimeout as sleep } from "node:timers/promises" import { errorMessage } from "@/util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" @@ -16,184 +14,212 @@ type Adapter = { description: string } -function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { - return createOpencodeClient({ - baseUrl: sdk.url, - fetch: sdk.fetch, - directory: sync.path.directory || sdk.directory, - experimental_workspaceID: workspaceID, - }) -} +export type WorkspaceSelection = + | { + type: "none" + } + | { + type: "new" + workspaceType: string + workspaceName: string + } + | { + type: "existing" + workspaceID: string + workspaceType: string + workspaceName: string + } -export async function openWorkspaceSession(input: { - dialog: ReturnType - route: ReturnType +type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } +type ExistingWorkspaceSelectValue = { workspace: Workspace } + +async function loadWorkspaceAdapters(input: { sdk: ReturnType sync: ReturnType toast: ReturnType - workspaceID: string }) { - const client = scoped(input.sdk, input.sync, input.workspaceID) - - while (true) { - const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined) - if (!result) { - input.toast.show({ - message: "Failed to create workspace session", - variant: "error", - }) - return - } - if (result.response?.status && result.response.status >= 500 && result.response.status < 600) { - await sleep(1000) - continue - } - if (!result.data) { - input.toast.show({ - message: "Failed to create workspace session", - variant: "error", - }) - return - } - - input.route.navigate({ - type: "session", - sessionID: result.data.id, - }) - input.dialog.clear() - return - } + const dir = input.sync.path.directory || input.sdk.directory + const url = new URL("/experimental/workspace/adapter", input.sdk.url) + if (dir) url.searchParams.set("directory", dir) + const res = await input.sdk + .fetch(url) + .then((x) => x.json() as Promise) + .catch(() => undefined) + if (res) return res + input.toast.show({ + message: "Failed to load workspace adapters", + variant: "error", + }) } -export async function restoreWorkspaceSession(input: { +export async function openWorkspaceSelect(input: { + dialog: ReturnType + sdk: ReturnType + sync: ReturnType + toast: ReturnType + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { + input.dialog.clear() + const adapters = await loadWorkspaceAdapters(input) + if (!adapters) return + input.dialog.replace(() => ) +} + +export async function warpWorkspaceSession(input: { dialog: ReturnType sdk: ReturnType sync: ReturnType project: ReturnType toast: ReturnType - workspaceID: string + workspaceID: string | null sessionID: string done?: () => void -}) { +}): Promise { const result = await input.sdk.client.experimental.workspace - .sessionRestore({ id: input.workspaceID, sessionID: input.sessionID }) + .warp({ + id: input.workspaceID ?? undefined, + sessionID: input.sessionID, + }) .catch(() => undefined) if (!result?.data) { input.toast.show({ - message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`, + message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`, variant: "error", }) - return + return false } input.project.workspace.set(input.workspaceID) await input.sync.bootstrap({ fatal: false }).catch(() => undefined) - await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]) + await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]) - input.toast.show({ - message: "Session restored into the new workspace", - variant: "success", - }) input.done?.() - if (input.done) return + if (input.done) return true input.dialog.clear() + return true } -export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise | void }) { +export function DialogWorkspaceSelect(props: { + adapters?: Adapter[] + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { const dialog = useDialog() - const sync = useSync() const project = useProject() + const sync = useSync() const sdk = useSDK() const toast = useToast() - const [creating, setCreating] = createSignal() - const [adapters, setAdapters] = createSignal() + const [adapters, setAdapters] = createSignal(props.adapters) onMount(() => { dialog.setSize("medium") void (async () => { - const dir = sync.path.directory || sdk.directory - const url = new URL("/experimental/workspace/adapter", sdk.url) - if (dir) url.searchParams.set("directory", dir) - const res = await sdk - .fetch(url) - .then((x) => x.json() as Promise) - .catch(() => undefined) - if (!res) { - toast.show({ - message: "Failed to load workspace adapters", - variant: "error", - }) - return - } + if (adapters()) return + const res = await loadWorkspaceAdapters({ sdk, sync, toast }) + if (!res) return setAdapters(res) })() }) - const options = createMemo(() => { - const type = creating() - if (type) { - return [ - { - title: `Creating ${type} workspace...`, - value: "creating" as const, - description: "This can take a while for remote environments", - }, - ] - } + const options = createMemo[]>(() => { const list = adapters() - if (!list) { - return [ - { - title: "Loading workspaces...", - value: "loading" as const, - description: "Fetching available workspace adapters", + if (!list) return [] + const recent = sync.data.session + .toSorted((a, b) => b.time.updated - a.time.updated) + .flatMap((session) => (session.workspaceID ? [session.workspaceID] : [])) + .filter((workspaceID, index, list) => list.indexOf(workspaceID) === index) + .slice(0, 3) + .flatMap((workspaceID) => { + const workspace = project.workspace.get(workspaceID) + return workspace ? [workspace] : [] + }) + return [ + ...list.map((adapter) => ({ + title: adapter.name, + value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name }, + description: adapter.description, + category: "New workspace", + })), + { + title: "None", + value: { type: "none" as const }, + description: "Use the local project", + category: "Choose workspace", + }, + ...recent.map((workspace: Workspace) => ({ + title: workspace.name, + description: `(${workspace.type})`, + value: { + type: "existing" as const, + workspaceID: workspace.id, + workspaceType: workspace.type, + workspaceName: workspace.name, }, - ] - } - return list.map((item) => ({ - title: item.name, - value: item.type, - description: item.description, - })) + category: "Choose workspace", + })), + { + title: "View all workspaces", + value: { type: "existing-list" as const }, + description: "Choose from all workspaces", + category: "Choose workspace", + }, + ] }) - const create = async (type: string) => { - if (creating()) return - setCreating(type) - - const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => { - toast.show({ - message: "Creating workspace failed", - variant: "error", - }) - return undefined - }) - - const workspace = result?.data - if (!workspace) { - setCreating(undefined) - toast.show({ - message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, - variant: "error", - }) - return - } - - await project.workspace.sync() - await props.onSelect(workspace.id) - setCreating(undefined) - } - + if (!adapters()) return null return ( - + title="Warp" skipFilter={true} + renderFilter={false} options={options()} onSelect={(option) => { - if (option.value === "creating" || option.value === "loading") return - void create(option.value) + if (!option.value) return + if (option.value.type === "none") { + void props.onSelect(option.value) + return + } + if (option.value.type === "new") { + void props.onSelect(option.value) + return + } + if (option.value.type === "existing") { + void props.onSelect(option.value) + return + } + + dialog.replace(() => ) + }} + /> + ) +} + +function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise | void }) { + const project = useProject() + + const options = createMemo[]>(() => + project.workspace + .list() + .filter((workspace) => project.workspace.status(workspace.id) === "connected") + .map((workspace: Workspace) => ({ + title: workspace.name, + description: `(${workspace.type})`, + value: { workspace }, + })), + ) + + return ( + + title="Existing Workspace" + options={options()} + onSelect={(option) => { + void props.onSelect({ + type: "existing", + workspaceID: option.value.workspace.id, + workspaceType: option.value.workspace.type, + workspaceName: option.value.workspace.name, + }) }} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index a6ba797f33..74332c77be 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { tint, useTheme } from "@tui/context/theme" import { EmptyBorder, SplitBorder } from "@tui/component/border" +import { Spinner } from "@tui/component/spinner" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" @@ -41,9 +42,11 @@ import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" -import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create" +import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" +import { Flag } from "@opencode-ai/core/flag/flag" +import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label" export type PromptProps = { sessionID?: string @@ -173,9 +176,92 @@ export function Prompt(props: PromptProps) { const [editorContextHover, setEditorContextHover] = createSignal(false) let lastSubmittedEditorSelectionKey: string | undefined const [auto, setAuto] = createSignal() + const [workspaceSelection, setWorkspaceSelection] = createSignal() + const [workspaceCreating, setWorkspaceCreating] = createSignal(false) + const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3) + const [warpNotice, setWarpNotice] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) + function selectWorkspace(selection: WorkspaceSelection | undefined) { + setWorkspaceSelection(selection) + } + + function setCreatingWorkspace(creating: boolean) { + setWorkspaceCreating(creating) + } + + function showWarpNotice(name: string) { + setWarpNotice(`Warped to ${name}`) + setTimeout(() => setWarpNotice(undefined), 4000) + } + + async function createWorkspace(selection: Extract) { + setCreatingWorkspace(true) + const result = await sdk.client.experimental.workspace + .create({ type: selection.workspaceType, branch: null }) + .catch(() => undefined) + if (result == undefined || result.error || !result.data) { + selectWorkspace(undefined) + setCreatingWorkspace(false) + toast.show({ + message: "Creating workspace failed", + variant: "error", + }) + return + } + + await project.workspace.sync() + const workspace = result.data + selectWorkspace({ + type: "existing", + workspaceID: workspace.id, + workspaceType: workspace.type, + workspaceName: workspace.name, + }) + setCreatingWorkspace(false) + return workspace + } + + async function warpSession(selection: WorkspaceSelection) { + if (!props.sessionID) { + selectWorkspace(selection) + dialog.clear() + if (selection.type === "new") void createWorkspace(selection) + return + } + selectWorkspace(selection) + dialog.clear() + + const workspace = + selection.type === "none" + ? { id: null, name: "local project" } + : selection.type === "existing" + ? { id: selection.workspaceID, name: selection.workspaceName } + : await createWorkspace(selection) + if (!workspace) return + + const warped = await warpWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + workspaceID: workspace.id, + sessionID: props.sessionID, + }) + if (warped) showWarpNotice(workspace.name) + } + + createEffect(() => { + if (!workspaceCreating()) { + setWorkspaceCreatingDots(3) + return + } + const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000) + onCleanup(() => clearInterval(timer)) + }) + function promptModelWarning() { toast.show({ variant: "warning", @@ -213,6 +299,7 @@ export function Prompt(props: PromptProps) { }) createEffect(() => { + if (!input || input.isDestroyed) return if (props.disabled) input.cursorColor = theme.backgroundElement if (!props.disabled) input.cursorColor = theme.text }) @@ -489,6 +576,27 @@ export function Prompt(props: PromptProps) { )) }, }, + { + title: "Warp", + description: "Change the workspace for the session", + value: "workspace.set", + category: "Session", + enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + slash: { + name: "warp", + }, + onSelect: (dialog) => { + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warpSession(selection) + }, + }) + }, + }, ] }) @@ -699,6 +807,8 @@ export function Prompt(props: PromptProps) { ]) async function submit() { + setWarpNotice(undefined) + // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read // plainText directly and sync before any downstream reads. @@ -707,6 +817,7 @@ export function Prompt(props: PromptProps) { syncExtmarksWithPromptParts() } if (props.disabled) return false + if (workspaceCreating()) return false if (autocomplete?.visible) return false if (!store.prompt.input) return false const agent = local.agent.current() @@ -729,21 +840,16 @@ export function Prompt(props: PromptProps) { dialog.replace(() => ( { - dialog.replace(() => ( - - restoreWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - workspaceID: nextWorkspaceID, - sessionID: props.sessionID!, - }) - } - /> - )) + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warpSession(selection) + }, + }) + return false }} /> )) @@ -753,6 +859,14 @@ export function Prompt(props: PromptProps) { const variant = local.model.variant.current() let sessionID = props.sessionID if (sessionID == null) { + const workspace = workspaceSelection() + const workspaceID = iife(() => { + if (!workspace) return undefined + if (workspace.type === "none") return undefined + if (workspace.type === "existing") return workspace.workspaceID + return undefined + }) + const res = await sdk.client.session.create({ workspace: props.workspaceID, agent: agent.name, @@ -1025,6 +1139,29 @@ export function Prompt(props: PromptProps) { return `Ask anything... "${list()[store.placeholder % list().length]}"` }) + const workspaceLabel = createMemo< + | { type: "new"; workspaceType: string } + | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus } + | undefined + >(() => { + const selected = workspaceSelection() + if (!selected) return + if (selected.type === "none") return + if (props.sessionID && !workspaceCreating()) return + if (selected.type === "new") { + return { + type: "new", + workspaceType: selected.workspaceType, + } + } + return { + type: "existing", + workspaceType: selected.workspaceType, + workspaceName: selected.workspaceName, + status: selected.type === "existing" ? "connected" : undefined, + } + }) + const spinnerDef = createMemo(() => { const agent = local.agent.current() const color = agent ? local.agent.color(agent.name) : theme.border @@ -1281,7 +1418,7 @@ export function Prompt(props: PromptProps) { }} onMouseDown={(r: MouseEvent) => r.target?.focus()} focusedBackgroundColor={theme.backgroundElement} - cursorColor={theme.text} + cursorColor={props.disabled ? theme.backgroundElement : theme.text} syntaxStyle={syntax()} /> @@ -1351,86 +1488,124 @@ export function Prompt(props: PromptProps) { /> - }> - - - - [⋯]}> - - - - - {(() => { - const retry = createMemo(() => { - const s = status() - if (s.type !== "retry") return - return s - }) - const message = createMemo(() => { - const r = retry() - if (!r) return - if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) - return "gemini is way too hot right now" - if (r.message.length > 80) return r.message.slice(0, 80) + "..." - return r.message - }) - const isTruncated = createMemo(() => { - const r = retry() - if (!r) return false - return r.message.length > 120 - }) - const [seconds, setSeconds] = createSignal(0) - onMount(() => { - const timer = setInterval(() => { - const next = retry()?.next - if (next) setSeconds(Math.round((next - Date.now()) / 1000)) - }, 1000) - - onCleanup(() => { - clearInterval(timer) + + + + + + [⋯]}> + + + + + {(() => { + const retry = createMemo(() => { + const s = status() + if (s.type !== "retry") return + return s }) - }) - const handleMessageClick = () => { - const r = retry() - if (!r) return - if (isTruncated()) { - void DialogAlert.show(dialog, "Retry Error", r.message) + const message = createMemo(() => { + const r = retry() + if (!r) return + if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) + return "gemini is way too hot right now" + if (r.message.length > 80) return r.message.slice(0, 80) + "..." + return r.message + }) + const isTruncated = createMemo(() => { + const r = retry() + if (!r) return false + return r.message.length > 120 + }) + const [seconds, setSeconds] = createSignal(0) + onMount(() => { + const timer = setInterval(() => { + const next = retry()?.next + if (next) setSeconds(Math.round((next - Date.now()) / 1000)) + }, 1000) + + onCleanup(() => { + clearInterval(timer) + }) + }) + const handleMessageClick = () => { + const r = retry() + if (!r) return + if (isTruncated()) { + void DialogAlert.show(dialog, "Retry Error", r.message) + } } - } - const retryText = () => { - const r = retry() - if (!r) return "" - const baseMessage = message() - const truncatedHint = isTruncated() ? " (click to expand)" : "" - const duration = formatDuration(seconds()) - const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` - return baseMessage + truncatedHint + retryInfo - } + const retryText = () => { + const r = retry() + if (!r) return "" + const baseMessage = message() + const truncatedHint = isTruncated() ? " (click to expand)" : "" + const duration = formatDuration(seconds()) + const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` + return baseMessage + truncatedHint + retryInfo + } - return ( - - - {retryText()} - - - ) - })()} + return ( + + + {retryText()} + + + ) + })()} + + 0 ? theme.primary : theme.text}> + esc{" "} + 0 ? theme.primary : theme.textMuted }}> + {store.interrupt > 0 ? "again to interrupt" : "interrupt"} + + - 0 ? theme.primary : theme.text}> - esc{" "} - 0 ? theme.primary : theme.textMuted }}> - {store.interrupt > 0 ? "again to interrupt" : "interrupt"} - - - - + + + {(notice) => ( + + {notice()} + + )} + + + {(workspace) => ( + + + + + + {(() => { + const item = workspace() + if (item.type === "new") { + if (workspaceCreating()) + return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}` + return ( + <> + Workspace (new {item.workspaceType}) + + ) + } + return ( + <> + Workspace {item.workspaceName} + + ) + })()} + + + )} + + {props.hint ?? } + diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx new file mode 100644 index 0000000000..efdbf71587 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx @@ -0,0 +1,19 @@ +import { useTheme } from "@tui/context/theme" + +export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" + +export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) { + const { theme } = useTheme() + const color = () => { + if (props.status === "connected") return theme.success + if (props.status === "error") return theme.error + return theme.textMuted + } + + return ( + <> + {props.icon ? : undefined} + {props.name} ({props.type}) + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index 9801f0a2f8..d9d23999d2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" function activeAssistant(messages: SessionMessage[]) { - const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed) if (index < 0) return const assistant = messages[index] return assistant?.type === "assistant" ? assistant : undefined } function activeCompaction(messages: SessionMessage[]) { - const index = messages.findLastIndex((message) => message.type === "compaction") + const index = messages.findIndex((message) => message.type === "compaction") if (index < 0) return const compaction = messages[index] return compaction?.type === "compaction" ? compaction : undefined } function activeShell(messages: SessionMessage[], callID: string) { - const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID) if (index < 0) return const shell = messages[index] return shell?.type === "shell" ? shell : undefined @@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( switch (event.type) { case "session.next.prompted": { update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "user", text: event.properties.prompt.text, @@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( } case "session.next.synthetic": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "synthetic", sessionID: event.properties.sessionID, @@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.shell.started": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "shell", callID: event.properties.callID, @@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( update(event.properties.sessionID, (draft) => { const currentAssistant = activeAssistant(draft) if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp - draft.push({ + draft.unshift({ id: event.id, type: "assistant", agent: event.properties.agent, @@ -259,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.compaction.started": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "compaction", reason: event.properties.reason, diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 7270a9c3b7..2e5cea9804 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner" import { useTheme } from "@tui/context/theme" import { useLocal } from "@tui/context/local" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { SyntaxStyle } from "@opentui/core" +import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import path from "path" @@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) const renderedMessages = createMemo(() => messages().toReversed()) const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) + const lastUserCreated = (index: number) => + renderedMessages() + .slice(0, index) + .findLast((message) => message.type === "user")?.time.created createEffect(() => { void sync.session.message.sync(props.sessionID) @@ -83,10 +87,11 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { last={lastAssistant()?.id === message.id} syntax={syntax()} subtleSyntax={subtleSyntax()} + start={lastUserCreated(index())} /> - + <> @@ -146,63 +151,36 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) { - - - } - > - {props.message.text} - - - - - {(file) => ( - - {file.mime} - {file.name ?? file.uri} - - )} - - - {(agent) => ( - - agent - {agent.name} - - )} - - - - {Locale.todayTimeOrDateTime(props.message.time.created)} - - - ) -} - -function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) { - const { theme } = useTheme() - return ( - - Synthetic {props.message.text} + + + + {(file) => ( + + {file.mime} + {file.name ?? file.uri} + + )} + + + {(agent) => ( + + agent + {agent.name} + + )} + + + ) } @@ -237,7 +215,7 @@ function ShellMessage(props: { message: SessionMessageShell }) { } function CompactionMessage(props: { message: SessionMessageCompaction }) { - const { theme } = useTheme() + const { theme, syntax } = useTheme() return ( - {props.message.summary} + {(summary) => ( + + + + )} ) @@ -294,12 +284,13 @@ function AssistantMessage(props: { last: boolean syntax: SyntaxStyle subtleSyntax: SyntaxStyle + start?: number }) { const { theme } = useTheme() const local = useLocal() const duration = createMemo(() => { if (!props.message.time.completed) return 0 - return props.message.time.completed - props.message.time.created + return props.message.time.completed - (props.start ?? props.message.time.created) }) const model = createMemo(() => { const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" @@ -361,7 +352,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta const { theme } = useTheme() return ( - + (props.part.state.status === "error" ? props.part.state.error.message : undefined)) + const complete = createMemo(() => !!props.complete) const denied = createMemo(() => { const message = error() if (!message) return false return ( message.includes("QuestionRejectedError") || message.includes("rejected permission") || + message.includes("specified a rule") || message.includes("user dismissed") ) }) + const fg = createMemo(() => { + if (error()) return theme.error + if (complete()) return theme.textMuted + return theme.text + }) + const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined)) return ( - - - - {props.children} - - - - ~ {props.pending}} when={props.complete}> - {props.icon} {props.children} - - - - - - {error()} - + error() && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (!error()) return + if (renderer.getSelection()?.getSelectedText()) return + setShowError((prev) => !prev) + }} + renderBefore={function () { + const el = this as BoxRenderable + const parent = el.parent + if (!parent) return + const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1] + if (!previous) { + setMargin(0) + return + } + if (previous.id.startsWith("text")) setMargin(1) + }} + > + + + + + + + + {props.icon} + + + + + ~ + + + + + + + + + + {props.children} + + + + + {props.pending} + + + + + + + {error()} + + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 7adc4c1db1..0f9214092e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { getScrollAcceleration } from "../../util/scroll" +import { WorkspaceLabel } from "../../component/workspace-label" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const project = useProject() @@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const { theme } = useTheme() const tuiConfig = useTuiConfig() const session = createMemo(() => sync.session.get(props.sessionID)) - const workspaceStatus = () => { + const workspace = () => { const workspaceID = session()?.workspaceID - if (!workspaceID) return "error" - return project.workspace.status(workspaceID) ?? "error" - } - const workspaceLabel = () => { - const workspaceID = session()?.workspaceID - if (!workspaceID) return "unknown" - const info = project.workspace.get(workspaceID) - if (!info) return "unknown" - return `${info.type}: ${info.name}` + if (!workspaceID) return + return project.workspace.get(workspaceID) } const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) @@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - {" "} - {workspaceLabel()} + } + > + {(item) => ( + + )} + diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 4d68c44308..ef7d4bd3bb 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -23,6 +23,7 @@ export interface DialogSelectProps { onFilter?: (query: string) => void onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean + renderFilter?: boolean keybind?: { keybind?: Keybind.Info title: string @@ -81,7 +82,7 @@ export function DialogSelect(props: DialogSelectProps) { let input: InputRenderable const filtered = createMemo(() => { - if (props.skipFilter) return props.options.filter((x) => x.disabled !== true) + if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true) const needle = store.filter.toLowerCase() const options = pipe( props.options, @@ -250,30 +251,32 @@ export function DialogSelect(props: DialogSelectProps) { esc - - { - batch(() => { - setStore("filter", e) - props.onFilter?.(e) - }) - }} - focusedBackgroundColor={theme.backgroundPanel} - cursorColor={theme.primary} - focusedTextColor={theme.textMuted} - ref={(r) => { - input = r - input.traits = { status: "FILTER" } - setTimeout(() => { - if (!input) return - if (input.isDestroyed) return - input.focus() - }, 1) - }} - placeholder={props.placeholder ?? "Search"} - placeholderColor={theme.textMuted} - /> - + + + { + batch(() => { + setStore("filter", e) + props.onFilter?.(e) + }) + }} + focusedBackgroundColor={theme.backgroundPanel} + cursorColor={theme.primary} + focusedTextColor={theme.textMuted} + ref={(r) => { + input = r + input.traits = { status: "FILTER" } + setTimeout(() => { + if (!input) return + if (input.isDestroyed) return + input.focus() + }, 1) + }} + placeholder={props.placeholder ?? "Search"} + placeholderColor={theme.textMuted} + /> + + 0} diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 485cb2e925..fe651fe3e3 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,10 +1,11 @@ -import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect" +import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" import { Database } from "@/storage/db" import { asc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { Project } from "@/project/project" +import { Instance } from "@/project/instance" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" @@ -20,6 +21,7 @@ import { getAdapter } from "./adapters" import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" import { SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" import { errorData } from "@/util/error" @@ -38,13 +40,6 @@ export const ConnectionStatus = Schema.Struct({ }) export type ConnectionStatus = Schema.Schema.Type -const Restore = Schema.Struct({ - workspaceID: WorkspaceID, - sessionID: SessionID, - total: NonNegativeInt, - step: NonNegativeInt, -}) - export const Event = { Ready: BusEvent.define( "workspace.ready", @@ -58,7 +53,6 @@ export const Event = { message: Schema.String, }), ), - Restore: BusEvent.define("workspace.restore", Restore), Status: BusEvent.define("workspace.status", ConnectionStatus), } @@ -84,15 +78,15 @@ export const CreateInput = Schema.Struct({ type: Info.fields.type, branch: Info.fields.branch, projectID: ProjectID, - extra: Info.fields.extra, + extra: Schema.optional(Info.fields.extra), }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) export type CreateInput = Schema.Schema.Type -export const SessionRestoreInput = Schema.Struct({ - workspaceID: WorkspaceID, +export const SessionWarpInput = Schema.Struct({ + workspaceID: Schema.NullOr(WorkspaceID), sessionID: SessionID, }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) -export type SessionRestoreInput = Schema.Schema.Type +export type SessionWarpInput = Schema.Schema.Type export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { message: Schema.String, @@ -116,8 +110,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass()( - "WorkspaceSessionRestoreHttpError", +export class SessionWarpHttpError extends Schema.TaggedErrorClass()( + "WorkspaceSessionWarpHttpError", { message: Schema.String, workspaceID: WorkspaceID, @@ -138,17 +132,17 @@ export class SyncAbortedError extends Schema.TaggedErrorClass( }) {} type CreateError = Auth.AuthError -type SessionRestoreError = +type SessionWarpError = | WorkspaceNotFoundError | SessionEventsNotFoundError - | SessionRestoreHttpError + | SessionWarpHttpError | HttpClientError.HttpClientError type WaitForSyncError = SyncTimeoutError | SyncAbortedError type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError export interface Interface { readonly create: (input: CreateInput) => Effect.Effect - readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError> + readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect readonly list: (project: Project.Info) => Effect.Effect readonly get: (id: WorkspaceID) => Effect.Effect readonly remove: (id: WorkspaceID) => Effect.Effect @@ -169,6 +163,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const auth = yield* Auth.Service const session = yield* Session.Service + const prompt = yield* SessionPrompt.Service const http = yield* HttpClient.HttpClient const sync = yield* SyncEvent.Service const connections = new Map() @@ -461,7 +456,7 @@ export const layer = Layer.effect( const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) const config = yield* EffectBridge.fromPromise(() => - adapter.configure({ ...input, id, name: Slug.create(), directory: null }), + adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }), ) const info: Info = { @@ -518,29 +513,93 @@ export const layer = Layer.effect( return info }) - const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) { + const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) { return yield* Effect.gen(function* () { - log.info("session restore requested", { + log.info("session warp requested", { workspaceID: input.workspaceID, sessionID: input.sessionID, }) - const space = yield* get(input.workspaceID) + const current = yield* db((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + + if (current?.workspaceID) { + const previous = yield* get(current.workspaceID) + if (previous) { + const adapter = getAdapter(previous.projectID, previous.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(previous)) + + if (target.type === "remote") { + yield* syncHistory(previous, target.url, target.headers).pipe( + Effect.catch((error) => + Effect.sync(() => { + log.warn("session warp final source sync failed", { + workspaceID: previous.id, + sessionID: input.sessionID, + error: errorData(error), + }) + }), + ), + ) + } else { + yield* prompt.cancel(input.sessionID) + } + + // "claim" this session so any future events coming from + // the old workspace are ignored + SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id) + } + } + + if (input.workspaceID === null) { + yield* Effect.sync(() => + SyncEvent.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: null, + }, + }), + ) + + log.info("session warp complete", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + target: "local", + }) + return + } + + const workspaceID = input.workspaceID + const space = yield* get(workspaceID) if (!space) return yield* new WorkspaceNotFoundError({ - message: `Workspace not found: ${input.workspaceID}`, - workspaceID: input.workspaceID, + message: `Workspace not found: ${workspaceID}`, + workspaceID, }) const adapter = getAdapter(space.projectID, space.type) const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) - yield* sync.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { + if (target.type === "local") { + yield* sync.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: input.workspaceID, + }, + }) + + log.info("session warp complete", { workspaceID: input.workspaceID, - }, - }) + sessionID: input.sessionID, + target: target.directory, + }) + return + } const rows = yield* db((db) => db @@ -562,130 +621,95 @@ export const layer = Layer.effect( sessionID: input.sessionID, }) - const size = 10 - // TODO: look into using effect APIs to process this in chunks - const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) => - rows.slice(i * size, (i + 1) * size), - ) - const total = sets.length + const batches = Iterable.chunksOf(rows, 10) + const total = Iterable.size(batches) - log.info("session restore prepared", { + log.info("session warp prepared", { workspaceID: input.workspaceID, sessionID: input.sessionID, - workspaceType: space.type, - directory: space.directory, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, + target: String(route(target.url, "/sync/replay")), events: rows.length, batches: total, first: rows[0]?.seq, last: rows.at(-1)?.seq, }) - yield* Effect.sync(() => - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: 0, - }, - }, - }), - ) - - for (const [i, events] of sets.entries()) { - log.info("session restore batch starting", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, - }) - - if (target.type === "local") { - yield* sync.replayAll(events) - log.info("session restore batch replayed locally", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - }) - } else { - const url = route(target.url, "/sync/replay") - const res = yield* http.execute( - HttpClientRequest.post(url, { - headers: new Headers(target.headers), - body: HttpBody.jsonUnsafe({ - directory: space.directory ?? "", - events, + yield* Effect.forEach( + batches, + (events, i) => + Effect.gen(function* () { + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/replay"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ + directory: space.directory ?? "", + events, + }), }), - }), - ) + ) - if (res.status < 200 || res.status >= 300) { - const body = yield* res.text - log.error("session restore batch failed", { + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + log.error("session warp batch failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + status: response.status, + body, + }) + return yield* new SessionWarpHttpError({ + message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + } + + log.info("session warp batch posted", { workspaceID: input.workspaceID, sessionID: input.sessionID, step: i + 1, total, - status: res.status, - body, + status: response.status, }) - return yield* new SessionRestoreHttpError({ - message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`, - workspaceID: input.workspaceID, - sessionID: input.sessionID, - status: res.status, - body, - }) - } - - log.info("session restore batch posted", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - status: res.status, - }) - } - - yield* Effect.sync(() => - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: i + 1, - }, - }, }), - ) + { discard: true }, + ) + + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/steal"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }), + }), + ) + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + log.error("session warp steal failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + return yield* new SessionWarpHttpError({ + message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) } - log.info("session restore complete", { + log.info("session warp complete", { workspaceID: input.workspaceID, sessionID: input.sessionID, batches: total, }) - - return { total } }).pipe( Effect.tapError((err) => Effect.sync(() => - log.error("session restore failed", { + log.error("session warp failed", { workspaceID: input.workspaceID, sessionID: input.sessionID, error: errorData(err), @@ -814,7 +838,7 @@ export const layer = Layer.effect( return Service.of({ create, - sessionRestore, + sessionWarp, list, get, remove, @@ -830,6 +854,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(SyncEvent.defaultLayer), + Layer.provide(SessionPrompt.defaultLayer), Layer.provide(FetchHttpClient.layer), ) diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 46c210fa5d..6d9a6447a0 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -13,6 +13,7 @@ const prefixes = { tool: "tool", workspace: "wrk", entry: "ent", + account: "act", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 28ac143eec..8b3bedbf5b 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -85,7 +85,9 @@ const fileFromPatchChunk = (chunk: string) => { } const splitGitPatch = (patch: Git.Patch) => { - const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index) + const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) => + match[0].startsWith("\n") ? match.index + 1 : match.index, + ) const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length)) if (!patch.truncated) return chunks return chunks.slice(0, -1) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 939110e044..4013dcee36 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -138,6 +138,14 @@ function useLanguageModel(sdk: any) { return sdk.responses === undefined && sdk.chat === undefined } +function selectAzureLanguageModel(sdk: any, modelID: string, useChat: boolean) { + if (useChat && sdk.chat) return sdk.chat(modelID) + if (sdk.responses) return sdk.responses(modelID) + if (sdk.messages) return sdk.messages(modelID) + if (sdk.chat) return sdk.chat(modelID) + return sdk.languageModel(modelID) +} + function custom(dep: CustomDep): Record { return { anthropic: () => @@ -222,12 +230,7 @@ function custom(dep: CustomDep): Record { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: { resourceName: resource, @@ -247,12 +250,7 @@ function custom(dep: CustomDep): Record { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: { baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined, diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 898acaf089..160d258796 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -13,6 +13,7 @@ import { compress } from "hono/compress" import * as ServerBackend from "./backend" import { isAllowedCorsOrigin, type CorsOptions } from "./cors" import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket" +import { isPublicUIPath } from "./shared/public-ui" const log = Log.create({ service: "server" }) @@ -45,6 +46,7 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => { if (c.req.method === "OPTIONS") return next() const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() + if (isPublicUIPath(c.req.method, c.req.path)) return next() if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next() const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 21a7810ce1..788aef3176 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -10,10 +10,6 @@ import { zodObject } from "@/util/effect-zod" import { Instance } from "@/project/instance" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import * as Log from "@opencode-ai/core/util/log" -import { errorData } from "@/util/error" - -const log = Log.create({ service: "server.workspace" }) export const WorkspaceRoutes = lazy(() => new Hono() @@ -151,60 +147,36 @@ export const WorkspaceRoutes = lazy(() => }, ) .post( - "/:id/session-restore", + "/warp", describeRoute({ - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", - operationId: "experimental.workspace.sessionRestore", + summary: "Warp session into workspace", + description: "Move a session's sync history into the target workspace, or detach it to the local project.", + operationId: "experimental.workspace.warp", responses: { - 200: { - description: "Session replay started", - content: { - "application/json": { - schema: resolver( - z.object({ - total: z.number().int().min(0), - }), - ), - }, - }, + 204: { + description: "Session warped", }, ...errors(400), }, }), - validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })), - validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })), + validator( + "json", + z.object({ + id: zodObject(Workspace.Info).shape.id.nullable(), + sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID, + }), + ), async (c) => { - const { id } = c.req.valid("param") - const body = c.req.valid("json") as Omit - log.info("session restore route requested", { - workspaceID: id, - sessionID: body.sessionID, - directory: Instance.directory, - }) - try { - const result = await AppRuntime.runPromise( - Workspace.Service.use((svc) => - svc.sessionRestore({ - workspaceID: id, - ...body, - }), - ), - ) - log.info("session restore route complete", { - workspaceID: id, - sessionID: body.sessionID, - total: result.total, - }) - return c.json(result) - } catch (err) { - log.error("session restore route failed", { - workspaceID: id, - sessionID: body.sessionID, - error: errorData(err), - }) - throw err - } + const body = c.req.valid("json") + await AppRuntime.runPromise( + Workspace.Service.use((workspace) => + workspace.sessionWarp({ + workspaceID: body.id, + sessionID: body.sessionID, + }), + ), + ) + return c.body(null, 204) }, ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 58d30b4c78..442e656554 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,4 +1,5 @@ import { NonNegativeInt } from "@/util/schema" +import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" @@ -21,6 +22,9 @@ export const ReplayPayload = Schema.Struct({ export const ReplayResponse = Schema.Struct({ sessionID: Schema.String, }) +export const SessionPayload = Schema.Struct({ + sessionID: SessionID, +}) export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) export const HistoryEvent = Schema.Struct({ id: Schema.String, @@ -33,6 +37,7 @@ export const HistoryEvent = Schema.Struct({ export const SyncPaths = { start: `${root}/start`, replay: `${root}/replay`, + steal: `${root}/steal`, history: `${root}/history`, } as const @@ -60,6 +65,17 @@ export const SyncApi = HttpApi.make("sync") description: "Validate and replay a complete sync event history.", }), ), + HttpApiEndpoint.post("steal", SyncPaths.steal, { + payload: SessionPayload, + success: described(SessionPayload, "Session stolen into workspace"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "sync.steal", + summary: "Steal session into workspace", + description: "Update a session to belong to the current workspace through the sync event system.", + }), + ), HttpApiEndpoint.post("history", SyncPaths.history, { payload: HistoryPayload, success: described(Schema.Array(HistoryEvent), "Sync events"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 08e9e044bb..f197ab9765 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -1,21 +1,17 @@ import { Workspace } from "@/control-plane/workspace" import { WorkspaceAdapterEntry } from "@/control-plane/types" -import { NonNegativeInt } from "@/util/schema" import { Schema, Struct } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" -export const CreatePayload = Schema.Struct({ - ...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]), - extra: Schema.optional(Workspace.CreateInput.fields.extra), -}) -export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"])) -export const SessionRestoreResponse = Schema.Struct({ - total: NonNegativeInt, +export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])) +export const WarpPayload = Schema.Struct({ + id: Schema.NullOr(Workspace.Info.fields.id), + sessionID: Workspace.SessionWarpInput.fields.sessionID, }) export const WorkspacePaths = { @@ -23,7 +19,7 @@ export const WorkspacePaths = { list: root, status: `${root}/status`, remove: `${root}/:id`, - sessionRestore: `${root}/:id/session-restore`, + warp: `${root}/warp`, } as const export const WorkspaceApi = HttpApi.make("workspace") @@ -79,16 +75,15 @@ export const WorkspaceApi = HttpApi.make("workspace") description: "Remove an existing workspace.", }), ), - HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, { - params: { id: Workspace.Info.fields.id }, - payload: SessionRestorePayload, - success: described(SessionRestoreResponse, "Session replay started"), + HttpApiEndpoint.post("warp", WorkspacePaths.warp, { + payload: WarpPayload, + success: described(HttpApiSchema.NoContent, "Session warped"), error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ - identifier: "experimental.workspace.sessionRestore", - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", + identifier: "experimental.workspace.warp", + summary: "Warp session into workspace", + description: "Move a session's sync history into the target workspace, or detach it to the local project.", }), ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index f4a2f315cd..152d22f98e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -1,5 +1,6 @@ import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" +import { Session } from "@/session/session" import { Database } from "@/storage/db" import { SyncEvent } from "@/sync" import { EventTable } from "@/sync/event.sql" @@ -12,7 +13,7 @@ import { or } from "drizzle-orm" import { Effect, Scope } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { HistoryPayload, ReplayPayload } from "../groups/sync" +import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync" import * as Log from "@opencode-ai/core/util/log" const log = Log.create({ service: "server.sync" }) @@ -56,6 +57,25 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl return { sessionID: source } }) + const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) { + const workspaceID = yield* InstanceState.workspaceID + if (!workspaceID) throw new Error("Cannot steal session without workspace context") + + yield* sync.run(Session.Event.Updated, { + sessionID: ctx.payload.sessionID, + info: { + workspaceID, + }, + }) + + log.info("sync session stolen", { + sessionID: ctx.payload.sessionID, + workspaceID, + }) + + return { sessionID: ctx.payload.sessionID } + }) + const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { const exclude = Object.entries(ctx.payload) return Database.use((db) => @@ -72,6 +92,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl ) }) - return handlers.handle("start", start).handle("replay", replay).handle("history", history) + return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 570f355e57..b415943a62 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -4,7 +4,7 @@ import * as InstanceState from "@/effect/instance-state" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { CreatePayload, SessionRestorePayload } from "../groups/workspace" +import { CreatePayload, WarpPayload } from "../groups/workspace" export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => Effect.gen(function* () { @@ -39,13 +39,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac return yield* workspace.remove(ctx.params.id) }) - const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { - params: { id: Workspace.Info["id"] } - payload: typeof SessionRestorePayload.Type - }) { - return yield* workspace - .sessionRestore({ - workspaceID: ctx.params.id, + const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) { + yield* workspace + .sessionWarp({ + workspaceID: ctx.payload.id, sessionID: ctx.payload.sessionID, }) .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) @@ -57,6 +54,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .handle("create", create) .handle("status", status) .handle("remove", remove) - .handle("sessionRestore", sessionRestore) + .handle("warp", warp) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 6c6d0cd1f1..6f5648f30a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -3,6 +3,7 @@ import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" +import { isPublicUIPath } from "@/server/shared/public-ui" const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 @@ -92,6 +93,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest const url = new URL(request.url, "http://localhost") + if (isPublicUIPath(request.method, url.pathname)) return yield* effect if (hasPtyConnectTicketURL(url)) return yield* effect return yield* credentialFromURL(url, request).pipe( Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts new file mode 100644 index 0000000000..6f3c33a647 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -0,0 +1,58 @@ +import { Provider } from "@/provider/provider" +import { Session } from "@/session/session" +import { NotFoundError } from "@/storage/storage" +import { iife } from "@/util/iife" +import { NamedError } from "@opencode-ai/core/util/error" +import * as Log from "@opencode-ai/core/util/log" +import { Cause, Effect } from "effect" +import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" + +const log = Log.create({ service: "server" }) + +// Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s. +export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => + effect.pipe( + Effect.catchCause((cause) => { + const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => { + if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false + if (HttpServerError.isHttpServerError(reason.defect)) return false + if (HttpServerRespondable.isRespondable(reason.defect)) return false + return true + }) + if (!defect) return Effect.failCause(cause) + + const error = defect.defect + log.error("failed", { error, cause: Cause.pretty(cause) }) + + if (error instanceof NamedError) { + return Effect.succeed( + HttpServerResponse.jsonUnsafe(error.toObject(), { + status: iife(() => { + if (error instanceof NotFoundError) return 404 + if (error instanceof Provider.ModelNotFoundError) return 400 + if (error.name === "ProviderAuthValidationFailed") return 400 + if (error.name.startsWith("Worktree")) return 400 + return 500 + }), + }), + ) + } + if (error instanceof Session.BusyError) { + return Effect.succeed( + HttpServerResponse.jsonUnsafe(new NamedError.Unknown({ message: error.message }).toObject(), { + status: 400, + }), + ) + } + + return Effect.succeed( + HttpServerResponse.jsonUnsafe( + new NamedError.Unknown({ + message: error instanceof Error && error.stack ? error.stack : String(error), + }).toObject(), + { status: 500 }, + ), + ) + }), + ), +).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index a3754c2e19..ef966036a9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -73,6 +73,7 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" +import { errorLayer } from "./middleware/error" export const context = Context.makeUnsafe(new Map()) @@ -144,6 +145,7 @@ const uiRoute = HttpRouter.use((router) => export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( Layer.provide([ + errorLayer, cors(corsOptions), runtime, Account.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 89b5641e58..71662dea90 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -155,7 +155,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) - app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context)) } return app diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts index b7bf413d4e..9894d8c8ee 100644 --- a/packages/opencode/src/server/routes/instance/sync.ts +++ b/packages/opencode/src/server/routes/instance/sync.ts @@ -16,6 +16,9 @@ import { Workspace } from "@/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" import { Instance } from "@/project/instance" import { errors } from "../../error" +import { Session } from "@/session/session" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { SessionID } from "@/session/schema" const ReplayEvent = z.object({ id: z.string(), @@ -24,6 +27,9 @@ const ReplayEvent = z.object({ type: z.string(), data: z.record(z.string(), z.unknown()), }) +const SessionPayload = z.object({ + sessionID: SessionID.zod, +}) const log = Log.create({ service: "server.sync" }) @@ -108,6 +114,47 @@ export const SyncRoutes = lazy(() => }) }, ) + .post( + "/steal", + describeRoute({ + summary: "Steal session into workspace", + description: "Update a session to belong to the current workspace through the sync event system.", + operationId: "sync.steal", + responses: { + 200: { + description: "Session stolen into workspace", + content: { + "application/json": { + schema: resolver(SessionPayload), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", SessionPayload), + async (c) => { + const body = c.req.valid("json") + const workspaceID = WorkspaceContext.workspaceID + if (!workspaceID) throw new Error("Cannot steal session without workspace context") + + SyncEvent.run(Session.Event.Updated, { + sessionID: body.sessionID, + info: { + workspaceID, + }, + }) + + log.info("sync session stolen", { + sessionID: body.sessionID, + workspaceID, + }) + + return c.json({ + sessionID: body.sessionID, + }) + }, + ) .post( "/history", describeRoute({ diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 3971214f3d..ca86599955 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,7 +5,7 @@ import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { WorkspaceID } from "@/control-plane/schema" -import { Context, Effect, Exit, Layer, Scope } from "effect" +import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import * as HttpApiServer from "#httpapi-server" @@ -259,6 +259,12 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec }).pipe( Layer.provideMerge(WebSocketTracker.layer), Layer.provideMerge(HttpApiServer.layer({ port, hostname: opts.hostname })), + // Install a fresh `ConfigProvider` per listener so `Config.string(...)` + // reads reflect the current `process.env`. Effect's default + // `ConfigProvider` snapshots `process.env` on first read and caches the + // result on a module-singleton Reference; without overriding it here, + // every later `Server.listen()` keeps observing that initial snapshot. + Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv())), ) const start = async (port: number) => { diff --git a/packages/opencode/src/server/shared/public-ui.ts b/packages/opencode/src/server/shared/public-ui.ts new file mode 100644 index 0000000000..fece09592f --- /dev/null +++ b/packages/opencode/src/server/shared/public-ui.ts @@ -0,0 +1,12 @@ +// Static UI assets the browser fetches without app-managed credentials, e.g. +// the manifest link in . These bypass auth so the page can install/render +// the manifest icons even when a server password is configured. +export const PUBLIC_UI_PATHS = new Set([ + "/site.webmanifest", + "/web-app-manifest-192x192.png", + "/web-app-manifest-512x512.png", +]) + +export function isPublicUIPath(method: string, pathname: string) { + return method === "GET" && PUBLIC_UI_PATHS.has(pathname) +} diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index c1558a1a4e..40d8aa7afb 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -33,6 +33,7 @@ function proxyResponseHeaders(headers: Record) { // transfer metadata makes browsers decode already-decoded assets again. result.delete("content-encoding") result.delete("content-length") + result.delete("transfer-encoding") return result } diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index cf1a7e0ae9..f22da92927 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -22,6 +22,7 @@ import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" import { EventV2 } from "@/v2/event" import { SessionEvent } from "@/v2/session-event" +import { Modelv2 } from "@/v2/model" import * as DateTime from "effect/DateTime" const DOOM_LOOP_THRESHOLD = 3 @@ -432,9 +433,9 @@ export const layer: Layer.Layer< sessionID: ctx.sessionID, agent: input.assistantMessage.agent, model: { - id: ctx.model.id, - providerID: ctx.model.providerID, - variant: input.assistantMessage.variant, + id: Modelv2.ID.make(ctx.model.id), + providerID: Modelv2.ProviderID.make(ctx.model.providerID), + variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), }, snapshot: ctx.snapshot, timestamp: DateTime.makeUnsafe(Date.now()), @@ -655,7 +656,7 @@ export const layer: Layer.Layer< EventV2.run(SessionEvent.Step.Failed.Sync, { sessionID: ctx.sessionID, error: { - type: error.name, + type: "unknown", message: errorMessage(e), }, timestamp: DateTime.makeUnsafe(Date.now()), diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts index 88f73acf1a..93298170cc 100644 --- a/packages/opencode/src/session/projectors-next.ts +++ b/packages/opencode/src/session/projectors-next.ts @@ -132,11 +132,7 @@ export default [ SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => { db.update(SessionTable) .set({ - model: { - id: data.id, - providerID: data.providerID, - variant: data.variant, - }, + model: data.model, time_updated: DateTime.toEpochMillis(data.timestamp), }) .where(eq(SessionTable.id, data.sessionID)) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0590fc3827..8286ecf8e6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,5 @@ import path from "path" import os from "os" -import z from "zod" import * as EffectZod from "@/util/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" @@ -56,6 +55,7 @@ import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" import { EventV2 } from "@/v2/event" import { SessionEvent } from "@/v2/session-event" +import { Modelv2 } from "@/v2/model" import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" @@ -120,9 +120,8 @@ export const layer = Layer.effect( return yield* EffectBridge.make() }) const ops = Effect.fn("SessionPrompt.ops")(function* () { - const run = yield* runner() return { - cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)), + cancel: (sessionID: SessionID) => cancel(sessionID), resolvePromptParts: (template: string) => resolvePromptParts(template), prompt: (input: PromptInput) => prompt(input), } satisfies TaskPromptOps @@ -978,9 +977,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the EventV2.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), - id: info.model.modelID, - providerID: info.model.providerID, - variant: info.model.variant, + model: { + id: Modelv2.ID.make(info.model.modelID), + providerID: Modelv2.ProviderID.make(info.model.providerID), + variant: Modelv2.VariantID.make(info.model.variant ?? "default"), + }, }) } diff --git a/packages/opencode/src/sync/event.sql.ts b/packages/opencode/src/sync/event.sql.ts index b51b5a5dfe..547a80f0f3 100644 --- a/packages/opencode/src/sync/event.sql.ts +++ b/packages/opencode/src/sync/event.sql.ts @@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" export const EventSequenceTable = sqliteTable("event_sequence", { aggregate_id: text().notNull().primaryKey(), seq: integer().notNull(), + owner_id: text(), }) export const EventTable = sqliteTable("event", { diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 2654767e9a..62b30ccf9a 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -59,8 +59,11 @@ export interface Interface { data: Event["data"], options?: { publish?: boolean }, ) => Effect.Effect - readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect - readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect + readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect + readonly replayAll: ( + events: SerializedEvent[], + options?: { publish: boolean; ownerID?: string }, + ) => Effect.Effect readonly remove: (aggregateID: string) => Effect.Effect } @@ -76,7 +79,7 @@ export const layer = Layer.effect(Service)( const row = Database.use((db) => db - .select({ seq: EventSequenceTable.seq }) + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) .from(EventSequenceTable) .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) .get(), @@ -85,6 +88,10 @@ export const layer = Layer.effect(Service)( const latest = row?.seq ?? -1 if (event.seq <= latest) return + if (row?.ownerID && row.ownerID !== options?.ownerID) { + return + } + const expected = latest + 1 if (event.seq !== expected) { throw new Error( @@ -99,7 +106,7 @@ export const layer = Layer.effect(Service)( workspace: yield* InstanceState.workspaceID, } : undefined - process(def, event, { publish, context }) + process(def, event, { publish, context, ownerID: options?.ownerID }) }) const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { @@ -263,7 +270,7 @@ export function project( function process( def: Def, event: Event, - options: { publish: boolean; context?: PublishContext }, + options: { publish: boolean; context?: PublishContext; ownerID?: string }, ) { if (projectors == null) { throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") @@ -274,8 +281,6 @@ function process( throw new Error(`Projector not found for event: ${def.type}`) } - // idempotent: need to ignore any events already logged - Database.transaction((tx) => { projector(tx, event.data, event) @@ -284,6 +289,7 @@ function process( .values({ aggregate_id: event.aggregateID, seq: event.seq, + owner_id: options?.ownerID, }) .onConflictDoUpdate({ target: EventSequenceTable.aggregate_id, @@ -332,11 +338,11 @@ function process( }) } -export function replay(event: SerializedEvent, options?: { publish: boolean }) { +export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) { return runtime.runSync((sync) => sync.replay(event, options)) } -export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) { +export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) { return runtime.runSync((sync) => sync.replayAll(events, options)) } @@ -348,6 +354,16 @@ export function remove(aggregateID: string) { return runtime.runSync((sync) => sync.remove(aggregateID)) } +export function claim(aggregateID: string, ownerID: string) { + Database.use((db) => + db + .update(EventSequenceTable) + .set({ owner_id: ownerID }) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .run(), + ) +} + export function payloads() { return registry .entries() diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e58ea9b122..22e4e5671c 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -6,10 +6,11 @@ import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" -import { Effect, Schema } from "effect" +import { Effect, Exit, Schema } from "effect" +import { EffectBridge } from "@/effect/bridge" export interface TaskPromptOps { - cancel(sessionID: SessionID): void + cancel(sessionID: SessionID): Effect.Effect resolvePromptParts(template: string): Effect.Effect prompt(input: SessionPrompt.PromptInput): Effect.Effect } @@ -118,16 +119,18 @@ export const TaskTool = Tool.define( const ops = ctx.extra?.promptOps as TaskPromptOps if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra")) + const runCancel = yield* EffectBridge.make() const messageID = MessageID.ascending() + const cancel = ops.cancel(nextSession.id) - function cancel() { - ops.cancel(nextSession.id) + function onAbort() { + runCancel.fork(cancel) } return yield* Effect.acquireUseRelease( Effect.sync(() => { - ctx.abort.addEventListener("abort", cancel) + ctx.abort.addEventListener("abort", onAbort) }), () => Effect.gen(function* () { @@ -163,10 +166,16 @@ export const TaskTool = Tool.define( ].join("\n"), } }), - () => - Effect.sync(() => { - ctx.abort.removeEventListener("abort", cancel) - }), + (_, exit) => + Effect.gen(function* () { + if (Exit.hasInterrupts(exit)) yield* cancel + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.abort.removeEventListener("abort", onAbort) + }), + ), + ), ) }) diff --git a/packages/opencode/src/v2/auth.ts b/packages/opencode/src/v2/auth.ts new file mode 100644 index 0000000000..1cc443974d --- /dev/null +++ b/packages/opencode/src/v2/auth.ts @@ -0,0 +1,246 @@ +import path from "path" +import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" +import { Identifier } from "@opencode-ai/core/util/identifier" +import { NonNegativeInt, withStatics } from "@/util/schema" +import { Global } from "@opencode-ai/core/global" +import { AppFileSystem } from "@opencode-ai/core/filesystem" + +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + +const AccountID = Schema.String.pipe( + Schema.brand("AccountID"), + withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), +) +export type AccountID = typeof AccountID.Type + +export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) +export type ServiceID = typeof ServiceID.Type + +export class OAuthCredential extends Schema.Class("AuthV2.OAuthCredential")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, +}) {} + +export class ApiKeyCredential extends Schema.Class("AuthV2.ApiKeyCredential")({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ + identifier: "AuthV2.Credential", + }) +export type Credential = Schema.Schema.Type + +export class Account extends Schema.Class("AuthV2.Account")({ + id: AccountID, + serviceID: ServiceID, + description: Schema.String, + credential: Credential, +}) {} + +export class AuthFileWriteError extends Schema.TaggedErrorClass()("AuthV2.FileWriteError", { + operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), + cause: Schema.Defect, +}) {} + +export type AuthError = AuthFileWriteError + +interface Writable { + version: 2 + accounts: Record + active: Record +} + +const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) + +function migrate(old: Record): Writable { + const accounts: Record = {} + const active: Record = {} + for (const [serviceID, value] of Object.entries(old)) { + const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) + const parsed = (decoded as Record)[serviceID] + if (!parsed) continue + const id = Identifier.ascending() + const accountID = AccountID.make(id) + const brandedServiceID = ServiceID.make(serviceID) + accounts[id] = new Account({ + id: accountID, + serviceID: brandedServiceID, + description: "default", + credential: parsed, + }) + active[brandedServiceID] = accountID + } + return { version: 2, accounts, active } +} + +export interface Interface { + readonly get: (accountID: AccountID) => Effect.Effect + readonly all: () => Effect.Effect + readonly create: (input: { + serviceID: ServiceID + credential: Credential + description?: string + active?: boolean + }) => Effect.Effect + readonly update: ( + accountID: AccountID, + updates: Partial>, + ) => Effect.Effect + readonly remove: (accountID: AccountID) => Effect.Effect + readonly activate: (accountID: AccountID) => Effect.Effect + readonly active: (serviceID: ServiceID) => Effect.Effect + readonly forService: (serviceID: ServiceID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Auth") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const global = yield* Global.Service + const file = path.join(global.data, "auth-v2.json") + + const load: () => Effect.Effect = Effect.fnUntraced(function* () { + if (process.env.OPENCODE_AUTH_CONTENT) { + try { + return JSON.parse(process.env.OPENCODE_AUTH_CONTENT) + } catch {} + } + + const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) + + if (!raw || typeof raw !== "object") return { version: 2, accounts: {}, active: {} } + + if ("version" in raw && raw.version === 2) return raw as Writable + + const migrated = migrate(raw as Record) + yield* fsys + .writeJson(file, migrated, 0o600) + .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause }))) + return migrated + }) + + const write = (data: Writable) => + fsys + .writeJson(file, data, 0o600) + .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause }))) + + const state = SynchronizedRef.makeUnsafe(yield* load()) + + const result: Interface = { + get: Effect.fn("AuthV2.get")(function* (accountID) { + return (yield* SynchronizedRef.get(state)).accounts[accountID] + }), + + all: Effect.fn("AuthV2.all")(function* () { + return Object.values((yield* SynchronizedRef.get(state)).accounts) + }), + + active: Effect.fn("AuthV2.active")(function* (serviceID) { + const data = yield* SynchronizedRef.get(state) + return ( + data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) + ) + }), + + forService: Effect.fn("AuthV2.list")(function* (serviceID) { + return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) + }), + + create: Effect.fn("AuthV2.add")(function* (input) { + return yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const account = new Account({ + id: AccountID.make(Identifier.ascending()), + serviceID: input.serviceID, + description: input.description ?? "default", + credential: input.credential, + }) + const next = { + ...data, + accounts: { ...data.accounts, [account.id]: account }, + active: + (input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID)) + ? { ...data.active, [input.serviceID]: account.id } + : data.active, + } + + yield* write(next) + return [account, next] as const + }), + ) + }), + + update: Effect.fn("AuthV2.update")(function* (accountID, updates) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const existing = data.accounts[accountID] + if (!existing) return [undefined, data] as const + + const next = { + ...data, + accounts: { + ...data.accounts, + [accountID]: new Account({ + id: accountID, + serviceID: existing.serviceID, + description: updates.description ?? existing.description, + credential: updates.credential ?? existing.credential, + }), + }, + } + + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + remove: Effect.fn("AuthV2.remove")(function* (accountID) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const accounts = { ...data.accounts } + const active = { ...data.active } + if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID) + delete active[accounts[accountID].serviceID] + delete accounts[accountID] + + const next = { ...data, accounts, active } + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + activate: Effect.fn("AuthV2.activate")(function* (accountID) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const account = data.accounts[accountID] + if (!account) return [undefined, data] as const + + const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } } + yield* write(next) + return [undefined, next] as const + }), + ) + }), + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer)) + +export * as AuthV2 from "./auth" diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts new file mode 100644 index 0000000000..db66199a59 --- /dev/null +++ b/packages/opencode/src/v2/model.ts @@ -0,0 +1,192 @@ +import { withStatics } from "@/util/schema" +import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" +import { DateTimeUtcFromMillis } from "effect/Schema" + +export const ID = Schema.String.pipe(Schema.brand("Model.ID")) +export type ID = typeof ID.Type + +export const ProviderID = Schema.String.pipe( + Schema.brand("Model.ProviderID"), + withStatics((schema) => ({ + // Well-known providers + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ProviderID = typeof ProviderID.Type + +export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export type VariantID = typeof VariantID.Type + +// Grouping of models, eg claude opus, claude sonnet +export const Family = Schema.String.pipe(Schema.brand("Family")) +export type Family = typeof Family.Type + +const OpenAIResponses = Schema.Struct({ + type: Schema.Literal("openai/responses"), + url: Schema.String, + websocket: Schema.optional(Schema.Boolean), +}) + +const OpenAICompletions = Schema.Struct({ + type: Schema.Literal("openai/completions"), + url: Schema.String, + reasoning: Schema.Union([ + Schema.Struct({ + type: Schema.Literal("reasoning_content"), + }), + Schema.Struct({ + type: Schema.Literal("reasoning_details"), + }), + ]).pipe(Schema.optional), +}) +export type OpenAICompletions = typeof OpenAICompletions.Type + +const AnthropicMessages = Schema.Struct({ + type: Schema.Literal("anthropic/messages"), + url: Schema.String, +}) + +export const Endpoint = Schema.Union([OpenAIResponses, OpenAICompletions, AnthropicMessages]).pipe( + Schema.toTaggedUnion("type"), +) +export type Endpoint = typeof Endpoint.Type + +export const Capabilities = Schema.Struct({ + tools: Schema.Boolean, + // mime patterns, image, audio, video/*, text/* + input: Schema.String.pipe(Schema.Array), + output: Schema.String.pipe(Schema.Array), +}) +export type Capabilities = typeof Capabilities.Type + +export const Options = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Any), +}) +export type Options = typeof Options.Type + +export const Cost = Schema.Struct({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(Schema.optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +export const Ref = Schema.Struct({ + id: ID, + providerID: ProviderID, + variant: VariantID, +}) +export type Ref = typeof Ref.Type + +export class Info extends Schema.Class("Model.Info")({ + id: ID, + providerID: ProviderID, + family: Family.pipe(Schema.optional), + name: Schema.String, + endpoint: Endpoint, + capabilities: Capabilities, + options: Schema.Struct({ + ...Options.fields, + variant: Schema.String.pipe(Schema.optional), + }), + variants: Schema.Struct({ + id: VariantID, + ...Options.fields, + }).pipe(Schema.Array), + time: Schema.Struct({ + released: DateTimeUtcFromMillis, + }), + cost: Cost.pipe(Schema.Array), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int, + }), +}) {} + +export function parse(input: string): { providerID: ProviderID; modelID: ID } { + const [providerID, ...modelID] = input.split("/") + return { + providerID: ProviderID.make(providerID), + modelID: ID.make(modelID.join("/")), + } +} + +export interface Interface { + readonly get: (providerID: ProviderID, modelID: ID) => Effect.Effect> + readonly add: (model: Info) => Effect.Effect + readonly remove: (providerID: ProviderID, modelID: ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly default: () => Effect.Effect> + readonly small: (provider: ProviderID) => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode/v2/Model") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + let models = HashMap.empty() + + function key(providerID: ProviderID, modelID: ID) { + return `${providerID}/${modelID}` + } + + const result: Interface = { + get: Effect.fn("V2Model.get")(function* (providerID, modelID) { + return HashMap.get(models, key(providerID, modelID)) + }), + + add: Effect.fn("V2Model.add")(function* (model) { + models = HashMap.set(models, key(model.providerID, model.id), model) + }), + + remove: Effect.fn("V2Model.remove")(function* (providerID, modelID) { + models = HashMap.remove(models, key(providerID, modelID)) + }), + + all: Effect.fn("V2Model.all")(function* () { + return pipe( + models, + HashMap.toValues, + Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + ) + }), + + default: Effect.fn("V2Model.default")(function* () { + const all = yield* result.all() + return Option.fromUndefinedOr(all[0]) + }), + + small: Effect.fn("V2Model.small")(function* (providerID) { + const all = yield* result.all() + const match = all.find((model) => model.providerID === providerID && model.id.toLowerCase().includes("small")) + return Option.fromUndefinedOr(match) + }), + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer + +export * as Modelv2 from "./model" diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 47938dcbed..7c768bd551 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -5,8 +5,8 @@ import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" export { FileAttachment } import { ToolOutput } from "./tool-output" -import { ModelID, ProviderID } from "@/provider/schema" import { V2Schema } from "./schema" +import { Modelv2 } from "./model" export const Source = Schema.Struct({ start: NonNegativeInt, @@ -22,10 +22,13 @@ const Base = { sessionID: SessionID, } -const Error = Schema.Struct({ - type: Schema.String, +export const UnknownError = Schema.Struct({ + type: Schema.Literal("unknown"), message: Schema.String, +}).annotate({ + identifier: "Session.Error.Unknown", }) +export type UnknownError = Schema.Schema.Type export const AgentSwitched = EventV2.define({ type: "session.next.agent.switched", @@ -44,9 +47,7 @@ export const ModelSwitched = EventV2.define({ version: 1, schema: { ...Base, - id: ModelID, - providerID: ProviderID, - variant: Schema.String.pipe(Schema.optional), + model: Modelv2.Ref, }, }) export type ModelSwitched = Schema.Schema.Type @@ -103,11 +104,7 @@ export namespace Step { schema: { ...Base, agent: Schema.String, - model: Schema.Struct({ - id: Schema.String, - providerID: Schema.String, - variant: Schema.String.pipe(Schema.optional), - }), + model: Modelv2.Ref, snapshot: Schema.String.pipe(Schema.optional), }, }) @@ -139,7 +136,7 @@ export namespace Step { aggregate: "sessionID", schema: { ...Base, - error: Error, + error: UnknownError, }, }) export type Failed = Schema.Schema.Type @@ -296,7 +293,7 @@ export namespace Tool { schema: { ...Base, callID: Schema.String, - error: Error, + error: UnknownError, provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index d5d5aac7b7..80ecb1011e 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -109,11 +109,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve id: event.id, type: "model-switched", metadata: event.metadata, - model: { - id: event.data.id, - providerID: event.data.providerID, - variant: event.data.variant, - }, + model: event.data.model, time: { created: event.data.timestamp }, }), ) diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 94f6b1cac2..024e28c450 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -4,6 +4,7 @@ import { SessionEvent } from "./session-event" import { EventV2 } from "./event" import { ToolOutput } from "./tool-output" import { V2Schema } from "./schema" +import { Modelv2 } from "./model" export const ID = EventV2.ID export type ID = Schema.Schema.Type @@ -25,11 +26,7 @@ export class AgentSwitched extends Schema.Class("Session.Message. export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ ...Base, type: Schema.Literal("model-switched"), - model: Schema.Struct({ - id: SessionEvent.ModelSwitched.fields.data.fields.id, - providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID, - variant: SessionEvent.ModelSwitched.fields.data.fields.variant, - }), + model: Modelv2.Ref, }) {} export class User extends Schema.Class("Session.Message.User")({ @@ -87,10 +84,7 @@ export class ToolStateError extends Schema.Class("Session.Messag input: Schema.Record(Schema.String, Schema.Unknown), content: ToolOutput.Content.pipe(Schema.Array), structured: ToolOutput.Structured, - error: Schema.Struct({ - type: Schema.String, - message: Schema.String, - }), + error: SessionEvent.UnknownError, }) {} export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 1f4cbcf1e0..bb86f039b2 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -3,17 +3,17 @@ import { SessionID } from "@/session/schema" import { WorkspaceID } from "@/control-plane/schema" import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db" import * as Database from "@/storage/db" -import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { Context, DateTime, Effect, Layer, Option, Schema } from "effect" import { SessionMessage } from "./session-message" import type { Prompt } from "./session-prompt" import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" -import { ModelID, ProviderID } from "@/provider/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" import { optionalOmitUndefined } from "@/util/schema" +import { Modelv2 } from "./model" -export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ +export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", }) export type Delivery = Schema.Schema.Type @@ -27,11 +27,7 @@ export class Info extends Schema.Class("Session.Info")({ workspaceID: optionalOmitUndefined(WorkspaceID), path: optionalOmitUndefined(Schema.String), agent: optionalOmitUndefined(Schema.String), - model: Schema.Struct({ - id: ModelID, - providerID: ProviderID, - variant: optionalOmitUndefined(Schema.String), - }).pipe(optionalOmitUndefined), + model: Modelv2.Ref.pipe(optionalOmitUndefined), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, updated: V2Schema.DateTimeUtcFromMillis, @@ -53,7 +49,18 @@ export class Info extends Schema.Class("Session.Info")({ */ }) {} +export class NotFoundError extends Schema.TaggedErrorClass()("Session.NotFoundError", { + sessionID: SessionID, +}) {} + export interface Interface { + readonly create: (input?: { + agent?: string + model?: Modelv2.Ref + parentID?: SessionID + workspaceID?: WorkspaceID + }) => Effect.Effect + readonly get: (sessionID: SessionID) => Effect.Effect readonly list: (input: { limit?: number order?: "asc" | "desc" @@ -88,13 +95,15 @@ export interface Interface { }) => Effect.Effect readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect + readonly subagent: (input: { + id?: EventV2.ID + parentID: SessionID + prompt: Prompt + agent: string + model?: Modelv2.Ref + }) => Effect.Effect readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect - readonly switchModel: (input: { - sessionID: SessionID - id: ModelID - providerID: ProviderID - variant?: string - }) => Effect.Effect + readonly switchModel: (input: { sessionID: SessionID; model: Modelv2.Ref }) => Effect.Effect readonly compact: (sessionID: SessionID) => Effect.Effect readonly wait: (sessionID: SessionID) => Effect.Effect } @@ -120,9 +129,9 @@ export const layer = Layer.effect( agent: row.agent ?? undefined, model: row.model ? { - id: ModelID.make(row.model.id), - providerID: ProviderID.make(row.model.providerID), - variant: row.model.variant, + id: Modelv2.ID.make(row.model.id), + providerID: Modelv2.ProviderID.make(row.model.providerID), + variant: Modelv2.VariantID.make(row.model.variant ?? "default"), } : undefined, time: { @@ -134,6 +143,14 @@ export const layer = Layer.effect( } const result: Interface = { + create: Effect.fn("V2Session.create")(function* (_input) { + return {} as any + }), + get: Effect.fn("V2Session.get")(function* (sessionID) { + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) + if (!row) return yield* new NotFoundError({ sessionID }) + return fromRow(row) + }), list: Effect.fn("V2Session.list")(function* (input) { const direction = input.cursor?.direction ?? "next" let order = input.order ?? "desc" @@ -262,11 +279,30 @@ export const layer = Layer.effect( EventV2.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), - id: input.id, - providerID: input.providerID, - variant: input.variant, + model: input.model, }) }), + subagent: Effect.fn("V2Session.subagent")(function* (input) { + const parent = yield* result.get(input.parentID) + const session = yield* result.create({ + agent: input.agent, + model: input.model, + parentID: input.parentID, + workspaceID: parent.workspaceID, + }) + yield* result.prompt({ + prompt: input.prompt, + sessionID: session.id, + }) + yield* Effect.gen(function* () { + yield* result.wait(session.id) + const messages = yield* result.messages({ sessionID: session.id, order: "desc" }) + const assistant = messages.find((msg) => msg.type === "assistant") + if (!assistant) return + const text = assistant.content.findLast((part) => part.type === "text") + if (!text) return + }).pipe(Effect.forkChild()) + }), compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), } diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 43453b561a..f4e4d2721c 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -291,16 +291,15 @@ export const layer: Layer.Layer< const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { yield* setup(info) - yield* boot(info, startCommand) + yield* boot(info, startCommand).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), + Effect.forkIn(scope), + ) }) const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) { const info = yield* makeWorktreeInfo(input?.name) - yield* setup(info) - yield* boot(info, input?.startCommand).pipe( - Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), - Effect.forkIn(scope), - ) + yield* createFromInfo(info, input?.startCommand) return info }) diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 3ac923c435..dff972d100 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,65 +1,28 @@ import { expect } from "bun:test" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Layer } from "effect" import path from "path" import { pathToFileURL } from "url" import { Agent } from "../../src/agent/agent" -import { InstanceRef } from "../../src/effect/instance-ref" -import { InstanceLayer } from "../../src/project/instance-layer" -import { InstanceStore } from "../../src/project/instance-store" -import { tmpdirScoped } from "../fixture/fixture" +import { Plugin } from "../../src/plugin" import { testEffect } from "../lib/effect" +import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" -const pluginAgent = { - name: "plugin_added", - description: "Added by a plugin via the config hook", - mode: "subagent", -} as const +// `it.instance` skips InstanceBootstrap so FileWatcher / LSP / MCP don't spin +// up — those services hang during scope teardown on Windows and aren't needed +// to verify plugin → config hook → Agent.list. +const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href -const it = testEffect(Layer.mergeAll(Agent.defaultLayer, InstanceLayer.layer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer)) -it.live("plugin-registered agents appear in Agent.list", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - const pluginFile = path.join(dir, "plugin.ts") - - yield* Effect.promise(async () => { - await Promise.all([ - Bun.write( - pluginFile, - [ - "export default async () => ({", - " config: async (cfg) => {", - " cfg.agent = cfg.agent ?? {}", - ` cfg.agent[${JSON.stringify(pluginAgent.name)}] = {`, - ` description: ${JSON.stringify(pluginAgent.description)},`, - ` mode: ${JSON.stringify(pluginAgent.mode)},`, - " }", - " },", - "})", - "", - ].join("\n"), - ), - Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(pluginFile).href], - }), - ), - ]) - }) - - const agents = yield* InstanceStore.Service.use((store) => - Effect.gen(function* () { - const ctx = yield* store.load({ directory: dir }) - yield* Effect.addFinalizer(() => store.dispose(ctx).pipe(Effect.ignore)) - return yield* Agent.Service.use((svc) => svc.list()).pipe(Effect.provideService(InstanceRef, ctx)) - }), - ) - const added = agents.find((agent) => agent.name === pluginAgent.name) - - expect(added?.description).toBe(pluginAgent.description) - expect(added?.mode).toBe(pluginAgent.mode) - }), +it.instance( + "plugin-registered agents appear in Agent.list", + () => + Effect.gen(function* () { + yield* Plugin.Service.use((p) => p.init()) + const agents = yield* Agent.Service.use((svc) => svc.list()) + const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name) + expect(added?.description).toBe(PLUGIN_AGENT.description) + expect(added?.mode).toBe(PLUGIN_AGENT.mode) + }), + { config: { plugin: [pluginUrl] } }, ) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 10a05e3b1e..84f5670064 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises" import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer } from "effect" import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { asc, eq } from "drizzle-orm" +import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus, type GlobalEvent } from "@/bus/global" @@ -16,11 +16,10 @@ import { ProjectTable } from "@/project/project.sql" import { Instance } from "@/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" -import { SessionID, MessageID, PartID } from "@/session/schema" +import { SessionID } from "@/session/schema" import { SessionTable } from "@/session/session.sql" -import { ModelID, ProviderID } from "@/provider/schema" import { SyncEvent } from "@/sync" -import { EventSequenceTable, EventTable } from "@/sync/event.sql" +import { EventSequenceTable } from "@/sync/event.sql" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -111,8 +110,8 @@ async function withInstance(fn: (dir: string) => T | Promise) { const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) const createWorkspace = (input: WorkspaceOld.CreateInput) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input))) -const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input))) +const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input))) const listWorkspaces = (project: Parameters[0]) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project))) const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id))) @@ -317,48 +316,24 @@ function sessionSequence(sessionID: SessionID) { )?.seq } -function eventRows(sessionID: SessionID) { +function sessionSequenceOwner(sessionID: SessionID) { return Database.use((db) => db - .select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data }) - .from(EventTable) - .where(eq(EventTable.aggregate_id, sessionID)) - .orderBy(asc(EventTable.seq)) - .all(), - ) + .select({ ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, sessionID)) + .get(), + )?.ownerID } function sessionUpdatedType() { return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version) } -function replaceSessionEvents(sessionID: SessionID, count: number) { - Database.use((db) => { - db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run() - if (count === 0) return - - db.insert(EventSequenceTable) - .values({ aggregate_id: sessionID, seq: count - 1 }) - .run() - db.insert(EventTable) - .values( - Array.from({ length: count }, (_, i) => ({ - id: `evt_${unique(`manual-${i}`)}`, - aggregate_id: sessionID, - seq: i, - type: sessionUpdatedType(), - data: { sessionID, info: { title: `manual ${i}` } }, - })), - ) - .run() - }) -} - describe("workspace-old schemas and exports", () => { test("keeps the historical event type names", () => { expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready") expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed") - expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore") expect(WorkspaceOld.Event.Status.type).toBe("workspace.status") }) @@ -375,17 +350,6 @@ describe("workspace-old schemas and exports", () => { expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() }) - - test("validates session restore input", () => { - const input = { - workspaceID: WorkspaceID.ascending("wrk_schema_restore"), - sessionID: SessionID.descending("ses_schema_restore"), - } - - expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input) - expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow() - expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow() - }) }) describe("workspace-old CRUD", () => { @@ -651,6 +615,144 @@ describe("workspace-old CRUD", () => { expect(await getWorkspace(info.id)).toBeUndefined() }) }) + + test("sessionWarp moves a session into a local workspace and claims ownership", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-prev-local") + const targetType = unique("warp-target-local") + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter) + registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id }) + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBe(target.id) + expect(sessionSequenceOwner(session.id)).toBe(target.id) + }) + }) + + test("sessionWarp detaches a session to the local project and claims project ownership", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-detach-local") + const previous = workspaceInfo(Instance.project.id, previousType) + insertWorkspace(previous) + registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: null, sessionID: session.id }) + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBeNull() + expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id) + }) + }) + + it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => { + const calls: FetchCall[] = [] + let historySessionID: SessionID | undefined + let historyNextSeq = 0 + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + const call = { + url: new URL(req.url, "http://localhost"), + method: req.method, + headers: new Headers(req.headers), + bodyText, + json: bodyText ? JSON.parse(bodyText) : undefined, + } + calls.push(call) + if (call.url.pathname === "/warp-source/sync/history") { + return yield* HttpServerResponse.json([ + { + id: `evt_${unique("warp-source-history")}`, + aggregate_id: historySessionID!, + seq: historyNextSeq, + type: sessionUpdatedType(), + data: { sessionID: historySessionID!, info: { title: "from source history" } }, + }, + ]) + } + if (call.url.pathname === "/warp-target/sync/replay") + return yield* HttpServerResponse.json({ sessionID: "ok" }) + if (call.url.pathname === "/warp-target/sync/steal") + return yield* HttpServerResponse.json({ sessionID: "ok" }) + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const previousType = unique("warp-remote-source") + const targetType = unique("warp-remote-target") + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" }) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter) + registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter) + const session = yield* sessionSvc.create({}) + attachSessionToWorkspace(session.id, previous.id) + historySessionID = session.id + historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 + + yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id }) + + expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([ + "POST /warp-source/sync/history", + "POST /warp-target/sync/replay", + "POST /warp-target/sync/steal", + ]) + expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 }) + expect(calls[1].json).toMatchObject({ + directory: "remote-target-dir", + events: [ + { + aggregateID: session.id, + seq: 0, + type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version), + }, + { + aggregateID: session.id, + seq: historyNextSeq, + type: sessionUpdatedType(), + }, + ], + }) + expect(calls[2].json).toEqual({ sessionID: session.id }) + expect((yield* sessionSvc.get(session.id)).title).toBe("from source history") + expect(sessionSequenceOwner(session.id)).toBe(target.id) + }), + { git: true }, + ) + }) + }) }) describe("workspace-old sync state", () => { @@ -1215,313 +1317,3 @@ describe("workspace-old waitForSync", () => { }) }, 7000) }) - -describe("workspace-old sessionRestore", () => { - test("throws when the workspace is missing", async () => { - await withInstance(async () => { - await expect( - restoreWorkspaceSession({ - workspaceID: WorkspaceID.ascending("wrk_restore_missing"), - sessionID: SessionID.descending("ses_restore_missing_workspace"), - }), - ).rejects.toThrow("Workspace not found: wrk_restore_missing") - }) - }) - - test("throws when switching a missing session fails", async () => { - await withInstance(async (dir) => { - const type = unique("restore-missing-session") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) - - await expect( - restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }), - ).rejects.toThrow("NotFoundError") - await removeWorkspace(info.id) - }) - }) - - it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - const call = { - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - } - if (call.url.pathname === "/restore/sync/replay") { - replay.push(call) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - } - return HttpServerResponse.text("unexpected", { status: 500 }) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-remote") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter( - Instance.project.id, - type, - remoteAdapter(`${url}/restore/?ignored=1#hash`, { - directory: dir, - headers: { authorization: "Bearer restore" }, - }).adapter, - ) - const session = yield* sessionSvc.create({ title: "restore remote" }) - replaceSessionEvents(session.id, 24) - - const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }) - - expect(result).toEqual({ total: 3 }) - expect(replay).toHaveLength(3) - expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([ - "/restore/sync/replay", - "/restore/sync/replay", - "/restore/sync/replay", - ]) - expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true) - expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true) - expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5]) - expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir]) - expect( - replay.flatMap((call) => - (call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq), - ), - ).toEqual(Array.from({ length: 25 }, (_, i) => i)) - expect( - (replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1), - ).toMatchObject({ - seq: 24, - type: sessionUpdatedType(), - data: { sessionID: session.id, info: { workspaceID: info.id } }, - }) - expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0, 1, 2, 3]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ) - }) - }) - - it.live("remote restore sends an empty directory string when the workspace directory is null", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - () => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const type = unique("restore-null-dir") - const info = workspaceInfo(Instance.project.id, type, { directory: null }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter) - const session = yield* sessionSvc.create({ title: "null dir" }) - replaceSessionEvents(session.id, 0) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 1, - }) - expect((replay[0].json as { directory: string }).directory).toBe("") - expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1) - yield* workspace.remove(info.id) - }), - { git: true }, - ) - }) - }) - - it.live("remote restore failures include status and body and do not emit completed batch progress", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.text("replay failed", { status: 503 }) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-remote-fail") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter) - const session = yield* sessionSvc.create({ title: "restore fail" }) - replaceSessionEvents(session.id, 11) - - const error = yield* Effect.flip( - workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }), - ) - expect((error as Error).message).toContain( - `Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`, - ) - - expect(replay).toHaveLength(1) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ) - }) - }) - - it.live("local restore replays batches and emits progress", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-local") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) - const session = yield* sessionSvc.create({ title: "restore local" }) - replaceSessionEvents(session.id, 20) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 3, - }) - expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id) - expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i)) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0, 1, 2, 3]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ), - ) - - it.live("session restore includes real message and part events in sequence order", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const type = unique("restore-real-events") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter) - const session = yield* sessionSvc.create({ title: "real events" }) - for (let i = 0; i < 3; i++) { - const msg = yield* sessionSvc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: session.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - yield* sessionSvc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: msg.id, - type: "text", - text: `message ${i}`, - }) - } - const before = eventRows(session.id) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 1, - }) - - const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events - expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1]) - expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type)) - expect(posted.at(-1)?.type).toBe(sessionUpdatedType()) - yield* workspace.remove(info.id) - }), - { git: true }, - ) - }) - }) -}) diff --git a/packages/opencode/test/fixture/agent-plugin.constants.ts b/packages/opencode/test/fixture/agent-plugin.constants.ts new file mode 100644 index 0000000000..9dd5f3910e --- /dev/null +++ b/packages/opencode/test/fixture/agent-plugin.constants.ts @@ -0,0 +1,6 @@ +// Separate file because every export in `agent-plugin.ts` must be a function. +export const PLUGIN_AGENT = { + name: "plugin_added", + description: "Added by a plugin via the config hook", + mode: "subagent", +} as const diff --git a/packages/opencode/test/fixture/agent-plugin.ts b/packages/opencode/test/fixture/agent-plugin.ts new file mode 100644 index 0000000000..892f636466 --- /dev/null +++ b/packages/opencode/test/fixture/agent-plugin.ts @@ -0,0 +1,12 @@ +// Every export in this file must be a plugin function — `getLegacyPlugins` +// (src/plugin/index.ts) throws on anything else. Test constants live in +// `agent-plugin.constants.ts`. +export default async () => ({ + config: async (cfg: { agent?: Record }) => { + cfg.agent = cfg.agent ?? {} + cfg.agent["plugin_added"] = { + description: "Added by a plugin via the config hook", + mode: "subagent", + } + }, +}) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 53ff547ac1..82eacfb6df 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,5 +1,6 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" +import { parsePatch } from "diff" import { Effect } from "effect" import fs from "fs/promises" import path from "path" @@ -288,6 +289,28 @@ describe("Vcs diff", () => { }) }) + test("diff('git') keeps carriage returns inside patch hunks", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8") + + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const file = diff.find((item) => item.file === "file.txt") + + expect(file?.patch).toContain(" same\rdiff --git inside") + expect(file?.patch).toContain("-delete") + expect(() => parsePatch(file?.patch ?? "")).not.toThrow() + }) + }, 20_000) + test("diff('branch') returns changes against default branch", async () => { await using tmp = await tmpdir({ git: true }) await $`git branch -M main`.cwd(tmp.path).quiet() diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index a89fda6ca5..b191a3c952 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -178,12 +178,13 @@ describe("Worktree", () => { }) describe("createFromInfo", () => { - wintest("creates and bootstraps git worktree", () => + wintest("creates git worktree and boots asynchronously", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { const svc = yield* Worktree.Service const info = yield* svc.makeWorktreeInfo("from-info-test") + const ready = waitReady() yield* svc.createFromInfo(info) const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) @@ -191,6 +192,7 @@ describe("Worktree", () => { const normalizedDir = info.directory.replace(/\\/g, "/") expect(normalizedList).toContain(normalizedDir) + yield* Effect.promise(() => ready) yield* svc.remove({ directory: info.directory }) }), { git: true }, diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index af4c0a01ce..b49fbe98b5 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -40,8 +40,8 @@ async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpap return Server.listen({ hostname: "127.0.0.1", port: 0 }) } -async function startNoAuthListener() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false +async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" Flag.OPENCODE_SERVER_PASSWORD = undefined Flag.OPENCODE_SERVER_USERNAME = auth.username delete process.env.OPENCODE_SERVER_PASSWORD @@ -257,18 +257,63 @@ describe("HttpApi Server.listen", () => { } }) - testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { + // Regression for #25698 (Ope): the app's SDK call to + // `client.pty.connectToken({ ptyID })` originally omitted `directory`, so + // the server resolved the PTY in its own cwd context — where the project + // PTY isn't registered — and returned 404. The fix is to always pass + // `directory` from the app side; this test locks in two contracts: + // 1. Mint without directory cannot find a PTY registered in another dir. + // 2. Mint with the project directory succeeds; the resulting ticket + // consumes cleanly when the WS upgrade carries the same directory. + testPty("PTY connect token requires matching directory across mint and connect", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startNoAuthListener() + const listener = await startListener() try { const info = await createCat(listener, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path)) - const message = waitForMessage(ws, (message) => message.includes("ping-no-auth")) - ws.send("ping-no-auth\n") - expect(await message).toContain("ping-no-auth") + + // Mint without directory — server uses its own cwd, can't find the PTY. + const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), { + method: "POST", + headers: { authorization: authorization(), "x-opencode-ticket": "1" }, + }) + expect(ambiguous.status).toBe(404) + + // Mint with the project directory — succeeds, ticket binds to that scope. + const scoped = await fetch( + new URL( + `${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`, + listener.url, + ), + { + method: "POST", + headers: { authorization: authorization(), "x-opencode-ticket": "1" }, + }, + ) + expect(scoped.status).toBe(200) + const mint = (await scoped.json()) as { ticket: string } + + // Same directory on the WS upgrade → consume succeeds. + const ws = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket)) ws.close(1000) } finally { - await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + await stop(listener, "timed out cleaning up directory-scope listener").catch(() => undefined) } }) + + for (const backend of ["effect-httpapi", "hono"] as const) { + testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener(backend) + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`)) + ws.send(`ping-no-auth-${backend}\n`) + expect(await message).toContain(`ping-no-auth-${backend}`) + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) + } }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index c9a0b53bb4..34cecd80d0 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -19,6 +19,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" import { SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionMessage } from "../../src/v2/session-message" +import { Modelv2 } from "../../src/v2/model" import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" @@ -214,7 +215,11 @@ describe("session HttpApi", () => { id: SessionMessage.ID.create(), type: "assistant", agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, time: { created: DateTime.makeUnsafe(1) }, content: [], }) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index f364491ace..85162f6a92 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -184,6 +184,52 @@ describe("HttpApi UI fallback", () => { expect(await response.text()).toBe("console.log('ok')") }) + // Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was + // forwarded through the proxy while the proxy itself re-frames the body, + // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`. + test("strips upstream transfer-encoding header from proxied assets", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const client = yield* HttpClient.HttpClient + return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), { + fs, + client, + }) + }).pipe( + Effect.provide( + Layer.mergeAll( + AppFileSystem.defaultLayer, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("opencode", { + headers: { + "transfer-encoding": "chunked", + "content-type": "text/html", + }, + }), + ), + ), + ), + ), + ), + ), + Effect.map(HttpServerResponse.toWeb), + ), + ) + + expect(response.status).toBe(200) + expect(response.headers.get("transfer-encoding")).toBeNull() + expect(await response.text()).toBe("opencode") + }) + test("serves embedded UI assets when Bun can read them but access reports missing", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true let readPath: string | undefined @@ -257,6 +303,25 @@ describe("HttpApi UI fallback", () => { expect(response.status).toBe(200) }) + // Regression for #25698 (Ope): the browser fetches the PWA manifest and + // its icons via flows that don't carry app-managed credentials (the + // `` request is not under page-auth control), so the + // server returning 401 breaks PWA install. These specific public assets + // should bypass auth. + test("serves the PWA manifest without auth even when a server password is set", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { + const response = await uiApp({ + password: "secret", + username: "opencode", + client: httpClient(new Response("ok")), + }).request(path) + expect(response.status).not.toBe(401) + } + }) + test("allows web UI preflight without auth", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 193c2971a1..21bf4120c9 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -168,22 +168,19 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-test", branch: null, extra: null }), + body: JSON.stringify({ type: "local-test", branch: null }), }) expect(created.status).toBe(200) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir)) - const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, { + const warped = yield* request(WorkspacePaths.warp, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ sessionID: session.id }), - }) - expect(restored.status).toBe(200) - expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({ - total: expect.any(Number), + body: JSON.stringify({ id: workspace.id, sessionID: session.id }), }) + expect(warped.status).toBe(204) const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) expect(removed.status).toBe(200) @@ -212,7 +209,6 @@ describe("workspace HttpApi", () => { expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ type: "local-test", name: "local-test", - extra: null, }) }), ) @@ -257,7 +253,6 @@ describe("workspace HttpApi", () => { expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ type: "local-test", name: "local-test", - extra: null, }) }), ) @@ -272,7 +267,7 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-target", branch: null, extra: null }), + body: JSON.stringify({ type: "local-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info @@ -327,7 +322,7 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "remote-target", branch: null, extra: null }), + body: JSON.stringify({ type: "remote-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info @@ -394,7 +389,7 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }), + body: JSON.stringify({ type: "remote-session-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info const session = yield* Session.Service.use((svc) => svc.create()).pipe( diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts new file mode 100644 index 0000000000..768a261a00 --- /dev/null +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -0,0 +1,148 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const stateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + } + + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES + await resetDatabase() + }), + ) + }), +) + +const it = testEffect(stateLayer) +type TestServer = ReturnType + +function serverScoped() { + return Effect.acquireRelease( + Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })), + (server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore), + ) +} + +function request(server: TestServer, input: string, init?: RequestInit) { + return Effect.promise(() => + server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context), + ) +} + +function withRequestTimeout(effect: Effect.Effect, label: string, ms = 5_000) { + return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label)) +} + +function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) { + return Effect.gen(function* () { + const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`) + expect(current.status).toBe(200) + const project = (yield* Effect.promise(() => current.json())) as { id: string } + const updated = yield* request( + input.server, + `/project/${project.id}?directory=${encodeURIComponent(input.directory)}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ commands: { start: input.command } }), + }, + ) + expect(updated.status).toBe(200) + }) +} + +describe("worktree endpoint reproduction", () => { + it.instance( + "direct HttpApi worktree create returns without waiting for boot", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* withRequestTimeout( + request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }), + "direct worktree create", + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) }) + }), + { git: true }, + ) + + it.instance( + "workspace worktree create does not hang", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* withRequestTimeout( + request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + "workspace worktree create", + 8_000, + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + type: "worktree", + directory: expect.any(String), + }) + }), + { git: true }, + ) + + it.instance( + "workspace worktree create returns without waiting for project start command", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + yield* setProjectStartCommand({ + server, + directory: test.directory, + command: 'bun -e "setTimeout(() => {}, 2000)"', + }) + + const started = Date.now() + const response = yield* withRequestTimeout( + request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + "workspace worktree create with project start command", + 6_000, + ) + + expect(response.status).toBe(200) + expect(Date.now() - started).toBeLessThan(1_500) + }), + { git: true }, + ) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index a602c0c8d7..c5170f3464 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -858,6 +858,43 @@ it.live( 30_000, ) +it.live( + "cancel propagates from slash command subtask to child session", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + const tool = taskMsg ? toolPart(taskMsg.parts) : undefined + const sessionID = tool?.state.status === "running" ? tool.state.metadata?.sessionId : undefined + expect(typeof sessionID).toBe("string") + if (typeof sessionID !== "string") throw new Error("missing child session id") + const childID = SessionID.make(sessionID) + expect((yield* status.get(childID)).type).toBe("busy") + + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + + expect((yield* status.get(chat.id)).type).toBe("idle") + expect((yield* status.get(childID)).type).toBe("idle") + }), + { git: true, config: providerCfg }, + ), + 10_000, +) + it.live( "cancel with queued callers resolves all cleanly", () => diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 234c5246ee..0986b39044 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Bus } from "../../src/bus" import { SyncEvent } from "../../src/sync" import { Database } from "@/storage/db" -import { EventTable } from "../../src/sync/event.sql" +import { EventSequenceTable, EventTable } from "../../src/sync/event.sql" import { MessageID } from "../../src/session/schema" import { Flag } from "@opencode-ai/core/flag/flag" import { initProjectors } from "../../src/server/projectors" @@ -252,5 +252,76 @@ describe("SyncEvent", () => { }), ), ) + + it.live( + "claims unowned event sequence on replay with ownerID", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() + + yield* SyncEvent.use.replay( + { + id: "evt_1", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 0, + aggregateID: id, + data: { id, name: "owned" }, + }, + { publish: false, ownerID: "owner-1" }, + ) + + const row = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .get(), + ) + expect(row).toEqual({ seq: 0, ownerID: "owner-1" }) + }), + ), + ) + + it.live( + "ignores replay from a different owner after sequence is claimed", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() + + yield* SyncEvent.use.replay( + { + id: "evt_1", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 0, + aggregateID: id, + data: { id, name: "first" }, + }, + { publish: false, ownerID: "owner-1" }, + ) + yield* SyncEvent.use.replay( + { + id: "evt_2", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 1, + aggregateID: id, + data: { id, name: "ignored" }, + }, + { publish: false, ownerID: "owner-2" }, + ) + + const events = Database.use((db) => db.select().from(EventTable).all()) + const sequence = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .get(), + ) + expect(events).toHaveLength(1) + expect(events[0].id).toBe("evt_1") + expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" }) + }), + ), + ) }) }) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index a8d62bb68c..f75fcf84b8 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,18 +1,17 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { Config } from "@/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Instance } from "../../src/project/instance" import { Session } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import type { SessionPrompt } from "../../src/session/prompt" -import { MessageID, PartID } from "../../src/session/schema" +import { MessageID, PartID, SessionID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { @@ -35,6 +34,14 @@ const it = testEffect( ), ) +function defer() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { const session = yield* Session.Service const chat = yield* session.create({ title }) @@ -66,7 +73,7 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { return { - cancel() {}, + cancel: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => Effect.sync(() => { @@ -107,102 +114,270 @@ function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithPa } describe("tool.task", () => { - it.live("description sorts subagents by name and is stable across calls", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const agent = yield* Agent.Service - const build = yield* agent.get("build") - const registry = yield* ToolRegistry.Service - const get = Effect.fnUntraced(function* () { - const tools = yield* registry.tools({ ...ref, agent: build }) - return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" - }) - const first = yield* get() - const second = yield* get() + it.instance( + "description sorts subagents by name and is stable across calls", + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const registry = yield* ToolRegistry.Service + const get = Effect.fnUntraced(function* () { + const tools = yield* registry.tools({ ...ref, agent: build }) + return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" + }) + const first = yield* get() + const second = yield* get() - expect(first).toBe(second) + expect(first).toBe(second) - const alpha = first.indexOf("- alpha: Alpha agent") - const explore = first.indexOf("- explore:") - const general = first.indexOf("- general:") - const zebra = first.indexOf("- zebra: Zebra agent") + const alpha = first.indexOf("- alpha: Alpha agent") + const explore = first.indexOf("- explore:") + const general = first.indexOf("- general:") + const zebra = first.indexOf("- zebra: Zebra agent") - expect(alpha).toBeGreaterThan(-1) - expect(explore).toBeGreaterThan(alpha) - expect(general).toBeGreaterThan(explore) - expect(zebra).toBeGreaterThan(general) - }), - { - config: { - agent: { - zebra: { - description: "Zebra agent", - mode: "subagent", - }, - alpha: { - description: "Alpha agent", - mode: "subagent", - }, + expect(alpha).toBeGreaterThan(-1) + expect(explore).toBeGreaterThan(alpha) + expect(general).toBeGreaterThan(explore) + expect(zebra).toBeGreaterThan(general) + }), + { + config: { + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", }, }, }, - ), + }, ) - it.live("description hides denied subagents for the caller", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const agent = yield* Agent.Service - const build = yield* agent.get("build") - const registry = yield* ToolRegistry.Service - const description = - (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" + it.instance( + "description hides denied subagents for the caller", + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const registry = yield* ToolRegistry.Service + const description = + (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" - expect(description).toContain("- alpha: Alpha agent") - expect(description).not.toContain("- zebra: Zebra agent") - }), - { - config: { - permission: { - task: { - "*": "allow", - zebra: "deny", - }, + expect(description).toContain("- alpha: Alpha agent") + expect(description).not.toContain("- zebra: Zebra agent") + }), + { + config: { + permission: { + task: { + "*": "allow", + zebra: "deny", }, - agent: { - zebra: { - description: "Zebra agent", - mode: "subagent", - }, - alpha: { - description: "Alpha agent", - mode: "subagent", - }, + }, + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", }, }, }, - ), + }, ) - it.live("execute resumes an existing task session from task_id", () => - provideTmpdirInstance(() => + it.instance("execute resumes an existing task session from task_id", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(child.id) + expect(result.metadata.sessionId).toBe(child.id) + expect(result.output).toContain(`task_id: ${child.id}`) + expect(seen?.sessionID).toBe(child.id) + }), + ) + + it.instance("execute asks by default and skips checks when bypassed", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const calls: unknown[] = [] + const promptOps = stubOps() + + const exec = (extra?: Record) => + def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps, ...extra }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), + }, + ) + + yield* exec() + yield* exec({ bypassAgentCheck: true }) + + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + permission: "task", + patterns: ["general"], + always: ["*"], + metadata: { + description: "inspect bug", + subagent_type: "general", + }, + }) + }), + ) + + it.instance("execute cancels child session when abort signal fires", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const ready = defer() + const cancelled = defer() + const abort = new AbortController() + const promptOps: TaskPromptOps = { + cancel: (sessionID) => + Effect.sync(() => { + cancelled.resolve(sessionID) + }), + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => + Effect.promise(() => { + ready.resolve(input) + return cancelled.promise + }).pipe(Effect.as(reply(input, "cancelled"))), + } + + const fiber = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: abort.signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.forkChild) + + const input = yield* Effect.promise(() => ready.promise) + abort.abort() + expect(yield* Effect.promise(() => cancelled.promise)).toBe(input.sessionID) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + }), + ) + + it.instance("execute creates a child when task_id does not exist", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: "ses_missing", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(result.metadata.sessionId) + expect(result.metadata.sessionId).not.toBe("ses_missing") + expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`) + expect(seen?.sessionID).toBe(result.metadata.sessionId) + }), + ) + + it.instance( + "execute shapes child permissions for task, todowrite, and primary tools", + () => Effect.gen(function* () { const sessions = yield* Session.Service const { chat, assistant } = yield* seed() - const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) const tool = yield* TaskTool const def = yield* tool.init() let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) const result = yield* def.execute( { description: "inspect bug", prompt: "look into the cache key path", - subagent_type: "general", - task_id: child.id, + subagent_type: "reviewer", }, { sessionID: chat.id, @@ -216,172 +391,45 @@ describe("tool.task", () => { }, ) - const kids = yield* sessions.children(chat.id) - expect(kids).toHaveLength(1) - expect(kids[0]?.id).toBe(child.id) - expect(result.metadata.sessionId).toBe(child.id) - expect(result.output).toContain(`task_id: ${child.id}`) - expect(seen?.sessionID).toBe(child.id) - }), - ), - ) - - it.live("execute asks by default and skips checks when bypassed", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - const calls: unknown[] = [] - const promptOps = stubOps() - - const exec = (extra?: Record) => - def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps, ...extra }, - messages: [], - metadata: () => Effect.void, - ask: (input) => - Effect.sync(() => { - calls.push(input) - }), - }, - ) - - yield* exec() - yield* exec({ bypassAgentCheck: true }) - - expect(calls).toHaveLength(1) - expect(calls[0]).toEqual({ - permission: "task", - patterns: ["general"], - always: ["*"], - metadata: { - description: "inspect bug", - subagent_type: "general", + const child = yield* sessions.get(result.metadata.sessionId) + expect(child.parentID).toBe(chat.id) + expect(child.permission).toEqual([ + { + permission: "todowrite", + pattern: "*", + action: "deny", }, + { + permission: "bash", + pattern: "*", + action: "allow", + }, + { + permission: "read", + pattern: "*", + action: "allow", + }, + ]) + expect(seen?.tools).toEqual({ + todowrite: false, + bash: false, + read: false, }) }), - ), - ) - - it.live("execute creates a child when task_id does not exist", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const sessions = yield* Session.Service - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) - - const result = yield* def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - task_id: "ses_missing", - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) - - const kids = yield* sessions.children(chat.id) - expect(kids).toHaveLength(1) - expect(kids[0]?.id).toBe(result.metadata.sessionId) - expect(result.metadata.sessionId).not.toBe("ses_missing") - expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`) - expect(seen?.sessionID).toBe(result.metadata.sessionId) - }), - ), - ) - - it.live("execute shapes child permissions for task, todowrite, and primary tools", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const sessions = yield* Session.Service - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) - - const result = yield* def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "reviewer", + { + config: { + agent: { + reviewer: { + mode: "subagent", + permission: { + task: "allow", }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) - - const child = yield* sessions.get(result.metadata.sessionId) - expect(child.parentID).toBe(chat.id) - expect(child.permission).toEqual([ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - { - permission: "bash", - pattern: "*", - action: "allow", - }, - { - permission: "read", - pattern: "*", - action: "allow", - }, - ]) - expect(seen?.tools).toEqual({ - todowrite: false, - bash: false, - read: false, - }) - }), - { - config: { - agent: { - reviewer: { - mode: "subagent", - permission: { - task: "allow", - }, - }, - }, - experimental: { - primary_tools: ["bash", "read"], }, }, + experimental: { + primary_tools: ["bash", "read"], + }, }, - ), + }, ) }) diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 128177167c..44ac031eda 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test" import * as DateTime from "effect/DateTime" import { SessionID } from "../../src/session/schema" import { EventV2 } from "../../src/v2/event" +import { Modelv2 } from "../../src/v2/model" import { SessionEvent } from "../../src/v2/session-event" import { SessionMessageUpdater } from "../../src/v2/session-message-updater" @@ -16,7 +17,11 @@ test("step snapshots carry over to assistant messages", () => { sessionID, timestamp: DateTime.makeUnsafe(1), agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, snapshot: "before", }, } satisfies SessionEvent.Event) @@ -56,7 +61,11 @@ test("text ended populates assistant text content", () => { sessionID, timestamp: DateTime.makeUnsafe(1), agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, }, } satisfies SessionEvent.Event) @@ -96,7 +105,11 @@ test("tool completion stores completed timestamp", () => { sessionID, timestamp: DateTime.makeUnsafe(1), agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, }, } satisfies SessionEvent.Event) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d6bfdd844b..661201d2d9 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.33", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index de69e685c5..bef0fee141 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.33", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index e94132c2b2..ffc0970c0e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -35,9 +35,9 @@ import type { ExperimentalWorkspaceListResponses, ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, - ExperimentalWorkspaceSessionRestoreErrors, - ExperimentalWorkspaceSessionRestoreResponses, ExperimentalWorkspaceStatusResponses, + ExperimentalWorkspaceWarpErrors, + ExperimentalWorkspaceWarpResponses, FileListResponses, FilePartInput, FilePartSource, @@ -169,6 +169,8 @@ import type { SyncReplayErrors, SyncReplayResponses, SyncStartResponses, + SyncStealErrors, + SyncStealResponses, TextPartInput, ToolIdsErrors, ToolIdsResponses, @@ -1009,15 +1011,15 @@ export class Workspace extends HeyApiClient { } /** - * Restore session into workspace + * Warp session into workspace * - * Replay a session's sync events into the target workspace in batches. + * Move a session's sync history into the target workspace, or detach it to the local project. */ - public sessionRestore( - parameters: { - id: string + public warp( + parameters?: { directory?: string workspace?: string + id?: string sessionID?: string }, options?: Options, @@ -1027,20 +1029,20 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "path", key: "id" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "id" }, { in: "body", key: "sessionID" }, ], }, ], ) return (options?.client ?? this.client).post< - ExperimentalWorkspaceSessionRestoreResponses, - ExperimentalWorkspaceSessionRestoreErrors, + ExperimentalWorkspaceWarpResponses, + ExperimentalWorkspaceWarpErrors, ThrowOnError >({ - url: "/experimental/workspace/{id}/session-restore", + url: "/experimental/workspace/warp", ...options, ...params, headers: { @@ -3956,6 +3958,43 @@ export class Sync extends HeyApiClient { }) } + /** + * Steal session into workspace + * + * Update a session to belong to the current workspace through the sync event system. + */ + public steal( + parameters?: { + directory?: string + workspace?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/sync/steal", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + private _history?: History get history(): History { return (this._history ??= new History({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 86c5a762b1..7734ca53eb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -35,7 +35,6 @@ export type Event = | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed - | EventWorkspaceRestore | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed @@ -801,7 +800,6 @@ export type GlobalEvent = { | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed - | EventWorkspaceRestore | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed @@ -1877,9 +1875,11 @@ export type SyncEventSessionNextModelSwitched = { data: { timestamp: number sessionID: string - id: string - providerID: string - variant?: string + model: { + id: string + providerID: string + variant: string + } } } @@ -1950,7 +1950,7 @@ export type SyncEventSessionNextStepStarted = { model: { id: string providerID: string - variant?: string + variant: string } snapshot?: string } @@ -1989,10 +1989,7 @@ export type SyncEventSessionNextStepFailed = { data: { timestamp: number sessionID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown } } @@ -2190,10 +2187,7 @@ export type SyncEventSessionNextToolFailed = { timestamp: number sessionID: string callID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown provider: { executed: boolean metadata?: { @@ -2478,17 +2472,6 @@ export type EventWorkspaceFailed = { } } -export type EventWorkspaceRestore = { - id: string - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - export type EventWorkspaceStatus = { id: string type: "workspace.status" @@ -2629,9 +2612,11 @@ export type EventSessionNextModelSwitched = { properties: { timestamp: number sessionID: string - id: string - providerID: string - variant?: string + model: { + id: string + providerID: string + variant: string + } } } @@ -2706,7 +2691,7 @@ export type EventSessionNextStepStarted = { model: { id: string providerID: string - variant?: string + variant: string } snapshot?: string } @@ -2733,16 +2718,18 @@ export type EventSessionNextStepEnded = { } } +export type SessionErrorUnknown = { + type: "unknown" + message: string +} + export type EventSessionNextStepFailed = { id: string type: "session.next.step.failed" properties: { timestamp: number sessionID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown } } @@ -2913,10 +2900,7 @@ export type EventSessionNextToolFailed = { timestamp: number sessionID: string callID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown provider: { executed: boolean metadata?: { @@ -3007,7 +2991,7 @@ export type SessionInfo = { model?: { id: string providerID: string - variant?: string + variant: string } time: { created: number @@ -3043,7 +3027,7 @@ export type SessionMessageModelSwitched = { model: { id: string providerID: string - variant?: string + variant: string } } @@ -3137,10 +3121,7 @@ export type SessionMessageToolStateError = { structured: { [key: string]: unknown } - error: { - type: string - message: string - } + error: SessionErrorUnknown } export type SessionMessageAssistantTool = { @@ -3180,7 +3161,7 @@ export type SessionMessageAssistant = { model: { id: string providerID: string - variant?: string + variant: string } content: Array snapshot?: { @@ -3198,10 +3179,7 @@ export type SessionMessageAssistant = { write: number } } - error?: { - type: string - message: string - } + error?: SessionErrorUnknown } export type SessionMessageCompaction = { @@ -6023,6 +6001,38 @@ export type SyncReplayResponses = { export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses] +export type SyncStealData = { + body?: { + sessionID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sync/steal" +} + +export type SyncStealErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SyncStealError = SyncStealErrors[keyof SyncStealErrors] + +export type SyncStealResponses = { + /** + * Session stolen into workspace + */ + 200: { + sessionID: string + } +} + +export type SyncStealResponse = SyncStealResponses[keyof SyncStealResponses] + export type SyncHistoryListData = { body?: { [key: string]: number @@ -6644,41 +6654,37 @@ export type ExperimentalWorkspaceRemoveResponses = { export type ExperimentalWorkspaceRemoveResponse = ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] -export type ExperimentalWorkspaceSessionRestoreData = { +export type ExperimentalWorkspaceWarpData = { body?: { + id: string sessionID: string } - path: { - id: string - } + path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/{id}/session-restore" + url: "/experimental/workspace/warp" } -export type ExperimentalWorkspaceSessionRestoreErrors = { +export type ExperimentalWorkspaceWarpErrors = { /** * Bad request */ 400: BadRequestError } -export type ExperimentalWorkspaceSessionRestoreError = - ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] +export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors] -export type ExperimentalWorkspaceSessionRestoreResponses = { +export type ExperimentalWorkspaceWarpResponses = { /** - * Session replay started + * Session warped */ - 200: { - total: number - } + 204: void } -export type ExperimentalWorkspaceSessionRestoreResponse = - ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] +export type ExperimentalWorkspaceWarpResponse = + ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses] export type PtyConnectData = { body?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6ff18b5155..fea9dd5a95 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6785,6 +6785,84 @@ ] } }, + "/sync/steal": { + "post": { + "tags": ["sync"], + "operationId": "sync.steal", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session stolen into workspace", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false, + "description": "Session stolen into workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Update a session to belong to the current workspace through the sync event system.", + "summary": "Steal session into workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.steal({\n ...\n})" + } + ] + } + }, "/sync/history": { "post": { "tags": ["sync"], @@ -8281,10 +8359,10 @@ ] } }, - "/experimental/workspace/{id}/session-restore": { + "/experimental/workspace/warp": { "post": { "tags": ["workspace"], - "operationId": "experimental.workspace.sessionRestore", + "operationId": "experimental.workspace.warp", "parameters": [ { "name": "directory", @@ -8301,36 +8379,11 @@ "schema": { "type": "string" } - }, - { - "name": "id", - "in": "path", - "schema": { - "type": "string", - "pattern": "^wrk.*" - }, - "required": true } ], "responses": { - "200": { - "description": "Session replay started", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["total"], - "additionalProperties": false, - "description": "Session replay started" - } - } - } + "204": { + "description": "Session warped" }, "400": { "description": "Bad request", @@ -8343,19 +8396,22 @@ } } }, - "description": "Replay a session's sync events into the target workspace in batches.", - "summary": "Restore session into workspace", + "description": "Move a session's sync history into the target workspace, or detach it to the local project.", + "summary": "Warp session into workspace", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { + "id": { + "type": "string" + }, "sessionID": { "type": "string" } }, - "required": ["sessionID"], + "required": ["id", "sessionID"], "additionalProperties": false } } @@ -8364,7 +8420,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.warp({\n ...\n})" } ] } @@ -8538,9 +8594,6 @@ { "$ref": "#/components/schemas/EventWorkspaceFailed" }, - { - "$ref": "#/components/schemas/EventWorkspaceRestore" - }, { "$ref": "#/components/schemas/EventWorkspaceStatus" }, @@ -10737,9 +10790,6 @@ { "$ref": "#/components/schemas/EventWorkspaceFailed" }, - { - "$ref": "#/components/schemas/EventWorkspaceRestore" - }, { "$ref": "#/components/schemas/EventWorkspaceStatus" }, @@ -13948,17 +13998,24 @@ "sessionID": { "type": "string" }, - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "id", "providerID"], + "required": ["timestamp", "sessionID", "model"], "additionalProperties": false } }, @@ -14181,7 +14238,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "snapshot": { @@ -14307,17 +14364,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["timestamp", "sessionID", "error"], @@ -14929,17 +14976,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" }, "provider": { "type": "object", @@ -15793,41 +15830,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventWorkspaceRestore": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["workspace.restore"] - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string" - }, - "sessionID": { - "type": "string" - }, - "total": { - "type": "integer", - "minimum": 0 - }, - "step": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventWorkspaceStatus": { "type": "object", "properties": { @@ -16252,17 +16254,24 @@ "sessionID": { "type": "string" }, - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "id", "providerID"], + "required": ["timestamp", "sessionID", "model"], "additionalProperties": false } }, @@ -16481,7 +16490,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "snapshot": { @@ -16565,6 +16574,20 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "SessionErrorUnknown": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, "EventSessionNextStepFailed": { "type": "object", "properties": { @@ -16585,17 +16608,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["timestamp", "sessionID", "error"], @@ -17098,17 +17111,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" }, "provider": { "type": "object", @@ -17361,7 +17364,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "time": { @@ -17457,7 +17460,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false } }, @@ -17716,17 +17719,7 @@ "type": "object" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["status", "input", "content", "structured", "error"], @@ -17839,7 +17832,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "content": { @@ -17906,17 +17899,7 @@ "additionalProperties": false }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["id", "time", "type", "agent", "model", "content"], diff --git a/packages/slack/package.json b/packages/slack/package.json index 04b996aca7..448df66401 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.33", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index cd210c4d61..1bc70c15ab 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.33", + "version": "1.14.35", "type": "module", "license": "MIT", "exports": { @@ -25,6 +25,8 @@ }, "scripts": { "typecheck": "tsgo --noEmit", + "test": "bun test src", + "test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "dev": "vite", "generate:tailwind": "bun run script/tailwind.ts" }, diff --git a/packages/ui/src/components/session-diff.test.ts b/packages/ui/src/components/session-diff.test.ts index 463a729778..172fe8d6c2 100644 --- a/packages/ui/src/components/session-diff.test.ts +++ b/packages/ui/src/components/session-diff.test.ts @@ -19,6 +19,21 @@ describe("session diff", () => { expect(text(view, "additions")).toBe("one\nthree\n") }) + test("keeps missing final newlines from unified patches", () => { + const diff = { + file: "a.ts", + patch: + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n\\ No newline at end of file\n+three\n\\ No newline at end of file\n", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(text(view, "deletions")).toBe("one\ntwo") + expect(text(view, "additions")).toBe("one\nthree") + }) + test("converts legacy content into a patch", () => { const diff = { file: "a.ts", @@ -34,4 +49,20 @@ describe("session diff", () => { expect(text(view, "deletions")).toBe("one\n") expect(text(view, "additions")).toBe("two\n") }) + + test("ignores malformed persisted patches", () => { + const diff = { + file: "a.ts", + patch: + "diff --git a/a.ts b/a.ts\nindex ff4ceb2..65a1de0 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,3 +1,3 @@\n keep\n+add\n same\r", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(view.patch).toBe(diff.patch) + expect(text(view, "deletions")).toBe("") + expect(text(view, "additions")).toBe("") + }) }) diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index a5fbdbc5c0..bd6bed88d8 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -27,26 +27,49 @@ const cache = new Map() function patch(diff: ReviewDiff) { if (typeof diff.patch === "string") { - const [patch] = parsePatch(diff.patch) + try { + const [patch] = parsePatch(diff.patch) + const beforeLines: Array<{ text: string; newline: boolean }> = [] + const afterLines: Array<{ text: string; newline: boolean }> = [] + let previous: "-" | "+" | " " | undefined - const beforeLines = [] - const afterLines = [] + for (const hunk of patch.hunks) { + for (const line of hunk.lines) { + if (line.startsWith("\\")) { + if (previous === "-" || previous === " ") { + const before = beforeLines.at(-1) + if (before) before.newline = false + } + if (previous === "+" || previous === " ") { + const after = afterLines.at(-1) + if (after) after.newline = false + } + continue + } - for (const hunk of patch.hunks) { - for (const line of hunk.lines) { - if (line.startsWith("-")) { - beforeLines.push(line.slice(1)) - } else if (line.startsWith("+")) { - afterLines.push(line.slice(1)) - } else { - // context line (starts with ' ') - beforeLines.push(line.slice(1)) - afterLines.push(line.slice(1)) + if (line.startsWith("-")) { + beforeLines.push({ text: line.slice(1), newline: true }) + previous = "-" + } else if (line.startsWith("+")) { + afterLines.push({ text: line.slice(1), newline: true }) + previous = "+" + } else { + // context line (starts with ' ') + beforeLines.push({ text: line.slice(1), newline: true }) + afterLines.push({ text: line.slice(1), newline: true }) + previous = " " + } } } - } - return { before: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch } + return { + before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), + after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), + patch: diff.patch, + } + } catch { + return { before: "", after: "", patch: diff.patch } + } } return { before: "before" in diff && typeof diff.before === "string" ? diff.before : "", diff --git a/packages/web/package.json b/packages/web/package.json index c346fe5e7e..a243a47078 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.33", + "version": "1.14.35", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/ar/providers.mdx b/packages/web/src/content/docs/ar/providers.mdx index 07a19b8ad2..c4812fe5d5 100644 --- a/packages/web/src/content/docs/ar/providers.mdx +++ b/packages/web/src/content/docs/ar/providers.mdx @@ -648,17 +648,17 @@ OpenCode Go هي خطة اشتراك منخفضة التكلفة توفّر وص --- -### Firmware +### FrogBot -1. توجّه إلى [Firmware dashboard](https://app.firmware.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API. +1. توجّه إلى [FrogBot dashboard](https://app.frogbot.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API. -2. شغّل الأمر `/connect` وابحث عن **Firmware**. +2. شغّل الأمر `/connect` وابحث عن **FrogBot**. ```txt /connect ``` -3. أدخل مفتاح API الخاص بـ Firmware. +3. أدخل مفتاح API الخاص بـ FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/bs/providers.mdx b/packages/web/src/content/docs/bs/providers.mdx index 4087db8cde..f6e54fc6ad 100644 --- a/packages/web/src/content/docs/bs/providers.mdx +++ b/packages/web/src/content/docs/bs/providers.mdx @@ -653,17 +653,17 @@ Također možete dodati modele kroz svoju opencode konfiguraciju. --- -### Firmware +### FrogBot -1. Idite na [kontrolnu tablu firmvera](https://app.firmware.ai/signup), kreirajte nalog i generišite API ključ. +1. Idite na [kontrolnu tablu firmvera](https://app.frogbot.ai/signup), kreirajte nalog i generišite API ključ. -2. Pokrenite naredbu `/connect` i potražite **Firmware**. +2. Pokrenite naredbu `/connect` i potražite **FrogBot**. ```txt /connect ``` -3. Unesite svoj Firmware API ključ. +3. Unesite svoj FrogBot API ključ. ```txt ┌ API key diff --git a/packages/web/src/content/docs/da/providers.mdx b/packages/web/src/content/docs/da/providers.mdx index 8817d23192..9b04d6be82 100644 --- a/packages/web/src/content/docs/da/providers.mdx +++ b/packages/web/src/content/docs/da/providers.mdx @@ -644,17 +644,17 @@ Cloudflare AI Gateway lader dig få adgang til modeller fra OpenAI, Anthropic, W --- -### Firmware +### FrogBot -1. Gå til [Firmware dashboard](https://app.firmware.ai/signup), opret en konto og generer en API-nøgle. +1. Gå til [FrogBot dashboard](https://app.frogbot.ai/signup), opret en konto og generer en API-nøgle. -2. Kør kommandoen `/connect` og søg efter **Firmware**. +2. Kør kommandoen `/connect` og søg efter **FrogBot**. ```txt /connect ``` -3. Indtast firmware API-nøglen. +3. Indtast frogbot API-nøglen. ```txt ┌ API key diff --git a/packages/web/src/content/docs/de/providers.mdx b/packages/web/src/content/docs/de/providers.mdx index 87f78c9d22..9298146930 100644 --- a/packages/web/src/content/docs/de/providers.mdx +++ b/packages/web/src/content/docs/de/providers.mdx @@ -650,17 +650,17 @@ Mit dem Cloudflare AI Gateway können Sie über einen einheitlichen Endpunkt auf --- -### Firmware +### FrogBot -1. Gehen Sie zu [Firmware dashboard](https://app.firmware.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel. +1. Gehen Sie zu [FrogBot dashboard](https://app.frogbot.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel. -2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **Firmware**. +2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **FrogBot**. ```txt /connect ``` -3. Geben Sie Ihren Firmware API-Schlüssel ein. +3. Geben Sie Ihren FrogBot API-Schlüssel ein. ```txt ┌ API key diff --git a/packages/web/src/content/docs/es/providers.mdx b/packages/web/src/content/docs/es/providers.mdx index b44ce9ee99..11489609bc 100644 --- a/packages/web/src/content/docs/es/providers.mdx +++ b/packages/web/src/content/docs/es/providers.mdx @@ -651,17 +651,17 @@ Cloudflare AI Gateway le permite acceder a modelos de OpenAI, Anthropic, Workers --- -### Firmware +### FrogBot -1. Dirígete al [Panel de firmware](https://app.firmware.ai/signup), crea una cuenta y genera una clave API. +1. Dirígete al [Panel de frogbot](https://app.frogbot.ai/signup), crea una cuenta y genera una clave API. -2. Ejecute el comando `/connect` y busque **Firmware**. +2. Ejecute el comando `/connect` y busque **FrogBot**. ```txt /connect ``` -3. Ingrese su clave de firmware API. +3. Ingrese su clave de frogbot API. ```txt ┌ API key diff --git a/packages/web/src/content/docs/fr/providers.mdx b/packages/web/src/content/docs/fr/providers.mdx index 6a902ab02f..90bdb1fbc3 100644 --- a/packages/web/src/content/docs/fr/providers.mdx +++ b/packages/web/src/content/docs/fr/providers.mdx @@ -654,11 +654,11 @@ Vous pouvez également ajouter des modèles via votre configuration opencode. --- -### Firmware +### FrogBot -1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.firmware.ai/signup), créez un compte et générez une clé API. +1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.frogbot.ai/signup), créez un compte et générez une clé API. -2. Exécutez la commande `/connect` et recherchez **Firmware**. +2. Exécutez la commande `/connect` et recherchez **FrogBot**. ```txt /connect diff --git a/packages/web/src/content/docs/it/providers.mdx b/packages/web/src/content/docs/it/providers.mdx index 96da8c4df1..f2d195d721 100644 --- a/packages/web/src/content/docs/it/providers.mdx +++ b/packages/web/src/content/docs/it/providers.mdx @@ -628,17 +628,17 @@ Cloudflare AI Gateway ti permette di accedere a modelli di OpenAI, Anthropic, Wo --- -### Firmware +### FrogBot -1. Vai alla [dashboard di Firmware](https://app.firmware.ai/signup), crea un account e genera una chiave API. +1. Vai alla [dashboard di FrogBot](https://app.frogbot.ai/signup), crea un account e genera una chiave API. -2. Esegui il comando `/connect` e cerca **Firmware**. +2. Esegui il comando `/connect` e cerca **FrogBot**. ```txt /connect ``` -3. Inserisci la tua chiave API di Firmware. +3. Inserisci la tua chiave API di FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/ja/providers.mdx b/packages/web/src/content/docs/ja/providers.mdx index 8017d0882e..c969c6d4a0 100644 --- a/packages/web/src/content/docs/ja/providers.mdx +++ b/packages/web/src/content/docs/ja/providers.mdx @@ -658,9 +658,9 @@ OpenCode 設定を通じてモデルを追加することもできます。 --- -### Firmware +### FrogBot -1. [ファームウェアダッシュボード](https://app.firmware.ai/signup) に移動し、アカウントを作成し、API キーを生成します。 +1. [ファームウェアダッシュボード](https://app.frogbot.ai/signup) に移動し、アカウントを作成し、API キーを生成します。 2. `/connect` コマンドを実行し、**ファームウェア**を検索します。 diff --git a/packages/web/src/content/docs/ko/providers.mdx b/packages/web/src/content/docs/ko/providers.mdx index 6ca3afccc3..87278bef23 100644 --- a/packages/web/src/content/docs/ko/providers.mdx +++ b/packages/web/src/content/docs/ko/providers.mdx @@ -654,17 +654,17 @@ Cloudflare AI Gateway는 OpenAI, Anthropic, Workers AI 등의 모델에 액세 --- -### Firmware +### FrogBot -1. [Firmware 대시보드](https://app.firmware.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다. +1. [FrogBot 대시보드](https://app.frogbot.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다. -2. `/connect` 명령을 실행하고 **Firmware**를 검색하십시오. +2. `/connect` 명령을 실행하고 **FrogBot**를 검색하십시오. ```txt /connect ``` -3. Firmware API 키를 입력하십시오. +3. FrogBot API 키를 입력하십시오. ```txt ┌ API key diff --git a/packages/web/src/content/docs/nb/providers.mdx b/packages/web/src/content/docs/nb/providers.mdx index 1fe8812e67..bf276918a9 100644 --- a/packages/web/src/content/docs/nb/providers.mdx +++ b/packages/web/src/content/docs/nb/providers.mdx @@ -652,17 +652,17 @@ Cloudflare AI Gateway lar deg få tilgang til modeller fra OpenAI, Anthropic, Wo --- -### Firmware +### FrogBot -1. Gå over til [Firmware dashboard](https://app.firmware.ai/signup), opprett en konto og generer en API nøkkel. +1. Gå over til [FrogBot dashboard](https://app.frogbot.ai/signup), opprett en konto og generer en API nøkkel. -2. Kjør kommandoen `/connect` og søk etter **Firmware**. +2. Kjør kommandoen `/connect` og søk etter **FrogBot**. ```txt /connect ``` -3. Skriv inn firmware API nøkkelen. +3. Skriv inn frogbot API nøkkelen. ```txt ┌ API key diff --git a/packages/web/src/content/docs/pl/providers.mdx b/packages/web/src/content/docs/pl/providers.mdx index deadd07d6a..0e722d5fde 100644 --- a/packages/web/src/content/docs/pl/providers.mdx +++ b/packages/web/src/content/docs/pl/providers.mdx @@ -650,17 +650,17 @@ Cloudflare AI Gateway umożliwia dostęp do modeli z OpenAI, Anthropic, Workers --- -### Firmware +### FrogBot -1. Przejdź do [Firmware dashboard](https://app.firmware.ai/signup), utwórz konto i wygeneruj klucz API. +1. Przejdź do [FrogBot dashboard](https://app.frogbot.ai/signup), utwórz konto i wygeneruj klucz API. -2. Uruchom polecenie `/connect` i wyszukaj **Firmware**. +2. Uruchom polecenie `/connect` i wyszukaj **FrogBot**. ```txt /connect ``` -3. Wprowadź klucz API Firmware. +3. Wprowadź klucz API FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 7c395022c1..8410c549f2 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -721,17 +721,17 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire --- -### Firmware +### FrogBot -1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key. +1. Head over to the [FrogBot dashboard](https://app.frogbot.ai/signup), create an account, and generate an API key. -2. Run the `/connect` command and search for **Firmware**. +2. Run the `/connect` command and search for **FrogBot**. ```txt /connect ``` -3. Enter your Firmware API key. +3. Enter your FrogBot API key. ```txt ┌ API key diff --git a/packages/web/src/content/docs/pt-br/providers.mdx b/packages/web/src/content/docs/pt-br/providers.mdx index 50f841cf36..174bc1679b 100644 --- a/packages/web/src/content/docs/pt-br/providers.mdx +++ b/packages/web/src/content/docs/pt-br/providers.mdx @@ -654,17 +654,17 @@ O Cloudflare AI Gateway permite que você acesse modelos do OpenAI, Anthropic, W --- -### Firmware +### FrogBot -1. Acesse o [painel Firmware](https://app.firmware.ai/signup), crie uma conta e gere uma chave da API. +1. Acesse o [painel FrogBot](https://app.frogbot.ai/signup), crie uma conta e gere uma chave da API. -2. Execute o comando `/connect` e procure por **Firmware**. +2. Execute o comando `/connect` e procure por **FrogBot**. ```txt /connect ``` -3. Insira sua chave da API Firmware. +3. Insira sua chave da API FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/ru/providers.mdx b/packages/web/src/content/docs/ru/providers.mdx index f5868ceaa0..39aae9e096 100644 --- a/packages/web/src/content/docs/ru/providers.mdx +++ b/packages/web/src/content/docs/ru/providers.mdx @@ -650,17 +650,17 @@ Cloudflare AI Gateway позволяет вам получать доступ к --- -### Firmware +### FrogBot -1. Перейдите на [панель Firmware](https://app.firmware.ai/signup), создайте учетную запись и сгенерируйте ключ API. +1. Перейдите на [панель FrogBot](https://app.frogbot.ai/signup), создайте учетную запись и сгенерируйте ключ API. -2. Запустите команду `/connect` и найдите **Firmware**. +2. Запустите команду `/connect` и найдите **FrogBot**. ```txt /connect ``` -3. Введите ключ API Firmware. +3. Введите ключ API FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/th/providers.mdx b/packages/web/src/content/docs/th/providers.mdx index 818f39213c..07008de218 100644 --- a/packages/web/src/content/docs/th/providers.mdx +++ b/packages/web/src/content/docs/th/providers.mdx @@ -650,17 +650,17 @@ Cloudflare AI Gateway ช่วยให้คุณเข้าถึงโม --- -### Firmware +### FrogBot -1. ไปที่ [แดชบอร์ด Firmware](https://app.firmware.ai/signup) สร้างบัญชี และสร้างคีย์ API +1. ไปที่ [แดชบอร์ด FrogBot](https://app.frogbot.ai/signup) สร้างบัญชี และสร้างคีย์ API -2. เรียกใช้คำสั่ง `/connect` และค้นหา **Firmware** +2. เรียกใช้คำสั่ง `/connect` และค้นหา **FrogBot** ```txt /connect ``` -3. ป้อนคีย์ Firmware API ของคุณ +3. ป้อนคีย์ FrogBot API ของคุณ ```txt ┌ API key diff --git a/packages/web/src/content/docs/tr/providers.mdx b/packages/web/src/content/docs/tr/providers.mdx index 527c20e15e..8c6ef23fee 100644 --- a/packages/web/src/content/docs/tr/providers.mdx +++ b/packages/web/src/content/docs/tr/providers.mdx @@ -652,17 +652,17 @@ Cloudflare AI Gateway, OpenAI, Anthropic, Workers AI ve daha fazlasındaki model --- -### Firmware +### FrogBot -1. [Firmware dashboard](https://app.firmware.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun. +1. [FrogBot dashboard](https://app.frogbot.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun. -2. `/connect` komutunu çalıştırın ve **Firmware**'i arayın. +2. `/connect` komutunu çalıştırın ve **FrogBot**'i arayın. ```txt /connect ``` -3. Firmware API anahtarınızı girin. +3. FrogBot API anahtarınızı girin. ```txt ┌ API key diff --git a/packages/web/src/content/docs/zh-cn/providers.mdx b/packages/web/src/content/docs/zh-cn/providers.mdx index 80dfe1e93d..9c0a5d8a3b 100644 --- a/packages/web/src/content/docs/zh-cn/providers.mdx +++ b/packages/web/src/content/docs/zh-cn/providers.mdx @@ -624,17 +624,17 @@ Cloudflare AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic --- -### Firmware +### FrogBot -1. 前往 [Firmware 仪表盘](https://app.firmware.ai/signup),创建账户并生成 API 密钥。 +1. 前往 [FrogBot 仪表盘](https://app.frogbot.ai/signup),创建账户并生成 API 密钥。 -2. 执行 `/connect` 命令并搜索 **Firmware**。 +2. 执行 `/connect` 命令并搜索 **FrogBot**。 ```txt /connect ``` -3. 输入你的 Firmware API 密钥。 +3. 输入你的 FrogBot API 密钥。 ```txt ┌ API key diff --git a/packages/web/src/content/docs/zh-tw/providers.mdx b/packages/web/src/content/docs/zh-tw/providers.mdx index c874170959..d4e55ed712 100644 --- a/packages/web/src/content/docs/zh-tw/providers.mdx +++ b/packages/web/src/content/docs/zh-tw/providers.mdx @@ -645,17 +645,17 @@ Cloudflare AI Gateway 允許您透過統一端點存取來自 OpenAI、Anthropic --- -### Firmware +### FrogBot -1. 前往 [Firmware 儀表板](https://app.firmware.ai/signup),建立帳號並產生 API 金鑰。 +1. 前往 [FrogBot 儀表板](https://app.frogbot.ai/signup),建立帳號並產生 API 金鑰。 -2. 執行 `/connect` 指令並搜尋 **Firmware**。 +2. 執行 `/connect` 指令並搜尋 **FrogBot**。 ```txt /connect ``` -3. 輸入您的 Firmware API 金鑰。 +3. 輸入您的 FrogBot API 金鑰。 ```txt ┌ API key diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 67617771f0..22d8adc54b 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.33", + "version": "1.14.35", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/specs/v2/session-concepts-gap.md b/specs/v2/session-concepts-gap.md deleted file mode 100644 index 20d84c8f47..0000000000 --- a/specs/v2/session-concepts-gap.md +++ /dev/null @@ -1,131 +0,0 @@ -# Session V2 Concept Gaps - -Compared with `packages/opencode/src/session/message-v2.ts` and `packages/opencode/src/session/processor.ts`, `packages/opencode/src/v2` currently captures the rough event stream for prompts, assistant steps, text, reasoning, tools, retries, and compaction, but it does not yet capture several persisted-message and processor concepts. - -## Message Metadata - -- User messages are missing selected `agent`, `model`, `system`, enabled `tools`, output `format`, and summary metadata. -- Assistant messages are missing `parentID`, `agent`, `providerID`, `modelID`, `variant`, `path.cwd`, `path.root`, deprecated `mode`, `summary`, `structured`, `finish`, and typed `error`. - -## Output Format - -- Text output format. -- JSON-schema output format. -- Structured-output retry count. -- Structured assistant result payload. -- Structured-output error classification. - -## Errors - -- Aborted error. -- Provider auth error. -- API error with status, retryability, headers, body, and metadata. -- Context-overflow error. -- Output-length error. -- Unknown error. -- V2 mostly reduces assistant errors to strings, except retry errors. - -## Part Identity - -- V1 has stable `MessageID`, `PartID`, `sessionID`, and `messageID` on every part. -- V2 assistant content does not preserve stable per-content IDs. -- Stable content IDs matter for deltas, updates, removals, sync events, and UI reconciliation. - -## Part Timing And Metadata - -- V1 text, reasoning, and tool states carry timing and provider metadata. -- V2 assistant text and reasoning content only store text. -- V2 events include metadata, but `SessionEntry` currently drops most provider metadata. - -## Snapshots And Patches - -- Snapshot parts. -- Patch parts. -- Step-start snapshot references. -- Step-finish snapshot references. -- Processor behavior that tracks a snapshot before the stream and emits patches after step finish or cleanup. - -## Step Boundaries - -- V1 stores `step-start` and `step-finish` as first-class parts. -- V2 has `step.started` and `step.ended` events, but the assistant entry only stores aggregate cost and tokens. -- V2 does not preserve step boundary parts, finish reason, or snapshot details in the entry model. - -## Compaction - -- V1 compaction parts have `auto`, `overflow`, and `tail_start_id`. -- V2 compacted events have `auto` and optional `overflow`, but no retained-tail marker. -- V1 also has history filtering semantics around completed summary messages and retained tails. - -## Files And Sources - -- V1 file parts have `mime`, `filename`, `url`, and typed source information. -- V1 source variants include file, symbol, and resource sources. -- Symbol sources include LSP range, name, and kind. -- Resource sources include client name and URI. -- V2 file attachments have `uri`, `mime`, `name`, `description`, and a generic text source, but lose source type, LSP metadata, and resource metadata. - -## Agents And Subtasks - -- Agent parts. -- Subtask parts. -- Subtask prompt, description, agent, model, and command. -- V2 has agent attachments on prompts, but no assistant/session content equivalent for subtask execution. - -## Text Flags - -- Synthetic text flag. -- Ignored text flag. -- V2 has a separate synthetic entry, but no ignored text concept. - -## Tool Calls - -- V1 pending tool state stores parsed input and raw input text separately. -- V2 pending tool state stores a string input but does not preserve a separate raw field. -- V1 completed tool state has `time.start`, `time.end`, and optional `time.compacted`. -- V2 tool time has `created`, `ran`, `completed`, and `pruned`, but the stepper currently does not set `completed` or `pruned`. -- V1 error tool state has `time.start` and `time.end`. -- V1 supports interrupted tool errors with `metadata.interrupted` and preserved partial output. -- V1 tracks provider execution and provider call metadata. -- V2 events include provider info, but `SessionEntryStepper` drops it from entries. -- V1 has tool-output compaction and truncation behavior via `time.compacted`. - -## Media Handling - -- V1 models tool attachments as file parts and has provider-specific handling for media in tool results. -- V1 can strip media, inject synthetic user messages for unsupported providers, and uses a synthetic attachment prompt. -- V2 has attachments but not these model-message conversion semantics. - -## Retries - -- V1 stores retries as independently addressable retry parts. -- V2 stores retries as an assistant aggregate. -- V2 captures some retry information, but not the independent part identity/update model. - -## Processor Control Flow - -- Session status transitions: busy, retry, and idle. -- Retry policy integration. -- Context-overflow-driven compaction. -- Abort and interrupt handling. -- Permission-denied blocking. -- Doom-loop detection. -- Plugin hook for `experimental.text.complete`. -- Background summary generation after steps. -- Cleanup semantics for open text, reasoning, and tool calls. - -## Sync And Bus Events - -- Message updated. -- Message removed. -- Message part updated. -- Message part delta. -- Message part removed. -- V2 has domain events, but not the sync/bus event model for persisted message and part updates/removals. - -## History Retrieval - -- Cursor encoding and decoding. -- Paged message retrieval. -- Reverse streaming through history. -- Compaction-aware history filtering. diff --git a/specs/v2/todo.md b/specs/v2/todo.md new file mode 100644 index 0000000000..77c650e55f --- /dev/null +++ b/specs/v2/todo.md @@ -0,0 +1,59 @@ +# TODO + +ok we need to work towards a launch of v2 so we can get out of this rebuild phase + +## Kill Hono - Kit + +Hono needs to go away so zod can go away. this is almost done + +## New Data Mode - Dax + +This is mostly done. I'm working through modeling subagents, skill invocations +and shell commands. + +## Rework agent loop - Kit? + +I think this needs to be done so we can take advantage of the simpler data +model. It can stop doing all the + +## Rework compaction - Aiden? + +The new agent loop needs to trigger compaction properly + +## Plugin API design - James? + +We need to figure out how we want server plugins to work and what hooks are useful. + +Some ideas: + +- plugins get immer drafts so bad mutations can be thrown away +- plugins get global "opencode" instance like in that post i showed +- opencode instance has stuff like `opencode.session.prompt()` or + `opencode.tool.register({...})` + +## Rework Config - ??? + +We should do another pass on config to clean up any mistakes we made with it and +simplify as much as possible. Old configs should get auto-converted to new + +## Auth - ??? + +I have a basic auth system that can track any kind of auth, not just providers + +## Model Database - ??? + +I have a basic model service that allows for models to be registered dynamically + +## Provider - ??? + +Providers should register as plugins and autoload based on whatever logic they +want / config. They should register models into model database + +## Event - Kit + +I have this v2/event.ts but it needs to be self contained instead of using the +old bus system + +## Everything is hotreloadable - ??? + +Instead of needing to tear down things when something changes every service should emit granular events so services can react to them and reconfigure themselves. Allows frontend to receive these too, eg model.added. also prevents startup from blocking diff --git a/turbo.json b/turbo.json index 28c2fa2de0..0183fabca4 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,15 @@ "dependsOn": ["^build"], "outputs": [".artifacts/unit/junit.xml"], "passThroughEnv": ["*"] + }, + "@opencode-ai/ui#test": { + "dependsOn": ["^build"], + "outputs": [] + }, + "@opencode-ai/ui#test:ci": { + "dependsOn": ["^build"], + "outputs": [".artifacts/unit/junit.xml"], + "passThroughEnv": ["*"] } } }