diff --git a/bun.lock b/bun.lock index 5114ec200db5..e37adbe9aee9 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -798,6 +799,7 @@ "version": "1.18.4", "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -6028,6 +6030,8 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="], + "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -6046,6 +6050,8 @@ "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="], + "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], diff --git a/packages/app/V1_API_MIGRATION.md b/packages/app/V1_API_MIGRATION.md new file mode 100644 index 000000000000..2850f107400c --- /dev/null +++ b/packages/app/V1_API_MIGRATION.md @@ -0,0 +1,220 @@ +# V1 API Migration Checklist + +The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name. + +## Events + +- [x] Replace `GET /global/event` with `GET /api/event`. + - `src/context/server-sdk.tsx` +- [x] Reduce current granular session and message events into the existing app projections. + - `src/context/server-session-v2-reducer.ts` + - `src/context/server-session.ts` +- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`. + - `src/context/global-sync/event-reducer.ts` + - `src/context/server-session.ts` + - `src/context/notification.tsx` + - `src/pages/session/usage-exceeded-dialogs.tsx` +- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`. + - `src/context/global-sync/event-reducer.ts` + - `src/context/server-session.ts` +- [x] Adapt current permission and question events to the existing request model. + - `src/context/global-sync/event-reducer.ts` + - `src/context/permission.tsx` +- [x] Consume current file watcher events. + - `src/context/file.tsx` +- [x] Consume current VCS events. + - `src/context/global-sync/event-reducer.ts` + - `src/pages/session.tsx` +- [x] Consume current `pty.exited` events. + - `src/context/terminal.tsx` +- [ ] Migrate LSP and reference events. + - `src/context/global-sync/event-reducer.ts` + +## Sessions + +- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events. + - `src/context/server-sync.tsx` +- [x] Migrate session listing from `GET /session`. + - `src/context/server-sync.tsx` + - `src/context/directory-sync.ts` + - `src/pages/layout.tsx` +- [x] Migrate the remaining direct session read from `GET /session/:sessionID`. + - `src/components/titlebar.tsx` +- [x] Migrate session updates from `PATCH /session/:sessionID`. + - `src/context/directory-sync.ts` + - `src/context/layout.tsx` + - `src/pages/home.tsx` + - `src/pages/layout.tsx` + - `src/pages/session/timeline/message-timeline.tsx` + - `src/components/titlebar-tab-nav.tsx` + - Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`. +- [x] Migrate session deletion from `DELETE /session/:sessionID`. + - `src/pages/session/timeline/message-timeline.tsx` +- [x] Remove session diff loading from `GET /session/:sessionID/diff`. + - Historical Session diffs remain unavailable until the current API defines their snapshot semantics. +- [x] Migrate abort from `POST /session/:sessionID/abort`. + - `src/components/prompt-input/submit.ts` + - `src/pages/session/use-session-commands.tsx` + - `src/pages/session.tsx` +- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`. + - `src/pages/session/use-session-commands.tsx` + - `src/pages/session.tsx` +- [x] Replace `POST /session/:sessionID/summarize` with the current compact API. + - `src/pages/session/use-session-commands.tsx` +- [x] Migrate slash commands from `POST /session/:sessionID/command`. + - `src/components/prompt-input/submit.ts` +- [x] Migrate shell execution from `POST /session/:sessionID/shell`. + - `src/components/prompt-input/submit.ts` +- [x] Migrate session fork from `POST /session/:sessionID/fork`. + - `src/components/dialog-fork.tsx` +- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`. + - `src/pages/session/use-session-commands.tsx` + - `src/pages/session/timeline/message-timeline.tsx` + - Blocked: the current API has no sharing contract or implementation. + +## Session Compatibility Fallbacks + +These calls are retained as fallback adapters. The current production path supplies the current session and message APIs. + +- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary. + - `src/context/server-session.ts` +- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary. + - `src/context/server-session.ts` +- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary. + - `src/context/server-session.ts` + +## Filesystem + +- [ ] Migrate file listing from `GET /file`. + - `src/context/file.tsx` +- [ ] Migrate file reads from `GET /file/content`. + - `src/context/file.tsx` + - `src/pages/session/review-tab.tsx` + - `src/pages/session/v2/review-panel-v2.tsx` +- [x] Migrate path discovery from `GET /path` to `GET /api/path`. + - `src/context/global-sync/bootstrap.ts` + - `src/components/dialog-select-directory.tsx` + - `src/components/dialog-select-directory-v2.tsx` + +## Projects And Worktrees + +- [x] Migrate project listing from `GET /project` to `GET /api/project`. + - `src/context/global-sync/bootstrap.ts` +- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`. + - `src/context/global-sync/bootstrap.ts` +- [ ] Migrate Git initialization from `POST /project/git/init`. + - `src/pages/session.tsx` +- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`. + - `src/context/layout.tsx` + - `src/components/edit-project.ts` + - `src/pages/layout.tsx` +- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`. + - `src/pages/layout.tsx` + - `src/components/prompt-input/submit.ts` + - Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain. +- [ ] Migrate instance disposal from `POST /instance/dispose`. + - `src/pages/layout.tsx` + +## VCS + +- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`. + - `src/context/global-sync/bootstrap.ts` +- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`. + - `src/pages/session.tsx` +- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`. + - `src/pages/layout.tsx` + +## Configuration And Authentication + +- [ ] Migrate global configuration reads from `GET /global/config`. + - `src/context/global-sync/bootstrap.ts` +- [ ] Migrate directory configuration reads from `GET /config`. + - `src/context/global-sync/bootstrap.ts` +- [ ] Migrate global configuration updates from `PATCH /global/config`. + - `src/context/server-sync.tsx` +- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`. + - `src/components/dialog-connect-provider.tsx` +- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`. + - `src/components/dialog-connect-provider.tsx` +- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`. + - Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`. + - `src/components/dialog-connect-provider.tsx` + - `src/components/dialog-custom-provider.tsx` + - `src/components/settings-providers.tsx` + - `src/components/settings-v2/providers.tsx` +- [ ] Migrate global disposal from `POST /global/dispose`. + - `src/components/dialog-connect-provider.tsx` + - `src/components/settings-providers.tsx` + - `src/components/settings-v2/providers.tsx` + +## Permissions And Questions + +- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`. + - `src/context/global-sync/bootstrap.ts` + - `src/context/permission.tsx` +- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`. + - `src/context/permission.tsx` + - `src/pages/session/composer/session-composer-state.ts` +- [x] Migrate question listing from `GET /question` to `GET /api/question/request`. + - `src/context/global-sync/bootstrap.ts` +- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`. + - `src/pages/session/composer/session-question-dock.tsx` + +## Commands, MCP, LSP, And References + +- [x] Migrate command listing from `GET /command` to `GET /api/command`. + - `src/context/global-sync/bootstrap.ts` + - `src/context/server-sync.tsx` +- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`. + - `src/context/server-sync.tsx` +- [ ] Replace legacy MCP authentication with the Integration OAuth workflow. + - `src/context/server-sync.tsx` +- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`. + - `src/context/server-sync.tsx` +- [ ] Migrate LSP status from `GET /lsp`. + - `src/context/server-sync.tsx` +- [x] Move `GET /api/reference` off the legacy generated SDK transport. + - `src/context/global-sync/bootstrap.ts` + +## Search + +- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`. + - `src/components/command-palette.ts` + - `src/components/dialog-command-palette-v2.tsx` + +## PTY And Terminal + +- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`. + - `src/context/terminal.tsx` + - `src/components/terminal.tsx` +- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`. + - `src/components/settings-general.tsx` + - `src/components/settings-v2/general.tsx` +- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`. + - `src/components/terminal.tsx` +- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`. + - `src/components/terminal.tsx` + +## Legacy Types And Adapters + +These are not V1 network requests, but they keep the UI coupled to V1 data contracts. + +- [ ] Replace the current-session-to-legacy-session adapter. + - `src/utils/session.ts` +- [ ] Replace the current-message-to-legacy-message-and-part adapter. + - `src/utils/session-message.ts` +- [ ] Replace current agent, provider, and model adapters to legacy SDK structures. + - `src/context/global-sync/utils.ts` +- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering. +- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone. + - `package.json` + +## Test Infrastructure + +- [ ] Replace V1 endpoint mocks with current API mocks. + - `e2e/utils/mock-server.ts` +- [x] Replace `/global/event` and `/event` interception with current event transport handling. + - `e2e/utils/sse-transport.ts` +- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests. + - `e2e/performance/timeline-stability/fixture.ts` +- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests. diff --git a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts index 2a214831daa8..838af17c9399 100644 --- a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts +++ b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts @@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => { const messages = [user, ...assistants] const target = fixture.sessions.find((session) => session.id === fixture.targetID)! const lastID = userID -const lastPartID = assistants.at(-1)!.parts.at(-1)!.id +const lastAssistant = assistants.at(-1)! +const lastPart = lastAssistant.parts.at(-1)! +const lastPartID = + lastPart.type === "tool" + ? lastPart.id + : `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}` benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => { benchmark.setTimeout(180_000) @@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) { return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined } }, }) - await page.route(`**/session/${fixture.targetID}`, (route) => - route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }), - ) + await page.route(`**/session/${fixture.targetID}`, (route) => { + const current = new URL(route.request().url()).pathname.startsWith("/api/") + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + current + ? { + data: { + ...target, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + location: { directory: target.directory }, + }, + } + : target, + ), + }) + }) await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] }) await page.goto(stressSessionHref(fixture.sourceID)) await expectSessionTitle(page, fixture.expected.sourceTitle) @@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) { parent: requests.filter((request) => request.type === "parent").length, } if (mode === "candidate") { - expect(requestCounts.parent).toBe(1) - expect(historyGates).toBe(1) + expect(requestCounts.parent).toBe(0) + expect(historyGates).toBe(0) } return { metrics, requestCounts, historyGateCount: historyGates } } diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index 159b5a506767..7a43089d7fd7 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" const serverA = "http://127.0.0.1:4096" const serverB = "http://127.0.0.1:4097" @@ -33,7 +34,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn await tabA.locator('[data-slot="tab-close"] button').click() await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) - await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true) + await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true) await expect(page.getByText(sessionB.title).first()).toBeVisible() const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) @@ -84,16 +85,19 @@ async function mockServers(page: Page, requests: string[]) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session") return json(route, [current]) + if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) + if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) @@ -116,7 +120,17 @@ async function mockServers(page: Page, requests: string[]) { directory: current.directory, home: current.directory, }) + if (url.pathname === "/api/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { location: { directory: current.directory }, data: { branch: "main", defaultBranch: "main" } }) return json(route, {}) }) } diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index c17ae5c1c66e..60f638d02f9b 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -1,6 +1,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page, type Route } from "@playwright/test" import { installSseTransport } from "../utils/sse-transport" +import { currentSession } from "../utils/mock-server" const serverA = "http://127.0.0.1:4096" const serverB = "http://127.0.0.1:4097" @@ -180,10 +181,35 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR return json(route, true) } if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session/status") return json(route, {}) - if (url.pathname === "/session") return json(route, sessions) + if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") + return json(route, { data: [] }) + if (url.pathname === "/api/model/default") return json(route, { data: null }) + if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname)) + return json(route, { location: { directory }, data: [] }) + if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] }) + if (url.pathname === "/api/mcp/resource") + return json(route, { location: { directory }, data: { resources: [], templates: [] } }) + if (url.pathname === "/api/project") { + return json(route, [ + { + id: remote ? sessionB.projectID : "project-server-a", + worktree: directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, + ]) + } + if (url.pathname === "/api/project/current") + return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory }) + if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`) + if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) + if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`)) + return json(route, { data: [], cursor: {} }) const current = sessions.find((session) => url.pathname === `/session/${session.id}`) if (current) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) @@ -193,7 +219,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR permissionRequests.push(url.toString()) return json(route, []) } - if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname)) + if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a")) @@ -216,7 +242,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR directory, home: directory, }) + if (url.pathname === "/api/path") + return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } }) + if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] }) return json(route, {}) }) } diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 119fc7ee2da6..e7cd11badeef 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" const serverA = "http://127.0.0.1:4096" const serverB = "http://127.0.0.1:4097" @@ -57,11 +58,14 @@ async function mockServers(page: Page) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route, url.pathname === "/api/event") if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session/status") - return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {}) - if (url.pathname === "/session") return json(route, [current]) + if (url.pathname === "/api/session/active") + return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) + if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) + if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) + if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (url.pathname === `/session/${current.id}/message`) return json(route, []) @@ -90,7 +94,17 @@ async function mockServers(page: Page) { directory: current.directory, home: current.directory, }) + if (url.pathname === "/api/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { location: { directory: current.directory }, data: { branch: "main", defaultBranch: "main" } }) return json(route, {}) }) } @@ -104,6 +118,10 @@ function json(route: Route, body: unknown, status = 200) { }) } -function sse(route: Route) { - return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +function sse(route: Route, current: boolean) { + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n", + }) } diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 042f926c537e..bc615871c9b3 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -143,9 +143,9 @@ async function openReview(page: Page) { await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) - const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff") + const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff") await page.getByRole("tab", { name: "Changes" }).click() - expect(await (await diffResponse).json()).toHaveLength(1) + expect((await (await diffResponse).json()).data).toHaveLength(1) const review = page.locator('[data-component="session-review"]') await expectAppVisible(review) diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index aa42f1bb516d..f94e93004cc8 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -92,18 +92,20 @@ async function setup(page: Page) { route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ branch: "feature", default_branch: "dev" }), + body: JSON.stringify({ location: { directory }, data: { branch: "feature", defaultBranch: "dev" } }), }), ) await page.route("**/vcs/diff**", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify( - new URL(route.request().url()).searchParams.get("mode") === "branch" - ? [diff("src/alpha.ts"), diff("src/beta.ts")] - : [diff("src/alpha.ts"), diff("src/gamma.ts")], - ), + body: JSON.stringify({ + location: { directory }, + data: + new URL(route.request().url()).searchParams.get("mode") === "branch" + ? [diff("src/alpha.ts"), diff("src/beta.ts")] + : [diff("src/alpha.ts"), diff("src/gamma.ts")], + }), }), ) await page.addInitScript( diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index 154bab48c47f..aa82ad16e10d 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -64,39 +64,80 @@ test("keeps the review tree and terminal sized when both panels are open", async route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }), + body: JSON.stringify({ + location: { directory }, + data: { branch: "review-pane-performance", defaultBranch: "dev" }, + }), }), ) - await page.route("**/vcs/diff**", (route) => { + await page.route("**/api/vcs/diff**", (route) => { const url = new URL(route.request().url()) - const scope = url.searchParams.get("directory")?.replaceAll("\\", "/") + const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/") const detail = scope?.endsWith("/src/branch/d00027") if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify( - url.searchParams.get("mode") === "branch" - ? detail - ? branchDiffs - .filter((diff) => diff.file.startsWith("src/branch/d00027/")) - .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) - : branchDiffs - : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), - ), + body: JSON.stringify({ + location: { directory: scope ?? directory, project: { id: projectID, directory } }, + data: + url.searchParams.get("mode") === "branch" + ? detail + ? branchDiffs + .filter((diff) => diff.file.startsWith("src/branch/d00027/")) + .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) + : branchDiffs + : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), + }), }) }) - await page.route("**/pty", (route) => + await page.route("**/api/pty?*", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }), + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_review_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), }), ) - await page.route("**/pty/pty_review_terminal", (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + await page.route("**/api/pty/pty_review_terminal?*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_review_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), + }), + ) + await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { ticket: "e2e-ticket", expires_in: 60 }, + }), + }), ) - await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) + await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem( @@ -134,8 +175,8 @@ test("keeps the review tree and terminal sized when both panels are open", async const lazyDiff = page.waitForRequest((request) => { const url = new URL(request.url()) return ( - url.pathname === "/vcs/diff" && - url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + url.pathname === "/api/vcs/diff" && + url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true ) }) await lastFile.click() @@ -148,8 +189,8 @@ test("keeps the review tree and terminal sized when both panels are open", async const refreshedDiff = page.waitForRequest((request) => { const url = new URL(request.url()) return ( - url.pathname === "/vcs/diff" && - url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + url.pathname === "/api/vcs/diff" && + url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true ) }) events.push(statusEvent("idle")) diff --git a/packages/app/e2e/regression/session-list-path-loading.spec.ts b/packages/app/e2e/regression/session-list-path-loading.spec.ts index 4a3855122a40..3319514df648 100644 --- a/packages/app/e2e/regression/session-list-path-loading.spec.ts +++ b/packages/app/e2e/regression/session-list-path-loading.spec.ts @@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async ( const pathBlocked = new Promise((resolve) => { releasePath = resolve }) - await page.route("**/path?*", async (route) => { - if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback() + await page.route("**/api/path?*", async (route) => { + if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback() await pathBlocked return route.fallback() }) diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 714d6ca96f15..6c253f462ac6 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => { const rejectRequests: string[] = [] page.on("request", (request) => { if (request.method() !== "POST") return - if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url()) + if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`) + rejectRequests.push(request.url()) }) await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() @@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => { await question.getByRole("radio", { name: /Minimal/ }).click() const reply = page.waitForRequest( - (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply", + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`, ) await question.getByRole("button", { name: "Submit" }).click() expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) @@ -97,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => { const reply = page.waitForRequest((request) => request.method() === "POST") await permission.getByRole("button", { name: "Allow once" }).click() const request = await reply - expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`) - expect(request.postDataJSON()).toEqual({ response: "once" }) + expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`) + expect(request.postDataJSON()).toEqual({ reply: "once" }) }) test("restores the draft caret before typing after a request dock closes", async ({ page }) => { diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 850e966d0b0f..db9bc1196e2d 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -89,7 +89,7 @@ test("reconnects after a stream error", async ({ page }) => { expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") }) -test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => { +test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { const timeline = await setupTimeline(page, { eventRetry: 10 }) const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { id: "timeline-event-7", @@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) = const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) expect(first.eventID).toBe("timeline-event-7") - expect(connection.headers["last-event-id"]).toBe("timeline-event-7") + expect(connection.headers["last-event-id"]).toBeUndefined() }) test("passes through non-event fetches", async ({ page }) => { diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 19d2c29af025..019cc156eca1 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -1,6 +1,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" +import { currentSession, mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/SubagentNavigation" @@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) { events, eventRetry: events ? 16 : undefined, }) - // The child session resolves via /session/:id but is absent from the /session list, + // The child session resolves by ID but is absent from the session list, // matching a subagent session that has not been loaded into the list cache yet. await page.route( - (url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), + (url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), (route) => route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]), + body: JSON.stringify({ + data: [currentSession(session(parentID, parentTitle, 1700000000000))], + cursor: {}, + }), }), ) await configurePage(page) diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index 94afbc9a9d66..4136c16d0129 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" const server = "http://127.0.0.1:4096" const sessionA = session("ses_tab_a", "Tab A session") @@ -56,9 +57,14 @@ async function mockServer(page: Page) { await page.route("**/*", async (route) => { const url = new URL(route.request().url()) if (url.origin !== server) return route.fallback() - if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session") return json(route, sessions) + if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) + if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) + if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`)) + return json(route, { data: [], cursor: {} }) const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) if (byId) return json(route, byId) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) @@ -66,7 +72,7 @@ async function mockServer(page: Page) { if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) @@ -89,7 +95,20 @@ async function mockServer(page: Page) { directory: sessionA.directory, home: sessionA.directory, }) + if (url.pathname === "/api/path") + return json(route, { + state: sessionA.directory, + config: sessionA.directory, + worktree: sessionA.directory, + directory: sessionA.directory, + home: sessionA.directory, + }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { + location: { directory: sessionA.directory }, + data: { branch: "main", defaultBranch: "main" }, + }) return json(route, {}) }) } diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts index f67260278228..f1f734f34377 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -46,25 +46,30 @@ test.beforeEach(async ({ page }) => { ], pageMessages: () => ({ items: [] }), }) - await page.route("**/pty", (route) => + await page.route("**/api/pty?*", (route) => { + expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory) + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), + }) + }) + await page.route(`**/api/pty/${ptyID}?*`, (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), }), ) - await page.route(`**/pty/${ptyID}`, (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), - ) - await page.route(`**/pty/${ptyID}/connect-token*`, (route) => + await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ ticket: "e2e-ticket" }), + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), }), ) - await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined) + await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) }) @@ -95,12 +100,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p const ghostty = Promise.withResolvers() const release = Promise.withResolvers() const created = { count: 0 } - await page.route("**/pty", (route) => { + await page.route("**/api/pty?*", (route) => { created.count += 1 return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), }) }) await page.route(/ghostty-web/, async (route) => { @@ -155,27 +160,31 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn test("focuses a terminal created from the new-terminal button", async ({ page }) => { const created = { count: 0 } - await page.route("**/pty", (route) => { + await page.route("**/api/pty?*", (route) => { created.count += 1 - const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" } + const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2") return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify(next), + body: JSON.stringify({ location: ptyLocation(), data: next }), }) }) - await page.route(`**/pty/${newPtyID}`, (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + await page.route(`**/api/pty/${newPtyID}?*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }), + }), ) - await page.route(`**/pty/${newPtyID}/connect-token*`, (route) => + await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) => route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ ticket: "e2e-ticket" }), + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), }), ) - await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined) + await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, "Terminal composer focus") @@ -207,3 +216,11 @@ function seedCachedTerminal(page: Page) { { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, ) } + +function ptyLocation() { + return { directory, project: { id: projectID, directory } } +} + +function ptyInfo(id: string, title: string) { + return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } +} diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 73821580af07..1b3369641556 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -43,17 +43,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => { ], pageMessages: () => ({ items: [] }), }) - await page.route("**/pty", (route) => + await page.route("**/api/pty?*", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }), + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_hidden_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), }), ) - await page.route("**/pty/pty_hidden_terminal", (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + await page.route("**/api/pty/pty_hidden_terminal?*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_hidden_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), + }), + ) + await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { ticket: "e2e-ticket", expires_in: 60 }, + }), + }), ) - await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined) + await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index cbb72958ad3a..d1ff35a2454d 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -29,6 +29,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac const terminal = page.locator('[data-component="terminal"]') await expect(terminal).toBeVisible() await expect.poll(() => connections.length).toBe(1) + const connection = new URL(connections[0]!) + expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`) + expect(connection.searchParams.get("location[directory]")).toBe(directory) + expect(connection.searchParams.get("ticket")).toBe("e2e-ticket") await writeProbe(page) await switchTab(page, titleB) @@ -85,26 +89,33 @@ async function setup(page: Page) { sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], pageMessages: () => ({ items: [] }), }) - await page.route("**/pty", (route) => + await page.route("**/api/pty?*", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), }), ) - await page.route(`**/pty/${ptyID}`, (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), - ) - await page.route(`**/pty/${ptyID}/connect-token*`, (route) => + await page.route(`**/api/pty/${ptyID}?*`, (route) => route.fulfill({ status: 200, contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ ticket: "e2e-ticket" }), + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), }), ) + await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => { + expect(route.request().headers()["x-opencode-ticket"]).toBe("1") + const url = new URL(route.request().url()) + expect(url.searchParams.get("location[directory]")).toBe(directory) + return route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), + }) + }) const connections: string[] = [] - await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => { + await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => { connections.push(ws.url()) }) @@ -143,3 +154,11 @@ function session(id: string, title: string, created: number) { function sessionHref(sessionID: string) { return `/server/${base64Encode(server)}/session/${sessionID}` } + +function ptyLocation() { + return { directory, project: { id: projectID, directory } } +} + +function ptyInfo() { + return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } +} diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 2bfba5871aba..ba91aa2aa25b 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -54,14 +54,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (url.port !== targetPort && url.port !== appPort) return route.fallback() const path = url.pathname - if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) + if (path === "/global/event" || path === "/event" || path === "/api/event") { + const events = config.events?.() + return sse( + route, + path === "/api/event" + ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] + : events, + config.eventRetry, + ) + } if (path === "/global/health") return json(route, { healthy: true }) - if (path === "/api/session") - return json(route, { - data: config.sessions.map((session) => v2Session(session, config.directory)), - cursor: {}, - }) - if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") @@ -89,10 +93,122 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }, data: [], }) + if (path === "/api/agent") + return json(route, { + location: location(config), + data: [ + { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + request: { settings: {}, headers: {}, body: {} }, + permissions: [], + }, + ], + }) + if (path === "/api/command") return json(route, { location: location(config), data: [] }) + if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) + if (path === "/api/mcp/resource") + return json(route, { location: location(config), data: { resources: [], templates: [] } }) + const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1] + if (integration && route.request().method() === "GET") + return json(route, { + location: location(config), + data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] }, + }) + if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST") + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + if (path === "/api/project") return json(route, [config.project]) + if (path === "/api/project/current") + return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) + if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project) + if (path === "/api/path") + return json(route, { + state: config.directory, + config: config.directory, + worktree: config.directory, + directory: config.directory, + home: "C:/OpenCode", + }) + if (path === "/api/permission/request") + return json(route, { + location: location(config), + data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( + currentPermission, + ), + }) + if (path === "/api/question/request") + return json(route, { + location: location(config), + data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), + }) + if (path === "/api/vcs") + return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) + if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) + if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] }) + if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] }) + if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) + return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) + if (path === "/api/session") { + const directory = url.searchParams.get("directory") + const parentID = url.searchParams.get("parentID") + const limit = Number(url.searchParams.get("limit") ?? 50) + const offset = Number(url.searchParams.get("cursor") ?? 0) + const sessions = config.sessions + .filter((session) => !directory || session.directory === directory) + .filter((session) => parentID !== "null" || session.parentID === undefined) + .filter((session) => { + const search = url.searchParams.get("search")?.toLowerCase() + return !search || String(session.title ?? "").toLowerCase().includes(search) + }) + const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions + const data = ordered.slice(offset, offset + limit) + const next = offset + limit < ordered.length ? String(offset + limit) : undefined + return json(route, { + data: data.map((session) => currentSession(session, config.directory)), + cursor: { next }, + }) + } + if (path === "/api/session/active") { + const statuses = (config.sessionStatus ?? {}) as Record + return json(route, { + data: Object.fromEntries( + Object.entries(statuses).flatMap(([id, status]) => (status.type === "idle" ? [] : [[id, { type: "running" }]])), + ), + }) + } + if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if ( + /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && + route.request().method() === "POST" + ) { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } if (path in staticRoutes) return json(route, staticRoutes[path]) + const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) + if (currentSessionMatch) { + const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) + if (!session) return json(route, { error: "Session not found" }, undefined, 404) + return json(route, { + data: currentSession(session, config.directory), + }) + } + const sessionMatch = path.match(/^\/session\/([^/]+)$/) if (sessionMatch) { const session = config.sessions.find((s) => s.id === sessionMatch[1]) @@ -115,6 +231,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) + const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) + if (currentMessagesMatch) { + const token = url.searchParams.get("cursor") ?? undefined + const before = token ? cursors.get(token) : undefined + if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) + config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" }) + await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) + config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" }) + const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined + if (cursor) cursors.set(cursor, pageData.cursor!) + return json(route, { + data: pageData.items.map(currentMessage).reverse(), + cursor: { next: cursor }, + }) + } + const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { const token = url.searchParams.get("before") ?? undefined @@ -137,12 +271,36 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } -function v2Session(session: { id: string } & Record, fallbackDirectory: string) { +function location(config: MockServerConfig) { + return { + directory: config.directory, + project: { id: (config.project as { id?: string }).id, directory: config.directory }, + } +} + +function currentPermission(value: unknown) { + const permission = value as Record + if (permission.action) return permission + const tool = permission.tool as { messageID?: string; callID?: string } | undefined + return { + id: permission.id, + sessionID: permission.sessionID, + action: permission.permission, + resources: permission.patterns ?? [], + save: permission.always, + metadata: permission.metadata, + source: tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, + } +} + +export function currentSession(session: { id: string } & Record, fallbackDirectory?: string) { const time = session.time && typeof session.time === "object" ? session.time : {} return { id: session.id, parentID: session.parentID, projectID: session.projectID ?? "project", + agent: session.agent ?? "build", + model: session.model ?? { id: "mock-model", providerID: "mock-provider" }, cost: session.cost ?? 0, tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { @@ -157,7 +315,67 @@ function v2Session(session: { id: string } & Record, fallbackDi directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), }, - ...(typeof session.path === "string" ? { subpath: session.path } : {}), + subpath: session.path, + revert: session.revert, + } +} + +function currentMessage(value: unknown) { + const item = value as { + info: Record & { id: string; role: "user" | "assistant"; time: { created: number } } + parts: Array & { type: string }> + } + if (item.info.role === "user") { + return { + id: item.info.id, + type: "user", + time: item.info.time, + text: item.parts + .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])) + .join("\n"), + } + } + return { + id: item.info.id, + type: "assistant", + time: item.info.time, + agent: item.info.agent ?? "build", + model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" }, + cost: item.info.cost, + tokens: item.info.tokens, + error: item.info.error, + content: item.parts.flatMap((part) => { + if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }] + if (part.type !== "tool") return [] + const state = part.state as Record + return [ + { + type: "tool", + id: part.id, + name: part.tool, + time: state.time ?? { created: item.info.time.created }, + state: + state.status === "pending" + ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) } + : state.status === "completed" + ? { + status: "completed", + input: state.input ?? {}, + structured: state.metadata ?? {}, + content: [{ type: "text", text: state.output ?? "" }], + } + : state.status === "error" + ? { + status: "error", + input: state.input ?? {}, + structured: state.metadata ?? {}, + content: [], + error: { type: "ToolError", message: state.error ?? "Tool failed" }, + } + : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] }, + }, + ] + }), } } @@ -181,3 +399,18 @@ function sse(route: Route, events?: unknown[], retry?: number) { body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, }) } + +function currentEvent(input: unknown) { + if (!input || typeof input !== "object" || !("payload" in input)) return input + const envelope = input as { directory?: string; payload?: unknown } + if (!envelope.payload || typeof envelope.payload !== "object") return input + const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } + if (!payload.type) return input + return { + id: payload.id ?? `evt_mock_${Date.now()}`, + created: Date.now(), + type: payload.type, + data: payload.properties ?? {}, + location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + } +} diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 55420485f399..66686ac25943 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -3,7 +3,7 @@ import type { Page } from "@playwright/test" export type SseConnectionRecord = { id: number url: string - path: "/global/event" | "/event" + path: "/global/event" | "/event" | "/api/event" headers: Record openedAt: number endedAt?: number @@ -93,6 +93,20 @@ export async function installSseTransport( eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, `data: ${JSON.stringify(payload)}\n\n`, ].join("") + const currentEvent = (input: unknown) => { + if (!input || typeof input !== "object" || !("payload" in input)) return input + const envelope = input as { directory?: string; payload?: unknown } + if (!envelope.payload || typeof envelope.payload !== "object") return input + const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } + if (!payload.type) return input + return { + id: payload.id ?? `evt_mock_${Date.now()}`, + created: Date.now(), + type: payload.type, + data: payload.properties ?? {}, + location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + } + } const acknowledge = ( connection: Connection, bytes: number, @@ -140,14 +154,14 @@ export async function installSseTransport( output.forEach((chunk) => connection.controller.enqueue(chunk)) return acknowledge(connection, input.bytes.length, output.length) } - const encoded = input.deliveries.map((delivery) => ({ - delivery, - bytes: encoder.encode(frame(delivery.payload, delivery.options)), - })) + const encoded = input.deliveries.map((delivery) => { + const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload + return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) } + }) encoded.forEach((item) => marker(item.delivery.options?.marker)) if (input.burst) { const bytes = encoder.encode( - encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""), + encoded.map((item) => frame(item.payload, item.delivery.options)).join(""), ) connection.controller.enqueue(bytes) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) @@ -161,7 +175,10 @@ export async function installSseTransport( const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) const url = new URL(request.url) - if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) + if ( + url.origin !== server || + (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") + ) return originalFetch(request) const id = ++nextConnectionID @@ -177,6 +194,12 @@ export async function installSseTransport( record.controller = controller connections.push(record) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) + if (url.pathname === "/api/event") + controller.enqueue( + encoder.encode( + frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} }), + ), + ) request.signal.addEventListener( "abort", () => { diff --git a/packages/app/package.json b/packages/app/package.json index faf019446128..f6a1bf9d2ea7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -53,6 +53,7 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index 59d3cbd1da72..487d8235509e 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -1,5 +1,6 @@ import { getFilename } from "@opencode-ai/core/util/path" -import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" +import type { Project } from "@opencode-ai/sdk/v2/client" +import type { SessionInfo } from "@opencode-ai/client/promise" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createMemo, onCleanup } from "solid-js" import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" @@ -13,6 +14,7 @@ import { useTabs } from "@/context/tabs" import { displayName, projectForSession } from "@/pages/layout/helpers" import { createSessionTabs } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" +import { normalizeSessionInfo } from "@/utils/session" export type CommandPaletteEntry = { id: string @@ -145,7 +147,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on opened: serverCtx.projects.list, stored: () => serverCtx.sync.data.project, load: (search, signal) => - serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), + serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) @@ -219,7 +221,7 @@ export function createServerSessionEntries(props: { server: ServerConnection.Key opened: () => LocalProject[] stored: () => Project[] - load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }> + load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }> untitled: () => string category: () => string }) { @@ -255,7 +257,8 @@ export function createServerSessionEntries(props: { return props .load(search, current.signal) .then((result) => - (result.data ?? []) + result.data + .map(normalizeSessionInfo) .filter((session) => !session.time.archived) .map((session) => { const project = @@ -264,7 +267,7 @@ export function createServerSessionEntries(props: { id: `session:${props.server}:${session.id}`, type: "session" as const, title: session.title || props.untitled(), - description: project ? displayName(project) : session.project?.name || getFilename(session.directory), + description: project ? displayName(project) : getFilename(session.directory), category: props.category(), directory: session.directory, sessionID: session.id, diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx index 85ca44ae69db..c23b703e5255 100644 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -80,7 +80,7 @@ export function DialogHomeCommandPaletteV2(props: { opened: serverCtx.projects.list, stored: () => serverCtx.sync.data.project, load: (search, signal) => - serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), + serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 4c58857249dd..93a62acb61d3 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,7 @@ -import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" +import type { + IntegrationMethod, + IntegrationOauthConnectOutput, +} from "@opencode-ai/client/promise" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -28,6 +31,8 @@ import { Switch, } from "solid-js" import { createStore, produce } from "solid-js/store" +import { useQueryClient } from "@tanstack/solid-query" +import { useParams } from "@solidjs/router" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" @@ -35,8 +40,11 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { popularProviders, useProviders } from "@/hooks/use-providers" import { CustomProviderForm } from "./dialog-custom-provider" +import { decode64 } from "@/utils/base64" +import { pathKey } from "@/utils/path-key" const CUSTOM_ID = "_custom" +type ConnectMethod = Extract export function useProviderConnectController(options: { onBack?: () => void } = {}) { const [store, setStore] = createStore({ selected: undefined as string | undefined }) @@ -228,8 +236,6 @@ function ProviderPickerV2(props: { }) { const providers = useProviders(props.directory) const language = useLanguage() - const serverSync = useServerSync() - const serverSDK = useServerSDK() const [store, setStore] = createStore({ filter: "", active: undefined as string | undefined, @@ -266,19 +272,7 @@ function ProviderPickerV2(props: { const connect = (provider: string) => { props.onPrepare?.() - if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) { - props.onSelect(provider) - return - } - if (store.connecting) return - setStore("connecting", provider) - void serverSDK() - .client.provider.auth() - .then((response) => { - serverSync().set("provider_auth", response.data ?? {}) - props.onSelect(provider) - }) - .catch(() => props.onSelect(provider)) + props.onSelect(provider) } const move = (event: KeyboardEvent, direction: number) => { @@ -395,10 +389,17 @@ function ProviderConnection(props: { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() + const queryClient = useQueryClient() + const params = useParams() const language = useLanguage() const settings = useSettings() const newLayout = settings.general.newLayoutDesigns const providers = useProviders(props.directory) + const directory = () => props.directory?.() ?? decode64(params.dir) + const location = () => { + const value = directory() + return value ? { directory: value } : undefined + } const alive = { value: true } const timer = { current: undefined as ReturnType | undefined } @@ -413,38 +414,34 @@ function ProviderConnection(props: { const provider = createMemo( () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) - const fallback = createMemo(() => [ + const fallback = createMemo(() => [ { - type: "api" as const, + type: "key" as const, label: language.t("provider.connect.method.apiKey"), }, ]) - const [auth] = createResource( - () => props.provider, - async () => { - const cached = serverSync().data.provider_auth[props.provider] - if (cached) return cached - const res = await serverSDK().client.provider.auth() - if (!alive.value) return fallback() - serverSync().set("provider_auth", res.data ?? {}) - return res.data?.[props.provider] ?? fallback() - }, + const [integration] = createResource( + () => ({ provider: props.provider, directory: directory() }), + (input) => + serverSDK() + .api.integration.get({ + integrationID: input.provider, + location: input.directory ? { directory: input.directory } : undefined, + }) + .then((result) => result.data), ) - const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) - const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) - const cachedMethods = serverSync().data.provider_auth[props.provider] - const directMethod = - cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined + const loading = createMemo(() => integration.loading) + const methods = createMemo(() => { + const values = integration.latest?.methods.filter( + (method): method is ConnectMethod => method.type === "key" || method.type === "oauth", + ) + return values?.length ? values : fallback() + }) const [store, setStore] = createStore({ - methodIndex: directMethod as undefined | number, - authorization: undefined as undefined | ProviderAuthAuthorization, + methodIndex: undefined as undefined | number, + authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], promptInputs: undefined as undefined | Record, - state: (directMethod === undefined ? "pending" : undefined) as - | undefined - | "pending" - | "complete" - | "error" - | "prompt", + state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", error: undefined as string | undefined, }) @@ -454,7 +451,7 @@ function ProviderConnection(props: { | { type: "auth.prompt" } | { type: "auth.inputs"; inputs: Record } | { type: "auth.pending" } - | { type: "auth.complete"; authorization: ProviderAuthAuthorization } + | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } | { type: "auth.error"; error: string } function dispatch(action: Action) { @@ -508,7 +505,7 @@ function ProviderConnection(props: { const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" - if (value.type === "api") return language.t("provider.connect.method.apiKey") + if (value.type === "key") return language.t("provider.connect.method.apiKey") return value.label ?? "" } @@ -518,7 +515,7 @@ function ProviderConnection(props: { const hint = suffix?.[1] return { label: suffix ? label.slice(0, -suffix[0].length) : label, - hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined, + hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined, } } @@ -549,46 +546,22 @@ function ProviderConnection(props: { const method = methods()[index] dispatch({ type: "method.select", index }) - if (method.type === "api" && method.prompts?.length) { - if (!inputs) { - dispatch({ type: "auth.prompt" }) - return - } - dispatch({ type: "auth.inputs", inputs }) - return - } - if (method.type === "oauth") { if (method.prompts?.length && !inputs) { dispatch({ type: "auth.prompt" }) return } dispatch({ type: "auth.pending" }) - const start = Date.now() await serverSDK() - .client.provider.oauth.authorize( - { - providerID: props.provider, - method: index, - inputs, - }, - { throwOnError: true }, - ) + .api.integration.oauth.connect({ + integrationID: props.provider, + methodID: method.id, + inputs: inputs ?? {}, + location: location(), + }) .then((x) => { if (!alive.value) return - const elapsed = Date.now() - start - const delay = 1000 - elapsed - - if (delay > 0) { - if (timer.current !== undefined) clearTimeout(timer.current) - timer.current = setTimeout(() => { - timer.current = undefined - if (!alive.value) return - dispatch({ type: "auth.complete", authorization: x.data! }) - }, delay) - return - } - dispatch({ type: "auth.complete", authorization: x.data! }) + dispatch({ type: "auth.complete", authorization: x.data }) }) .catch((e) => { if (!alive.value) return @@ -603,9 +576,9 @@ function ProviderConnection(props: { index: 0, }) - const prompts = createMemo>(() => { + const prompts = createMemo(() => { const value = method() - return value?.prompts ?? [] + return value?.type === "oauth" ? (value.prompts ?? []) : [] }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true @@ -636,10 +609,6 @@ function ProviderConnection(props: { setFormStore("index", next) return } - if (method()?.type === "api") { - dispatch({ type: "auth.inputs", inputs: value }) - return - } await selectMethod(store.methodIndex, value) } @@ -741,7 +710,10 @@ function ProviderConnection(props: { }) async function complete() { - await serverSDK().client.global.dispose() + const value = directory() + await queryClient + .refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null)) + .catch(() => undefined) dialog.close() showToast({ variant: "success", @@ -805,7 +777,7 @@ function ProviderConnection(props: { listRef = ref }} items={methods} - key={(m) => m?.label} + key={(m) => m?.label ?? m?.type} onSelect={async (selected, index) => { if (!selected) return void selectMethod(index) @@ -851,13 +823,10 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - await serverSDK().client.auth.set({ - providerID: props.provider, - auth: { - type: "api", - key: apiKey, - ...(store.promptInputs ? { metadata: store.promptInputs } : {}), - }, + await serverSDK().api.integration.connect.key({ + integrationID: props.provider, + location: location(), + key: apiKey, }) await complete() } @@ -984,12 +953,13 @@ function ProviderConnection(props: { setFormStore("error", undefined) const result = await serverSDK() - .client.provider.oauth.callback({ - providerID: props.provider, - method: store.methodIndex, + .api.integration.oauth.complete({ + integrationID: props.provider, + attemptID: store.authorization!.attemptID, + location: location(), code, }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .then(() => ({ ok: true as const })) .catch((error) => ({ ok: false as const, error })) if (result.ok) { await complete() @@ -1076,25 +1046,37 @@ function ProviderConnection(props: { }) onMount(() => { - void (async () => { + const poll = async () => { + const authorization = store.authorization + if (!authorization || !alive.value) return const result = await serverSDK() - .client.provider.oauth.callback({ - providerID: props.provider, - method: store.methodIndex, + .api.integration.oauth.status({ + integrationID: props.provider, + attemptID: authorization.attemptID, + location: location(), }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .then((value) => ({ ok: true as const, status: value.data })) .catch((error) => ({ ok: false as const, error })) - if (!alive.value) return - if (!result.ok) { - const message = formatError(result.error, language.t("common.requestFailed")) - dispatch({ type: "auth.error", error: message }) + dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) }) return } - - await complete() - })() + if (result.status.status === "complete") { + await complete() + return + } + if (result.status.status === "failed") { + dispatch({ type: "auth.error", error: result.status.message }) + return + } + if (result.status.status === "expired") { + dispatch({ type: "auth.error", error: language.t("common.requestFailed") }) + return + } + timer.current = setTimeout(poll, 1_000) + } + void poll() }) return ( @@ -1178,15 +1160,15 @@ function ProviderConnection(props: { - + - + - + diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 601f03084cea..5187d980ea26 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -69,15 +69,11 @@ export const DialogFork: Component = () => { const dir = base64Encode(sdk().directory) sdk() - .client.session.fork({ sessionID, messageID: item.id }) + .api.session.fork({ sessionID, messageID: item.id }) .then((forked) => { - if (!forked.data) { - showToast({ title: language.t("common.requestFailed") }) - return - } dialog.close() - prompt.set(restored, undefined, { dir, id: forked.data.id }) - navigate(`/${dir}/session/${forked.data.id}`) + prompt.set(restored, undefined, { dir, id: forked.id }) + navigate(`/${dir}/session/${forked.id}`) }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index 69d46ddcb62a..a457c9a2f5e5 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -28,6 +28,7 @@ import { } from "./directory-picker-domain" import "./dialog-select-directory-v2.css" import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { getFilename } from "@opencode-ai/core/util/path" interface DialogSelectDirectoryV2Props { title?: string @@ -68,9 +69,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), () => - sdk.client.path + sdk.api.path .get() - .then((result) => result.data) .catch(() => undefined), { initialValue: undefined }, ) @@ -85,18 +85,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { ) const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) const [suggestions] = createResource(input, async (value) => { - const typed = cleanPickerInput(value).replace(/\/+$/, "") + const cleaned = cleanPickerInput(value) + const typed = cleaned.replace(/\/+$/, "") const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") - if (!typed || typed === current) return { query: value, items: [] } + if (!cleaned || (root() && typed === current)) return { query: value, items: [] } const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } - const files = await sdk.client.find - .files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 }) - .then((result) => result.data ?? []) + const base = pickerRoot(cleaned) || root() || start() + if (!base) return { query: value, items: directories.slice(0, 5) } + const files = await sdk.api.file + .find({ + location: { directory: base }, + query: pickerFileSearchQuery(base, value, home()), + type: "file", + limit: 20, + }) + .then((result) => result.data) .catch(() => []) const results = [ ...directories, - ...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })), + ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })), ] return { query: value, @@ -115,9 +123,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { existing ?? loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) - return sdk.client.file - .list({ directory: absolute, path: "" }) - .then((result) => result.data ?? []) + return sdk.api.file + .list({ location: { directory: absolute } }) + .then((result) => + result.data.map((entry) => ({ + name: getFilename(entry.path.replace(/[\\/]+$/, "")), + type: entry.type, + })), + ) .catch(() => undefined) }) listings.set(key, request) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 8ba09a9f9046..80ac070750da 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), async () => { - return sdk.client.path + return sdk.api.path .get() - .then((x) => x.data) .catch(() => undefined) }, { initialValue: undefined }, diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 05253381f0c9..4f1a3cd2392c 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => { filterKeys={["name", "status"]} sortBy={(a, b) => a.name.localeCompare(b.name)} onSelect={(x) => { - if (!x || toggle.isPending) return + if (!x || x.status === "pending" || toggle.isPending) return toggle.mutate(x.name) }} > @@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
e.stopPropagation()}> { if (toggle.isPending) return toggle.mutate(i.name) diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 57464106106c..1bc9af08334c 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => { test("resolves directory autocomplete from the current browser root", async () => { const directories: string[] = [] const sdk = { - client: { - find: { - files: (input: { directory: string }) => { - directories.push(input.directory) + api: { + file: { + find: (input: { location?: { directory?: string } }) => { + directories.push(input.location?.directory ?? "") return Promise.resolve({ data: [] }) }, }, @@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () = expect(directories).toEqual(["/repo", "/repo/src"]) }) +test("searches from an absolute root without a default base", async () => { + const directories: string[] = [] + const sdk = { + api: { + file: { + list: (input: { location?: { directory?: string } }) => { + directories.push(input.location?.directory ?? "") + return Promise.resolve({ + data: [ + { path: "Users/", type: "directory" }, + { path: "tmp/", type: "directory" }, + ], + }) + }, + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined }) + + expect(await search("/")).toEqual(["/Users", "/tmp"]) + expect(directories).toEqual(["/"]) +}) + test("identifies the next directory level to preload", () => { expect( preloadTreeDirectories("src/", [ diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 9900265962ea..9539ae1d01dc 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string let current = 0 const scoped = (value: string) => { + const raw = normalizePickerDrive(value) + const root = pickerRoot(raw) + if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } const base = args.base() if (!base) return - const raw = normalizePickerDrive(value) if (!raw) return { directory: trimPickerPath(base), path: "" } const home = args.home() if (raw === "~") return { directory: trimPickerPath(home || base), path: "" } if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) } - const root = pickerRoot(raw) - if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } return { directory: trimPickerPath(base), path: raw } } @@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const key = trimPickerPath(directory) const existing = cache.get(key) if (existing) return existing - const request = args.sdk.client.file - .list({ directory: key, path: "" }) - .then((result) => result.data ?? []) + const request = args.sdk.api.file + .list({ location: { directory: key } }) + .then((result) => result.data) .catch(() => []) .then((nodes) => nodes .filter((node) => node.type === "directory") - .map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })), + .map((node) => { + const relative = trimPickerPath(normalizePickerDrive(node.path)) + return { name: getFilename(relative), absolute: joinPickerPath(key, relative) } + }), ) cache.set(key, request) return request @@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const query = normalizePickerDrive(input.path) if (!pathInput) { - const results = await args.sdk.client.find - .files({ directory: input.directory, query, type: "directory", limit: 50 }) - .then((result) => result.data ?? []) + const results = await args.sdk.api.file + .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) + .then((result) => result.data.map((entry) => entry.path)) .catch(() => []) if (!active()) return [] return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts index 3ec999da0639..42053f6eff81 100644 --- a/packages/app/src/components/edit-project.ts +++ b/packages/app/src/components/edit-project.ts @@ -1,6 +1,7 @@ import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useMutation } from "@tanstack/solid-query" +import { normalizeProjectInfo } from "@/context/global-sync/utils" import { createMemo } from "solid-js" import { createStore } from "solid-js/store" import { useGlobal } from "@/context/global" @@ -70,13 +71,15 @@ export function createEditProjectModel(props: { project: LocalProject; server: S const start = store.startup.trim() if (props.project.id && props.project.id !== "global") { - await serverCtx().sdk.client.project.update({ + const project = await serverCtx().sdk.api.project.update({ projectID: props.project.id, - directory: props.project.worktree, name, icon: { color: store.color || "", override: store.iconOverride || "" }, commands: { start }, }) + serverCtx().sync.set("project", (items) => + items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)), + ) serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) dialog.close() return diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 921d8ff45c16..13df57bec2b4 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -310,7 +310,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): ) const resources = createMemo(() => Object.values(sync().data.mcp_resource).map((resource) => ({ - id: `resource:${resource.client}:${resource.uri}`, + id: `resource:${resource.server}:${resource.uri}`, kind: "resource" as const, label: `@${resource.name}`, path: resource.uri, @@ -327,7 +327,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): source: { type: "resource" as const, text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, - clientName: resource.client, + clientName: resource.server, uri: resource.uri, }, }, diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index bcc5acc0bb72..3842b087914e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -591,7 +591,7 @@ export const PromptInput: Component = (props) => { type: "resource", name: resource.name, uri: resource.uri, - client: resource.client, + client: resource.server, display: resource.name, description: resource.description, mime: resource.mimeType, diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index f563a509822a..ac0691646451 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit const createdClients: string[] = [] const createdSessions: string[] = [] +const sessionCreateInputs: Array<{ + agent?: string + model?: { id: string; providerID: string; variant?: string } + location?: { directory: string } +}> = [] const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = [] const optimistic: Array<{ directory?: string @@ -20,9 +25,14 @@ const optimistic: Array<{ const optimisticSeeded: boolean[] = [] const storedSessions: Record> = {} const promoted: Array<{ directory: string; sessionID: string }> = [] -const sentShell: string[] = [] +const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const syncedDirectories: string[] = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] +const sentPrompts: string[] = [] +const promptInputs: unknown[] = [] +const sentCommands: unknown[] = [] +const commands: Array<{ name: string }> = [] +let serverSessionSyncs = 0 let params: { id?: string } = {} let search: { draftId?: string } = {} @@ -31,7 +41,7 @@ let variant: string | undefined let permissionServer = "server-a" let createSessionGate: Promise | undefined -const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] +let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const [promptStore, setPromptStore] = createStore({ prompt: promptValue, cursor: 0, @@ -63,23 +73,39 @@ const prompt = { const clientFor = (directory: string) => { createdClients.push(directory) return { - session: { - create: async () => { - await createSessionGate - createdSessions.push(directory) - return { - data: { + api: { + session: { + create: async (input: (typeof sessionCreateInputs)[number]) => { + await createSessionGate + const location = input.location?.directory ?? directory + createdSessions.push(location) + sessionCreateInputs.push(input) + return { id: `session-${createdSessions.length}`, + projectID: "project", + agent: input.agent, + model: input.model, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, title: `New session ${createdSessions.length}`, - }, - } - }, - shell: async () => { - sentShell.push(directory) - return { data: undefined } + location: { directory: location }, + } + }, + prompt: async (input: unknown) => { + sentPrompts.push(directory) + promptInputs.push(input) + return { data: undefined } + }, + command: async (input: unknown) => { + sentCommands.push(input) + }, + shell: async (input: { sessionID: string; id?: string; command: string }) => { + sentShell.push(input) + }, }, - prompt: async () => ({ data: undefined }), - promptAsync: async () => ({ data: undefined }), + }, + session: { command: async () => ({ data: undefined }), abort: async () => ({ data: undefined }), }, @@ -172,6 +198,7 @@ beforeAll(async () => { scope: "local", directory: "/repo/main", client: rootClient, + api: rootClient.api, url: "http://localhost:4096", createClient(opts: any) { return clientFor(opts.directory) @@ -183,7 +210,7 @@ beforeAll(async () => { mock.module("@/context/sync", () => ({ useSync: () => () => ({ - data: { command: [] }, + data: { command: commands }, session: { optimistic: { add: (value: { @@ -210,6 +237,9 @@ beforeAll(async () => { session: { remember: () => undefined, set: () => undefined, + sync: async () => { + serverSessionSyncs++ + }, }, child: (directory: string) => { syncedDirectories.push(directory) @@ -251,11 +281,17 @@ beforeAll(async () => { beforeEach(() => { createdClients.length = 0 createdSessions.length = 0 + sessionCreateInputs.length = 0 enabledAutoAccept.length = 0 optimistic.length = 0 optimisticSeeded.length = 0 promoted.length = 0 promotedDrafts.length = 0 + sentPrompts.length = 0 + promptInputs.length = 0 + sentCommands.length = 0 + commands.length = 0 + promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] params = {} search = {} sentShell.length = 0 @@ -264,6 +300,7 @@ beforeEach(() => { variant = undefined permissionServer = "server-a" createSessionGate = undefined + serverSessionSyncs = 0 for (const key of Object.keys(storedSessions)) delete storedSessions[key] }) @@ -297,8 +334,24 @@ describe("prompt submit worktree selection", () => { expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) - expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + expect(sessionCreateInputs).toEqual([ + { + agent: "agent", + model: { id: "model", providerID: "provider", variant: undefined }, + location: { directory: "/repo/worktree-a" }, + }, + { + agent: "agent", + model: { id: "model", providerID: "provider", variant: undefined }, + location: { directory: "/repo/worktree-b" }, + }, + ]) + expect(sentShell).toEqual([ + expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }), + expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }), + ]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) + expect(serverSessionSyncs).toBe(0) expect(promoted).toEqual([ { directory: "/repo/worktree-a", sessionID: "session-1" }, { directory: "/repo/worktree-b", sessionID: "session-2" }, @@ -419,6 +472,7 @@ describe("prompt submit worktree selection", () => { const event = { preventDefault: () => undefined } as unknown as Event await submit.handleSubmit(event) + await Bun.sleep(0) expect(optimistic).toHaveLength(1) expect(optimistic[0]).toMatchObject({ @@ -427,6 +481,53 @@ describe("prompt submit worktree selection", () => { model: { providerID: "provider", modelID: "model", variant: "high" }, }, }) + expect(sentPrompts).toEqual(["/repo/main"]) + expect(promptInputs[0]).toMatchObject({ + sessionID: "session-1", + text: "ls", + files: [], + agents: [], + }) + expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") + }) + + test("submits slash commands through the current session API", async () => { + params = { id: "session-1" } + variant = "high" + commands.push({ name: "review" }) + promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }] + + const submit = createPromptSubmit({ + prompt, + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + + expect(sentCommands).toEqual([ + { + sessionID: "session-1", + id: expect.stringMatching(/^msg_/), + command: "review", + arguments: "staged changes", + agent: "agent", + model: { id: "model", providerID: "provider", variant: "high" }, + files: [], + }, + ]) + expect(serverSessionSyncs).toBe(0) }) test("uses an injected model selection", async () => { @@ -487,7 +588,8 @@ describe("prompt submit worktree selection", () => { await submit.handleSubmit(event) - expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) + expect(storedSessions["/repo/worktree-a"]).toHaveLength(1) + expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" }) expect(optimisticSeeded).toEqual([true]) }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 203722fc6c74..2cd30da3ef94 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -20,6 +20,8 @@ import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" import { ScopedKey } from "@/utils/server-scope" import { createPromptSubmissionState } from "./submission-state" +import { normalizeSessionInfo } from "@/utils/session" +import { Event } from "@opencode-ai/schema/event" type PendingPrompt = { abort: AbortController @@ -39,7 +41,7 @@ export type FollowupDraft = { } type FollowupSendInput = { - client: DirectorySDK["client"] + api: DirectorySDK["api"]["session"] serverSync: ServerSync sync: DirectorySync draft: FollowupDraft @@ -81,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return false } - await input.client.session.command({ + const messageID = Identifier.ascending("message") + await input.api.command({ sessionID: input.draft.sessionID, + id: messageID, command: cmd, arguments: tail.join(" "), agent: input.draft.agent, - model: `${input.draft.model.providerID}/${input.draft.model.modelID}`, - variant: input.draft.variant, - parts: images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, + model: { + id: input.draft.model.modelID, + providerID: input.draft.model.providerID, + variant: input.draft.variant, + }, + files: images.map((attachment) => ({ + uri: attachment.dataUrl, + name: attachment.filename, })), }) return true @@ -152,13 +156,36 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return false } - await input.client.session.promptAsync({ + await input.api.prompt({ sessionID: input.draft.sessionID, + id: messageID, agent: input.draft.agent, model: input.draft.model, - messageID, - parts: requestParts, variant: input.draft.variant, + text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), + files: requestParts.flatMap((part) => { + if (part.type !== "file") return [] + const text = part.source?.text + return [ + { + uri: part.url, + name: part.filename, + mention: text ? { start: text.start, end: text.end, text: text.value } : undefined, + }, + ] + }), + agents: requestParts.flatMap((part) => + part.type === "agent" + ? [ + { + name: part.name, + mention: part.source + ? { start: part.source.start, end: part.source.end, text: part.source.value } + : undefined, + }, + ] + : [], + ), }) return true } catch (err) { @@ -210,6 +237,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID) const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data if (data?.message) return data.message @@ -235,9 +263,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { return Promise.resolve() } return sdk() - .client.session.abort({ - sessionID, - }) + .api.session.interrupt({ sessionID }) .catch(() => {}) } @@ -364,9 +390,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { let session = input.info() if (!session && isNewSession) { - const created = await client.session - .create() - .then((x) => x.data ?? undefined) + const created = await sdk() + .api.session.create({ + agent: currentAgent.name, + model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, + location: { directory: sessionDirectory }, + }) + .then(normalizeSessionInfo) .catch((err) => { showToast({ title: language.t("prompt.toast.sessionCreateFailed.title"), @@ -450,12 +480,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (mode === "shell") { clearInput() - client.session - .shell({ + const eventID = Event.ID.create() + sdk() + .api.session.shell({ sessionID: session.id, + id: eventID, + command: text, agent, model, - command: text, }) .catch((err) => { showToast({ @@ -473,23 +505,23 @@ export function createPromptSubmit(input: PromptSubmitInput) { const customCommand = sync().data.command.find((c) => c.name === commandName) if (customCommand) { clearInput() - client.session - .command({ + const messageID = Identifier.ascending("message") + serverSync().session.set("session_status", session.id, { type: "busy" }) + sdk() + .api.session.command({ sessionID: session.id, + id: messageID, command: commandName, arguments: args.join(" "), agent, - model: `${model.providerID}/${model.modelID}`, - variant, - parts: images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, + model: { id: model.modelID, providerID: model.providerID, variant }, + files: images.map((attachment) => ({ + uri: attachment.dataUrl, + name: attachment.filename, })), }) .catch((err) => { + serverSync().session.set("session_status", session.id, { type: "idle" }) showToast({ title: language.t("prompt.toast.commandSendFailed.title"), description: formatServerError(err, language.t, language.t("common.requestFailed")), @@ -573,7 +605,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } void sendFollowupDraft({ - client, + api: sdk().api.session, sync: sync(), serverSync: serverSync(), draft, diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 3beb97225a4e..1b6cc9b3c0b4 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -129,8 +129,8 @@ export const SettingsGeneral: Component = () => { const [shells] = createResource( () => serverSdk() - .client.pty.shells() - .then((res) => res.data ?? []) + .api.pty.shells() + .then((res) => res.data) .catch(() => [] as ShellOption[]), { initialValue: [] as ShellOption[] }, ) diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index ed2328c89aa5..2607ece3173c 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -124,8 +124,8 @@ export const SettingsGeneralV2: Component<{ const [shells] = createResource( () => serverSdk() - .client.pty.shells() - .then((res) => res.data ?? []) + .api.pty.shells() + .then((res) => res.data) .catch(() => [] as ShellOption[]), { initialValue: [] as ShellOption[] }, ) diff --git a/packages/app/src/components/status-popover-indicator.test.ts b/packages/app/src/components/status-popover-indicator.test.ts index e3c62d2a9520..c1c57b9d7082 100644 --- a/packages/app/src/components/status-popover-indicator.test.ts +++ b/packages/app/src/components/status-popover-indicator.test.ts @@ -26,7 +26,7 @@ describe("hasNonBlockingServiceIssue", () => { expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true) - expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false) + expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false) }) test("detects LSP failures that do not block chatting", () => { diff --git a/packages/app/src/components/status-popover-indicator.ts b/packages/app/src/components/status-popover-indicator.ts index efb7473753e8..d89f90febbf6 100644 --- a/packages/app/src/components/status-popover-indicator.ts +++ b/packages/app/src/components/status-popover-indicator.ts @@ -1,11 +1,12 @@ -import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client" +import type { LspStatus } from "@opencode-ai/sdk/v2/client" +import type { McpServer } from "@opencode-ai/client/promise" export function hasNonBlockingServiceIssue(input: { - mcp: Array + mcp: Array lsp: Array }) { return ( - input.mcp.some((status) => status !== "connected" && status !== "disabled") || + input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") || input.lsp.some((status) => status === "error") ) } diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index c512e782c496..70fa6a2eb9e3 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -11,7 +11,6 @@ import { matchKeybind, parseKeybind } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" -import { useServerSDK } from "@/context/server-sdk" import { terminalFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" @@ -176,15 +175,9 @@ export const Terminal = (props: TerminalProps) => { const theme = useTheme() const language = useLanguage() // Terminal captures its connection for the PTY lifetime, so callers must key it per server/session. - const connection = useServerSDK()().server const directory = sdk().directory - const client = sdk().client + const client = sdk().api.pty const url = sdk().url - const auth = connection.http - const username = auth?.username ?? "opencode" - const password = auth?.password ?? "" - const authToken = connection.type === "http" ? connection.authToken : false - const sameOrigin = new URL(url, location.href).origin === location.origin let container!: HTMLDivElement const [local, others] = splitProps(props, [ "pty", @@ -242,9 +235,10 @@ export const Terminal = (props: TerminalProps) => { } const pushSize = (cols: number, rows: number) => { - return client.pty + return client .update({ ptyID: id, + location: { directory }, size: { cols, rows }, }) .catch((err) => { @@ -523,33 +517,29 @@ export const Terminal = (props: TerminalProps) => { } const gone = () => - client.pty - .get({ ptyID: id }, { throwOnError: false }) - .then((result) => result.response.status === 404) + client + .get({ ptyID: id, location: { directory } }) + .then((result) => result.data.status === "exited") .catch((err) => { + if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true debugTerminal("failed to inspect terminal session", err) return false }) const connectToken = async () => { - const result = await client.pty - .connectToken( - { ptyID: id, directory }, - { - throwOnError: false, - headers: { "x-opencode-ticket": "1" }, - }, - ) + return client + .connectToken({ + ptyID: id, + location: { directory }, + "x-opencode-ticket": "1", + }) + .then((result) => result.data.ticket) .catch((err: unknown) => { - if (err instanceof Error && err.message.includes("Request is not supported")) return + if (err && typeof err === "object" && "_tag" in err && err._tag === "ForbiddenError") { + throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") + } throw err }) - if (!result) return - if (result.response.status === 200 && result.data?.ticket) return result.data.ticket - if (result.response.status === 404 || result.response.status === 405) return - if (result.response.status === 403) - throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") - throw new Error(`PTY connect ticket failed with ${result.response.status}`) } const retry = (err: unknown) => { @@ -579,7 +569,7 @@ export const Terminal = (props: TerminalProps) => { fail(err) return undefined }) - if (once.value) return + if (!ticket || once.value) return if (disposed) return const socket = new WebSocket( @@ -589,10 +579,6 @@ export const Terminal = (props: TerminalProps) => { directory, cursor: seek, ticket, - sameOrigin, - username, - password, - authToken, }), ) socket.binaryType = "arraybuffer" diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index a397046f9b3e..3058e6881af7 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -120,8 +120,7 @@ export function TabNavItem(props: { const ctx = serverCtx() const session = props.session() if (!ctx || !session) return - const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true }) - await client.session.update({ sessionID: session.id, title }) + await ctx.sdk.api.session.rename({ sessionID: session.id, title }) } const closeRename = async (save: boolean) => { diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 45786561077a..aa2f220e4949 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -28,6 +28,7 @@ import { tabKey, useTabs } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" +import { normalizeSessionInfo } from "@/utils/session" type TauriDesktopWindow = { startDragging?: () => Promise @@ -267,9 +268,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined }, ({ route, sdk }) => - sdk.client.session + sdk.api.session .get({ sessionID: route.sessionId }) - .then((x) => x.data) + .then(normalizeSessionInfo) .catch(() => {}), ) diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 68e6b19cef14..ca6df85a53ad 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -5,6 +5,7 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import type { createServerSdkContext } from "./server-sdk" import type { createServerSyncContextInner } from "./server-sync" import type { State } from "./global-sync/types" +import { normalizeSessionInfo } from "@/utils/session" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const sessionFields = new Set([ @@ -15,6 +16,7 @@ const sessionFields = new Set([ "permission", "question", "message", + "session_message", "part", "part_text_accum_delta", ]) @@ -114,7 +116,6 @@ export const createDirSyncContext = ( await serverSync.session.sync(sessionID, options) index(sessionID) }, - diff: serverSync.session.diff, todo: serverSync.session.todo, history: serverSync.session.history, evict(sessionID: string) { @@ -123,9 +124,9 @@ export const createDirSyncContext = ( fetch: async (count = 10) => { const [store, setStore] = current() setStore("limit", (value) => value + count) - const response = await client.session.list() - const sessions = (response.data ?? []) - .filter((session) => !!session?.id) + const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" }) + const sessions = response.data + .map(normalizeSessionInfo) .sort((a, b) => cmp(a.id, b.id)) .slice(0, store.limit) sessions.forEach(serverSync.session.remember) @@ -133,7 +134,7 @@ export const createDirSyncContext = ( }, more: createMemo(() => current()[0].session.length >= current()[0].limit), archive: async (sessionID: string) => { - await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } }) + await serverSDK.api.session.archive({ sessionID, directory }) current()[1]( "session", produce((draft) => { diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 6032b81dde70..fbbef3a2a8e7 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -204,10 +204,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ } const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => - sdk() - .client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal }) + serverSDK() + .api.file.find( + { + location: { directory: sdk().directory }, + query, + type: dirs === "true" ? "directory" : "file", + limit: options?.limit, + }, + { signal: options?.signal }, + ) .then( - (x) => (x.data ?? []).map(path.normalize), + (x) => x.data.map((entry) => path.normalize(entry.path)), (error) => { if (options?.signal?.aborted) throw error return [] diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 40735fb822c0..dceab47d8667 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -1,14 +1,39 @@ import { describe, expect, test } from "bun:test" import { createStore } from "solid-js/store" import { QueryClient } from "@tanstack/solid-query" -import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client" +import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" +import type { AgentApi, CatalogApi, CommandApi, ProjectApi, ReferenceApi } from "@opencode-ai/client/promise" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap" +import { + bootstrapDirectory, + loadAgentsQuery, + loadCommands, + loadPathQuery, + loadProjectsQuery, + loadProvidersQuery, + loadReferencesQuery, +} from "./bootstrap" import type { State, VcsCache } from "./types" -import { createServerSession } from "../server-session" import { ServerScope } from "@/utils/server-scope" +import type { ServerApi } from "@/utils/server" const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse +const api = { + agent: { list: async () => ({ location: {}, data: [] }) }, + provider: { list: async () => ({ location: {}, data: [] }) }, + model: { + list: async () => ({ location: {}, data: [] }), + default: async () => ({ location: {}, data: null }), + }, + permission: { request: { list: async () => ({ location: {}, data: [] }) } }, + project: { + list: async () => [], + current: async () => ({ id: "project", directory: "/project" }), + }, + question: { request: { list: async () => ({ location: {}, data: [] }) } }, + reference: { list: async () => ({ location: {}, data: [] }) }, + vcs: { get: async () => ({ location: {}, data: {} }) }, +} as unknown as ServerApi function directoryState() { return createStore({ @@ -41,6 +66,7 @@ function directoryState() { vcs: undefined, limit: 5, message: {}, + session_message: {}, part: {}, part_text_accum_delta: {}, }) @@ -64,7 +90,6 @@ describe("bootstrapDirectory", () => { sdk: { app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, config: { get: async () => ({ data: {} }) }, - session: { status: async () => ({ data: {} }) }, vcs: { get: async () => ({ data: undefined }) }, command: { list: async () => { @@ -83,6 +108,7 @@ describe("bootstrapDirectory", () => { }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as unknown as OpencodeClient, + api, store, setStore, vcsCache: { setStore() {} } as unknown as VcsCache, @@ -99,78 +125,108 @@ describe("bootstrapDirectory", () => { expect(mcpReads).toEqual([]) }) - test("seeds session status even while warming session info stalls", async () => { - const [store, setStore] = directoryState() - const stalled = Promise.withResolvers() - const client = { - app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, - config: { get: async () => ({ data: {} }) }, - session: { - status: async () => ({ data: { ses_busy: { type: "busy" } } }), - get: () => stalled.promise, - }, - vcs: { get: async () => ({ data: undefined }) }, - command: { list: async () => ({ data: [] }) }, - permission: { list: async () => ({ data: [] }) }, - question: { list: async () => ({ data: [] }) }, - v2: { reference: { list: async () => ({ data: { data: [] } }) } }, - mcp: { status: async () => ({ data: {} }) }, - provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, - } as unknown as OpencodeClient - const session = createServerSession(client) - const stale: Session = { - id: "ses_stale", - slug: "ses_stale", - projectID: "project", - directory: "/project", - title: "stale", - version: "1", - time: { created: 1, updated: 1 }, - } - session.remember(stale) - session.set("session_status", stale.id, { type: "busy" }) - - await bootstrapDirectory({ - directory: "/project", - scope: ServerScope.local, - mcp: false, - global: { - config: {} satisfies Config, - path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, - project: [{ id: "project", worktree: "/project" } as Project], - provider, - }, - sdk: client, - store, - setStore, - vcsCache: { setStore() {} } as unknown as VcsCache, - loadSessions() {}, - translate: (key) => key, - queryClient: new QueryClient(), - session, - }) - - const deadline = Date.now() + 500 - while (!session.data.session_working("ses_busy") && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 10)) - } - - expect(session.data.session_status["ses_busy"]?.type).toBe("busy") - expect(session.data.session_status[stale.id]).toBeUndefined() - }) }) describe("query keys", () => { test("partitions identical directories by server scope", () => { - const client = {} as OpencodeClient + const client = {} as Parameters[2] + const api = {} as CatalogApi const remote = "https://debian.example" as typeof ServerScope.local expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"]) expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) - expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([ - "https://debian.example", - null, - "providers", + expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"]) + }) + + test("loads the current provider and model catalog", async () => { + const calls: unknown[] = [] + const api = { + provider: { + list: async (input: unknown) => { + calls.push(["provider", input]) + return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] } + }, + }, + model: { + list: async (input: unknown) => { + calls.push(["model", input]) + return { location: {}, data: [] } + }, + default: async (input: unknown) => { + calls.push(["default", input]) + return { location: {}, data: null } + }, + }, + } as unknown as CatalogApi + + const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api)) + + expect(calls).toEqual([ + ["provider", { location: { directory: "/repo" } }], + ["model", { location: { directory: "/repo" } }], + ["default", { location: { directory: "/repo" } }], ]) + expect(result.connected).toEqual(["openai"]) + }) + + test("loads agents from the current location-scoped endpoint", async () => { + const calls: unknown[] = [] + const api = { + list: async (input: unknown) => { + calls.push(input) + return { location: {}, data: [] } + }, + } as unknown as AgentApi + + const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api)) + + expect(calls).toEqual([{ location: { directory: "/repo" } }]) + expect(result).toEqual([]) + }) + + test("loads commands from the current location-scoped endpoint", async () => { + const calls: unknown[] = [] + const api = { + list: async (input: unknown) => { + calls.push(input) + return { + location: {}, + data: [{ name: "review", template: "Review files", source: "command" as const }], + } + }, + } as unknown as CommandApi + + const result = await loadCommands("/repo", api) + + expect(calls).toEqual([{ location: { directory: "/repo" } }]) + expect(result).toEqual([{ name: "review", template: "Review files", source: "command" }]) + }) + + test("loads projects from the current endpoint", async () => { + const api = { + list: async () => [ + { id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, + { id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, + ], + } as unknown as ProjectApi + + const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api)) + + expect(result.map((project) => project.id)).toEqual(["a", "b"]) + }) + + test("loads references from the current location-scoped endpoint", async () => { + const calls: unknown[] = [] + const api = { + list: async (input: unknown) => { + calls.push(input) + return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] } + }, + } as unknown as ReferenceApi + + const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api)) + + expect(calls).toEqual([{ location: { directory: "/repo" } }]) + expect(result).toHaveLength(1) }) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index c63b702522f5..4c527a958036 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -9,6 +9,26 @@ import type { ReferenceInfo, Session, } from "@opencode-ai/sdk/v2/client" +import type { + AgentListInput, + AgentListOutput, + CatalogApi, + CommandInfo, + CommandListInput, + CommandListOutput, + McpApi, + PathGetInput, + PathGetOutput, + PermissionApi, + ProjectCurrentInput, + ProjectCurrentOutput, + ProjectListOutput, + QuestionApi, + ReferenceListInput, + ReferenceListOutput, + SessionApi, + VcsApi, +} from "@opencode-ai/client/promise" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { retry } from "@opencode-ai/core/util/retry" @@ -16,12 +36,20 @@ import { batch } from "solid-js" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" import type { ServerSession } from "../server-session" -import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" +import { + cmp, + normalizeAgentList, + normalizePermissionRequest, + normalizeProjectInfo, + normalizeProviderList, +} from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { ScopedKey, type ServerScope } from "@/utils/server-scope" +import { normalizeSessionInfo } from "@/utils/session" +import type { ServerProtocol } from "@/utils/server-protocol" type GlobalStore = { ready: boolean @@ -88,15 +116,25 @@ export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) = queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)), }) -export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) => +type ProjectApi = { + readonly list: () => Promise + readonly current: (input?: ProjectCurrentInput) => Promise +} + +type PathApi = { + readonly get: (input?: PathGetInput) => Promise +} + +export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => queryOptions({ queryKey: [scope, "project"], queryFn: () => retry(() => - sdk.project.list().then((x) => { - return (x.data ?? []) + api.list().then((projects) => { + return projects .filter((p) => !!p?.id) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .map(normalizeProjectInfo) .slice() .sort((a, b) => cmp(a.id, b.id)) }), @@ -105,6 +143,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) => export async function bootstrapGlobal(input: { serverSDK: OpencodeClient + serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi } + protocol?: Promise scope: ServerScope requestFailedTitle: string translate: (key: string, vars?: Record) => string @@ -114,11 +154,14 @@ export async function bootstrapGlobal(input: { }) { const slow = [ () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)), - () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)), - () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)), + () => + input.queryClient.fetchQuery( + loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol), + ), + () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.path)), () => input.queryClient - .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK)) + .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) .then((data) => input.setGlobalStore("project", data)), ] await runAll(slow) @@ -162,44 +205,117 @@ function warmSessions(input: { ids: string[] store: Store setStore: SetStoreFunction - sdk: OpencodeClient + api: SessionApi }) { const known = new Set(input.store.session.map((item) => item.id)) const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id)) if (ids.length === 0) return Promise.resolve() return Promise.all( ids.map((sessionID) => - retry(() => input.sdk.session.get({ sessionID })).then((x) => { - const session = x.data - if (!session?.id) return - mergeSession(input.setStore, session) - }), + retry(() => input.api.get({ sessionID })).then((session) => + mergeSession(input.setStore, normalizeSessionInfo(session)), + ), ), ).then(() => undefined) } -export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => +export const loadProvidersQuery = ( + scope: ServerScope, + directory: string | null, + sdk: CatalogApi, + legacy?: OpencodeClient, + protocol?: Promise, +) => queryOptions({ queryKey: [scope, directory, "providers"], - queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))), + queryFn: () => + retry(async () => { + if ((await protocol) === "v1" && legacy) { + const result = await legacy.provider.list() + return normalizeProviderList(result.data!) + } + const location = directory ? { location: { directory } } : undefined + const [providers, models, defaultModel] = await Promise.all([ + sdk.provider.list(location), + sdk.model.list(location), + sdk.model.default(location), + ]) + return normalizeProviderList(providers.data, models.data, defaultModel.data) + }), }) -export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => +type AgentListApi = { + readonly list: (input?: AgentListInput) => Promise +} + +type CommandListApi = { + readonly list: (input?: CommandListInput) => Promise +} + +type ReferenceListApi = { + readonly list: (input?: ReferenceListInput) => Promise +} + +export const loadAgentsQuery = ( + scope: ServerScope, + directory: string, + sdk: AgentListApi, + legacy?: OpencodeClient, + protocol?: Promise, +) => queryOptions({ queryKey: [scope, directory, "agents"], - queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))), + queryFn: () => + retry(async () => { + if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? []) + return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data)) + }), }) -export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => +export const loadCommands = ( + directory: string, + api: CommandListApi, + legacy?: OpencodeClient, + protocol?: Promise, +): Promise => + retry(async () => { + if ((await protocol) === "v1" && legacy) { + return ((await legacy.command.list()).data ?? []).map((command) => { + const [providerID, id] = command.model?.split("/") ?? [] + return { + name: command.name, + template: command.template, + description: command.description, + agent: command.agent, + model: providerID && id ? { providerID, id } : undefined, + subtask: command.subtask, + source: command.source === "skill" ? undefined : command.source, + } + }) + } + return api.list({ location: { directory } }).then((result) => result.data) + }) + +export const loadPathQuery = (scope: ServerScope, directory: string | null, api: PathApi) => queryOptions({ queryKey: [scope, directory, "path"], - queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), + queryFn: () => retry(() => api.get(directory ? { location: { directory } } : undefined)), }) -export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => +export const loadReferencesQuery = ( + scope: ServerScope, + directory: string, + api: ReferenceListApi, + legacy?: OpencodeClient, + protocol?: Promise, +) => queryOptions({ queryKey: [scope, directory, "references"] as const, - queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []), + queryFn: () => + retry(async () => { + if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? [] + return api.list({ location: { directory } }).then((result) => result.data) + }).catch(() => []), placeholderData: [], }) @@ -208,6 +324,18 @@ export async function bootstrapDirectory(input: { scope: ServerScope mcp: boolean sdk: OpencodeClient + api: CatalogApi & { + readonly agent: AgentListApi + readonly command: CommandListApi + readonly mcp: McpApi + readonly path: PathApi + readonly permission: PermissionApi + readonly project: ProjectApi + readonly question: QuestionApi + readonly reference: ReferenceListApi + readonly session: SessionApi + readonly vcs: VcsApi + } store: Store setStore: SetStoreFunction vcsCache: VcsCache @@ -221,6 +349,7 @@ export async function bootstrapDirectory(input: { } queryClient: QueryClient session?: ServerSession + protocol?: Promise }) { const loading = input.store.status !== "complete" const seededProject = projectID(input.directory, input.global.project) @@ -240,66 +369,55 @@ export async function bootstrapDirectory(input: { () => Promise.resolve(input.loadSessions(input.directory)), () => input.queryClient - .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk)) + .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol)) .then((data) => input.setStore("agent", data)), () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), - () => - retry(() => - input.sdk.session.status().then(async (x) => { - if (!input.session) { - input.setStore("session_status", x.data!) - return - } - const statuses = x.data ?? {} - input.session.set( - "session_status", - produce((draft) => { - for (const sessionID of Object.keys(draft)) { - if (statuses[sessionID]) continue - if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID] - } - }), - ) - for (const [sessionID, status] of Object.entries(statuses)) { - input.session.set("session_status", sessionID, reconcile(status)) - } - // Warm session info only after seeding statuses so a stalled session - // fetch cannot park busy indicators behind it, mirroring how live - // session.status events apply first and resolve info in the background. - await Promise.all( - Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)), - ) - }), - ), !seededProject && - (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), + (() => + retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => + input.setStore("project", project.id), + )), !seededPath && (() => - input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => { - const next = projectID(data.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - })), + input.queryClient + .ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.path)) + .then((data) => { + const next = projectID(data.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + })), () => retry(() => - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs + input.api.vcs.get({ location: { directory: input.directory } }).then((result) => { + const next = { branch: result.data.branch, default_branch: result.data.defaultBranch } input.setStore("vcs", next) if (next) input.vcsCache.setStore("value", next) }), ), - input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), - () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)), + input.mcp && + (() => + loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) => + input.setStore("command", commands), + )), + () => + input.queryClient.fetchQuery( + loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol), + ), () => retry(() => - input.sdk.permission.list().then((x) => { - const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id) + (async () => { + if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? [] + return input.api.permission.request + .list({ location: { directory: input.directory } }) + .then((result) => result.data.map(normalizePermissionRequest)) + })().then((permissions) => { + const ids = permissions.map((permission) => permission.sessionID) const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + permissions.filter((permission) => !!permission.id && !!permission.sessionID), ) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) + : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) return warm.then(() => batch(() => { const current = input.session?.data.permission ?? input.store.permission @@ -323,12 +441,19 @@ export async function bootstrapDirectory(input: { ), () => retry(() => - input.sdk.question.list().then((x) => { - const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + (async () => { + if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? [] + return input.api.question.request + .list({ location: { directory: input.directory } }) + .then((result) => result.data) + })().then((questions) => { + const ids = questions.map((question) => question.sessionID) + const grouped = groupBySession( + questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], + ) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) + : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) return warm.then(() => batch(() => { const current = input.session?.data.question ?? input.store.question @@ -351,17 +476,20 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), - input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), - input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))), + input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))), + input.mcp && + (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))), () => - input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), - }) - }), + input.queryClient + .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol)) + .catch((err) => { + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + }), ].filter(Boolean) as (() => Promise)[] await waitForPaint() diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index b36973e7f029..4eaa785789fc 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -255,6 +255,7 @@ export function createChildStoreManager(input: { vcs: vcsStore.value, limit: 5, message: {}, + session_message: {}, part: {}, part_text_accum_delta: {}, }) diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index fb58fc48322b..b53fb691b3cf 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -80,6 +80,7 @@ const baseState = (input: Partial = {}) => vcs: undefined, limit: 10, message: {}, + session_message: {}, part: {}, part_text_accum_delta: {}, ...input, @@ -261,8 +262,8 @@ describe("applyDirectoryEvent", () => { test("cleans session caches when deleted and decrements only root totals", () => { const cases = [ - { info: rootSession({ id: "ses_1" }), expectedTotal: 1 }, - { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 }, + { info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false }, + { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true }, ] for (const item of cases) { @@ -286,7 +287,10 @@ describe("applyDirectoryEvent", () => { ) applyDirectoryEvent({ - event: { type: "session.deleted", properties: { info: item.info } }, + event: { + type: "session.deleted", + properties: item.current ? { sessionID: item.info.id } : { info: item.info }, + }, store, setStore, push() {}, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index b12df5eb5591..39ba22c59d51 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -8,9 +8,9 @@ import type { QuestionRequest, Session, SessionStatus, - SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" @@ -171,8 +171,11 @@ export function applyDirectoryEvent(input: { break } case "session.deleted": { - const info = (event.properties as { info: Session }).info - const result = Binary.search(input.store.session, info.id, (s) => s.id) + const properties = event.properties as { sessionID?: string; info?: Session } + const sessionID = properties.info?.id ?? properties.sessionID + if (!sessionID) break + const result = Binary.search(input.store.session, sessionID, (s) => s.id) + const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined) if (result.found) { input.setStore( "session", @@ -181,14 +184,77 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) - if (info.parentID) break + cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo) + if (info?.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } + case "session.renamed": { + const properties = event.properties as { sessionID: string; title: string } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + input.setStore("session", result.index, (session) => ({ + ...session, + title: properties.title, + time: { ...session.time, updated: Date.now() }, + })) + break + } + case "session.usage.updated": { + const properties = event.properties as Pick & { sessionID: string } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + input.setStore("session", result.index, (session) => ({ + ...session, + cost: properties.cost, + tokens: properties.tokens, + })) + break + } + case "session.archived": { + const properties = event.properties as { sessionID: string } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + const info = input.store.session[result.index] + input.setStore( + "session", + produce((draft) => void draft.splice(result.index, 1)), + ) + cleanupSessionCaches(input.setStore, properties.sessionID) + if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + break + } + case "session.moved": { + const properties = event.properties as { + sessionID: string + location: { directory: string; workspaceID?: string } + projectID?: string + subpath?: string + } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + if (properties.location.directory === input.directory) { + input.setStore("session", result.index, (session) => ({ + ...session, + projectID: properties.projectID ?? session.projectID, + workspaceID: properties.location.workspaceID, + directory: properties.location.directory, + path: properties.subpath, + time: { ...session.time, updated: Date.now() }, + })) + break + } + const info = input.store.session[result.index] + input.setStore( + "session", + produce((draft) => void draft.splice(result.index, 1)), + ) + if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + break + } case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } - input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) + const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } + input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" })) break } case "todo.updated": { diff --git a/packages/app/src/context/global-sync/mcp.test.ts b/packages/app/src/context/global-sync/mcp.test.ts index a292d23df94b..ebfd9738ee59 100644 --- a/packages/app/src/context/global-sync/mcp.test.ts +++ b/packages/app/src/context/global-sync/mcp.test.ts @@ -31,4 +31,24 @@ describe("toggleMcp", () => { await toggleMcp(input("disabled")) expect(calls).toEqual(["connect", "refresh"]) }) + + test("does not toggle a server while its connection is pending", async () => { + const calls: string[] = [] + await toggleMcp({ + status: "pending", + connect: async () => { + calls.push("connect") + }, + disconnect: async () => { + calls.push("disconnect") + }, + authenticate: async () => { + calls.push("authenticate") + }, + refresh: async () => { + calls.push("refresh") + }, + }) + expect(calls).toEqual([]) + }) }) diff --git a/packages/app/src/context/global-sync/mcp.ts b/packages/app/src/context/global-sync/mcp.ts index 2eeb297b955a..cd91f396d0e4 100644 --- a/packages/app/src/context/global-sync/mcp.ts +++ b/packages/app/src/context/global-sync/mcp.ts @@ -1,12 +1,13 @@ -import type { McpStatus } from "@opencode-ai/sdk/v2/client" +import type { McpServer } from "@opencode-ai/client/promise" export async function toggleMcp(input: { - status: McpStatus["status"] + status: McpServer["status"]["status"] connect: () => Promise disconnect: () => Promise authenticate: () => Promise refresh: () => Promise }) { + if (input.status === "pending") return await { connected: input.disconnect, needs_auth: input.authenticate, diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 4b2be505eaa7..45fbe38abe73 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -1,13 +1,6 @@ import { describe, expect, test } from "bun:test" -import type { - Message, - Part, - PermissionRequest, - QuestionRequest, - SessionStatus, - SnapshotFileDiff, - Todo, -} from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" const msg = (id: string, sessionID: string) => @@ -33,9 +26,10 @@ describe("app session cache", () => { test("dropSessionCaches clears orphaned parts without message rows", () => { const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record + session_message: Record part: Record permission: Record question: Record @@ -45,6 +39,7 @@ describe("app session cache", () => { session_diff: { ses_1: [] }, todo: { ses_1: [] as Todo[] }, message: {}, + session_message: {}, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, question: { ses_1: [] as QuestionRequest[] }, @@ -67,9 +62,10 @@ describe("app session cache", () => { const m = msg("msg_1", "ses_1") const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record + session_message: Record part: Record permission: Record question: Record @@ -79,6 +75,7 @@ describe("app session cache", () => { session_diff: {}, todo: {}, message: { ses_1: [m] }, + session_message: {}, part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, permission: {}, question: {}, diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 05cdc8464380..7d684a5a1ab0 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -1,20 +1,15 @@ -import type { - Message, - Part, - PermissionRequest, - QuestionRequest, - SessionStatus, - SnapshotFileDiff, - Todo, -} from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" export const SESSION_CACHE_LIMIT = 40 type SessionCache = { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record + session_message: Record part: Record permission: Record question: Record @@ -37,6 +32,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable; directory: string; limit: number }) { + const result = await input.api.list({ + directory: input.directory, + parentID: null, + limit: input.limit, + order: "desc", + }) + return { + data: result.data.map(normalizeSessionInfo), + limit: input.limit, + limited: true, + } as const +} + +export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) { try { - const result = await input.list({ directory: input.directory, roots: true, limit: input.limit }) - return { - data: result.data, - limit: input.limit, - limited: true, - } as const + const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit }) + return { data: result.data, limit: input.limit, limited: true } as const } catch { - const result = await input.list({ directory: input.directory, roots: true }) - return { - data: result.data, - limit: input.limit, - limited: false, - } as const + const result = await input.client.session.list({ directory: input.directory, roots: true }) + return { data: result.data, limit: input.limit, limited: false } as const } } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 86b489cd090b..74191e10b079 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -1,10 +1,7 @@ import type { Agent, - Command, Config, LspStatus, - McpResource, - McpStatus, Message, Part, Path, @@ -13,11 +10,12 @@ import type { ReferenceInfo, Session, SessionStatus, - SnapshotFileDiff, Todo, VcsInfo, } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" @@ -35,7 +33,7 @@ export type ProjectMeta = { export type State = { status: "loading" | "partial" | "complete" agent: Agent[] - command: Command[] + command: CommandInfo[] reference: ReferenceInfo[] project: string projectMeta: ProjectMeta | undefined @@ -51,7 +49,7 @@ export type State = { } session_working(id: string): boolean session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: FileDiffInfo[] } todo: { [sessionID: string]: Todo[] @@ -64,7 +62,7 @@ export type State = { } mcp_ready: boolean mcp: { - [name: string]: McpStatus + [name: string]: McpServer["status"] } mcp_resource: { [key: string]: McpResource @@ -76,6 +74,9 @@ export type State = { message: { [sessionID: string]: Message[] } + session_message: { + [sessionID: string]: SessionMessageInfo[] + } part: { [messageID: string]: Part[] } @@ -128,18 +129,6 @@ export type DisposeCheck = { loadingSessions: boolean } -export type RootLoadArgs = { - directory: string - limit: number - list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> -} - -export type RootLoadResult = { - data?: Session[] - limit: number - limited: boolean -} - export const MAX_DIR_STORES = 30 export const DIR_IDLE_TTL_MS = 20 * 60 * 1000 export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000 diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts index 406c0f124ed8..83989244a0f1 100644 --- a/packages/app/src/context/global-sync/utils.test.ts +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -1,36 +1,112 @@ import { describe, expect, test } from "bun:test" -import type { Agent } from "@opencode-ai/sdk/v2/client" -import { directoryKey, normalizeAgentList } from "./utils" - -const agent = (name = "build") => - ({ - name, - mode: "primary", - permission: {}, - options: {}, - }) as Agent +import type { AgentListOutput, ModelDefaultOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise" +import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils" describe("normalizeAgentList", () => { - test("keeps array payloads", () => { - expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")]) - }) + test("adapts current agents to the app agent shape", () => { + const result = normalizeAgentList([ + { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + color: "primary", + model: { id: "gpt-5", providerID: "openai", variant: "high" }, + request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} }, + system: "Build software", + permissions: [{ action: "read", resource: "*", effect: "allow" }], + }, + ] as AgentListOutput["data"]) - test("wraps a single agent payload", () => { - expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")]) + expect(result).toEqual([ + { + name: "build", + description: undefined, + mode: "primary", + hidden: false, + temperature: 0.2, + topP: 0.9, + color: "primary", + permission: [{ permission: "read", pattern: "*", action: "allow" }], + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "high", + prompt: "Build software", + options: { temperature: 0.2, topP: 0.9 }, + steps: undefined, + }, + ]) }) +}) - test("extracts agents from keyed objects", () => { +describe("normalizePermissionRequest", () => { + test("adapts the current permission request to app state", () => { expect( - normalizeAgentList({ - build: agent("build"), - docs: agent("docs"), + normalizePermissionRequest({ + id: "permission-1", + sessionID: "session-1", + action: "read", + resources: ["README.md"], + save: ["*.md"], + metadata: { path: "README.md" }, + source: { type: "tool", messageID: "message-1", callID: "call-1" }, }), - ).toEqual([agent("build"), agent("docs")]) + ).toEqual({ + id: "permission-1", + sessionID: "session-1", + permission: "read", + patterns: ["README.md"], + always: ["*.md"], + metadata: { path: "README.md" }, + tool: { messageID: "message-1", callID: "call-1" }, + }) }) +}) + +describe("normalizeProviderList", () => { + test("groups current models into the app provider catalog", () => { + const result = normalizeProviderList( + [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"], + [ + { + id: "gpt-5", + modelID: "gpt-5", + providerID: "openai", + name: "GPT-5", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + variants: [{ id: "high" }], + time: { released: 1 }, + cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }], + status: "active", + enabled: true, + limit: { context: 128_000, output: 8_192 }, + }, + { + id: "gpt-old", + modelID: "gpt-old", + providerID: "openai", + name: "GPT Old", + capabilities: { tools: false, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 0 }, + cost: [], + status: "deprecated", + enabled: true, + limit: { context: 1, output: 1 }, + }, + ] as ModelListOutput["data"], + { id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"], + ) - test("drops invalid payloads", () => { - expect(normalizeAgentList({ name: "AbortError" })).toEqual([]) - expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")]) + expect(result.connected).toEqual(["openai"]) + expect(result.default).toEqual({ openai: "gpt-5" }) + expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined() + expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({ + id: "gpt-5", + providerID: "openai", + capabilities: { toolcall: true, attachment: true }, + cost: { input: 1, output: 2 }, + variants: { high: {} }, + }) }) }) diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index e54bc88d4dea..59632e53c92e 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -1,39 +1,152 @@ -import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +import type { + AgentListOutput, + ModelDefaultOutput, + ModelListOutput, + PermissionV2Request, + ProviderListOutput, +} from "@opencode-ai/client/promise" +import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +import type { Project as CurrentProject } from "@opencode-ai/client/promise" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) -function isAgent(input: unknown): input is Agent { - if (!input || typeof input !== "object") return false - const item = input as { name?: unknown; mode?: unknown } - if (typeof item.name !== "string") return false - return item.mode === "subagent" || item.mode === "primary" || item.mode === "all" +export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] { + if (input.every((agent) => !("request" in agent))) return input as Agent[] + return (input as AgentListOutput["data"]).map((agent) => ({ + name: agent.id, + description: agent.description, + mode: agent.mode, + hidden: agent.hidden, + temperature: + typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined, + topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined, + color: agent.color, + permission: agent.permissions.map((rule) => ({ + permission: rule.action, + pattern: rule.resource, + action: rule.effect, + })), + model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id }, + variant: agent.model?.variant, + prompt: agent.system, + options: agent.request.settings, + steps: agent.steps, + })) } -export function normalizeAgentList(input: unknown): Agent[] { - if (Array.isArray(input)) return input.filter(isAgent) - if (isAgent(input)) return [input] - if (!input || typeof input !== "object") return [] - return Object.values(input).filter(isAgent) +export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest { + if ("permission" in input) return input + return { + id: input.id, + sessionID: input.sessionID, + permission: input.action, + patterns: input.resources, + always: input.save ?? [], + metadata: input.metadata ?? {}, + tool: + input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined, + } } -export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse { - return { - ...input, - all: new Map( - input.all.map( - (provider) => - [ - provider.id, - { - ...provider, - models: Object.fromEntries( - Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"), - ), - }, - ] as const, +export function normalizeProviderList( + providers: ProviderListOutput["data"] | ProviderListResponse, + models?: ModelListOutput["data"], + defaultModel?: ModelDefaultOutput["data"], +): NormalizedProviderListResponse { + if (!Array.isArray(providers)) { + return { + ...providers, + all: new Map( + providers.all.map((provider) => [ + provider.id, + { + ...provider, + models: Object.fromEntries( + Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"), + ), + }, + ]), ), + } + } + const all = new Map() + + for (const provider of providers) { + all.set(provider.id, { + id: provider.id, + name: provider.name, + source: "custom", + env: [], + options: provider.settings ?? {}, + models: {}, + }) + } + + for (const model of models ?? []) { + const provider = all.get(model.providerID) + if (!provider || model.status === "deprecated") continue + const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0] + provider.models[model.id] = { + id: model.id, + providerID: model.providerID, + api: { + id: model.modelID, + url: "", + npm: model.package ?? provider.id, + }, + name: model.name, + family: model.family, + capabilities: { + temperature: false, + reasoning: false, + attachment: model.capabilities.input.some((item) => item !== "text"), + toolcall: model.capabilities.tools, + input: { + text: model.capabilities.input.includes("text"), + audio: model.capabilities.input.includes("audio"), + image: model.capabilities.input.includes("image"), + video: model.capabilities.input.includes("video"), + pdf: model.capabilities.input.includes("pdf"), + }, + output: { + text: model.capabilities.output.includes("text"), + audio: model.capabilities.output.includes("audio"), + image: model.capabilities.output.includes("image"), + video: model.capabilities.output.includes("video"), + pdf: model.capabilities.output.includes("pdf"), + }, + interleaved: false, + }, + cost: { + input: cost?.input ?? 0, + output: cost?.output ?? 0, + cache: { + read: cost?.cache.read ?? 0, + write: cost?.cache.write ?? 0, + }, + }, + limit: model.limit, + status: model.status, + options: model.settings ?? {}, + headers: model.headers ?? {}, + release_date: new Date(model.time.released).toISOString().slice(0, 10), + variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])), + } + } + + return { + all, + connected: providers.map((provider) => provider.id), + default: Object.fromEntries( + providers.flatMap((provider) => { + const model = + defaultModel?.providerID === provider.id + ? defaultModel + : models?.find((item) => item.providerID === provider.id && item.status !== "deprecated") + return model ? [[provider.id, model.id]] : [] + }), ), } } @@ -49,3 +162,10 @@ export function sanitizeProject(project: Project) { }, } } + +export function normalizeProjectInfo(project: Project | CurrentProject): Project { + return { + ...project, + vcs: project.vcs === "git" ? "git" : undefined, + } +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 7ac248dc1d1b..c039b3d48277 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk" import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { usePlatform } from "./platform" import { Project } from "@opencode-ai/sdk/v2" +import { normalizeProjectInfo } from "./global-sync/utils" import { Persist, persisted, removePersisted } from "@/utils/persist" import { pathKey } from "@/utils/path-key" import { decode64 } from "@/utils/base64" @@ -570,7 +571,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } void serverSdk() - .client.project.update({ projectID: project.id, directory: worktree, icon: { color } }) + .api.project.update({ projectID: project.id, icon: { color } }) + .then((result) => + serverSync().set("project", (items) => + items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)), + ), + ) .catch(() => { if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) }) diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 496d1ab4a282..388e4534a11a 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs" import { useSettings } from "./settings" import { requireServerKey } from "@/utils/session-route" import type { ServerScope } from "@/utils/server-scope" +import { normalizePermissionRequest } from "./global-sync/utils" import { acceptKey, directoryAcceptKey, @@ -243,9 +244,20 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } const respond: PermissionRespondFn = (request) => { if (meta.disposed) return - input.sdk.client.permission.respond(request).catch(() => { - responded.delete(request.permissionID) - }) + input.sdk.api.permission + .reply({ sessionID: request.sessionID, requestID: request.permissionID, reply: request.response }) + .catch(() => { + responded.delete(request.permissionID) + }) + } + + const list = async (directory: string) => { + if ((await input.sdk.protocol) === "v1") { + return (await input.sdk.client.permission.list({ directory })).data ?? [] + } + return input.sdk.api.permission.request + .list({ location: { directory } }) + .then((result) => result.data.map(normalizePermissionRequest)) } function respondOnce(permission: PermissionRequest, directory?: string) { @@ -343,14 +355,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } }), ) - input.sdk.client.permission - .list({ directory }) - .then((x) => { + list(directory) + .then((permissions) => { if (meta.disposed) return if (!isAutoAcceptingDirectory(directory)) return - for (const perm of x.data ?? []) { - if (!perm?.id) continue - void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory)) + for (const permission of permissions) { + void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory)) } }) .catch(() => undefined) @@ -377,16 +387,14 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } }), ) - input.sdk.client.permission - .list({ directory }) - .then((x) => { + list(directory) + .then((permissions) => { if (meta.disposed) return if (enableVersion.get(key) !== version) return if (!isAutoAccepting(sessionID, directory)) return - for (const perm of x.data ?? []) { - if (!perm?.id) continue + for (const permission of permissions) { void respondPending( - perm, + permission, directory, () => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory), ) diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 7b592178fa24..1c17a6b9de5e 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" describe("resumeStreamAfterPageShow", () => { @@ -14,6 +15,23 @@ describe("resumeStreamAfterPageShow", () => { }) }) +describe("adaptServerEvent", () => { + test("preserves V2 events while adapting permission requests for existing consumers", () => { + const current = { + id: "evt_1", + created: 1, + type: "permission.v2.asked", + data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] }, + } as OpenCodeEvent + + expect(adaptServerEvent(current)).toMatchObject({ + type: "permission.asked", + properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] }, + current, + }) + }) +}) + describe("coalesceServerEvents", () => { const delta = (value: string, field = "text", partID = "part") => ({ directory: "/repo", @@ -34,6 +52,23 @@ describe("coalesceServerEvents", () => { expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) }) + test("merges adjacent current text deltas", () => { + const current = (id: string, value: string) => adaptServerEvent({ + id, + created: 1, + type: "session.text.delta", + location: { directory: "/repo" }, + data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value }, + } as OpenCodeEvent) + const result = coalesceServerEvents([ + { directory: "/repo", payload: current("evt_1", "hello ") }, + { directory: "/repo", payload: current("evt_2", "world") }, + ]) + + expect(result).toHaveLength(1) + expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } }) + }) + test("preserves event boundaries and distinct fields", () => { const status = { directory: "/repo", diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 06597e56e7ba..0e6051eadb97 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -1,21 +1,60 @@ +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" -import { createSdkForServer } from "@/utils/server" +import { createApiForServer, createSdkForServer } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" import { ServerConnection, useServer } from "./server" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" +import { detectServerProtocol } from "@/utils/server-protocol" +import { createCompatibleApi } from "@/utils/server-compat" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true -type QueuedServerEvent = { directory: string; payload: Event } +export type ServerEvent = Event & { current?: OpenCodeEvent } +type QueuedServerEvent = { directory: string; payload: ServerEvent } +type CurrentDelta = Extract< + OpenCodeEvent, + { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" } +> + +export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { + if (event.type === "permission.v2.asked") { + return { + id: event.id, + type: "permission.asked", + properties: { + id: event.data.id, + sessionID: event.data.sessionID, + permission: event.data.action, + patterns: event.data.resources, + always: event.data.save ?? [], + metadata: event.data.metadata ?? {}, + tool: + event.data.source?.type === "tool" + ? { messageID: event.data.source.messageID, callID: event.data.source.callID } + : undefined, + }, + current: event, + } as ServerEvent + } + if (event.type === "permission.v2.replied") + return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.asked") + return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.replied") + return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.rejected") + return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent + return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent +} const coalescedKey = (event: QueuedServerEvent) => { if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` @@ -40,6 +79,34 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ export function coalesceServerEvents(events: QueuedServerEvent[]) { const output: QueuedServerEvent[] = [] events.forEach((event) => { + const current = currentDelta(event.payload.current) + if (current) { + const previous = output[output.length - 1] + const prior = currentDelta(previous?.payload.current) + if ( + previous && + prior && + previous.directory === event.directory && + currentDeltaKey(prior) === currentDeltaKey(current) + ) { + const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current) + const data = + current.type === "session.compaction.delta" + ? { ...current.data, text: fragment } + : { ...current.data, delta: fragment } + output[output.length - 1] = { + directory: event.directory, + payload: { + ...event.payload, + properties: data, + current: { ...current, data } as CurrentDelta, + } as ServerEvent, + } + return + } + output.push(event) + return + } if (event.payload.type !== "message.part.delta") { output.push(event) return @@ -71,6 +138,27 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) { return output } +function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined { + if ( + event?.type === "session.text.delta" || + event?.type === "session.reasoning.delta" || + event?.type === "session.tool.input.delta" || + event?.type === "session.compaction.delta" + ) + return event +} + +function currentDeltaKey(event: CurrentDelta) { + if (event.type === "session.tool.input.delta") + return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}` + if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}` + return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}` +} + +function currentDeltaFragment(event: CurrentDelta) { + return event.type === "session.compaction.delta" ? event.data.text : event.data.delta +} + export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { if (!event.persisted) return start() @@ -91,13 +179,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } })() + const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) const eventSdk = createSdkForServer({ signal: abort.signal, fetch: eventFetch, server: server.http, }) + const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch) const emitter = createGlobalEmitter<{ - [key: string]: Event + [key: string]: ServerEvent }>() type Queued = QueuedServerEvent @@ -142,21 +232,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS let run: Promise | undefined let started = false let generation = 0 - const HEARTBEAT_TIMEOUT_MS = 15_000 - let lastEventAt = Date.now() - let heartbeat: ReturnType | undefined - const resetHeartbeat = () => { - lastEventAt = Date.now() - if (heartbeat) clearTimeout(heartbeat) - heartbeat = setTimeout(() => { - attempt?.abort() - }, HEARTBEAT_TIMEOUT_MS) - } - const clearHeartbeat = () => { - if (!heartbeat) return - clearTimeout(heartbeat) - heartbeat = undefined - } const start = () => { if (started) return run @@ -168,35 +243,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit while (!abort.signal.aborted && started && generation === active) { attempt = new AbortController() - lastEventAt = Date.now() const onAbort = () => { attempt?.abort() } abort.signal.addEventListener("abort", onAbort) try { - const events = await eventSdk.global.event({ - signal: attempt.signal, - onSseError: (error) => { - if (isStreamClosed(error, attempt?.signal)) return - if (streamErrorLogged) return - streamErrorLogged = true - console.error("[global-sdk] event stream error", { - url: server.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) - }, - }) + const kind = await protocol + const events = + kind === "v1" + ? (await eventSdk.global.event({ signal: attempt.signal })).stream + : eventApi.event.subscribe({ signal: attempt.signal }) let yielded = Date.now() - resetHeartbeat() - for await (const event of events.stream) { - resetHeartbeat() + for await (const event of events) { streamErrorLogged = false - if (event.payload.type !== "sync") { - const directory = event.directory ?? "global" - const payload = event.payload as Event - if (enqueueServerEvent(queue, { directory, payload })) schedule() - } + const legacy = "payload" in event + if (legacy && event.payload.type === "sync") continue + const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global") + const payload = legacy ? (event.payload as Event) : adaptServerEvent(event) + if (enqueueServerEvent(queue, { directory, payload })) schedule() if (Date.now() - yielded < STREAM_YIELD_MS) continue yielded = Date.now() @@ -214,7 +278,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } finally { abort.signal.removeEventListener("abort", onAbort) attempt = undefined - clearHeartbeat() } if (abort.signal.aborted || !started || generation !== active) return @@ -233,18 +296,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS started = false generation++ attempt?.abort() - clearHeartbeat() } onMount(() => { makeEventListener(window, "pagehide", stop) makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) - makeEventListener(document, "visibilitychange", () => { - if (document.visibilityState !== "visible") return - if (!started) return - if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return - attempt?.abort() - }) }) onCleanup(() => { @@ -258,12 +314,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS fetch: platform.fetch, throwOnError: true, }) + const currentApi = createApiForServer({ server: server.http, fetch: platform.fetch }) + const legacy = (directory?: string) => + createSdkForServer({ + server: server.http, + fetch: platform.fetch, + throwOnError: true, + directory, + }) + const api = createCompatibleApi({ protocol, current: currentApi, legacy }) return { server, scope, + protocol, url: server.http.url, client: sdk, + api, + currentApi, event: { on: emitter.on.bind(emitter), listen: emitter.listen.bind(emitter), @@ -309,7 +377,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo }) type SDKEventMap = { - [key in Event["type"]]: Extract + [key in Event["type"]]: Extract } function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { @@ -329,6 +397,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { scope: serverSDK.scope, directory, client, + api: createCompatibleApi({ + protocol: serverSDK.protocol, + current: serverSDK.currentApi, + legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), + directory, + }), event: emitter, get url() { return serverSDK.url diff --git a/packages/app/src/context/server-session-v2-reducer.test.ts b/packages/app/src/context/server-session-v2-reducer.test.ts new file mode 100644 index 000000000000..578d636ef641 --- /dev/null +++ b/packages/app/src/context/server-session-v2-reducer.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "bun:test" +import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" +import { createV2SessionReducer } from "./server-session-v2-reducer" + +const event = (input: object) => input as OpenCodeEvent +const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } } + +describe("v2 session reducer", () => { + test("projects promoted input and streaming assistant content", () => { + const reducer = createV2SessionReducer() + let messages: SessionMessageInfo[] = [] + const apply = (input: object) => { + const result = reducer.reduce(messages, event(input)) + if (result) messages = result.messages + return result + } + + apply({ + ...base, + id: "evt_admitted", + type: "session.input.admitted", + data: { + sessionID: "ses_1", + inputID: "msg_user", + input: { type: "user", delivery: "steer", data: { text: "hello" } }, + }, + }) + apply({ ...base, id: "evt_promoted", type: "session.input.promoted", data: { sessionID: "ses_1", inputID: "msg_user" } }) + apply({ + ...base, + id: "evt_step", + type: "session.step.started", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + apply({ + ...base, + id: "evt_text_start", + type: "session.text.started", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 }, + }) + apply({ + ...base, + id: "evt_text_delta", + type: "session.text.delta", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" }, + }) + apply({ + ...base, + id: "evt_text_end", + type: "session.text.ended", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" }, + }) + + expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" }) + expect(messages[1]).toMatchObject({ + id: "msg_assistant", + type: "assistant", + content: [{ type: "text", text: "hello" }], + }) + }) + + test("folds tool, retry, and completion events", () => { + const reducer = createV2SessionReducer() + let messages: SessionMessageInfo[] = [] + const apply = (input: object) => { + const result = reducer.reduce(messages, event(input)) + if (result) messages = result.messages + } + + apply({ + ...base, + id: "evt_step", + type: "session.step.started", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + apply({ + ...base, + id: "evt_tool_start", + type: "session.tool.input.started", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" }, + }) + apply({ + ...base, + id: "evt_tool_delta", + type: "session.tool.input.delta", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" }, + }) + apply({ + ...base, + id: "evt_tool_called", + type: "session.tool.called", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true }, + }) + apply({ + ...base, + id: "evt_tool_success", + type: "session.tool.success", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + callID: "call_1", + structured: {}, + content: [{ type: "text", text: "done" }], + executed: true, + }, + }) + apply({ + ...base, + id: "evt_retry", + type: "session.retry.scheduled", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + attempt: 2, + at: 10, + error: { type: "ProviderError", message: "retry" }, + }, + }) + apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } }) + + expect(messages[0]).toMatchObject({ + type: "assistant", + retry: undefined, + content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }], + }) + }) + + test("requests hydration when promotion admission was missed", () => { + const result = createV2SessionReducer().reduce([], event({ + ...base, + id: "evt_promoted", + type: "session.input.promoted", + data: { sessionID: "ses_1", inputID: "msg_user" }, + })) + + expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) + }) +}) diff --git a/packages/app/src/context/server-session-v2-reducer.ts b/packages/app/src/context/server-session-v2-reducer.ts new file mode 100644 index 000000000000..10c66764198c --- /dev/null +++ b/packages/app/src/context/server-session-v2-reducer.ts @@ -0,0 +1,434 @@ +import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise" + +type Assistant = Extract +type Compaction = Extract +type Shell = Extract + +export type V2SessionReduction = { + sessionID: string + messages: SessionMessageInfo[] + touched: string[] + missing?: string +} + +export function createV2SessionReducer() { + const pending = new Map() + + const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => { + if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return + const sessionID = event.data.sessionID + const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({ + sessionID, + messages, + touched, + }) + const append = (message: SessionMessageInfo) => + result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id]) + + switch (event.type) { + case "session.input.admitted": + pending.set(key(sessionID, event.data.inputID), event.data.input) + return result([...source]) + case "session.input.promoted": { + const input = pending.get(key(sessionID, event.data.inputID)) + pending.delete(key(sessionID, event.data.inputID)) + if (!input) return { ...result([...source]), missing: event.data.inputID } + if (input.type === "user") + return append({ + id: event.data.inputID, + type: "user", + metadata: input.data.metadata, + text: input.data.text, + files: input.data.files, + agents: input.data.agents, + time: { created: event.created }, + }) + return append({ + id: event.data.inputID, + type: "synthetic", + metadata: input.data.metadata, + text: input.data.text, + description: input.data.description, + time: { created: event.created }, + }) + } + case "session.agent.selected": + return append({ + id: messageID(event.id), + type: "agent-switched", + metadata: event.metadata, + agent: event.data.agent, + time: { created: event.created }, + }) + case "session.model.selected": + return append({ + id: messageID(event.id), + type: "model-switched", + metadata: event.metadata, + model: event.data.model, + previous: source.findLast( + (item): item is Extract => + item.type === "model-switched" || item.type === "assistant", + )?.model, + time: { created: event.created }, + }) + case "session.synthetic": + return append({ + id: messageID(event.id), + type: "synthetic", + metadata: event.data.metadata, + text: event.data.text, + description: event.data.description, + time: { created: event.created }, + }) + case "session.skill.activated": + return append({ + id: messageID(event.id), + type: "skill", + metadata: event.metadata, + skill: event.data.id, + name: event.data.name, + text: event.data.text, + time: { created: event.created }, + }) + case "session.shell.started": + return append({ + id: messageID(event.id), + type: "shell", + metadata: event.metadata, + shellID: event.data.shell.id, + command: event.data.shell.command, + status: event.data.shell.status, + exit: event.data.shell.exit, + time: { created: event.created }, + }) + case "session.shell.ended": + return updateMessage(source, (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id, (item) => ({ + ...item, + status: event.data.shell.status, + exit: event.data.shell.exit, + output: event.data.output, + time: { ...item.time, completed: event.created }, + }), sessionID) + case "session.step.started": { + const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed) + const completed = current && current.id !== event.data.assistantMessageID + ? update(source, current.id, (item) => item.type === "assistant" ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } } : item) + : [...source] + const existing = completed.find((item) => item.id === event.data.assistantMessageID) + if (existing?.type === "assistant") + return result(update(completed, existing.id, (item) => item.type === "assistant" ? { + ...item, + agent: event.data.agent, + model: event.data.model, + retry: undefined, + error: undefined, + finish: undefined, + snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot, + time: { ...item.time, completed: undefined }, + } : item), current && current.id !== existing.id ? [current.id, existing.id] : [existing.id]) + return result([...completed, { + id: event.data.assistantMessageID, + type: "assistant", + metadata: event.metadata, + agent: event.data.agent, + model: event.data.model, + content: [], + snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, + time: { created: event.created }, + }], current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID]) + } + case "session.step.ended": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + finish: event.data.finish, + cost: event.data.cost, + tokens: event.data.tokens, + snapshot: event.data.snapshot || event.data.files + ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } + : item.snapshot, + time: { ...item.time, completed: event.created }, + })) + case "session.step.failed": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + finish: "error", + error: event.data.error, + retry: undefined, + cost: event.data.cost ?? item.cost, + tokens: event.data.tokens ?? item.tokens, + snapshot: event.data.snapshot || event.data.files + ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } + : item.snapshot, + time: { ...item.time, completed: event.created }, + })) + case "session.text.started": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + content: insertOrdinal(item.content, "text", event.data.ordinal, { type: "text", text: "" }), + })) + case "session.text.delta": + return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({ + ...item, + text: item.text + event.data.delta, + })) + case "session.text.ended": + return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({ + ...item, + text: event.data.text, + })) + case "session.reasoning.started": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + content: insertOrdinal(item.content, "reasoning", event.data.ordinal, { + type: "reasoning", + text: "", + state: event.data.state, + time: { created: event.created }, + }), + })) + case "session.reasoning.delta": + return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({ + ...item, + text: item.text + event.data.delta, + })) + case "session.reasoning.ended": + return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({ + ...item, + text: event.data.text, + state: event.data.state ?? item.state, + time: { created: item.time?.created ?? event.created, completed: event.created }, + })) + case "session.tool.input.started": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID) + ? item.content + : [...item.content, { + type: "tool", + id: event.data.callID, + name: event.data.name, + state: { status: "streaming", input: "" }, + time: { created: event.created }, + }], + })) + case "session.tool.input.delta": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => + tool.state.status === "streaming" + ? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } } + : tool, + ) + case "session.tool.input.ended": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => + tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool, + ) + case "session.tool.called": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({ + ...tool, + executed: event.data.executed, + providerState: event.data.state, + state: { status: "running", input: event.data.input, structured: {}, content: [] }, + time: { ...tool.time, ran: event.created }, + })) + case "session.tool.progress": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => + tool.state.status === "running" + ? { ...tool, state: { ...tool.state, structured: event.data.structured, content: event.data.content } } + : tool, + ) + case "session.tool.success": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => { + if (tool.state.status !== "running") return tool + return { + ...tool, + executed: event.data.executed || tool.executed === true, + providerResultState: event.data.resultState, + state: { + status: "completed", + input: tool.state.input, + structured: event.data.structured, + content: event.data.content, + result: event.data.result, + }, + time: { ...tool.time, completed: event.created }, + } + }) + case "session.tool.failed": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => { + if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool + return { + ...tool, + executed: event.data.executed || tool.executed === true, + providerResultState: event.data.resultState, + state: { + status: "error", + input: typeof tool.state.input === "string" ? {} : tool.state.input, + structured: tool.state.status === "running" ? tool.state.structured : {}, + content: tool.state.status === "running" ? tool.state.content : [], + error: event.data.error, + result: event.data.result, + }, + time: { ...tool.time, completed: event.created }, + } + }) + case "session.retry.scheduled": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error }, + })) + case "session.execution.succeeded": + case "session.execution.failed": + case "session.execution.interrupted": { + const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed) + if (!current?.retry) return result([...source]) + return updateAssistant(source, current.id, sessionID, (item) => ({ ...item, retry: undefined })) + } + case "session.compaction.started": + return append({ + id: event.data.inputID ?? messageID(event.id), + type: "compaction", + status: "running", + metadata: event.metadata, + reason: event.data.reason, + summary: "", + recent: event.data.recent, + time: { created: event.created }, + }) + case "session.compaction.delta": + return updateMessage>(source, (item): item is Extract => item.type === "compaction" && item.status === "running", (item) => ({ + ...item, + summary: item.summary + event.data.text, + }), sessionID) + case "session.compaction.ended": { + const current = source.findLast((item): item is Extract => item.type === "compaction" && item.status === "running") + if (!current) + return append({ + id: messageID(event.id), + type: "compaction", + status: "completed", + metadata: event.metadata, + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + time: { created: event.created }, + }) + return result(update(source, current.id, () => ({ + ...current, + status: "completed", + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + })), [current.id]) + } + case "session.compaction.failed": { + const current = source.findLast((item): item is Extract => item.type === "compaction" && item.status === "running") + const failed: Extract = { + id: current?.id ?? event.data.inputID ?? messageID(event.id), + type: "compaction", + status: "failed", + metadata: current?.metadata ?? event.metadata, + reason: event.data.reason, + error: event.data.error, + time: current?.time ?? { created: event.created }, + } + if (!current) return append(failed) + return result(update(source, current.id, () => failed), [failed.id]) + } + default: + return + } + } + + return { + reduce, + clear(sessionID: string) { + for (const id of pending.keys()) { + if (id.startsWith(`${sessionID}:`)) pending.delete(id) + } + }, + } +} + +function key(sessionID: string, inputID: string) { + return `${sessionID}:${inputID}` +} + +function messageID(eventID: string) { + return eventID.replace(/^evt_/, "msg_") +} + +function update( + source: readonly SessionMessageInfo[], + id: string, + apply: (item: SessionMessageInfo) => SessionMessageInfo, +) { + return source.map((item) => item.id === id ? apply(item) : item) +} + +function updateMessage( + source: readonly SessionMessageInfo[], + matches: (item: SessionMessageInfo) => item is T, + apply: (item: T) => T, + sessionID: string, +): V2SessionReduction { + const current = source.findLast(matches) + if (!current) return { sessionID, messages: [...source], touched: [] } + return { sessionID, messages: update(source, current.id, (item) => matches(item) ? apply(item) : item), touched: [current.id] } +} + +function updateAssistant( + source: readonly SessionMessageInfo[], + id: string, + sessionID: string, + apply: (item: Assistant) => Assistant, +): V2SessionReduction { + return { + sessionID, + messages: update(source, id, (item) => item.type === "assistant" ? apply(item) : item), + touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [], + } +} + +function updateContent( + source: readonly SessionMessageInfo[], + messageID: string, + sessionID: string, + type: T, + ordinal: number, + apply: (item: Extract) => Extract, +) { + return updateAssistant(source, messageID, sessionID, (assistant) => { + let index = -1 + return { + ...assistant, + content: assistant.content.map((item) => { + if (item.type !== type || ++index !== ordinal) return item + return apply(item as Extract) + }), + } + }) +} + +function updateTool( + source: readonly SessionMessageInfo[], + messageID: string, + callID: string, + sessionID: string, + apply: (item: Extract) => Extract, +) { + return updateAssistant(source, messageID, sessionID, (assistant) => ({ + ...assistant, + content: assistant.content.map((item) => item.type === "tool" && item.id === callID ? apply(item) : item), + })) +} + +function insertOrdinal( + source: Assistant["content"], + type: T, + ordinal: number, + item: Extract, +) { + const matches = source.filter((content) => content.type === type) + if (matches[ordinal]) return source + return [...source, item] +} diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 555492807597..30723ecfbf28 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { retry } from "@opencode-ai/core/util/retry" +import type { MessageApi, OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise" import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" import { createServerSession } from "./server-session" @@ -158,6 +159,57 @@ function setup(sessions: Record) { } describe("server session", () => { + test("projects V2 session events into current and legacy message state", () => { + const ctx = setup({ child: session("child") }) + ctx.store.remember(session("child")) + ctx.store.set("session_message", "child", [ + { + id: "msg_1_user", + type: "user", + text: "hello", + time: { created: 1 }, + }, + ]) + const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent) + + apply({ + id: "evt_step", + created: 2, + type: "session.step.started", + durable: { aggregateID: "child", seq: 1, version: 1 }, + location: { directory: "/repo" }, + data: { + sessionID: "child", + assistantMessageID: "msg_2_assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + apply({ + id: "evt_text_start", + created: 3, + type: "session.text.started", + durable: { aggregateID: "child", seq: 2, version: 1 }, + location: { directory: "/repo" }, + data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 }, + }) + apply({ + id: "evt_text_delta", + created: 4, + type: "session.text.delta", + location: { directory: "/repo" }, + data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" }, + }) + + expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({ + id: "msg_2_assistant", + type: "assistant", + content: [{ type: "text", text: "world" }], + }) + expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"]) + expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }]) + }) + test("resolves lineage by session ID without directory", async () => { const ctx = setup({ child: session("child", "root"), root: session("root") }) @@ -178,6 +230,111 @@ describe("server session", () => { expect(ctx.store.data.message.root).toEqual([]) }) + test("loads current session content through the current message API", async () => { + const requests: unknown[] = [] + const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } } + const assistant = { + id: "msg_a_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "hi" }], + time: { created: 2, completed: 3 }, + } + const client = { + session: { + messages: () => { + throw new Error("legacy message endpoint called") + }, + }, + } as unknown as OpencodeClient + const messageApi = { + list: async (input: unknown) => { + requests.push(input) + return { data: [assistant, user], cursor: { previous: null, next: null } } + }, + } as unknown as MessageApi + const store = createServerSession(client, {} as SessionApi, messageApi) + store.remember(session("root")) + + await store.sync("root") + + expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) + expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + }) + + test("reprojects current assistants when an older page supplies their user", async () => { + const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const + const assistant = (id: string, created: number) => ({ + id, + type: "assistant" as const, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text" as const, text: id }], + time: { created, completed: created }, + }) + const assistants = [ + assistant("msg_2_assistant", 2), + assistant("msg_3_assistant", 3), + assistant("msg_4_assistant", 4), + ] + const pages = [ + { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } }, + { data: [assistants[0], user], cursor: { previous: null, next: null } }, + ] + const messageApi = { + list: async () => pages.shift()!, + } as unknown as MessageApi + const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi) + store.remember(session("root")) + + await store.sync("root") + expect(store.data.message.root).toEqual([]) + + await store.history.loadMore("root") + + expect(store.data.message.root.map((message) => message.id)).toEqual([ + user.id, + ...assistants.map((item) => item.id), + ]) + expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"]) + }) + + test("indexes V1 messages for the current timeline projection", async () => { + const user = userMessage("message-1", { sessionID: "root" }) + const assistant = assistantMessage("message-2", user.id, { sessionID: "root" }) + const client = messageClient( + response([ + { info: user, parts: [textPart(user.id, { sessionID: "root" })] }, + { info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] }, + ]), + ) + const messageApi = { + list: () => { + throw new Error("current message endpoint called") + }, + } as unknown as MessageApi + const store = createServerSession(client, {} as SessionApi, messageApi, { + protocol: Promise.resolve("v1"), + }) + store.remember(session("root")) + + await store.sync("root") + + expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + expect(store.data.session_message.root).toMatchObject([ + { id: user.id, type: "user", text: "text" }, + { id: assistant.id, type: "assistant" }, + ]) + + const next = userMessage("message-3", { sessionID: "root" }) + store.apply({ type: "message.updated", properties: { info: next } }) + expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id]) + + store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } }) + expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + }) + test("backfills an assistant-only initial page through its user root", async () => { const user = userMessage("message-1") const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 5a892f79158f..6bf0f47f5cc2 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -1,5 +1,6 @@ import { Binary } from "@opencode-ai/core/util/binary" import { retry } from "@opencode-ai/core/util/retry" +import type { MessageApi, OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Message, OpencodeClient, @@ -8,15 +9,18 @@ import type { QuestionRequest, Session, SessionStatus, - SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" -import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs" +import { message as cleanMessage } from "@/utils/diffs" import { sessionNotFoundError } from "@/utils/server-errors" import { rootSession } from "@/utils/session-route" +import { normalizeSessionInfo } from "@/utils/session" +import { normalizeSessionMessages } from "@/utils/session-message" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" +import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) @@ -36,10 +40,37 @@ type OptimisticItem = { type MessagePage = { session: Message[] part: { id: string; part: Part[] }[] + source?: SessionMessageInfo[] + sourceMode?: "latest" | "older" + projectSource?: boolean cursor?: string complete: boolean } +function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] { + return items + .slice() + .sort((a, b) => cmp(a.info.id, b.info.id)) + .map((item) => { + if (item.info.role === "user") { + return { + id: item.info.id, + type: "user" as const, + text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), + time: item.info.time, + } + } + return { + id: item.info.id, + type: "assistant" as const, + agent: item.info.agent ?? item.info.mode, + model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant }, + content: [], + time: item.info.time, + } + }) +} + // Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. type MessageLoadState = { touchedMessages: Set @@ -52,6 +83,7 @@ type MessageLoadState = { optimisticParts: Map> orphanParents: Set clearedMessageParts: Set + touchedSource: Set } type MessageLoadBaseline = Pick< @@ -137,15 +169,25 @@ function reconcileFetched( return [...result.values()].sort((a, b) => cmp(a.id, b.id)) } -export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) { +type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> } + +export function createServerSession( + client: OpencodeClient, + sessionApiOrOptions?: SessionApi | ServerSessionOptions, + messageApi?: MessageApi, + currentOptions?: ServerSessionOptions, +) { + const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined + const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined) const [data, setData] = createStore({ info: {} as Record, session_status: {} as Record, - session_diff: {} as Record, + session_diff: {} as Record, todo: {} as Record, permission: {} as Record, question: {} as Record, message: {} as Record, + session_message: {} as Record, part: {} as Record, part_text_accum_delta: {} as Record, session_working(id: string) { @@ -154,9 +196,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: }) const requests = new Map>() const inflight = new Map>() - const inflightDiff = new Map>() const inflightTodo = new Map>() const optimistic = new Map>() + const v2 = createV2SessionReducer() const messageLoads = new Map() const pendingParts = new Map>>() const orphanParts = new Map>() @@ -191,6 +233,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: at: {} as Record, }) + const indexLegacyMessage = (message: Message) => { + const current = data.session_message[message.sessionID] ?? [] + if (current.some((item) => item.id === message.id)) return + setData( + "session_message", + message.sessionID, + reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]), + ) + } + const remember = (session: Session) => { setData("info", session.id, reconcile(session)) infoSeen.delete(session.id) @@ -200,7 +252,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ...pinned.keys(), ...requests.keys(), ...inflight.keys(), - ...inflightDiff.keys(), ...inflightTodo.keys(), ...messageLoads.keys(), ...optimistic.keys(), @@ -242,27 +293,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: const pending = requests.get(sessionID) if (pending) return pending const active = generation(sessionID) - const request = client.session.get({ sessionID }).then((result) => { - if (!result.data) throw sessionNotFoundError(sessionID) - if (generations.get(sessionID) !== active) return result.data - return remember(result.data) + const request = sessionApi + ? sessionApi.get({ sessionID }).then(normalizeSessionInfo) + : client.session.get({ sessionID }).then((result) => { + if (!result.data) throw sessionNotFoundError(sessionID) + return result.data + }) + const resolved = request.then((result) => { + if (generations.get(sessionID) !== active) return result + return remember(result) }) - requests.set(sessionID, request) + requests.set(sessionID, resolved) const cleanup = () => { - if (requests.get(sessionID) === request) requests.delete(sessionID) + if (requests.get(sessionID) === resolved) requests.delete(sessionID) if ( generations.get(sessionID) === active && !data.info[sessionID] && !requests.has(sessionID) && !messageLoads.has(sessionID) && !inflight.has(sessionID) && - !inflightDiff.has(sessionID) && !inflightTodo.has(sessionID) ) generations.delete(sessionID) } - void request.then(cleanup, cleanup) - return request + void resolved.then(cleanup, cleanup) + return resolved } const peekLineage = (sessionID: string) => { @@ -419,9 +474,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: clearOptimistic(sessionID) requests.delete(sessionID) inflight.delete(sessionID) - inflightDiff.delete(sessionID) inflightTodo.delete(sessionID) messageLoads.delete(sessionID) + v2.clear(sessionID) pendingParts.delete(sessionID) orphanParts.delete(sessionID) removedMessages.delete(sessionID) @@ -449,7 +504,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ...pinned.keys(), ...requests.keys(), ...inflight.keys(), - ...inflightDiff.keys(), ...inflightTodo.keys(), ...messageLoads.keys(), ...optimistic.keys(), @@ -470,6 +524,25 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ) const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { + if (messageApi && (await options?.protocol) !== "v1") { + const response = await (options?.retry ?? retry)(() => { + onAttempt?.() + return messageApi.list(before ? { sessionID, limit, cursor: before } : { sessionID, limit, order: "desc" }) + }) + const source = [...response.data].reverse() + const normalized = normalizeSessionMessages(sessionID, source) + return { + session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), + part: [...normalized.parts.entries()] + .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) + .sort((a, b) => cmp(a.id, b.id)), + source, + sourceMode: before ? ("older" as const) : ("latest" as const), + projectSource: true, + cursor: response.cursor.next ?? undefined, + complete: response.data.length === 0, + } + } const response = await (options?.retry ?? retry)(() => { onAttempt?.() return client.session.messages({ sessionID, limit, before }) @@ -481,12 +554,24 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: id: item.info.id, part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), })), + source: legacyMessageSource(items), + sourceMode: before ? ("older" as const) : ("latest" as const), cursor: response.response.headers.get("x-next-cursor") ?? undefined, complete: !response.response.headers.get("x-next-cursor"), } } const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => { + if (sessionApi && (await options?.protocol) !== "v1") { + const response = await (options?.retry ?? retry)(() => { + onAttempt?.() + return sessionApi.message({ sessionID, messageID }) + }) + const normalized = normalizeSessionMessages(sessionID, [response]) + const message = normalized.messages[0] + if (!message) throw new Error(`Message not found: ${messageID}`) + return { message, parts: normalized.parts.get(messageID) ?? [] } + } const response = await (options?.retry ?? retry)(() => { onAttempt?.() return client.session.message({ sessionID, messageID }) @@ -571,7 +656,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: preserveUnfetched: boolean | ((message: Message) => boolean), cleanupOrphans: boolean, ) => { - const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])]) + const source = page.source + ? (() => { + const incoming = new Map(page.source.map((message) => [message.id, message])) + const existing = data.session_message[sessionID] ?? [] + const current = existing.filter((message) => !incoming.has(message.id)) + const live = new Map(existing.map((message) => [message.id, message])) + return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map( + (message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message), + ) + })() + : undefined + const projected = + page.projectSource && source + ? (() => { + const normalized = normalizeSessionMessages(sessionID, source) + return { + ...page, + session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), + part: [...normalized.parts.entries()] + .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) + .sort((a, b) => cmp(a.id, b.id)), + } + })() + : page + const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])]) merged.observed.forEach((item) => { if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts) }) @@ -583,6 +692,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: preserveUnfetched, }) batch(() => { + if (source) setData("session_message", sessionID, reconcile(source)) const messageIDs = replaceMessages(sessionID, messages) replaceParts(sessionID, merged.part, messageIDs, load) const orphans = orphanParts.get(sessionID) @@ -613,6 +723,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: optimisticParts: new Map(), orphanParents: new Set(), clearedMessageParts: new Set(), + touchedSource: new Set(), } messageLoads.set(sessionID, load) setMeta("loading", sessionID, true) @@ -747,6 +858,109 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return properties.part.sessionID } + const projectV2 = (reduction: V2SessionReduction) => { + reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID)) + setData("session_message", reduction.sessionID, reconcile(reduction.messages)) + if (reduction.touched.length === 0) return + + const touched = new Set(reduction.touched) + let parentID: string | undefined + for (const message of reduction.messages) { + if (message.type === "user" || (message.type === "synthetic" && message.description?.trim())) + parentID = message.id + if (message.type === "shell") { + if (touched.has(message.id)) touched.add(`${message.id}:assistant`) + parentID = undefined + } + if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID) + if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID) + } + + const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages) + batch(() => { + for (const message of normalized.messages) { + if (!touched.has(message.id)) continue + apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } }) + } + for (const messageID of touched) { + const next = normalized.parts.get(messageID) ?? [] + const nextIDs = new Set(next.map((part) => part.id)) + for (const part of next) { + apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } }) + } + for (const part of data.part[messageID] ?? []) { + if (nextIDs.has(part.id)) continue + apply({ + type: "message.part.removed", + properties: { sessionID: reduction.sessionID, messageID, partID: part.id }, + }) + } + } + }) + } + + const hydrateV2Message = (sessionID: string, messageID: string) => { + if (!sessionApi) return + void sessionApi + .message({ sessionID, messageID }) + .then((message) => { + const current = data.session_message[sessionID] ?? [] + const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id)) + projectV2({ sessionID, messages, touched: [message.id] }) + }) + .catch(() => {}) + } + + const applyV2 = (event: OpenCodeEvent) => { + if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return + const sessionID = event.data.sessionID + const reduction = v2.reduce(data.session_message[sessionID] ?? [], event) + if (reduction) { + projectV2(reduction) + if (reduction.missing) hydrateV2Message(sessionID, reduction.missing) + } + + const info = data.info[sessionID] + if (event.type === "session.renamed" && info) + remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } }) + if (event.type === "session.moved" && info) + remember({ + ...info, + projectID: event.data.projectID ?? info.projectID, + workspaceID: event.data.location.workspaceID, + directory: event.data.location.directory, + path: event.data.subpath, + time: { ...info.time, updated: event.created }, + }) + if (event.type === "session.usage.updated" && info) + remember({ ...info, cost: event.data.cost, tokens: event.data.tokens }) + if (event.type === "session.archived") { + if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } }) + evict([sessionID]) + } + if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" }) + if ( + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ) + setData("session_status", sessionID, { type: "idle" }) + if (event.type === "session.retry.scheduled") + setData("session_status", sessionID, { + type: "retry", + attempt: event.data.attempt, + message: event.data.error.message, + next: event.data.at, + }) + if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {}) + if ( + event.type === "session.revert.staged" || + event.type === "session.revert.cleared" || + event.type === "session.revert.committed" + ) + void resolve(sessionID, { force: true }).catch(() => {}) + } + const apply = (event: { type: string; properties?: unknown }) => { const eventID = eventSessionID(event) if (eventID) { @@ -770,7 +984,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return } case "session.deleted": { - const sessionID = (event.properties as { info: Session }).info.id + const properties = event.properties as { sessionID?: string; info?: Session } + const sessionID = properties.info?.id ?? properties.sessionID + if (!sessionID) return infoSeen.delete(sessionID) setData( "info", @@ -779,11 +995,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: evict([sessionID]) return } - case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } - setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" })) - return - } case "todo.updated": { const props = event.properties as { sessionID: string; todos: Todo[] } setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) @@ -796,6 +1007,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: } case "message.updated": { const info = cleanMessage((event.properties as { info: Message }).info) + indexLegacyMessage(info) const load = messageLoads.get(info.sessionID) load?.touchedMessages.add(info.id) load?.removedMessages.delete(info.id) @@ -828,6 +1040,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: } case "message.removed": { const props = event.properties as { sessionID: string; messageID: string } + setData("session_message", props.sessionID, (messages) => + messages?.filter((message) => message.id !== props.messageID), + ) const load = messageLoads.get(props.sessionID) load?.touchedMessages.add(props.messageID) load?.removedMessages.add(props.messageID) @@ -1140,23 +1355,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: setData(produce((draft) => deleteMessageParts(draft, input.messageID))) }, }, - diff(sessionID: string, options?: { force?: boolean }) { + async todo(sessionID: string, request?: { force?: boolean }) { touch(sessionID) - if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve() - return runInflight(inflightDiff, sessionID, () => { - const active = generation(sessionID) - return retry(() => client.session.diff({ sessionID })).then((result) => { - if (generations.get(sessionID) !== active) return - setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" })) - }) - }) - }, - todo(sessionID: string, options?: { force?: boolean }) { - touch(sessionID) - if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve() + if (data.todo[sessionID] !== undefined && !request?.force) return + if ((await options?.protocol) === "v2") { + setData("todo", sessionID, []) + return + } return runInflight(inflightTodo, sessionID, () => { const active = generation(sessionID) - return retry(() => client.session.todo({ sessionID })).then((result) => { + return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => { if (generations.get(sessionID) !== active) return setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" })) }) @@ -1190,6 +1398,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: if (count && count > 1) pinned.set(sessionID, count - 1) }, apply, + applyV2, } } diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index 93e9c4175557..3614d57f666f 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -1,6 +1,108 @@ import { describe, expect, test } from "bun:test" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { + McpApi, + McpListInput, + McpResourceCatalogInput, + SessionApi, + SessionInfo, + SessionListInput, +} from "@opencode-ai/client/promise" +import { QueryClient } from "@tanstack/solid-query" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" -import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" +import { + loadActiveSessionsQuery, + loadMcpQuery, + loadMcpResourcesQuery, + seedActiveSessionStatuses, +} from "./server-sync" +import { ServerScope } from "@/utils/server-scope" +import { createServerSession } from "./server-session" + +describe("MCP queries", () => { + test("loads current servers for the requested location", async () => { + const calls: unknown[] = [] + const queryClient = new QueryClient() + const result = await queryClient.fetchQuery( + loadMcpQuery(ServerScope.local, "/project", { + list: async (input: McpListInput = {}) => { + calls.push(input) + return { + location: { directory: "/project", project: { id: "project", directory: "/project" } }, + data: [ + { name: "docs", status: { status: "connected" } }, + { name: "search", status: { status: "pending" } }, + ], + } + }, + } as unknown as McpApi), + ) + + expect(calls).toEqual([{ location: { directory: "/project" } }]) + expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } }) + }) + + test("loads and keys the current resource catalog", async () => { + const calls: unknown[] = [] + const queryClient = new QueryClient() + const result = await queryClient.fetchQuery( + loadMcpResourcesQuery(ServerScope.local, "/project", { + resource: { + catalog: async (input: McpResourceCatalogInput = {}) => { + calls.push(input) + return { + location: { directory: "/project", project: { id: "project", directory: "/project" } }, + data: { + resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }], + templates: [], + }, + } + }, + }, + } as unknown as McpApi), + ) + + expect(calls).toEqual([{ location: { directory: "/project" } }]) + expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } }) + }) +}) + +describe("active session query", () => { + test("loads active sessions once per server cache", async () => { + let calls = 0 + const queryClient = new QueryClient() + const options = loadActiveSessionsQuery(ServerScope.local, { + active: async () => { + calls++ + return { ses_running: { type: "running" } } + }, + }) + + expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } }) + expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } }) + expect(calls).toBe(1) + expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"]) + }) + + test("does not overwrite statuses already written by events", () => { + const session = createServerSession({} as OpencodeClient) + session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 }) + + seedActiveSessionStatuses(session, { + ses_running: { type: "running" }, + ses_retry: { type: "running" }, + }) + + expect(session.data.session_status.ses_running).toEqual({ type: "busy" }) + expect(session.data.session_status.ses_retry).toEqual({ + type: "retry", + attempt: 2, + message: "retrying", + next: 10, + }) + }) +}) describe("pickDirectoriesToEvict", () => { test("keeps pinned stores and evicts idle stores", () => { @@ -23,46 +125,57 @@ describe("pickDirectoriesToEvict", () => { }) }) -describe("loadRootSessionsWithFallback", () => { - test("uses limited roots query when supported", async () => { - const calls: Array<{ directory: string; roots: true; limit?: number }> = [] +describe("loadRootSessions", () => { + test("loads and normalizes a limited page of root sessions", async () => { + const calls: SessionListInput[] = [] - const result = await loadRootSessionsWithFallback({ + const result = await loadRootSessions({ + api: { + list: async (query = {}) => { + calls.push(query) + return { data: [sessionInfo("session-1")], cursor: {} } + }, + } satisfies Pick, directory: "dir", limit: 10, - list: async (query) => { - calls.push(query) - return { data: [] } - }, }) - expect(result.data).toEqual([]) + expect(result.data).toEqual([ + expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }), + ]) expect(result.limited).toBe(true) - expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }]) + expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }]) }) - test("falls back to full roots query on limited-query failure", async () => { - const calls: Array<{ directory: string; roots: true; limit?: number }> = [] - - const result = await loadRootSessionsWithFallback({ - directory: "dir", - limit: 25, - list: async (query) => { - calls.push(query) - if (query.limit) throw new Error("unsupported") - return { data: [] } - }, - }) - - expect(result.data).toEqual([]) - expect(result.limited).toBe(false) - expect(calls).toEqual([ - { directory: "dir", roots: true, limit: 25 }, - { directory: "dir", roots: true }, - ]) + test("propagates list failures", () => { + expect( + loadRootSessions({ + api: { + list: async () => { + throw new Error("failed") + }, + } satisfies Pick, + directory: "dir", + limit: 25, + }), + ).rejects.toThrow("failed") }) }) +function sessionInfo(id: string) { + return { + id, + projectID: "project-1", + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: id, + location: { directory: "dir" }, + } as SessionInfo +} + describe("estimateRootSessionTotal", () => { test("keeps exact total for full fetches", () => { expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 05806fba5477..5ce530bbb0eb 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,10 +1,10 @@ import type { Config, - McpResource, OpencodeClient, Path, Project, ProviderAuthResponse, + SessionStatus, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" @@ -18,6 +18,7 @@ import { bootstrapGlobal, clearProviderRev, loadAgentsQuery, + loadCommands, loadGlobalConfigQuery, loadPathQuery, loadProjectsQuery, @@ -26,12 +27,13 @@ import { } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" -import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { formatServerError } from "@/utils/server-errors" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" +import type { SolidQueryOptions } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" import { PathKey } from "@/utils/path-key" @@ -45,8 +47,18 @@ import { retry } from "@opencode-ai/core/util/retry" import type { ServerScope } from "@/utils/server-scope" import { createHomeSessionIndexCache } from "./global-sync/home-session-index" import { persisted } from "@/utils/persist" +import type { ServerApi } from "@/utils/server" +import type { + McpListInput, + McpListOutput, + McpResource, + McpResourceCatalogInput, + McpResourceCatalogOutput, + McpServer, + SessionActiveOutput, +} from "@opencode-ai/client/promise" import { toggleMcp } from "./global-sync/mcp" -import { createServerSession } from "./server-session" +import { createServerSession, type ServerSession } from "./server-session" type GlobalStore = { ready: boolean @@ -59,16 +71,76 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } -export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => - queryOptions({ +type McpListApi = { + readonly list: (input?: McpListInput) => Promise +} + +type McpResourceApi = { + readonly resource: { + readonly catalog: (input?: McpResourceCatalogInput) => Promise + } +} + +type ApiQueryOptions = SolidQueryOptions & { + initialData?: undefined + queryKey: K +} + +type SessionActiveApi = { + readonly active: () => Promise +} + +export const loadMcpQuery = ( + scope: ServerScope, + directory: string, + api: McpListApi, + legacy?: OpencodeClient, + protocol?: Promise<"v1" | "v2">, +): ApiQueryOptions, readonly [ServerScope, string, "mcp"]> => + queryOptions< + Record, + Error, + Record, + readonly [ServerScope, string, "mcp"] + >({ queryKey: [scope, directory, "mcp"] as const, - queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), + queryFn: async () => { + if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {} + return api + .list({ location: { directory } }) + .then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status]))) + }, }) -export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => - queryOptions>({ +export const loadMcpResourcesQuery = ( + scope: ServerScope, + directory: string, + api: McpResourceApi, + legacy?: OpencodeClient, + protocol?: Promise<"v1" | "v2">, +): ApiQueryOptions, readonly [ServerScope, string, "mcpResources"]> => + queryOptions< + Record, + Error, + Record, + readonly [ServerScope, string, "mcpResources"] + >({ queryKey: [scope, directory, "mcpResources"] as const, - queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}), + queryFn: async () => { + if ((await protocol) === "v1" && legacy) { + return Object.fromEntries( + Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [ + key, + { ...resource, server: resource.client }, + ]), + ) + } + return api.resource + .catalog({ location: { directory } }) + .then((result) => + Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])), + ) + }, placeholderData: {}, }) @@ -78,22 +150,51 @@ export const loadLspQuery = (scope: ServerScope, directory: string, sdk: Opencod queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), }) +export const loadActiveSessionsQuery = ( + scope: ServerScope, + api: SessionActiveApi, +): ApiQueryOptions => + queryOptions({ + queryKey: [scope, "activeSessions"] as const, + queryFn: () => api.active(), + enabled: false, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }) + +export function seedActiveSessionStatuses( + session: Pick, + active: SessionActiveOutput | Record, +) { + for (const sessionID of Object.keys(active)) { + if (session.data.session_status[sessionID] !== undefined) continue + const status = active[sessionID] + session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status) + } +} + function makeQueryOptionsApi( scope: ServerScope, serverSDK: () => OpencodeClient, + serverAPI: ServerApi, sdkFor: (dir: PathKey) => OpencodeClient, + protocol: Promise<"v1" | "v2">, ) { return { globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()), - projects: () => loadProjectsQuery(scope, serverSDK()), + projects: () => loadProjectsQuery(scope, serverAPI.project), providers: (directory: PathKey | null) => - loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), - path: (directory: PathKey | null) => - loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), - agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), - references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)), - mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), - mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)), + loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol), + path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.path), + agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol), + references: (directory: PathKey) => + loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol), + mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), + mcpResources: (directory: PathKey) => + loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), } @@ -122,11 +223,44 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return sdk } - const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor) + const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, { + protocol: serverSDK.protocol, + }) + const queryOptionsApi = makeQueryOptionsApi( + serverSDK.scope, + () => serverSDK.client, + serverSDK.api, + sdkFor, + serverSDK.protocol, + ) const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], })) + const activeSessionsQuery = useQuery(() => + loadActiveSessionsQuery(serverSDK.scope, { + active: async () => { + if ((await serverSDK.protocol) === "v1") { + const statuses = (await serverSDK.client.session.status()).data ?? {} + for (const [sessionID, status] of Object.entries(statuses)) { + session.set("session_status", sessionID, reconcile(status)) + void session.resolve(sessionID).catch(() => undefined) + } + return Object.fromEntries( + Object.entries(statuses).flatMap(([sessionID, status]) => + status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], + ), + ) + } + const active = await serverSDK.api.session.active() + seedActiveSessionStatuses(session, active) + for (const sessionID of Object.keys(active)) { + void session.resolve(sessionID).catch(() => undefined) + } + return active + }, + }), + ) const [globalStore, setGlobalStore] = createStore({ get ready() { @@ -183,6 +317,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { queryFn: async () => { await bootstrapGlobal({ serverSDK: serverSDK.client, + serverAPI: serverSDK.api, + protocol: serverSDK.protocol, scope: serverSDK.scope, requestFailedTitle: language.t("common.requestFailed"), translate: language.t, @@ -212,8 +348,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { bootstrapInstance, }) - const session = createServerSession(serverSDK.client) - const children = createChildStoreManager({ owner, scope: serverSDK.scope, @@ -224,17 +358,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { void bootstrapInstance(directory) }, onMcp: (directory, setStore) => { - void retry(() => - sdkFor(directory) - .command.list() - .then((x) => setStore("command", x.data ?? [])), - ).catch((err) => { - showToast({ - variant: "error", - title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), - description: formatServerError(err, language.t), + void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol) + .then((commands) => setStore("command", commands)) + .catch((err) => { + showToast({ + variant: "error", + title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), + description: formatServerError(err, language.t), + }) }) - }) }, onDispose: (directory) => { const key = directoryKey(directory) @@ -279,11 +411,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { .fetchQuery({ ...queryOptionsApi.sessions(key), queryFn: () => - loadRootSessionsWithFallback({ - directory, - limit, - list: (query) => serverSDK.client.session.list(query), - }) + serverSDK.protocol + .then((protocol) => + protocol === "v1" + ? loadRootSessionsV1({ client: sdkFor(directory), directory, limit }) + : loadRootSessions({ api: serverSDK.api.session, directory, limit }), + ) .then((x) => { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) @@ -353,6 +486,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { provider: globalStore.provider, }, sdk, + api: serverSDK.api, store: child[0], setStore: child[1], vcsCache: cache, @@ -360,6 +494,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { translate: language.t, queryClient, session, + protocol: serverSDK.protocol, }) }) @@ -371,12 +506,31 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return promise } + const indexSession = (info: Parameters[0]) => { + const key = directoryKey(info.directory) + const existing = children.children[key] + if (!existing) return + applyDirectoryEvent({ + event: { type: "session.created", properties: { info } }, + directory: key, + store: existing[0], + setStore: existing[1], + push: queue.push, + retainedLimit: sessionMeta.get(key)?.limit, + sessionContent: false, + permission: session.data.permission, + loadLsp() {}, + }) + } + const unsub = serverSDK.event.listen((e) => { const directory = e.name const key = directoryKey(directory) const event = e.details + const eventType: string = event.type const recent = bootingRoot || Date.now() - bootedAt < 1500 + if (event.current) session.applyV2(event.current) session.apply(event) if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { homeSessions.apply(event) @@ -384,6 +538,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { homeSessions.refresh(event.type) if (directory === "global") { + if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching) + void activeSessionsQuery.refetch() applyGlobalEvent({ event, project: globalStore.project, @@ -393,7 +549,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }, setGlobalProject: setProjects, }) - if (event.type === "server.connected" || event.type === "global.disposed") { + if ( + eventType === "config.updated" || + eventType === "catalog.updated" || + eventType === "agent.updated" || + eventType === "project.directories.updated" + ) + bootstrap.refetch() + if (eventType === "server.connected" || eventType === "global.disposed") { if (recent) return for (const directory of Object.keys(children.children)) { if (!children.active(directory)) continue @@ -403,9 +566,30 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return } + if (event.current?.type === "session.moved") { + const info = session.get(event.current.data.sessionID) + if (info) indexSession(info) + } + if (event.current?.type === "session.forked") + void session + .resolve(event.current.data.sessionID, { force: true }) + .then(indexSession) + .catch(() => {}) + const existing = children.children[key] if (!existing) return children.mark(key) + if ( + event.current?.type === "session.moved" || + event.current?.type === "session.archived" || + event.current?.type === "session.forked" || + eventType === "command.updated" || + eventType === "config.updated" || + eventType === "agent.updated" + ) + queue.push(key) + if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key)) + if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key)) const [store, setStore] = existing applyDirectoryEvent({ event, @@ -502,14 +686,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { toggle: async (directory: string, name: string) => { const key = directoryKey(directory) const sdk = sdkFor(key) - const status = children.child(key, { bootstrap: false })[0].mcp[name].status + const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status + if (!status) return await toggleMcp({ status, connect: async () => { - await sdk.mcp.connect({ name }) + if ((await serverSDK.protocol) === "v1") { + await sdk.mcp.connect({ name }) + return + } + await serverSDK.api.mcp.connect({ server: name, location: { directory: key } }) }, disconnect: async () => { - await sdk.mcp.disconnect({ name }) + if ((await serverSDK.protocol) === "v1") { + await sdk.mcp.disconnect({ name }) + return + } + await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } }) }, authenticate: async () => { await sdk.mcp.auth.authenticate({ name }) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index d2d616248d33..44dde9241db6 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -149,6 +149,7 @@ function createWorkspaceTerminalSession( scope: ServerScopeValue, legacySessionID?: string, ) { + const location = { directory: sdk.directory } const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : [] const [store, setStore, _, ready] = persisted( @@ -240,15 +241,16 @@ function createWorkspaceTerminalSession( }) onCleanup(unsub) - const update = (client: DirectorySDK["client"], pty: Partial & { id: string }) => { + const update = (pty: Partial & { id: string }) => { const index = store.all.findIndex((x) => x.id === pty.id) const previous = index >= 0 ? store.all[index] : undefined if (index >= 0) { setStore("all", index, (item) => ({ ...item, ...pty })) } - client.pty + sdk.api.pty .update({ ptyID: pty.id, + location, title: pty.title, size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, }) @@ -261,12 +263,13 @@ function createWorkspaceTerminalSession( }) } - const clone = async (client: DirectorySDK["client"], id: string) => { + const clone = async (id: string) => { const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const next = await client.pty + const next = await sdk.api.pty .create({ + location, title: pty.title, }) .catch((error: unknown) => { @@ -308,17 +311,17 @@ function createWorkspaceTerminalSession( const nextNumber = pickNextTerminalNumber() const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined - sdk.client.pty - .create({ title: defaultTitle(nextNumber) }) - .then((pty: { data?: { id?: string; title?: string } }) => { - const id = pty.data?.id + sdk.api.pty + .create({ location, title: defaultTitle(nextNumber) }) + .then((pty) => { + const id = pty.data.id if (!id) { if (focusRequest !== undefined) cancelFocus(focusRequest) return } const newTerminal = { id, - title: pty.data?.title ?? defaultTitle(nextNumber), + title: pty.data.title ?? defaultTitle(nextNumber), titleNumber: nextNumber, } batch(() => { @@ -335,7 +338,7 @@ function createWorkspaceTerminalSession( }) }, update(pty: Partial & { id: string }) { - update(sdk.client, pty) + update(pty) }, trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -350,10 +353,9 @@ function createWorkspaceTerminalSession( }) }, async clone(id: string) { - await clone(sdk.client, id) + await clone(id) }, bind() { - const client = sdk.client return { trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -361,10 +363,10 @@ function createWorkspaceTerminalSession( setStore("all", index, (pty) => trimTerminal(pty)) }, update(pty: Partial & { id: string }) { - update(client, pty) + update(pty) }, async clone(id: string) { - await clone(client, id) + await clone(id) }, } }, @@ -412,7 +414,7 @@ function createWorkspaceTerminalSession( }) } - await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => { + await sdk.api.pty.remove({ ptyID: id, location }).catch((error: unknown) => { console.error("Failed to close terminal", error) }) }, diff --git a/packages/app/src/pages/home-session-archive.test.ts b/packages/app/src/pages/home-session-archive.test.ts index 0ad30afcd250..2d04e808f46c 100644 --- a/packages/app/src/pages/home-session-archive.test.ts +++ b/packages/app/src/pages/home-session-archive.test.ts @@ -19,7 +19,7 @@ test("archiving a Home session removes its open titlebar tab", async () => { await archiveHomeSession({ server: remote, session: { id: "ses_1", directory: "/workspace" }, - update: async () => undefined, + archive: async () => undefined, remove: () => { removed = true }, @@ -37,7 +37,7 @@ test("reports archive failures without removing the session", async () => { await archiveHomeSession({ server: remote, session: { id: "ses_1", directory: "/workspace" }, - update: async () => Promise.reject(failure), + archive: async () => Promise.reject(failure), remove: () => { removed = true }, diff --git a/packages/app/src/pages/home-session-archive.ts b/packages/app/src/pages/home-session-archive.ts index 7e6634ed7ab7..bafca66e72df 100644 --- a/packages/app/src/pages/home-session-archive.ts +++ b/packages/app/src/pages/home-session-archive.ts @@ -6,25 +6,15 @@ type HomeSession = { directory: string } -type SessionUpdate = { - directory: string - sessionID: string - time: { archived: number } -} - export async function archiveHomeSession(input: { server: ServerConnection.Key session: HomeSession - update: (value: SessionUpdate) => Promise + archive: (sessionID: string) => Promise remove: () => void onError?: (error: unknown) => void }) { await input - .update({ - directory: input.session.directory, - sessionID: input.session.id, - time: { archived: Date.now() }, - }) + .archive(input.session.id) .then(() => { input.remove() notifySessionTabsRemoved({ diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 03924ec973d6..da7cad7e1952 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -606,7 +606,7 @@ export function NewHome() { await archiveHomeSession({ server: ServerConnection.key(conn), session, - update: (value) => ctx.sdk.client.session.update(value), + archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }), remove: () => setStore( produce((draft) => { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 812a479b200f..59474423184a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -36,6 +36,7 @@ import { useProviders } from "@/hooks/use-providers" import { toaster } from "@opencode-ai/ui/toast" import { setV2Toast, showToast, ToastRegion } from "@/utils/toast" import { useServerSDK } from "@/context/server-sdk" +import { normalizeProjectInfo } from "@/context/global-sync/utils" import { clearWorkspaceTerminals } from "@/context/terminal" import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { useNotification } from "@/context/notification" @@ -48,6 +49,7 @@ import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" +import { listAllSessions } from "@/utils/session" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" @@ -875,11 +877,7 @@ export default function LegacyLayout(props: ParentProps) { const index = sessions.findIndex((s) => s.id === session.id) const nextSession = sessions[index + 1] ?? sessions[index - 1] - await serverSDK().client.session.update({ - directory: session.directory, - sessionID: session.id, - time: { archived: Date.now() }, - }) + await serverSDK().api.session.archive({ sessionID: session.id, directory: session.directory }) setStore( produce((draft) => { const match = Binary.search(draft.session, session.id, (s) => s.id) @@ -1185,9 +1183,12 @@ export default function LegacyLayout(props: ParentProps) { } const refreshDirs = async (target?: string) => { if (!target || target === root || canOpen(target)) return canOpen(target) - const listed = await serverSDK() - .client.worktree.list({ directory: root }) - .then((x) => x.data ?? []) + const listed = await Promise.resolve( + project?.id ?? serverSDK().api.project.current({ location: { directory: root } }), + ) + .then((value) => (typeof value === "string" ? value : value.id)) + .then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } })) + .then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root))) .catch(() => [] as string[]) dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root]) return canOpen(target) @@ -1231,10 +1232,11 @@ export default function LegacyLayout(props: ParentProps) { await Promise.all( dirs.map(async (item) => ({ path: { directory: item }, - session: await serverSDK() - .client.session.list({ directory: item }) - .then((x) => x.data ?? []) - .catch(() => []), + session: await listAllSessions(serverSDK().api.session, { + directory: item, + parentID: null, + order: "desc", + }).catch(() => []), })), ), Date.now(), @@ -1294,7 +1296,10 @@ export default function LegacyLayout(props: ParentProps) { const name = next === getFilename(project.worktree) ? "" : next if (project.id && project.id !== "global") { - await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name }) + const result = await serverSDK().api.project.update({ projectID: project.id, name }) + serverSync().set("project", (items) => + items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)), + ) return } @@ -1445,10 +1450,7 @@ export default function LegacyLayout(props: ParentProps) { }) const dismiss = () => toaster.dismiss(progress) - const sessions: Session[] = await serverSDK() - .client.session.list({ directory }) - .then((x) => x.data ?? []) - .catch(() => []) + const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => []) clearWorkspaceTerminals( directory, @@ -1477,17 +1479,12 @@ export default function LegacyLayout(props: ParentProps) { return } - const archivedAt = Date.now() await Promise.all( sessions .filter((session) => session.time.archived === undefined) .map((session) => serverSDK() - .client.session.update({ - sessionID: session.id, - directory: session.directory, - time: { archived: archivedAt }, - }) + .api.session.archive({ sessionID: session.id, directory: session.directory }) .catch(() => undefined), ), ) @@ -1524,9 +1521,9 @@ export default function LegacyLayout(props: ParentProps) { onMount(() => { serverSDK() - .client.vcs.status({ directory: props.directory }) - .then((x) => { - const files = x.data ?? [] + .api.vcs.status({ location: { directory: props.directory } }) + .then((result) => { + const files = result.data const dirty = files.length > 0 setData({ status: "ready", dirty }) }) @@ -1582,19 +1579,19 @@ export default function LegacyLayout(props: ParentProps) { }) const refresh = async () => { - const sessions = await serverSDK() - .client.session.list({ directory: props.directory }) - .then((x) => x.data ?? []) - .catch(() => []) + const sessions = await listAllSessions(serverSDK().api.session, { + directory: props.directory, + order: "desc", + }).catch(() => []) const active = sessions.filter((session) => session.time.archived === undefined) setState({ sessions: active }) } onMount(() => { serverSDK() - .client.vcs.status({ directory: props.directory }) - .then((x) => { - const files = x.data ?? [] + .api.vcs.status({ location: { directory: props.directory } }) + .then((result) => { + const files = result.data const dirty = files.length > 0 setState({ status: "ready", dirty }) void refresh() diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 3ce28416760f..067a79694561 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -532,7 +532,6 @@ export default function Page() { const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const isChildSession = createMemo(() => !!info()?.parentID) - const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : [])) const canReview = createMemo(() => !!sync().project) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ @@ -690,8 +689,8 @@ export default function Page() { queryFn: mode ? () => sdk() - .client.vcs.diff({ mode }) - .then((result) => list(result.data)) + .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode }) + .then((result) => result.data) .catch((error) => { console.debug("[session-review] failed to load vcs diff", { mode, error }) return [] @@ -738,8 +737,12 @@ export default function Page() { retry: 2, queryFn: () => sdk() - .client.vcs.diff({ mode, directory: scope, context }) - .then((result) => result.data ?? []), + .api.vcs.diff({ + location: { directory: scope }, + mode: mode === "git" ? "working" : mode, + context, + }) + .then((result) => result.data), }) .then((diffs) => diffs.find((diff) => diff.file === file)) @@ -946,10 +949,11 @@ export default function Page() { ) const stopVcs = sdk().event.listen((evt) => { - if (evt.details.type !== "file.watcher.updated") return + const details = evt.details as { type: string; properties?: unknown } + if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return const props = - typeof evt.details.properties === "object" && evt.details.properties - ? (evt.details.properties as Record) + typeof details.properties === "object" && details.properties + ? (details.properties as Record) : undefined const file = typeof props?.file === "string" ? props.file : undefined if (!file || file.startsWith(".git/")) return @@ -1464,44 +1468,6 @@ export default function Page() { requestAnimationFrame(() => attempt(0)) }) - createEffect(() => { - const id = params.id - if (!id) return - - if (!wantsReview()) return - if (sync().data.session_diff[id] !== undefined) return - if (sync().status === "loading") return - - void sync().session.diff(id) - }) - - createEffect( - on( - () => [sessionKey(), wantsReview()] as const, - ([key, wants]) => { - if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) - if (diffTimer !== undefined) window.clearTimeout(diffTimer) - diffFrame = undefined - diffTimer = undefined - if (!wants) return - - const id = params.id - if (!id) return - if (!untrack(() => sync().data.session_diff[id] !== undefined)) return - - diffFrame = requestAnimationFrame(() => { - diffFrame = undefined - diffTimer = window.setTimeout(() => { - diffTimer = undefined - if (sessionKey() !== key) return - void sync().session.diff(id, { force: true }) - }, 0) - }) - }, - { defer: true }, - ), - ) - let treeDir: string | undefined createEffect(() => { const dir = sdk().directory @@ -1757,7 +1723,7 @@ export default function Page() { setFollowup("failed", input.sessionID, undefined) const ok = await sendFollowupDraft({ - client: sdk().client, + api: sdk().api.session, sync: sync(), serverSync: serverSync(), draft: item, @@ -1853,13 +1819,13 @@ export default function Page() { const halt = (sessionID: string) => busy(sessionID) ? sdk() - .client.session.abort({ sessionID }) + .api.session.interrupt({ sessionID }) .catch(() => {}) : Promise.resolve() const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { - const client = sdk().client + const session = sdk().api.session const target = sync() const last = target.session.get(input.sessionID)?.revert const value = draft(input.messageID) @@ -1869,10 +1835,8 @@ export default function Page() { roll(input.sessionID, { messageID: input.messageID }, target) prompt.set(value) }, - request: () => halt(input.sessionID).then(() => client.session.revert(input)), - complete: (result) => { - if (result.data) merge(result.data, target) - }, + request: () => halt(input.sessionID).then(() => session.revert.stage(input)), + complete: () => undefined, rollback: () => roll(input.sessionID, last, target), fail, }) @@ -1884,7 +1848,7 @@ export default function Page() { const sessionID = params.id if (!sessionID) return - const client = sdk().client + const session = sdk().api.session const target = sync() const next = userMessages().find((item) => item.id > id) const last = target.session.get(sessionID)?.revert @@ -1901,11 +1865,9 @@ export default function Page() { }, request: () => !next - ? halt(sessionID).then(() => client.session.unrevert({ sessionID })) - : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })), - complete: (result) => { - if (result.data) merge(result.data, target) - }, + ? halt(sessionID).then(() => session.revert.clear({ sessionID })) + : halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)), + complete: () => undefined, rollback: () => roll(sessionID, last, target), fail, }) diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index f52b7f4b4223..4ae7827e210a 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -45,7 +45,8 @@ export function createPromptInputController(input: { model: { selection: input.model ?? local.model, paid: providers.paid().length > 0, - loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, + loading: + (local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading, }, session: { id: input.sessionID(), diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 45f5e4cb26ff..f54e0c9e4f5d 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -82,7 +82,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( setStore("responding", perm.id) sdk() - .client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) + .api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response }) .catch((err: unknown) => { const description = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description }) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 445a9f47a082..941424e247cf 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -223,7 +223,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const replyMutation = useMutation(() => ({ - mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }), + mutationFn: (answers: QuestionAnswer[]) => + sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }), onMutate: () => { props.onSubmit() }, @@ -235,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit })) const rejectMutation = useMutation(() => ({ - mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }), + mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }), onMutate: () => { props.onSubmit() }, diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 688714fcf15c..45dd44bc9927 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -235,7 +235,7 @@ export function SessionFileView(props: SessionFileViewProps) { const source = props.diff if (!source) return const loaded = loadedDiff() - return normalize(loaded?.source === source && loaded.version === props.diffVersion ? loaded.value : source) + return normalize(loaded && loaded.source === source && loaded.version === props.diffVersion ? loaded.value : source) }) return ( diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 3854bf0276ea..1b65af7121ac 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,6 +1,7 @@ import { createEffect, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { SessionReview } from "@opencode-ai/session-ui/session-review" import type { SessionReviewCommentActions, @@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments" export type DiffStyle = "unified" | "split" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff export interface SessionReviewTabProps { title?: JSX.Element diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 571428e29f0b..8615d537cd53 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -24,6 +24,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -56,15 +57,16 @@ import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" -type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff +type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff -function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { +function renderDiff(value: ReviewDiff): value is RenderDiff { return typeof value.file === "string" } export function SessionSidePanel(props: { canReview: () => boolean - diffs: () => (SnapshotFileDiff | VcsFileDiff)[] + diffs: () => ReviewDiff[] diffsReady: () => boolean empty: () => string hasReview: () => boolean diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 2959e9f8a9bd..ddc6a4fadeab 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -283,6 +283,14 @@ export function MessageTimeline(props: { return sync().data.session_status[id] ?? idle }) const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : [])) + const projectedMessages = createMemo(() => { + const id = sessionID() + if (!id) return [] + const visible = new Set(props.userMessages.map((message) => message.id)) + const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id + const messages = sync().data.session_message[id] ?? [] + return boundary ? messages.filter((message) => message.id < boundary) : messages + }) const info = createMemo(() => { const id = sessionID() if (!id) return @@ -324,7 +332,7 @@ export function MessageTimeline(props: { const showHeader = createMemo(() => !!(titleValue() || parentID())) const projection = createTimelineProjection({ messages: sessionMessages, - userMessages: () => props.userMessages, + sessionMessages: projectedMessages, parts: getMsgParts, status: sessionStatus, showReasoningSummaries: settings.general.showReasoningSummaries, @@ -664,8 +672,7 @@ export function MessageTimeline(props: { })) const titleMutation = useMutation(() => ({ - mutationFn: (input: { id: string; title: string }) => - sdk().client.session.update({ sessionID: input.id, title: input.title }), + mutationFn: (input: { id: string; title: string }) => sdk().api.session.rename({ sessionID: input.id, title: input.title }), onSuccess: (_, input) => { sync().set( produce((draft) => { @@ -809,7 +816,7 @@ export function MessageTimeline(props: { const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) await sdk() - .client.session.update({ sessionID, time: { archived: Date.now() } }) + .api.session.archive({ sessionID }) .then(() => { sync().set( produce((draft) => { @@ -838,8 +845,8 @@ export function MessageTimeline(props: { const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) const result = await sdk() - .client.session.delete({ sessionID }) - .then((x) => x.data) + .api.session.remove({ sessionID }) + .then(() => true) .catch((err) => { showToast({ title: language.t("session.delete.failed.title"), diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts index ea8ea4f13228..b430dba4daee 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -1,16 +1,14 @@ -import { Binary } from "@opencode-ai/core/util/binary" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" -import { createMemo, mapArray, type Accessor } from "solid-js" +import { createMemo, type Accessor } from "solid-js" import { reuseTimelineRows } from "./row-reconciliation" import { Timeline, TimelineRow } from "./rows" export { reuseTimelineRows } from "./row-reconciliation" -const emptyAssistantMessages: AssistantMessage[] = [] - export function createTimelineProjection(input: { messages: Accessor - userMessages: Accessor + sessionMessages: Accessor parts: (messageID: string) => Part[] status: Accessor showReasoningSummaries: Accessor @@ -30,47 +28,19 @@ export function createTimelineProjection(input: { }) return result }) - const activeMessageID = createMemo(() => { - const parentID = input - .messages() - .findLast( - (message): message is AssistantMessage => - message.role === "assistant" && typeof message.time.completed !== "number", - )?.parentID - if (parentID) { - const messages = input.messages() - const result = Binary.search(messages, parentID, (message) => message.id) - const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) - if (message?.role === "user") return message.id - } - - if (input.status().type === "idle") return - return input.messages().findLast((message) => message.role === "user")?.id - }) - const messageRowMemos = createMemo( - mapArray(input.userMessages, (userMessage, indexAccessor) => - createMemo((previous: TimelineRow.TimelineRow[] | undefined) => - reuseTimelineRows( - previous, - Timeline.constructMessageRows( - userMessage, - input.parts, - assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages, - indexAccessor(), - input.showReasoningSummaries(), - input.status().type, - activeMessageID() === userMessage.id, - input.inlineComments(), - ), - ), - ), + const projection = createMemo(() => + Timeline.constructSessionMessageRows( + input.sessionMessages(), + (messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined, + input.parts, + input.showReasoningSummaries(), + input.status().type, + input.inlineComments(), ), ) + const activeMessageID = createMemo(() => projection().activeMessageID) const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => - reuseTimelineRows( - previous, - messageRowMemos().flatMap((memo) => memo()), - ), + reuseTimelineRows(previous, projection().rows), ) const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const))) const messageRowIndex = createMemo(() => { diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts new file mode 100644 index 000000000000..941ac59b23dd --- /dev/null +++ b/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, mock, test } from "bun:test" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { normalizeSessionMessages } from "@/utils/session-message" + +mock.module("@opencode-ai/session-ui/message-part", () => ({ + renderable: () => true, + groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) => + refs.map((ref) => ({ + type: "part" as const, + key: ref.part.id, + ref: { messageID: ref.messageID, partID: ref.part.id }, + })), +})) + +const { Timeline, TimelineRow } = await import("./rows") + +describe("current session timeline rows", () => { + test("derives turns and tagged rows from chronological current messages", () => { + const source = [ + { id: "msg_1", type: "user", text: "first", time: { created: 1 } }, + { + id: "msg_2", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "answer" }], + time: { created: 2, completed: 3 }, + }, + { id: "msg_3", type: "user", text: "second", time: { created: 4 } }, + { + id: "msg_4", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "reasoning", text: "working" }], + time: { created: 5 }, + }, + ] satisfies SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((message) => [message.id, message])) + + const result = Timeline.constructSessionMessageRows( + source, + (messageID) => messages.get(messageID), + (messageID) => normalized.parts.get(messageID) ?? [], + true, + "busy", + true, + ) + + expect(result.activeMessageID).toBe("msg_3") + expect(result.rows.map(TimelineRow.key)).toEqual([ + "user-message:msg_1", + "assistant-part:msg_1:msg_2:text:0", + "turn-gap:msg_3", + "user-message:msg_3", + "assistant-part:msg_3:msg_4:reasoning:0", + ]) + }) + + test("renders a current shell message as a standalone turn", () => { + const source = [ + { + id: "msg_shell", + type: "shell", + shellID: "shell_1", + command: "pwd", + status: "exited", + exit: 0, + output: { output: "/repo", cursor: 5, size: 5, truncated: false }, + time: { created: 1, completed: 2 }, + }, + ] satisfies SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((message) => [message.id, message])) + + const result = Timeline.constructSessionMessageRows( + source, + (messageID) => messages.get(messageID), + (messageID) => normalized.parts.get(messageID) ?? [], + true, + "idle", + true, + ) + + expect(result.activeMessageID).toBe("msg_shell") + expect(result.rows.map(TimelineRow.key)).toEqual([ + "user-message:msg_shell", + "assistant-part:msg_shell:msg_shell:tool", + ]) + }) +}) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index a9c9a2d2881a..a8e97c012ac9 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -1,4 +1,5 @@ import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part" import { TimelineRow, type SummaryDiff } from "./timeline-row" @@ -31,6 +32,47 @@ export type TimelineRowMap = { } export namespace Timeline { + export function constructSessionMessageRows( + messages: SessionMessageInfo[], + getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined, + getMessageParts: (messageID: string) => Part[], + showReasoning: boolean, + status: SessionStatus["type"], + inlineComments: boolean, + ) { + const turns = messages.reduce<{ user: UserMessage; assistants: AssistantMessage[] }[]>((result, message) => { + const projected = getMessage(message.id) + if (message.type === "shell" && projected?.role === "user") { + const assistant = getMessage(`${message.id}:assistant`) + result.push({ user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] }) + return result + } + if (projected?.role === "user") { + result.push({ user: projected, assistants: [] }) + return result + } + if (projected?.role !== "assistant") return result + result.at(-1)?.assistants.push(projected) + return result + }, []) + const activeMessageID = turns.at(-1)?.user.id + return { + activeMessageID, + rows: turns.flatMap((turn, index) => + constructMessageRows( + turn.user, + getMessageParts, + turn.assistants, + index, + showReasoning, + status, + turn.user.id === activeMessageID, + inlineComments, + ), + ), + } + } + export function constructMessageRows( userMessage: UserMessage, getMessageParts: (messageID: string) => Part[], diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 275e6ec4bc22..12dd96a5e66b 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -5,7 +5,6 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" @@ -19,6 +18,7 @@ import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionOwnership } from "./session-ownership" +import { useLocal } from "@/context/local" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -40,7 +40,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const dialog = useDialog() const file = useFile() const language = useLanguage() - const local = useLocal() const permission = usePermission() const prompt = usePrompt() const sdk = useSDK() @@ -48,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sync = useSync() const terminal = useTerminal() const layout = useLayout() + const local = useLocal() const navigate = useNavigate() const { params, sessionKey, tabs, view } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) @@ -306,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const client = sdk().client + const session = sdk().api.session const directory = sdk().directory const promptSession = prompt.capture() const revert = info()?.revert?.messageID @@ -316,13 +316,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const parts = sync().data.part[message.id] if (sync().data.session_working(sessionID)) { - await client.session.abort({ sessionID }).catch(() => {}) + await session.interrupt({ sessionID }).catch(() => {}) } await runCommand({ owner, prompt: promptSession, - request: () => client.session.revert({ sessionID, messageID: message.id }), + request: () => session.revert.stage({ sessionID, messageID: message.id }), updatePrompt: (promptSession) => { if (parts) promptSession.set(extractPromptFromParts(parts, { directory })) }, @@ -334,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const client = sdk().client + const session = sdk().api.session const messages = userMessages() const promptSession = prompt.capture() @@ -346,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => client.session.unrevert({ sessionID }), + request: () => session.revert.clear({ sessionID }), updatePrompt: (promptSession) => promptSession.reset(), updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)), }) @@ -356,7 +356,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => client.session.revert({ sessionID, messageID: next.id }), + request: () => session.revert.stage({ sessionID, messageID: next.id }), updatePrompt: () => undefined, updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)), }) @@ -375,10 +375,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return } - await sdk().client.session.summarize({ + await sdk().api.session.compact({ sessionID, - modelID: model.id, - providerID: model.provider.id, + model: { providerID: model.provider.id, modelID: model.id }, }) } diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts index 49cec334bc47..d3adb1f2fffa 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -1,14 +1,15 @@ import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Kind } from "@/components/file-tree-v2" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" -export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff export function normalizePath(p: string) { return normalizeFileTreeV2Path(p) } -export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { +export function filterRenderableDiff(value: FileDiffInfo | SnapshotFileDiff | VcsFileDiff): value is RenderDiff { return typeof value.file === "string" } diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index 4f0cf612e1e2..fcd6bbb79feb 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -1,5 +1,6 @@ import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, @@ -30,7 +31,7 @@ import { import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff export type ReviewPanelV2Props = { title?: JSX.Element diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts index 5fbca469b713..a3d25f427959 100644 --- a/packages/app/src/utils/diffs.test.ts +++ b/packages/app/src/utils/diffs.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Message } from "@opencode-ai/sdk/v2/client" import { diffs, message } from "./diffs" @@ -9,7 +10,7 @@ const item = { additions: 1, deletions: 1, status: "modified", -} satisfies SnapshotFileDiff +} satisfies FileDiffInfo & SnapshotFileDiff describe("diffs", () => { test("keeps valid arrays", () => { diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts index 0cb2504fbe92..a8eec75a9af3 100644 --- a/packages/app/src/utils/diffs.ts +++ b/packages/app/src/utils/diffs.ts @@ -1,7 +1,8 @@ import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Message } from "@opencode-ai/sdk/v2/client" -type Diff = SnapshotFileDiff | VcsFileDiff +type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff function diff(value: unknown): value is Diff { if (!value || typeof value !== "object" || Array.isArray(value)) return false diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts new file mode 100644 index 000000000000..eca83effa0d1 --- /dev/null +++ b/packages/app/src/utils/server-compat.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test" +import { createApiForServer, createSdkForServer } from "./server" +import { createCompatibleApi } from "./server-compat" + +function setup(protocol: "v1" | "v2") { + const requests: Request[] = [] + const fetcher = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init) + requests.push(request) + if (request.method === "PATCH") { + return Response.json({ + id: "ses_1", + slug: "ses_1", + projectID: "project", + directory: "/repo", + title: "Session", + version: "1", + time: { created: 1, updated: 1 }, + }) + } + if (request.method === "POST" && request.url.endsWith("/prompt_async")) + return new Response(undefined, { status: 204 }) + if (request.method === "POST" && request.url.endsWith("/prompt")) { + return Response.json({ + admittedSeq: 1, + id: "msg_1", + sessionID: "ses_1", + timeCreated: 1, + type: "user", + data: { text: "hello" }, + delivery: "steer", + }) + } + if (request.method === "GET") return Response.json([]) + return new Response(undefined, { status: 204 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const server = { url: "http://localhost:4096" } + const api = createCompatibleApi({ + protocol: Promise.resolve(protocol), + current: createApiForServer({ server, fetch: fetcher }), + legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }), + directory: "/repo", + }) + return { api, requests } +} + +describe("createCompatibleApi", () => { + test("routes V1 archive through the legacy session update", async () => { + const { api, requests } = setup("v1") + await api.session.archive({ sessionID: "ses_1", directory: "/repo" }) + + const url = new URL(requests[0]!.url) + expect(url.pathname).toBe("/session/ses_1") + expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo") + expect(requests[0]!.method).toBe("PATCH") + expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } }) + }) + + test("converts current prompts to the V1 prompt contract", async () => { + const { api, requests } = setup("v1") + await api.session.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "hello", + agent: "build", + model: { providerID: "provider", modelID: "model" }, + }) + + expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async") + expect(await requests[0]!.json()).toMatchObject({ + messageID: "msg_1", + agent: "build", + model: { providerID: "provider", modelID: "model" }, + parts: [{ type: "text", text: "hello" }], + }) + }) + + test("keeps V2 session actions on the current API", async () => { + const { api, requests } = setup("v2") + await api.session.archive({ sessionID: "ses_1" }) + + expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive") + expect(requests[0]!.method).toBe("POST") + }) + + test("uses the global V1 session search endpoint", async () => { + const { api, requests } = setup("v1") + await api.session.list({ parentID: null, search: "session", limit: 50 }) + + expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session") + }) +}) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts new file mode 100644 index 000000000000..95854d03b2c9 --- /dev/null +++ b/packages/app/src/utils/server-compat.ts @@ -0,0 +1,496 @@ +import type { ServerApi } from "./server" +import type { ServerProtocol } from "./server-protocol" +import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client" +import type { + Project, + ProjectCurrent, + SessionApi, + SessionCommandInput, + SessionCommandOutput, + SessionCompactInput, + SessionCompactOutput, + SessionInfo, + SessionPromptInput, + SessionPromptOutput, + SessionShellInput, + SessionShellOutput, +} from "@opencode-ai/client/promise" + +type LegacyClient = OpencodeClient +type LegacyFor = (directory?: string) => LegacyClient +type CompatibleSessionApi = Omit< + SessionApi, + "prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove" +> & { + prompt: (input: SessionPromptInput & LegacyPrompt) => Promise + command: (input: SessionCommandInput) => Promise + shell: (input: SessionShellInput & LegacyPrompt) => Promise + compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise + rename: (input: Parameters[0] & LegacyLocation) => ReturnType + archive: (input: Parameters[0] & LegacyLocation) => ReturnType + remove: (input: Parameters[0] & LegacyLocation) => ReturnType +} +export type CompatibleApi = Omit & { readonly session: CompatibleSessionApi } +type LegacyPrompt = { + agent?: string + model?: { providerID: string; modelID: string } + variant?: string +} +type LegacyLocation = { directory?: string } + +function mime(uri: string) { + const match = /^data:([^;,]+)/.exec(uri) + return match?.[1] ?? "application/octet-stream" +} + +function sessionInfo(session: Session): SessionInfo { + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID, + agent: session.agent, + model: session.model && { + id: session.model.id, + providerID: session.model.providerID, + variant: session.model.variant, + }, + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: session.time, + title: session.title, + location: { directory: session.directory, workspaceID: session.workspaceID }, + subpath: session.path, + revert: session.revert && { + messageID: session.revert.messageID, + partID: session.revert.partID, + snapshot: session.revert.snapshot, + }, + } +} + +export function createCompatibleApi(input: { + protocol: Promise + current: ServerApi + legacy: LegacyFor + directory?: string +}): CompatibleApi { + const directory = (location?: { directory?: string }) => location?.directory ?? input.directory + const legacy = (location?: { directory?: string }) => input.legacy(directory(location)) + const isV1 = () => input.protocol.then((protocol) => protocol === "v1") + const located = (data: T, value?: { directory?: string }) => ({ + location: { + directory: directory(value) ?? "", + project: { id: "", directory: directory(value) ?? "" }, + }, + data, + }) + + return { + ...input.current, + session: { + ...input.current.session, + async list( + value?: Parameters[0], + options?: Parameters[1], + ) { + if (!(await isV1())) return input.current.session.list(value, options) + if (!value?.directory && value?.search !== undefined) { + const result = await legacy().experimental.session.list( + { + roots: value.parentID === null ? true : undefined, + search: value.search, + limit: value.limit, + }, + options, + ) + return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + } + const result = await legacy({ directory: value?.directory }).session.list({ + directory: value?.directory, + roots: value?.parentID === null ? true : undefined, + search: value?.search, + limit: value?.limit, + }) + return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + }, + async create(value?: Parameters[0]) { + if (!(await isV1())) return input.current.session.create(value) + const result = await legacy(value?.location ?? undefined).session.create({ + directory: directory(value?.location ?? undefined), + }) + if (!result.data) throw new Error("Failed to create session") + return sessionInfo(result.data) + }, + async get(value: Parameters[0]) { + if (!(await isV1())) return input.current.session.get(value) + const result = await legacy().session.get(value) + if (!result.data) throw new Error(`Session not found: ${value.sessionID}`) + return sessionInfo(result.data) + }, + async active() { + if (!(await isV1())) return input.current.session.active() + const result = await legacy().session.status() + return Object.fromEntries( + Object.entries(result.data ?? {}).flatMap(([sessionID, status]) => + status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], + ), + ) + }, + async rename(value: Parameters[0] & LegacyLocation) { + if (!(await isV1())) return input.current.session.rename(value) + await legacy(value).session.update({ sessionID: value.sessionID, title: value.title }) + }, + async archive(value: Parameters[0] & LegacyLocation) { + if (!(await isV1())) return input.current.session.archive(value) + await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } }) + }, + async remove(value: Parameters[0] & LegacyLocation) { + if (!(await isV1())) return input.current.session.remove(value) + await legacy(value).session.delete(value) + }, + async fork(value: Parameters[0]) { + if (!(await isV1())) return input.current.session.fork(value) + const result = await legacy().session.fork(value) + if (!result.data) throw new Error("Failed to fork session") + return sessionInfo(result.data) + }, + async interrupt(value: Parameters[0]) { + if (!(await isV1())) return input.current.session.interrupt(value) + await legacy().session.abort(value) + }, + async prompt(value: SessionPromptInput & LegacyPrompt) { + if (!(await isV1())) return input.current.session.prompt(value) + await legacy().session.promptAsync({ + sessionID: value.sessionID, + messageID: value.id ?? undefined, + agent: value.agent, + model: value.model, + variant: value.variant, + parts: [ + { type: "text", text: value.text }, + ...(value.files ?? []).map((file) => ({ + type: "file" as const, + mime: mime(file.uri), + url: file.uri, + filename: file.name, + })), + ...(value.agents ?? []).map((agent) => ({ + type: "agent" as const, + name: agent.name, + source: agent.mention + ? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end } + : undefined, + })), + ], + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "user", + data: { text: value.text }, + delivery: value.delivery ?? "steer", + } + }, + async command(value: SessionCommandInput) { + if (!(await isV1())) return input.current.session.command(value) + await legacy().session.command({ + sessionID: value.sessionID, + messageID: value.id ?? undefined, + command: value.command, + arguments: value.arguments ?? "", + agent: value.agent ?? undefined, + model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined, + variant: value.model?.variant, + parts: value.files?.map((file) => ({ + type: "file" as const, + mime: mime(file.uri), + url: file.uri, + filename: file.name, + })), + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "user", + data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() }, + delivery: value.delivery ?? "steer", + } + }, + async shell(value: SessionShellInput & LegacyPrompt) { + if (!(await isV1())) return input.current.session.shell(value) + await legacy().session.shell({ + sessionID: value.sessionID, + command: value.command, + agent: value.agent, + model: value.model, + }) + }, + compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => { + if (!(await isV1())) return input.current.session.compact(value) + if (!value.model) throw new Error("A model is required to compact a V1 session") + await legacy().session.summarize({ + sessionID: value.sessionID, + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "compaction", + } + }, + revert: { + stage: async (value: Parameters[0]) => { + if (!(await isV1())) return input.current.session.revert.stage(value) + await legacy().session.revert(value) + return { messageID: value.messageID } + }, + clear: async (value: Parameters[0]) => { + if (!(await isV1())) return input.current.session.revert.clear(value) + await legacy().session.unrevert(value) + }, + commit: input.current.session.revert.commit, + }, + }, + project: { + ...input.current.project, + async list() { + if (!(await isV1())) return input.current.project.list() + return ((await legacy().project.list()).data ?? []) as Project[] + }, + async current(value?: Parameters[0]) { + if (!(await isV1())) return input.current.project.current(value) + const result = await legacy(value?.location).project.current() + if (!result.data) throw new Error("Project not found") + return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent + }, + async update(value: Parameters[0]) { + if (!(await isV1())) return input.current.project.update(value) + const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID) + const result = await legacy({ directory: project?.worktree }).project.update({ + ...value, + directory: project?.worktree, + }) + if (!result.data) throw new Error(`Project not found: ${value.projectID}`) + return result.data as Project + }, + async directories(value: Parameters[0]) { + if (!(await isV1())) return input.current.project.directories(value) + const result = await legacy(value.location).worktree.list() + return (result.data ?? []).map((item) => ({ directory: item })) + }, + }, + path: { + ...input.current.path, + async get(value?: Parameters[0]) { + if (!(await isV1())) return input.current.path.get(value) + const result = await legacy(value?.location).path.get() + if (!result.data) throw new Error("Path unavailable") + return result.data + }, + }, + vcs: { + ...input.current.vcs, + async get(value?: Parameters[0]) { + if (!(await isV1())) return input.current.vcs.get(value) + const result = await legacy(value?.location).vcs.get() + return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location) + }, + async status(value?: Parameters[0]) { + if (!(await isV1())) return input.current.vcs.status(value) + const result = await legacy(value?.location).vcs.status() + return located(result.data ?? [], value?.location) + }, + async diff(value: Parameters[0]) { + if (!(await isV1())) return input.current.vcs.diff(value) + const result = await legacy(value.location).vcs.diff({ + mode: value.mode === "working" ? "git" : value.mode, + context: value.context, + }) + return located( + (result.data ?? []).map((file) => ({ + file: file.file, + patch: file.patch ?? "", + additions: file.additions, + deletions: file.deletions, + status: file.status ?? "modified", + })), + value.location, + ) + }, + }, + file: { + ...input.current.file, + async list(value?: Parameters[0]) { + if (!(await isV1())) return input.current.file.list(value) + const result = await legacy(value?.location).file.list({ path: value?.path ?? "" }) + return located(result.data ?? [], value?.location) + }, + async find(value: Parameters[0]) { + if (!(await isV1())) return input.current.file.find(value) + const result = await legacy(value.location).find.files({ + query: value.query, + type: value.type, + limit: value.limit, + }) + return located( + (result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })), + value.location, + ) + }, + }, + integration: { + ...input.current.integration, + async get(value: Parameters[0]) { + if (!(await isV1())) return input.current.integration.get(value) + const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map( + (method, index) => + method.type === "api" + ? { type: "key" as const, label: method.label } + : { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts }, + ) + return located( + { + id: value.integrationID, + name: value.integrationID, + methods, + connections: [], + }, + value.location, + ) + }, + connect: { + ...input.current.integration.connect, + key: async (value: Parameters[0]) => { + if (!(await isV1())) return input.current.integration.connect.key(value) + await legacy(value.location).auth.set({ + providerID: value.integrationID, + auth: { type: "api", key: value.key }, + }) + }, + }, + oauth: { + ...input.current.integration.oauth, + connect: async (value: Parameters[0]) => { + if (!(await isV1())) return input.current.integration.oauth.connect(value) + const method = Number(value.methodID) + const result = await legacy(value.location).provider.oauth.authorize( + { providerID: value.integrationID, method, inputs: value.inputs }, + { throwOnError: true }, + ) + if (!result.data) throw new Error("Failed to start OAuth authorization") + return located( + { + attemptID: `${value.integrationID}:${method}`, + url: result.data.url, + instructions: result.data.instructions, + mode: result.data.method, + time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 }, + }, + value.location, + ) + }, + complete: async (value: Parameters[0]) => { + if (!(await isV1())) return input.current.integration.oauth.complete(value) + const method = Number(value.attemptID.split(":").at(-1)) + await legacy(value.location).provider.oauth.callback( + { providerID: value.integrationID, method, code: value.code }, + { throwOnError: true }, + ) + }, + status: async (value: Parameters[0]) => { + if (!(await isV1())) return input.current.integration.oauth.status(value) + const method = Number(value.attemptID.split(":").at(-1)) + await legacy(value.location).provider.oauth.callback( + { providerID: value.integrationID, method }, + { throwOnError: true }, + ) + return located( + { status: "complete" as const, time: { created: Date.now(), expires: Date.now() } }, + value.location, + ) + }, + }, + }, + pty: { + ...input.current.pty, + async shells(value?: Parameters[0]) { + if (!(await isV1())) return input.current.pty.shells(value) + return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location) + }, + async list(value?: Parameters[0]) { + if (!(await isV1())) return input.current.pty.list(value) + return located((await legacy(value?.location).pty.list()).data ?? [], value?.location) + }, + async create(value?: Parameters[0]) { + if (!(await isV1())) return input.current.pty.create(value) + const result = await legacy(value?.location).pty.create({ + command: value?.command, + args: value?.args ? [...value.args] : undefined, + cwd: value?.cwd, + title: value?.title, + env: value?.env, + }) + if (!result.data) throw new Error("Failed to create terminal") + return located(result.data, value?.location) + }, + async get(value: Parameters[0]) { + if (!(await isV1())) return input.current.pty.get(value) + const result = await legacy(value.location).pty.get({ ptyID: value.ptyID }) + if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) + return located(result.data, value.location) + }, + async update(value: Parameters[0]) { + if (!(await isV1())) return input.current.pty.update(value) + const result = await legacy(value.location).pty.update({ + ptyID: value.ptyID, + title: value.title, + size: value.size, + }) + if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) + return located(result.data, value.location) + }, + async remove(value: Parameters[0]) { + if (!(await isV1())) return input.current.pty.remove(value) + await legacy(value.location).pty.remove({ ptyID: value.ptyID }) + }, + async connectToken(value: Parameters[0]) { + if (!(await isV1())) return input.current.pty.connectToken(value) + const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID }) + if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`) + return located(result.data, value.location) + }, + }, + permission: { + ...input.current.permission, + async reply(value: Parameters[0]) { + if (!(await isV1())) return input.current.permission.reply(value) + await legacy().permission.respond({ + sessionID: value.sessionID, + permissionID: value.requestID, + response: value.reply, + }) + }, + }, + question: { + ...input.current.question, + async reply(value: Parameters[0]) { + if (!(await isV1())) return input.current.question.reply(value) + await legacy().question.reply({ + requestID: value.requestID, + answers: value.answers.map((answer) => [...answer]), + }) + }, + async reject(value: Parameters[0]) { + if (!(await isV1())) return input.current.question.reject(value) + await legacy().question.reject({ requestID: value.requestID }) + }, + }, + } +} diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts index b1c8f2c7e2e0..86df2d8760b6 100644 --- a/packages/app/src/utils/server-health.test.ts +++ b/packages/app/src/utils/server-health.test.ts @@ -14,15 +14,32 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { describe("checkServerHealth", () => { test("returns healthy response with version", async () => { - const fetch = (async () => - new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + let request: URL | undefined + const fetch = (async (input: RequestInfo | URL) => { + request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { status: 200, headers: { "content-type": "application/json" }, - })) as unknown as typeof globalThis.fetch + }) + }) as unknown as typeof globalThis.fetch const result = await checkServerHealth(server, fetch) expect(result).toEqual({ healthy: true, version: "1.2.3" }) + expect(request?.pathname).toBe("/api/health") + }) + + test("falls back to the V1 health endpoint", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + if (url.pathname === "/api/health") return new Response(undefined, { status: 404 }) + return Response.json({ healthy: true, version: "1.18.4" }) + }) as unknown as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) + expect(paths).toEqual(["/api/health", "/global/health"]) }) test("allows slow servers thirty seconds by default", async () => { @@ -142,7 +159,7 @@ describe("checkServerHealth", () => { retryDelayMs: 1, }) - expect(count).toBe(3) + expect(count).toBe(6) expect(result).toEqual({ healthy: false }) }) }) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 1b684d9af774..358d39655ff6 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -1,6 +1,7 @@ import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server" -import { createSdkForServer } from "./server" +import { authTokenFromCredentials, createSdkForServer } from "./server" +import { ClientError, OpenCode } from "@opencode-ai/client" import { Accessor, createEffect, onCleanup } from "solid-js" import { createStore, reconcile } from "solid-js/store" @@ -61,6 +62,7 @@ function wait(ms: number, signal?: AbortSignal) { function retryable(error: unknown, signal?: AbortSignal) { if (signal?.aborted) return false + if (error instanceof ClientError) return error.reason === "Transport" if (!(error instanceof Error)) return false if (error.name === "AbortError" || error.name === "TimeoutError") return false if (error instanceof TypeError) return true @@ -82,15 +84,27 @@ export async function checkServerHealth( .then(() => attempt(count + 1)) .catch(() => ({ healthy: false })) } - const attempt = (count: number): Promise => - createSdkForServer({ - server, + const attempt = async (count: number): Promise => { + const current = await OpenCode.make({ + baseUrl: server.url, fetch, - signal, + headers: server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } + : undefined, }) + .health.get({ signal }) + .then((x) => ({ data: { healthy: x.healthy === true, version: x.version } })) + .catch((error) => ({ error })) + if ("data" in current) return current.data + if (signal?.aborted) return { healthy: false } + + return createSdkForServer({ server, fetch, signal }) .global.health() .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) .catch((error) => next(count, error)) + } return attempt(0).finally(() => timeout?.clear?.()) } diff --git a/packages/app/src/utils/server-protocol.test.ts b/packages/app/src/utils/server-protocol.test.ts new file mode 100644 index 000000000000..2130a968c4bc --- /dev/null +++ b/packages/app/src/utils/server-protocol.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { detectServerProtocol } from "./server-protocol" + +const server = { url: "http://localhost:4096" } +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }) +const mockFetch = (run: (input: string | URL | Request) => Promise) => + Object.assign(run, { preconnect: globalThis.fetch.preconnect }) + +describe("detectServerProtocol", () => { + test("prefers the legacy health endpoint when both API generations exist", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" })) + return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v1") + }) + + test("recognizes V2 health by its process identifier", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({}, 404)) + return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v2") + }) + + test("recognizes the transitional V1 API health response", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({}, 404)) + return Promise.resolve(json({ healthy: true })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v1") + }) +}) diff --git a/packages/app/src/utils/server-protocol.ts b/packages/app/src/utils/server-protocol.ts new file mode 100644 index 000000000000..27b8dc208eac --- /dev/null +++ b/packages/app/src/utils/server-protocol.ts @@ -0,0 +1,35 @@ +import type { ServerConnection } from "@/context/server" +import { authTokenFromCredentials } from "./server" + +export type ServerProtocol = "v1" | "v2" + +function headers(server: ServerConnection.HttpBase) { + if (!server.password) return + return { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } +} + +async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) { + const response = await fetch(new URL(path, server.url), { + headers: headers(server), + signal: AbortSignal.timeout(5_000), + }) + if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return + const value: unknown = await response.json() + if (!value || typeof value !== "object") return + return value +} + +export async function detectServerProtocol( + server: ServerConnection.HttpBase, + fetch: typeof globalThis.fetch, +): Promise { + const legacy = await probe(server, fetch, "/global/health").catch(() => undefined) + if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1" + + const current = await probe(server, fetch, "/api/health").catch(() => undefined) + if (current && "pid" in current && typeof current.pid === "number") return "v2" + if (current && "healthy" in current && current.healthy === true) return "v1" + return "v2" +} diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 603784e4d42f..bc7de1515fe6 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,4 +1,23 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { OpenCode } from "@opencode-ai/client/promise" +import type { + AgentApi, + CatalogApi, + CommandApi, + EventApi, + FileApi, + IntegrationApi, + McpApi, + MessageApi, + PathApi, + PermissionApi, + ProjectApi, + PtyApi, + QuestionApi, + ReferenceApi, + SessionApi, + VcsApi, +} from "@opencode-ai/client/promise" import type { ServerConnection } from "@/context/server" import { decode64 } from "@/utils/base64" @@ -39,3 +58,39 @@ export function createSdkForServer({ baseUrl: server.url, }) } + +export interface ServerApi extends CatalogApi { + readonly agent: AgentApi + readonly command: CommandApi + readonly event: EventApi + readonly file: FileApi + readonly integration: IntegrationApi + readonly mcp: McpApi + readonly message: MessageApi + readonly path: PathApi + readonly permission: PermissionApi + readonly project: ProjectApi + readonly pty: PtyApi + readonly question: QuestionApi + readonly reference: ReferenceApi + readonly session: SessionApi + readonly vcs: VcsApi +} + +export function createApiForServer(input: { + server: ServerConnection.HttpBase + fetch?: typeof globalThis.fetch +}): ServerApi { + return OpenCode.make({ + baseUrl: input.server.url, + fetch: input.fetch, + headers: input.server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ + username: input.server.username, + password: input.server.password, + })}`, + } + : undefined, + }) +} diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts new file mode 100644 index 000000000000..a4455c15fe0f --- /dev/null +++ b/packages/app/src/utils/session-message.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { normalizeSessionMessages } from "./session-message" + +describe("normalizeSessionMessages", () => { + test("projects current turns into stable legacy rendering records", () => { + const source = [ + { id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } }, + { + id: "msg_2", + type: "model-switched", + model: { id: "claude", providerID: "anthropic", variant: "high" }, + time: { created: 2 }, + }, + { + id: "msg_3", + type: "user", + text: "inspect this", + files: [ + { + data: "aGVsbG8=", + mime: "text/plain", + name: "note.txt", + source: { type: "inline" }, + }, + ], + agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }], + time: { created: 3 }, + }, + { + id: "msg_4", + type: "assistant", + agent: "build", + model: { id: "claude", providerID: "anthropic", variant: "high" }, + content: [ + { type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } }, + { type: "text", text: "Result" }, + { + type: "tool", + id: "call_1", + name: "read", + state: { + status: "completed", + input: { filePath: "note.txt" }, + structured: { title: "note.txt" }, + content: [{ type: "text", text: "hello" }], + }, + time: { created: 5, ran: 6, completed: 7 }, + }, + ], + cost: 0.1, + tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } }, + time: { created: 4, completed: 7 }, + }, + { + id: "msg_5", + type: "compaction", + status: "completed", + reason: "auto", + summary: "summary", + recent: "recent", + time: { created: 8 }, + }, + ] satisfies SessionMessageInfo[] + + const result = normalizeSessionMessages("ses_1", source) + + expect(result.messages).toHaveLength(2) + expect(result.messages[0]).toMatchObject({ + id: "msg_3", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude", variant: "high" }, + }) + expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 }) + expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([ + "msg_3:text:0", + "msg_3:file:0", + "msg_3:agent:0", + "msg_5:compaction", + ]) + expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"]) + expect(result.parts.get("msg_4")?.[2]).toMatchObject({ + type: "tool", + tool: "read", + state: { status: "completed", output: "hello" }, + }) + }) + + test("does not invent a parent for an assistant-only page", () => { + const source = [ + { + id: "msg_2", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "orphan" }], + time: { created: 2 }, + }, + ] satisfies SessionMessageInfo[] + + expect(normalizeSessionMessages("ses_1", source).messages).toEqual([]) + }) + + test("projects a current shell message into a renderable standalone turn", () => { + const source = [ + { + id: "msg_shell", + type: "shell", + shellID: "shell_1", + command: "printf hello", + status: "exited", + exit: 0, + output: { output: "hello", cursor: 5, size: 5, truncated: false }, + time: { created: 1, completed: 2 }, + }, + ] satisfies SessionMessageInfo[] + + const result = normalizeSessionMessages("ses_1", source) + + expect(result.messages).toEqual([ + expect.objectContaining({ id: "msg_shell", role: "user" }), + expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }), + ]) + expect(result.parts.get("msg_shell")).toEqual([ + expect.objectContaining({ type: "text", text: "printf hello" }), + ]) + expect(result.parts.get("msg_shell:assistant")).toEqual([ + expect.objectContaining({ + type: "tool", + tool: "bash", + state: expect.objectContaining({ + status: "completed", + input: { command: "printf hello" }, + output: "hello", + title: "Shell", + }), + }), + ]) + }) + + test("adapts current edit fields for the legacy edit renderer", () => { + const source = [ + { id: "msg_user", type: "user", text: "edit it", time: { created: 1 } }, + { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "call_edit", + name: "edit", + state: { + status: "completed", + input: { path: "/repo/README.md", oldString: "old", newString: "new" }, + content: [{ type: "text", text: "Edited file successfully" }], + structured: { + files: [ + { + file: "README.md", + patch: "@@ -1 +1 @@\n-old\n+new", + additions: 1, + deletions: 1, + status: "modified", + }, + ], + replacements: 1, + }, + }, + time: { created: 2, ran: 3, completed: 4 }, + }, + ], + time: { created: 2, completed: 4 }, + }, + ] satisfies SessionMessageInfo[] + + const result = normalizeSessionMessages("ses_1", source) + + expect(result.parts.get("msg_assistant")).toEqual([ + expect.objectContaining({ + type: "tool", + tool: "edit", + state: expect.objectContaining({ + status: "completed", + input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }), + metadata: expect.objectContaining({ + filediff: { + file: "README.md", + patch: "@@ -1 +1 @@\n-old\n+new", + additions: 1, + deletions: 1, + }, + }), + }), + }), + ]) + }) +}) diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts new file mode 100644 index 000000000000..71eebb864efd --- /dev/null +++ b/packages/app/src/utils/session-message.ts @@ -0,0 +1,348 @@ +import type { + SessionMessageAssistant, + SessionMessageAssistantTool, + SessionMessageInfo, + SessionMessageShell, + SessionMessageUser, +} from "@opencode-ai/client/promise" +import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2" +import { Option, Schema } from "effect" + +const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } +const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" } +const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) + +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function normalizeToolInput(name: string, input: Record) { + if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string") + return input + return { ...input, filePath: input.path } +} + +function normalizeToolMetadata(name: string, metadata: Record) { + if (name !== "edit" || !Array.isArray(metadata.files)) return metadata + const file = metadata.files.find(record) + if (!file || typeof file.file !== "string") return metadata + return { + ...metadata, + filediff: { + file: file.file, + patch: typeof file.patch === "string" ? file.patch : undefined, + additions: typeof file.additions === "number" ? file.additions : 0, + deletions: typeof file.deletions === "number" ? file.deletions : 0, + }, + } +} + +export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) { + const messages: Message[] = [] + const parts = new Map() + let agent = "" + let model = emptyModel + let parentID: string | undefined + + source.forEach((message) => { + if (message.type === "agent-switched") { + agent = message.agent + return + } + if (message.type === "model-switched") { + model = message.model + return + } + if (message.type === "user") { + parentID = message.id + messages.push(userMessage(sessionID, message, agent, model)) + parts.set(message.id, userParts(sessionID, message)) + return + } + if (message.type === "synthetic" && message.description?.trim()) { + parentID = message.id + messages.push({ + id: message.id, + sessionID, + role: "user", + time: message.time, + agent, + model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, + }) + parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)]) + return + } + if (message.type === "shell") { + messages.push(...shellMessages(sessionID, message, agent, model)) + parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)]) + parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)]) + parentID = undefined + return + } + if (message.type === "assistant") { + agent = message.agent + model = message.model + if (!parentID) return + const parent = messages.findLast((item) => item.id === parentID) + if (parent?.role === "user") { + parent.agent = message.agent + parent.model = { + providerID: message.model.providerID, + modelID: message.model.id, + variant: message.model.variant, + } + } + messages.push(assistantMessage(sessionID, parentID, message)) + parts.set(message.id, assistantParts(sessionID, message)) + return + } + if (message.type !== "compaction" || !parentID) return + parts.set(parentID, [ + ...(parts.get(parentID) ?? []), + { + id: `${message.id}:compaction`, + sessionID, + messageID: parentID, + type: "compaction", + auto: message.reason === "auto", + }, + ]) + }) + + return { messages, parts } +} + +function shellMessages( + sessionID: string, + message: SessionMessageShell, + agent: string, + model: { id: string; providerID: string; variant?: string }, +): [UserMessage, AssistantMessage] { + return [ + { + id: message.id, + sessionID, + role: "user", + time: { created: message.time.created }, + agent, + model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, + }, + { + id: `${message.id}:assistant`, + sessionID, + role: "assistant", + time: message.time, + parentID: message.id, + modelID: model.id, + providerID: model.providerID, + variant: model.variant, + mode: agent, + agent, + path: { cwd: "", root: "" }, + cost: 0, + tokens: emptyTokens, + }, + ] +} + +function shellPart(sessionID: string, message: SessionMessageShell): ToolPart { + const input = { command: message.command } + const start = message.time.created + const state: ToolPart["state"] = + message.status === "running" + ? { status: "running", input, time: { start } } + : { + status: "completed", + input, + output: message.output?.output ?? "", + title: "Shell", + metadata: { + status: message.status, + exit: message.exit, + truncated: message.output?.truncated, + }, + time: { start, end: message.time.completed ?? start }, + } + return { + id: `${message.id}:tool`, + sessionID, + messageID: `${message.id}:assistant`, + type: "tool", + callID: message.shellID, + tool: "bash", + state, + } +} + +export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) { + return `${messageID}:${type}:${ordinal}` +} + +function userMessage( + sessionID: string, + message: SessionMessageUser, + agent: string, + model: { id: string; providerID: string; variant?: string }, +): UserMessage { + return { + id: message.id, + sessionID, + role: "user", + time: message.time, + agent, + model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, + } +} + +function userParts(sessionID: string, message: SessionMessageUser): Part[] { + return [ + textPart(sessionID, message.id, 0, message.text), + ...(message.files ?? []).map( + (file, index): FilePart => ({ + id: `${message.id}:file:${index}`, + sessionID, + messageID: message.id, + type: "file", + mime: file.mime, + filename: file.name, + url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, + }), + ), + ...(message.agents ?? []).map( + (item, index): Part => ({ + id: `${message.id}:agent:${index}`, + sessionID, + messageID: message.id, + type: "agent", + name: item.name, + source: item.mention + ? { value: item.mention.text, start: item.mention.start, end: item.mention.end } + : undefined, + }), + ), + ] +} + +function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage { + const error = message.error + ? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt") + ? { name: "MessageAbortedError" as const, data: { message: message.error.message } } + : { name: "UnknownError" as const, data: { message: message.error.message } } + : undefined + return { + id: message.id, + sessionID, + role: "assistant", + time: message.time, + error, + parentID, + modelID: message.model.id, + providerID: message.model.providerID, + variant: message.model.variant, + mode: message.agent, + agent: message.agent, + path: { cwd: "", root: "" }, + cost: message.cost ?? 0, + tokens: message.tokens ?? emptyTokens, + finish: message.finish, + } +} + +function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] { + const ordinals = { text: 0, reasoning: 0 } + return message.content.flatMap((content): Part[] => { + if (content.type === "text") { + const part = textPart(sessionID, message.id, ordinals.text++, content.text) + return content.text.trim() ? [part] : [] + } + if (content.type === "reasoning") { + const part: Part = { + id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++), + sessionID, + messageID: message.id, + type: "reasoning", + text: content.text, + metadata: content.state, + time: { + start: content.time?.created ?? message.time.created, + end: content.time?.completed, + }, + } + return content.text.trim() ? [part] : [] + } + return [toolPart(sessionID, message.id, content)] + }) +} + +function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part { + return { + id: sessionMessagePartID(messageID, "text", ordinal), + sessionID, + messageID, + type: "text", + text, + synthetic, + } +} + +function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart { + const start = tool.time.ran ?? tool.time.created + const state = (() => { + if (tool.state.status === "streaming") { + const value = Option.getOrUndefined(decodeToolInput(tool.state.input)) + const input = normalizeToolInput(tool.name, record(value) ? value : {}) + return { status: "pending" as const, input, raw: tool.state.input } + } + if (tool.state.status === "running") { + return { + status: "running" as const, + input: normalizeToolInput(tool.name, tool.state.input), + metadata: normalizeToolMetadata(tool.name, tool.state.structured), + time: { start }, + } + } + if (tool.state.status === "error") { + return { + status: "error" as const, + input: normalizeToolInput(tool.name, tool.state.input), + error: tool.state.error.message, + metadata: normalizeToolMetadata(tool.name, tool.state.structured), + time: { start, end: tool.time.completed ?? start }, + } + } + const attachments = tool.state.content.flatMap((item, index): FilePart[] => + item.type === "file" + ? [ + { + id: `${tool.id}:file:${index}`, + sessionID, + messageID, + type: "file", + mime: item.mime, + filename: item.name, + url: item.uri, + }, + ] + : [], + ) + return { + status: "completed" as const, + input: normalizeToolInput(tool.name, tool.state.input), + output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"), + title: tool.name, + metadata: normalizeToolMetadata(tool.name, tool.state.structured), + time: { start, end: tool.time.completed ?? start }, + attachments: attachments.length ? attachments : undefined, + } + })() + return { + id: tool.id, + sessionID, + messageID, + type: "tool", + callID: tool.id, + tool: tool.name, + state, + metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState }, + } +} diff --git a/packages/app/src/utils/session.test.ts b/packages/app/src/utils/session.test.ts new file mode 100644 index 000000000000..b15c23b66048 --- /dev/null +++ b/packages/app/src/utils/session.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test" +import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" +import { listAllSessions, normalizeSessionInfo } from "./session" + +describe("normalizeSessionInfo", () => { + test("adapts a current session to the app session shape", () => { + const result = normalizeSessionInfo({ + id: "session-1", + projectID: "project-1", + agent: "build", + model: { id: "gpt-5", providerID: "openai", variant: "high" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "New session", + location: { directory: "/repo/worktree", workspaceID: "workspace-1" }, + subpath: "worktree", + revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] }, + } as SessionInfo) + + expect(result).toEqual({ + id: "session-1", + slug: "session-1", + projectID: "project-1", + workspaceID: "workspace-1", + directory: "/repo/worktree", + path: "worktree", + parentID: undefined, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + title: "New session", + agent: "build", + model: { id: "gpt-5", providerID: "openai", variant: "high" }, + version: "", + time: { created: 1, updated: 1 }, + revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" }, + }) + }) +}) + +describe("listAllSessions", () => { + test("loads every page in server order and retains the query", async () => { + const calls: SessionListInput[] = [] + const pages = new Map([ + [undefined, { data: [sessionInfo("session-3"), sessionInfo("session-2")], cursor: { next: "next" } }], + ["next", { data: [sessionInfo("session-1", true)], cursor: {} }], + ]) + const api = { + list: async (query = {}) => { + calls.push(query) + return pages.get(query.cursor) ?? { data: [], cursor: {} } + }, + } satisfies Pick + + const result = await listAllSessions(api, { directory: "/repo", order: "desc" }) + + expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"]) + expect(result[2]?.time.archived).toBe(2) + expect(calls).toEqual([ + { directory: "/repo", order: "desc", limit: 100, cursor: undefined }, + { directory: "/repo", order: "desc", limit: 100, cursor: "next" }, + ]) + }) + + test("requests the terminal empty page when the server returns a next cursor", async () => { + const cursors: Array = [] + const api = { + list: async (query = {}) => { + cursors.push(query.cursor) + if (query.cursor) return { data: [], cursor: { next: "unused" } } + return { data: [sessionInfo("session-1")], cursor: { next: "terminal" } } + }, + } satisfies Pick + + const result = await listAllSessions(api, { directory: "/repo", limit: 25 }) + + expect(result.map((session) => session.id)).toEqual(["session-1"]) + expect(cursors).toEqual([undefined, "terminal"]) + }) +}) + +function sessionInfo(id: string, archived = false) { + return { + id, + projectID: "project-1", + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1, archived: archived ? 2 : undefined }, + title: id, + location: { directory: "/repo" }, + } as SessionInfo +} diff --git a/packages/app/src/utils/session.ts b/packages/app/src/utils/session.ts new file mode 100644 index 000000000000..faf847967bdd --- /dev/null +++ b/packages/app/src/utils/session.ts @@ -0,0 +1,37 @@ +import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" +import type { Session } from "@opencode-ai/sdk/v2/client" + +export function normalizeSessionInfo(input: SessionInfo | Session): Session { + if (!("location" in input)) return input + return { + id: input.id, + slug: input.id, + projectID: input.projectID, + workspaceID: input.location.workspaceID, + directory: input.location.directory, + path: input.subpath, + parentID: input.parentID, + cost: input.cost, + tokens: input.tokens, + title: input.title, + agent: input.agent, + model: input.model, + version: "", + time: input.time, + revert: input.revert && { + messageID: input.revert.messageID, + partID: input.revert.partID, + snapshot: input.revert.snapshot, + }, + } +} + +export async function listAllSessions(api: Pick, input: Omit) { + const load = async (cursor?: string): Promise => { + const result = await api.list({ ...input, limit: input.limit ?? 100, cursor }) + const sessions = result.data.map(normalizeSessionInfo) + if (result.data.length === 0 || !result.cursor.next) return sessions + return [...sessions, ...(await load(result.cursor.next))] + } + return load() +} diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts index 5fa1506b1e65..3c6e61a0f2a8 100644 --- a/packages/app/src/utils/terminal-websocket-url.test.ts +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -2,51 +2,22 @@ import { describe, expect, test } from "bun:test" import { terminalWebSocketURL } from "./terminal-websocket-url" describe("terminalWebSocketURL", () => { - test("uses query auth without embedding credentials in websocket URL", () => { + test("uses the current ticketed PTY route", () => { const url = terminalWebSocketURL({ url: "http://127.0.0.1:49365", id: "pty_test", directory: "/tmp/project", cursor: 0, - sameOrigin: false, - username: "opencode", - password: "secret", + ticket: "connect-ticket", }) expect(url.protocol).toBe("ws:") expect(url.username).toBe("") expect(url.password).toBe("") - expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) - }) - - test("omits query auth for same-origin saved credentials", () => { - const url = terminalWebSocketURL({ - url: "https://app.example.test", - id: "pty_test", - directory: "/tmp/project", - cursor: 10, - sameOrigin: true, - username: "opencode", - password: "secret", - }) - - expect(url.protocol).toBe("wss:") + expect(url.pathname).toBe("/api/pty/pty_test/connect") + expect(url.searchParams.get("location[directory]")).toBe("/tmp/project") + expect(url.searchParams.get("cursor")).toBe("0") + expect(url.searchParams.get("ticket")).toBe("connect-ticket") expect(url.searchParams.has("auth_token")).toBe(false) }) - - test("uses query auth for same-origin credentials from auth_token", () => { - const url = terminalWebSocketURL({ - url: "https://app.example.test", - id: "pty_test", - directory: "/tmp/project", - cursor: 10, - sameOrigin: true, - username: "opencode", - password: "secret", - authToken: true, - }) - - expect(url.protocol).toBe("wss:") - expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) - }) }) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index 06facdc7d245..25991e09835c 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -1,28 +1,14 @@ -import { authTokenFromCredentials } from "@/utils/server" - export function terminalWebSocketURL(input: { url: string id: string directory: string cursor: number - ticket?: string - sameOrigin?: boolean - username?: string - password?: string - authToken?: boolean + ticket: string }) { - const next = new URL(`${input.url}/pty/${input.id}/connect`) - next.searchParams.set("directory", input.directory) + const next = new URL(`${input.url}/api/pty/${input.id}/connect`) + next.searchParams.set("location[directory]", input.directory) next.searchParams.set("cursor", String(input.cursor)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" - if (input.ticket) { - next.searchParams.set("ticket", input.ticket) - return next - } - if (input.password && (!input.sameOrigin || input.authToken)) - next.searchParams.set( - "auth_token", - authTokenFromCredentials({ username: input.username, password: input.password }), - ) + next.searchParams.set("ticket", input.ticket) return next } diff --git a/packages/app/test-browser/command-palette.test.ts b/packages/app/test-browser/command-palette.test.ts index 421a2e71fd32..6a74834fd076 100644 --- a/packages/app/test-browser/command-palette.test.ts +++ b/packages/app/test-browser/command-palette.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" +import type { Project } from "@opencode-ai/sdk/v2/client" +import type { SessionInfo } from "@opencode-ai/client/promise" import { createRoot } from "solid-js" import { createServerSessionEntries } from "@/components/command-palette" import type { LocalProject } from "@/context/layout" @@ -14,15 +15,16 @@ const stored: Project = { time: { created: 1, updated: 1 }, } -const session: GlobalSession = { +const session: SessionInfo = { id: "session-1", - slug: "session-1", projectID: stored.id, - directory: stored.worktree, + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + location: { directory: stored.worktree }, title: "Palette session", - version: "1", time: { created: 1, updated: 2 }, - project: { id: stored.id, name: stored.name, worktree: stored.worktree }, } describe("command palette sessions", () => { diff --git a/packages/app/vendor/opencode-ai-client-1.17.13.tgz b/packages/app/vendor/opencode-ai-client-1.17.13.tgz new file mode 100644 index 000000000000..5939f2cb39c2 Binary files /dev/null and b/packages/app/vendor/opencode-ai-client-1.17.13.tgz differ diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index b213dbc82a23..0f2d9d6ad120 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -182,9 +182,9 @@ export async function spawnLocalServer( } export async function checkHealth(url: string, password?: string | null): Promise { - let healthUrl: URL + let healthUrls: URL[] try { - healthUrl = new URL("/global/health", url) + healthUrls = [new URL("/api/health", url), new URL("/global/health", url)] } catch { return false } @@ -195,16 +195,17 @@ export async function checkHealth(url: string, password?: string | null): Promis headers.set("authorization", `Basic ${auth}`) } - try { - const res = await fetch(healthUrl, { - method: "GET", - headers, - signal: AbortSignal.timeout(3000), - }) - return res.ok - } catch { - return false + for (const healthUrl of healthUrls) { + try { + const res = await fetch(healthUrl, { + method: "GET", + headers, + signal: AbortSignal.timeout(3000), + }) + if (res.ok) return true + } catch {} } + return false } function createSidecarEnv(): Record { diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 96611da331ec..3cf74b043b67 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index ce2f7b25c3e1..55275f3ab9aa 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -521,6 +521,7 @@ export function getToolInfo( } } case "bash": + case "shell": return { icon: "console", title: i18n.t("ui.tool.shell"), @@ -538,6 +539,7 @@ export function getToolInfo( title: i18n.t("ui.messagePart.title.write"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } + case "patch": case "apply_patch": return { icon: "code-lines", @@ -729,8 +731,8 @@ export function renderable(part: PartType, showReasoningSummaries = true) { } function toolDefaultOpen(tool: string, shell = false, edit = false) { - if (tool === "bash") return shell - if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit + if (tool === "bash" || tool === "shell") return shell + if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit } export function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -1506,7 +1508,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) { } export function getTool(name: string) { - return state[name]?.render + return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render } export const ToolRegistry = { @@ -2101,7 +2103,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "bash", + name: "shell", render(props) { const i18n = useI18n() const pending = () => props.status === "pending" || props.status === "running" @@ -2337,7 +2339,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "apply_patch", + name: "patch", render(props) { const i18n = useI18n() const fileComponent = useFileComponent() diff --git a/packages/session-ui/src/components/session-diff.ts b/packages/session-ui/src/components/session-diff.ts index 48e8eee3108a..2fbd022235f9 100644 --- a/packages/session-ui/src/components/session-diff.ts +++ b/packages/session-ui/src/components/session-diff.ts @@ -1,6 +1,7 @@ import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" import { parsePatch } from "diff" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" type LegacyDiff = { file: string @@ -13,7 +14,7 @@ type LegacyDiff = { } type SnapshotDiff = SnapshotFileDiff & { file: string } -type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff +type ReviewDiff = SnapshotDiff | FileDiffInfo | VcsFileDiff | LegacyDiff export type DiffSource = Pick export type ViewDiff = { diff --git a/packages/session-ui/src/components/session-review.tsx b/packages/session-ui/src/components/session-review.tsx index 8db21f025b71..1585a8aa3278 100644 --- a/packages/session-ui/src/components/session-review.tsx +++ b/packages/session-ui/src/components/session-review.tsx @@ -16,6 +16,7 @@ import { checksum } from "@opencode-ai/core/util/encode" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" @@ -62,10 +63,10 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { +type RawReviewDiff = (SnapshotFileDiff | FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } -type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { +type ReviewDiff = ((SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index 75274fc50e92..301a74d3f03d 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -4,6 +4,7 @@ import { Message as MessageType, Part as PartType, } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useFileComponent } from "@opencode-ai/ui/context/file" @@ -90,7 +91,7 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } -type SummaryDiff = SnapshotFileDiff & { file: string } +type SummaryDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { return typeof value.file === "string" diff --git a/packages/session-ui/src/components/tool-error-card.tsx b/packages/session-ui/src/components/tool-error-card.tsx index 35720a275313..4313d48aef4e 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -51,6 +51,8 @@ export function ToolErrorCard(props: ToolErrorCardProps) { webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", bash: "ui.tool.shell", + shell: "ui.tool.shell", + patch: "ui.tool.patch", apply_patch: "ui.tool.patch", question: "ui.tool.questions", } diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 999ff510d5f0..056fc9c0fd98 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -1,4 +1,5 @@ import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { createSimpleContext } from "@opencode-ai/ui/context" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -21,7 +22,7 @@ type Data = { [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: (SnapshotFileDiff | FileDiffInfo)[] } session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index 9f42cd90fec6..ba276a8f52cf 100644 --- a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -7,6 +7,7 @@ import { useI18n } from "@opencode-ai/ui/context/i18n" import { mediaKindFromPath } from "../../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge" import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" @@ -27,7 +28,7 @@ import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import "./session-review-v2.css" -type ReviewDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +type ReviewDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff export type SessionReviewFilePreviewV2Props = { file: string